Dev environments have no real OAuth provider configured, so GET /auth/providers returns [] and the native mobile SSO flow cannot be exercised locally. Add dependency-free dev tooling under scripts/dev/: - stub-idp.js: stub OAuth2/OIDC IdP (authorize picker, token, userinfo) - seed-sso-provider.js: registers a 'devstub' auth_providers row + pre-links each principal's sub to a dev account (SSO is link-only) - sso-bridge-smoketest.js: drives the full app flow headless (PKCE → start → IdP → callback → deep link → exchange) and asserts a bearer pair - README.md: host + emulator usage Verified end-to-end against the local site: player and admin principals both sign in and receive the correct role. DEV ONLY — never deploy the stub. Co-Authored-By: Claude <noreply@anthropic.com>
110 lines
5.0 KiB
JavaScript
110 lines
5.0 KiB
JavaScript
#!/usr/bin/env node
|
|
/*
|
|
* Mobile SSO bridge smoketest — DEV ONLY, pairs with stub-idp.js.
|
|
*
|
|
* Drives the full native-SSO flow the Android app performs, headless, so the
|
|
* website bridge can be verified without an emulator/IdP:
|
|
* 1. mint PKCE (Layer B) + state
|
|
* 2. GET /auth/mobile/sso/start → 302 to the stub /authorize
|
|
* 3. follow the stub picker (inject `login_as`) → 302 to the website callback
|
|
* 4. website callback exchanges the IdP code server-side, resolves the linked
|
|
* user, and 302s to the app deep link runicgateway://auth/callback?code&state
|
|
* 5. POST /auth/mobile/sso/exchange { code, code_verifier } → the bearer pair
|
|
*
|
|
* Prereqs: stub-idp.js running, seed-sso-provider.js applied, website on :3000.
|
|
*
|
|
* Run: node website/scripts/dev/sso-bridge-smoketest.js [stub-colby|stub-admin]
|
|
* Env: BASE_URL (default http://127.0.0.1:3000), REDIRECT_URI
|
|
* (default runicgateway://auth/callback)
|
|
*/
|
|
|
|
'use strict'
|
|
|
|
const crypto = require('crypto')
|
|
|
|
const BASE = (process.env.BASE_URL || 'http://127.0.0.1:3000').replace(/\/$/, '')
|
|
const REDIRECT = process.env.REDIRECT_URI || 'runicgateway://auth/callback'
|
|
const PROVIDER = process.env.PROVIDER_ID || 'devstub'
|
|
const LOGIN_AS = process.argv[2] || 'stub-colby'
|
|
|
|
const b64url = (buf) => buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
|
|
// A minimal cookie jar: name → value, updated from every Set-Cookie.
|
|
const jar = {}
|
|
function storeCookies(res) {
|
|
const raw = res.headers.getSetCookie ? res.headers.getSetCookie() : res.headers.raw?.()['set-cookie'] || []
|
|
for (const c of raw) {
|
|
const [pair] = c.split(';')
|
|
const idx = pair.indexOf('=')
|
|
if (idx > 0) jar[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim()
|
|
}
|
|
}
|
|
const cookieHeader = () =>
|
|
Object.entries(jar)
|
|
.map(([k, v]) => `${k}=${v}`)
|
|
.join('; ')
|
|
|
|
async function step(name, url, opts = {}) {
|
|
const res = await fetch(url, { redirect: 'manual', headers: { Cookie: cookieHeader(), ...(opts.headers || {}) }, ...opts })
|
|
storeCookies(res)
|
|
const loc = res.headers.get('location')
|
|
console.log(`\n[${name}] ${res.status} ${url.split('?')[0]}`)
|
|
if (loc) console.log(` → Location: ${loc}`)
|
|
return { res, loc }
|
|
}
|
|
|
|
async function main() {
|
|
const verifier = b64url(crypto.randomBytes(32))
|
|
const challenge = b64url(crypto.createHash('sha256').update(verifier).digest())
|
|
const state = b64url(crypto.randomBytes(16))
|
|
console.log(`PKCE verifier=${verifier.slice(0, 12)}… challenge=${challenge.slice(0, 12)}… state=${state.slice(0, 12)}… loginAs=${LOGIN_AS}`)
|
|
|
|
// 2. start → 302 to stub /authorize
|
|
const startUrl =
|
|
`${BASE}/api/v1/auth/mobile/sso/start?provider=${PROVIDER}` +
|
|
`&code_challenge=${encodeURIComponent(challenge)}&state=${encodeURIComponent(state)}` +
|
|
`&redirect_uri=${encodeURIComponent(REDIRECT)}`
|
|
let { loc } = await step('start', startUrl)
|
|
if (!loc || !loc.includes('/authorize')) throw new Error('start did not redirect to the IdP authorize endpoint')
|
|
|
|
// 3. stub authorize: inject the account choice the picker would make.
|
|
const authUrl = new URL(loc)
|
|
authUrl.searchParams.set('login_as', LOGIN_AS)
|
|
;({ loc } = await step('idp-authorize', authUrl.toString()))
|
|
if (!loc || !loc.includes('/sso/')) throw new Error('IdP did not redirect back to the website callback')
|
|
|
|
// 4. website callback: server-side token+userinfo, resolve user, deep-link back.
|
|
;({ loc } = await step('callback', loc))
|
|
if (!loc) throw new Error('callback produced no redirect')
|
|
const deep = new URL(loc)
|
|
const appCode = deep.searchParams.get('code')
|
|
const appState = deep.searchParams.get('state')
|
|
const appErr = deep.searchParams.get('error')
|
|
if (appErr) throw new Error(`callback returned error to app: ${appErr}`)
|
|
if (!appCode) throw new Error(`callback did not deep-link a code (got ${loc})`)
|
|
if (appState !== state) throw new Error(`state mismatch: sent ${state}, got ${appState}`)
|
|
console.log(` ✓ deep link carries code=${appCode.slice(0, 10)}… state matches`)
|
|
|
|
// 5. exchange the app code + PKCE verifier for the bearer pair.
|
|
const exRes = await fetch(`${BASE}/api/v1/auth/mobile/sso/exchange`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ code: appCode, code_verifier: verifier, device_name: 'sso-smoketest' }),
|
|
})
|
|
const exBody = await exRes.json().catch(() => ({}))
|
|
console.log(`\n[exchange] ${exRes.status}`)
|
|
if (!exRes.ok) throw new Error(`exchange failed: ${exRes.status} ${JSON.stringify(exBody)}`)
|
|
const hasPair = exBody.accessToken && exBody.refreshToken
|
|
console.log(` user: ${JSON.stringify(exBody.user)}`)
|
|
console.log(` accessToken: ${exBody.accessToken ? exBody.accessToken.slice(0, 16) + '…' : '(none)'}`)
|
|
console.log(` refreshToken: ${exBody.refreshToken ? '(present)' : '(none)'}`)
|
|
if (!hasPair) throw new Error('exchange did not return an access/refresh pair')
|
|
|
|
console.log('\n✅ SSO bridge smoketest PASSED')
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error('\n❌ SSO bridge smoketest FAILED:', err.message)
|
|
process.exitCode = 1
|
|
})
|