#!/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 ) * * 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 `
  • ${p.name} <${p.email}> ${p.sub}
  • ` }).join('\n') return `Stub IdP

    Stub IdP — choose a test account

    DEV ONLY. Signs you in as the selected pre-linked identity.

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