/** * TASK-0131 — OIDC RP helpers (authorization-code + PKCE), pure + unit * testable. * * Security model: * - `state` (CSRF) + `nonce` (replay) + `code_verifier` (PKCE) are * generated per flow and held in Redis (10 min TTL) keyed by `state`; * the callback validates ALL three before exchanging the code. * - The ID token is RS256-signed; we verify the signature against the * issuer's JWKS (fetched + cached in Redis, 1 h TTL), then the * `iss`/`aud`/`exp`/`nbf`/`iat`/`nonce` claims in that order — each * failure has a distinct reason (the OIDC acceptance matrix). * - `alg === "none"` and non-RS256 algorithms are rejected up front * (the classic alg-confusion attack surface). * * RS256 is verified with `node:crypto` (JWK → `createPublicKey({format:"jwk"})` * → `createVerify("RSA-SHA256")`) — no extra crypto dependency. */ import { createHash, createHmac, createPublicKey, createVerify, randomBytes } from "node:crypto"; import type { KeyLike } from "node:crypto"; export interface Jwk { kty: string; kid?: string; use?: string; alg?: string; n?: string; e?: string; x5c?: string[]; [key: string]: unknown; } export interface JwtHeader { alg: string; kid?: string; typ?: string; [key: string]: unknown; } export type OidcRejectReason = | "state_invalid" | "state_replayed" | "missing_code" | "token_exchange_failed" | "invalid_id_token" | "unsupported_alg" | "unknown_key" | "invalid_signature" | "issuer_mismatch" | "audience_mismatch" | "expired" | "not_yet_valid" | "nonce_mismatch" | "missing_claims"; export const base64urlEncode = (buf: Buffer): string => buf.toString("base64url"); export const base64urlDecode = (s: string): Buffer => Buffer.from(s, "base64url"); /** 32 random bytes → base64url (state, nonce). */ export const randomToken = (bytes = 32): string => base64urlEncode(randomBytes(bytes)); /** * PKCE `code_verifier` (43–128 unreserved chars; we use 64 bytes of * base64url = 86 chars) + its S256 `code_challenge`. */ export function pkcePair(): { verifier: string; challenge: string } { const verifier = base64urlEncode(randomBytes(64)); const challenge = base64urlEncode(createHash("sha256").update(verifier).digest()); return { verifier, challenge }; } /** HS256/HMAC is NEVER used for ID tokens, but a test helper: none. */ export function decodeJwtUnverified(token: string): { header: JwtHeader; claims: Record } | null { const parts = token.split("."); if (parts.length !== 3) return null; try { const header = JSON.parse(base64urlDecode(parts[0]).toString("utf8")) as JwtHeader; const claims = JSON.parse(base64urlDecode(parts[1]).toString("utf8")) as Record; return { header, claims }; } catch { return null; } } /** * Verify an RS256-signed JWT against a JWKS. Returns `{ ok, reason, claims }`. * Order: structure → alg → kid lookup → signature → iss → aud → time claims. * `now` is injectable for tests. */ export function verifyIdToken( token: string, jwks: { keys: Jwk[] }, expected: { issuer: string; clientId: string; nonce?: string }, now: Date = new Date() ): { ok: true; claims: Record } | { ok: false; reason: OidcRejectReason; detail?: string } { const decoded = decodeJwtUnverified(token); if (!decoded) return { ok: false, reason: "invalid_id_token" }; const { header, claims } = decoded; if (header.alg !== "RS256") return { ok: false, reason: "unsupported_alg", detail: `alg=${header.alg}` }; if (!header.kid) return { ok: false, reason: "unknown_key", detail: "no kid in header" }; const jwk = jwks.keys.find((k) => k.kid === header.kid && k.kty === "RSA"); if (!jwk) return { ok: false, reason: "unknown_key", detail: `kid=${header.kid}` }; let key: KeyLike; try { key = createPublicKey({ key: jwk as unknown as { kty: "RSA"; n: string; e: string }, format: "jwk" }); } catch { return { ok: false, reason: "unknown_key", detail: "JWK does not import" }; } const signingInput = `${token.split(".")[0]}.${token.split(".")[1]}`; let valid = false; try { const verifier = createVerify("RSA-SHA256"); verifier.update(signingInput); valid = verifier.verify(key, base64urlDecode(token.split(".")[2])); } catch { valid = false; } if (!valid) return { ok: false, reason: "invalid_signature" }; if (claims.iss !== expected.issuer) return { ok: false, reason: "issuer_mismatch" }; // `aud` may be a string or an array. const aud: unknown = claims.aud; const audOk = Array.isArray(aud) ? aud.includes(expected.clientId) : aud === expected.clientId; if (!audOk) return { ok: false, reason: "audience_mismatch" }; const nowSec = Math.floor(now.getTime() / 1000); const exp = claims.exp; if (typeof exp === "number" && exp < nowSec) return { ok: false, reason: "expired" }; const nbf = claims.nbf; if (typeof nbf === "number" && nbf > nowSec + 60) return { ok: false, reason: "not_yet_valid" }; if (expected.nonce !== undefined && claims.nonce !== undefined && claims.nonce !== expected.nonce) { return { ok: false, reason: "nonce_mismatch" }; } return { ok: true, claims }; } /** * Resolve a dotted claim path (`profile.email`) in an ID-token claim map. * Returns undefined when any hop is missing. */ export function resolveClaim(claims: Record, path: string): unknown { let cur: unknown = claims; for (const part of path.split(".")) { if (cur === null || typeof cur !== "object" || Array.isArray(cur)) return undefined; cur = (cur as Record)[part]; } return cur; } /** HMAC-SHA256 (kept for the SCIM bearer-token hash reuse, TASK-0132). */ export const hmacSha256 = (key: string, data: string): string => createHmac("sha256", key).update(data).digest("hex");