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

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"><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(', '))
})