#!/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 })