Merge pull request 'chore(dev): stub OAuth IdP tooling for local mobile SSO testing' (#91) from feat/m10-native-sso-fix into main
All checks were successful
Build container images / build (push) Successful in 57s
Build container images / deploy (push) Successful in 37s
SonarQube / analysis (push) Successful in 11m55s

Reviewed-on: #91
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
This commit is contained in:
2026-07-21 21:42:57 +00:00
4 changed files with 447 additions and 0 deletions

58
scripts/dev/README.md Normal file
View File

@@ -0,0 +1,58 @@
# Dev SSO tooling
Local-only helpers for exercising the native **mobile SSO bridge** without a real
OAuth provider. Dev environments have no IdP configured, so `GET /auth/providers`
returns `[]`, the app renders no SSO buttons, and the flow can't be tested. These
scripts stand up a stub IdP, register it, and verify the full bridge headlessly.
> **DEV ONLY.** `stub-idp.js` performs no credential checks and will sign in anyone.
> Never run it against a shared/production database or expose it publicly.
## Files
| File | Role |
|---|---|
| `stub-idp.js` | Dependency-free stub OAuth2/OIDC IdP: `GET /authorize` (account picker), `POST /token`, `GET /userinfo`. |
| `seed-sso-provider.js` | Registers an `auth_providers` row (`devstub`) pointing at the stub and pre-links each principal's `sub` to a dev account (SSO is link-only). Reads DB creds from `server/.env`. |
| `sso-bridge-smoketest.js` | Drives the whole app flow headless: PKCE → `/auth/mobile/sso/start` → stub → website callback → `runicgateway://auth/callback` deep link → `/auth/mobile/sso/exchange`. |
## Usage (host / headless)
```bash
# 1. seed the provider + linked identities (one-time; idempotent)
node scripts/dev/seed-sso-provider.js
# 2. run the stub IdP (leave running)
node scripts/dev/stub-idp.js
# 3. run the website with the callback origin pointed at the API port, so the
# whole flow is same-origin (dev default APP_BASE_URL is the Vite client :5173)
cd server && APP_BASE_URL=http://127.0.0.1:3000 npm start
# 4. verify the bridge (from the website root)
node scripts/dev/sso-bridge-smoketest.js stub-colby # or stub-admin
```
A pass prints the resolved user, an access token, and a present refresh token.
## Usage (Android emulator)
The **authorize** URL is followed by the device browser (Custom Tab); **token** and
**userinfo** are called server-side by the website. On an emulator the host is
`10.0.2.2`, so seed with split URLs:
```bash
STUB_IDP_PUBLIC_URL=http://10.0.2.2:9099 \
STUB_IDP_INTERNAL_URL=http://127.0.0.1:9099 \
node scripts/dev/seed-sso-provider.js
```
Point the app's server at `http://10.0.2.2:3000`, and run the website with
`APP_BASE_URL=http://10.0.2.2:3000` so the IdP callback returns to a device-reachable
origin.
## Principals
`stub-admin` → dev user `admin` (role admin) · `stub-colby` → dev user `colby`
(role player). Keep the `sub` list in sync between `stub-idp.js` and
`seed-sso-provider.js`.

View File

@@ -0,0 +1,105 @@
#!/usr/bin/env node
/*
* Seed a dev SSO provider + pre-linked identities — DEV ONLY.
*
* Registers an `auth_providers` row pointing at the local `stub-idp.js`, so
* `GET /auth/providers` returns a provider and the native mobile SSO flow becomes
* exercisable. Because SSO is link-only (identities are never auto-provisioned),
* it also pre-links each stub principal's `sub` to an existing dev account.
*
* Idempotent: re-running upserts the provider and skips already-linked identities.
*
* Run (loads website/server/.env for DB creds):
* node website/scripts/dev/seed-sso-provider.js
*
* Env overrides:
* STUB_PROVIDER_ID provider slug (default 'devstub')
* STUB_IDP_PUBLIC_URL browser-facing base (default http://127.0.0.1:9099)
* STUB_IDP_INTERNAL_URL server-facing base (default = STUB_IDP_PUBLIC_URL)
*
* The authorize URL is followed by the browser (Custom Tab); token/userinfo are
* called server-side by the website. On an emulator set PUBLIC to the host's
* reachable address (e.g. http://10.0.2.2:9099) and INTERNAL to http://127.0.0.1:9099.
*/
'use strict'
const fs = require('fs')
const path = require('path')
// Load website/server/.env into process.env WITHOUT the dotenv dependency (it
// lives in server/node_modules and wouldn't resolve from this scripts/ location).
// The file is simple KEY=value; that is all we need for the DB credentials.
function loadEnv(envPath) {
if (!fs.existsSync(envPath)) return
for (const line of fs.readFileSync(envPath, 'utf8').split(/\r?\n/)) {
const m = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line)
if (!m) continue
const key = m[1]
let val = m[2].trim()
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
val = val.slice(1, -1)
}
if (process.env[key] === undefined) process.env[key] = val
}
}
loadEnv(path.join(__dirname, '..', '..', 'server', '.env'))
const authProviders = require('../../server/src/model/authProviders/authProviders.model')
const userIdentities = require('../../server/src/model/userIdentities/userIdentities.model')
const { close } = require('../../server/src/utils/db')
const PROVIDER_ID = process.env.STUB_PROVIDER_ID || 'devstub'
const PUBLIC_URL = (process.env.STUB_IDP_PUBLIC_URL || 'http://127.0.0.1:9099').replace(/\/$/, '')
const INTERNAL_URL = (process.env.STUB_IDP_INTERNAL_URL || PUBLIC_URL).replace(/\/$/, '')
// sub → dev account id. Keep the subs in sync with stub-idp.js PRINCIPALS.
const PRINCIPALS = [
{ sub: 'stub-admin', email: 'admin@dev.local', userId: 1 },
{ sub: 'stub-colby', email: 'colby@dev.local', userId: 14 },
]
async function main() {
// eslint-disable-next-line no-console
const log = (...a) => console.log('[seed-sso]', ...a)
await authProviders.save(PROVIDER_ID, {
kind: 'oauth2',
name: 'Dev Stub IdP',
enabled: true,
clientId: 'stub-client',
secret: 'stub-secret',
authorizeUrl: `${PUBLIC_URL}/authorize`,
tokenUrl: `${INTERNAL_URL}/token`,
userinfoUrl: `${INTERNAL_URL}/userinfo`,
scopes: 'openid email profile',
priority: 50,
})
log(`provider '${PROVIDER_ID}' upserted (authorize=${PUBLIC_URL}/authorize, token/userinfo=${INTERNAL_URL})`)
for (const p of PRINCIPALS) {
const existing = await userIdentities.findByProviderSubject(PROVIDER_ID, p.sub)
if (existing) {
log(`identity ${PROVIDER_ID}:${p.sub} already linked to user ${existing.user_id} — skip`)
continue
}
await userIdentities.link({ userId: p.userId, provider: PROVIDER_ID, subject: p.sub, email: p.email })
log(`linked ${PROVIDER_ID}:${p.sub} → user ${p.userId}`)
}
log('done.')
}
main()
.catch((err) => {
// eslint-disable-next-line no-console
console.error('[seed-sso] FAILED', err)
process.exitCode = 1
})
.finally(async () => {
try {
await close()
} catch {
/* ignore shutdown errors */
}
})

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
})

