/** * TASK-0131 — mock OIDC (OpenID Connect) issuer for the E2E round trip. * * A per-run RS256 keypair (node:crypto, 2048-bit) with: * GET /authorize — auto-approve: 302 back to * `redirect_uri?code=&state=`, storing the authorize * params (nonce, code_challenge, redirect_uri, client_id) keyed by * the code for the token exchange. * POST /token — validates grant_type/code/ * redirect_uri/client_id/client_secret + PKCE (S256(verifier) must * equal the stored code_challenge) and returns an RS256-signed * `id_token` (+ a throwaway `access_token` the SP discards). * GET /.well-known/openid-configuration — discovery (the admin * test-connection probe + the SP's jwksUri fallback). * GET /.well-known/jwks.json — the public JWKS (RS256). * * The SP verifies: signature (JWKS kid → RSA), `iss` = configured issuer, * `aud` = client_id, `exp`/`nbf` (+60s skew), `nonce` = the authorize nonce. * Tests mutate `behavior` to tamper (wrong aud / expired / alg=none / * wrong secret / broken PKCE) and assert the SP rejects with the right * `sso_oidc_*` reason. */ import { createHash, createSign, generateKeyPairSync, randomBytes, type KeyObject } from "node:crypto"; import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import type { AddressInfo } from "node:net"; export interface OidcBehavior { /** The `sub` claim (the SSO external identity id). */ sub: string; /** The `email` claim (the provisioning email). */ email: string; /** The `name` claim. */ name?: string; /** The ID-token lifetime in seconds (default 900). */ expiresInSec?: number; /** Override the `aud` claim (tamper: audience_mismatch). */ audience?: string; /** Omit / change the `nonce` claim (tamper: nonce_mismatch). */ nonce?: string | null; /** Issue the token with `alg:"none"` (tamper: unsupported_alg). */ algNone?: boolean; /** Require the exact client secret on /token (default true; false = accept * anything — NOT how a real IdP behaves, only for probe tampering). */ requireSecret?: boolean; /** Require a valid PKCE verifier (default true). */ requirePkce?: boolean; /** Reject the token exchange outright (400) (tamper: token_exchange_failed). */ failExchange?: boolean; /** Extra claims to merge into the ID token (nested paths for mapping tests). */ extraClaims?: Record; } interface StoredAuth { code: string; nonce: string; codeChallenge: string; redirectUri: string; clientId: string; scope: string; consumed: boolean; } export interface MockOidc { /** The issuer (== the mock's base URL — the SP `issuer` config must equal it). */ issuer: string; url: string; clientId: string; clientSecret: string; authorizationEndpoint: string; tokenEndpoint: string; jwksUri: string; /** The public JWKS (kid + RSA n/e). */ jwks(): { keys: Array> }; /** The mutable behavior for the next token exchange. */ behavior: OidcBehavior; /** How many authorize requests were handled. */ authorizeCount: () => number; /** How many token exchanges succeeded. */ tokenCount: () => number; close(): Promise; } const b64url = (buf: Buffer | string) => Buffer.from(buf).toString("base64url"); function signJwt( header: Record, claims: Record, privateKey: KeyObject ): string { const head = b64url(JSON.stringify(header)); const payload = b64url(JSON.stringify(claims)); const signingInput = `${head}.${payload}`; const signer = createSign("RSA-SHA256"); signer.update(signingInput); const sig = signer.sign(privateKey); return `${signingInput}.${b64url(sig)}`; } export async function startMockOidc(opts: { clientId: string; clientSecret: string; initialBehavior?: OidcBehavior; }): Promise { const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); const jwk = publicKey.export({ format: "jwk" }) as { n: string; e: string }; const kid = `e2e-${randomBytes(4).toString("hex")}`; const authorizes = new Map(); let authorizeN = 0; let tokenN = 0; const behavior: OidcBehavior = { sub: "sub-1", email: "x@y.z", ...opts.initialBehavior }; const server = createServer(async (req: IncomingMessage, res: ServerResponse) => { const url = new URL(req.url ?? "/", "http://localhost"); // ---- discovery ------------------------------------------------------- if (req.method === "GET" && url.pathname === "/.well-known/openid-configuration") { res.writeHead(200, { "content-type": "application/json" }); res.end( JSON.stringify({ issuer: `${serverUrl}`, authorization_endpoint: `${serverUrl}/authorize`, token_endpoint: `${serverUrl}/token`, jwks_uri: `${serverUrl}/.well-known/jwks.json`, response_types_supported: ["code"], subject_types_supported: ["public"], id_token_signing_alg_values_supported: ["RS256"], scopes_supported: ["openid", "email", "profile"] }) ); return; } // ---- JWKS ------------------------------------------------------------ if (req.method === "GET" && url.pathname === "/.well-known/jwks.json") { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify({ keys: [{ kty: "RSA", n: jwk.n, e: jwk.e, kid, alg: "RS256", use: "sig" }] })); return; } // ---- authorize (auto-approve) ---------------------------------------- if (req.method === "GET" && url.pathname === "/authorize") { authorizeN += 1; const redirectUri = url.searchParams.get("redirect_uri") ?? ""; const state = url.searchParams.get("state") ?? ""; const code = `oc_${randomBytes(6).toString("hex")}`; authorizes.set(code, { code, nonce: url.searchParams.get("nonce") ?? "", codeChallenge: url.searchParams.get("code_challenge") ?? "", redirectUri, clientId: url.searchParams.get("client_id") ?? "", scope: url.searchParams.get("scope") ?? "", consumed: false }); if (!redirectUri || !state) { res.writeHead(400, { "content-type": "text/plain" }); res.end("missing redirect_uri or state"); return; } const target = new URL(redirectUri); target.searchParams.set("code", code); target.searchParams.set("state", state); res.writeHead(302, { location: target.toString() }); res.end(); return; } // ---- token exchange ---------------------------------------------------- if (req.method === "POST" && url.pathname === "/token") { let body = ""; req.on("data", (c) => (body += c)); req.on("end", () => { const form = new URLSearchParams(body); const code = form.get("code") ?? ""; const auth = authorizes.get(code); const ok = form.get("grant_type") === "authorization_code" && auth && !auth.consumed && form.get("redirect_uri") === auth.redirectUri && form.get("client_id") === auth.clientId && (behavior.requireSecret === false || form.get("client_secret") === opts.clientSecret) && (behavior.requirePkce === false || b64url(createHash("sha256").update(form.get("code_verifier") ?? "").digest()) === auth.codeChallenge); if (behavior.failExchange || !ok) { res.writeHead(400, { "content-type": "application/json" }); res.end(JSON.stringify({ error: "invalid_grant", error_description: "mock: exchange rejected" })); return; } auth.consumed = true; tokenN += 1; const now = Math.floor(Date.now() / 1000); const claims: Record = { iss: serverUrl, sub: behavior.sub, aud: behavior.audience ?? opts.clientId, exp: now + (behavior.expiresInSec ?? 900), iat: now, nbf: now - 5, nonce: behavior.nonce === undefined ? auth.nonce : behavior.nonce ?? null, email: behavior.email, ...(behavior.name ? { name: behavior.name } : {}) }; if (behavior.extraClaims) Object.assign(claims, behavior.extraClaims); const header: Record = behavior.algNone ? { alg: "none", typ: "JWT" } : { alg: "RS256", typ: "JWT", kid }; const idToken = behavior.algNone ? `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(claims))}.` : signJwt(header, claims, privateKey); res.writeHead(200, { "content-type": "application/json" }); res.end( JSON.stringify({ access_token: `at_${randomBytes(8).toString("hex")}`, id_token: idToken, token_type: "Bearer", expires_in: 900, scope: auth.scope }) ); }); return; } res.writeHead(404, { "content-type": "text/plain" }); res.end("not found"); }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); const { port } = server.address() as AddressInfo; const serverUrl = `http://127.0.0.1:${port}`; return { issuer: serverUrl, url: serverUrl, clientId: opts.clientId, clientSecret: opts.clientSecret, authorizationEndpoint: `${serverUrl}/authorize`, tokenEndpoint: `${serverUrl}/token`, jwksUri: `${serverUrl}/.well-known/jwks.json`, jwks: () => ({ keys: [{ kty: "RSA", n: jwk.n, e: jwk.e, kid, alg: "RS256", use: "sig" }] }), behavior, authorizeCount: () => authorizeN, tokenCount: () => tokenN, close: () => new Promise((resolve, reject) => server.close((e) => (e ? reject(e) : resolve()))) }; }