chore(dev): add stub OAuth IdP + seed + bridge smoketest for native SSO

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>
This commit is contained in:
2026-07-21 14:56:43 -05:00
parent 1edef8e6db
commit 70849f96ee
4 changed files with 447 additions and 0 deletions

View File

@@ -0,0 +1,109 @@
#!/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
})