175
scripts/dev/stub-idp.js Normal file
View File

@@ -0,0 +1,175 @@
#!/usr/bin/env node
/*
* Stub OAuth2 / OIDC IdP — DEV ONLY.
*
* Dev environments have no real OAuth provider configured, so `GET /auth/providers`
* returns `[]` and the native mobile SSO flow can never be exercised. This tiny,
* dependency-free IdP stands in for Google/Discord/a custom OIDC so the full bridge
* (app → website `/auth/mobile/sso/start` → IdP → callback → `/exchange`) can be
* driven end-to-end against the local site. It mirrors the throwaway-stub precedent
* in `servuo-plugins/tools/stub_sidecar.ps1`.
*
* It implements the three endpoints `oauth2.provider.js` calls:
* GET /authorize → account picker, then 302 to redirect_uri?code&state
* POST /token → { access_token, token_type, expires_in }
* GET /userinfo → { sub, email, name } (Bearer <access_token>)
*
* Pair it with `seed-sso-provider.js`, which registers a matching `auth_providers`
* row and pre-links each principal's `sub` to a dev account (SSO is link-only).
*
* Run: node website/scripts/dev/stub-idp.js
* Env: STUB_IDP_PORT (default 9099), STUB_IDP_HOST (default 127.0.0.1)
*
* NEVER deploy this. It performs no credential checks and signs in anyone.
*/
'use strict'
const http = require('http')
const crypto = require('crypto')
const { URL, URLSearchParams } = require('url')
const PORT = Number(process.env.STUB_IDP_PORT || 9099)
const HOST = process.env.STUB_IDP_HOST || '127.0.0.1'
// Test principals the picker offers. Each `sub` must be pre-linked to a real dev
// account by seed-sso-provider.js, or the link-only login will reject it. Keep
// this list in sync with that script's PRINCIPALS.
const PRINCIPALS = [
{ sub: 'stub-admin', email: 'admin@dev.local', name: 'Dev Admin' },
{ sub: 'stub-colby', email: 'colby@dev.local', name: 'Dev Colby' },
]
// Short-lived in-memory maps: auth code → principal, access token → principal.
// Codes are single-use; both are cleared on process exit (dev only).
const codes = new Map()
const tokens = new Map()
function log(...args) {
// eslint-disable-next-line no-console
console.log(`[stub-idp ${new Date().toISOString()}]`, ...args)
}
function pickerPage(query) {
const rows = PRINCIPALS.map((p) => {
const q = new URLSearchParams(query)
q.set('login_as', p.sub)
return `<li><a href="/authorize?${q.toString()}">${p.name} &lt;${p.email}&gt; <code>${p.sub}</code></a></li>`
}).join('\n')
return `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Stub IdP</title>
<style>body{font:16px system-ui;margin:3rem auto;max-width:34rem}a{display:block;padding:.6rem;border:1px solid #ccc;border-radius:8px;margin:.4rem 0;text-decoration:none;color:#123}code{color:#888}</style>
<h1>Stub IdP — choose a test account</h1>
<p>DEV ONLY. Signs you in as the selected pre-linked identity.</p>
<ul style="list-style:none;padding:0">${rows}</ul>`
}
function sendJson(res, status, obj) {
const body = JSON.stringify(obj)
res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) })
res.end(body)
}
function handleAuthorize(req, res, url) {
const params = url.searchParams
const redirectUri = params.get('redirect_uri')
const state = params.get('state') || ''
const loginAs = params.get('login_as')
if (!redirectUri) {
res.writeHead(400, { 'Content-Type': 'text/plain' })
return res.end('missing redirect_uri')
}
// No account chosen yet → show the picker (preserving the OAuth query params).
if (!loginAs) {
const html = pickerPage(params)
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
return res.end(html)
}
const principal = PRINCIPALS.find((p) => p.sub === loginAs)
if (!principal) {
res.writeHead(400, { 'Content-Type': 'text/plain' })
return res.end(`unknown principal '${loginAs}'`)
}
// Mint a single-use authorization code bound to the principal and redirect back.
const code = crypto.randomBytes(24).toString('hex')
codes.set(code, principal)
const back = new URL(redirectUri)
back.searchParams.set('code', code)
if (state) back.searchParams.set('state', state)
log('authorize → issuing code for', principal.sub, '→', back.toString())
res.writeHead(302, { Location: back.toString() })
res.end()
}
function readBody(req) {
return new Promise((resolve) => {
let data = ''
req.on('data', (c) => {
data += c
})
req.on('end', () => resolve(data))
})
}
async function handleToken(req, res) {
const raw = await readBody(req)
const body = new URLSearchParams(raw)
const code = body.get('code')
const principal = code && codes.get(code)
if (!principal) {
log('token → invalid/expired code', code)
return sendJson(res, 400, { error: 'invalid_grant' })
}
codes.delete(code) // single-use
const accessToken = crypto.randomBytes(24).toString('hex')
tokens.set(accessToken, principal)
log('token → access token for', principal.sub)
return sendJson(res, 200, {
access_token: accessToken,
token_type: 'Bearer',
expires_in: 3600,
scope: body.get('scope') || 'openid email profile',
})
}
function handleUserinfo(req, res) {
const auth = req.headers.authorization || ''
const token = auth.startsWith('Bearer ') ? auth.slice(7) : null
const principal = token && tokens.get(token)
if (!principal) {
log('userinfo → missing/invalid bearer')
return sendJson(res, 401, { error: 'invalid_token' })
}
log('userinfo → returning profile for', principal.sub)
return sendJson(res, 200, { sub: principal.sub, email: principal.email, name: principal.name })
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`)
log(req.method, url.pathname)
try {
if (req.method === 'GET' && url.pathname === '/authorize') return handleAuthorize(req, res, url)
if (req.method === 'POST' && url.pathname === '/token') return await handleToken(req, res)
if (req.method === 'GET' && url.pathname === '/userinfo') return handleUserinfo(req, res)
if (req.method === 'GET' && url.pathname === '/') {
res.writeHead(200, { 'Content-Type': 'text/plain' })
return res.end('stub-idp OK. Endpoints: GET /authorize, POST /token, GET /userinfo')
}
res.writeHead(404, { 'Content-Type': 'text/plain' })
res.end('not found')
} catch (err) {
log('error', err)
sendJson(res, 500, { error: 'server_error' })
}
})
server.listen(PORT, HOST, () => {
log(`listening on http://${HOST}:${PORT}`)
log('authorize:', `http://${HOST}:${PORT}/authorize`)
log('token: ', `http://${HOST}:${PORT}/token`)
log('userinfo: ', `http://${HOST}:${PORT}/userinfo`)
log('principals:', PRINCIPALS.map((p) => p.sub).join(', '))
})