diff --git a/scripts/dev/README.md b/scripts/dev/README.md new file mode 100644 index 0000000..a31b461 --- /dev/null +++ b/scripts/dev/README.md @@ -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`. diff --git a/scripts/dev/seed-sso-provider.js b/scripts/dev/seed-sso-provider.js new file mode 100644 index 0000000..031937b --- /dev/null +++ b/scripts/dev/seed-sso-provider.js @@ -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 */ + } + }) diff --git a/scripts/dev/sso-bridge-smoketest.js b/scripts/dev/sso-bridge-smoketest.js new file mode 100644 index 0000000..8f8bec1 --- /dev/null +++ b/scripts/dev/sso-bridge-smoketest.js @@ -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 +}) diff --git a/scripts/dev/stub-idp.js b/scripts/dev/stub-idp.js new file mode 100644 index 0000000..b701a3d --- /dev/null +++ b/scripts/dev/stub-idp.js @@ -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 ) + * + * 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(', ')) +})