Merge pull request 'chore(dev): stub OAuth IdP tooling for local mobile SSO testing' (#91) from feat/m10-native-sso-fix into main
Reviewed-on: #91 Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
This commit is contained in:
58
scripts/dev/README.md
Normal file
58
scripts/dev/README.md
Normal 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`.
|
||||||
105
scripts/dev/seed-sso-provider.js
Normal file
105
scripts/dev/seed-sso-provider.js
Normal 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 */
|
||||||
|
}
|
||||||
|
})
|
||||||
109
scripts/dev/sso-bridge-smoketest.js
Normal file
109
scripts/dev/sso-bridge-smoketest.js
Normal 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
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"><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(', '))
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user