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:
175
scripts/dev/stub-idp.js
Normal file
175
scripts/dev/stub-idp.js
Normal 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} <${p.email}> <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(', '))
|
||||
})
|
||||
Reference in New Issue
Block a user