/** * TASK-0131 — OIDC ID-token verification matrix (unit). * * A per-run RSA keypair is exported as a JWK (node:crypto, no `jose`) and * signs real RS256 JWTs with `node:crypto` — the exact path `verifyIdToken` * takes (JWK import + RSA-SHA256 verify). The matrix asserts the happy path * plus every rejection reason with the DISTINCT enum value. */ import { beforeAll, describe, expect, it } from "vitest"; import { createHash, createSign, generateKeyPairSync, type KeyObject } from "node:crypto"; import { base64urlEncode, decodeJwtUnverified, pkcePair, randomToken, resolveClaim, verifyIdToken, type Jwk } from "../oidc.js"; const ISSUER = "https://idp.acme.test"; const CLIENT_ID = "factory-client"; const KID = "unit-key-1"; let jwk: Jwk; let signKey: KeyObject; beforeAll(() => { const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); signKey = privateKey; const j = (publicKey as KeyObject).export({ format: "jwk" }) as { n: string; e: string }; jwk = { kty: "RSA", n: j.n, e: j.e, kid: KID, alg: "RS256", use: "sig" }; }); const now = new Date(); const sec = (offset: number) => Math.floor(now.getTime() / 1000) + offset; function sign(claims: Record, header: Record = { alg: "RS256", typ: "JWT", kid: KID }): string { const h = base64urlEncode(Buffer.from(JSON.stringify(header))); const p = base64urlEncode(Buffer.from(JSON.stringify(claims))); const sig = createSign("RSA-SHA256") .update(`${h}.${p}`) .sign(signKey, "base64"); return `${h}.${p}.${sig}`; } const baseClaims = (over: Record = {}): Record => ({ iss: ISSUER, aud: CLIENT_ID, sub: "user-42", email: "alice@acme.test", email_verified: true, name: "Alice", nonce: "n-1", iat: sec(-10), nbf: sec(-10), exp: sec(3600), ...over }); describe("TASK-0131 OIDC verifyIdToken matrix", () => { it("accepts a well-formed RS256 ID token (happy path)", () => { const token = sign(baseClaims()); const res = verifyIdToken(token, { keys: [jwk] }, { issuer: ISSUER, clientId: CLIENT_ID, nonce: "n-1" }, now); expect(res.ok).toBe(true); if (res.ok) { expect(res.claims.sub).toBe("user-42"); expect(res.claims.email).toBe("alice@acme.test"); } }); it("accepts `aud` as an array containing the client id", () => { const token = sign(baseClaims({ aud: [CLIENT_ID, "other-client"] })); expect(verifyIdToken(token, { keys: [jwk] }, { issuer: ISSUER, clientId: CLIENT_ID, nonce: "n-1" }, now).ok).toBe(true); }); it("accepts a token without a nonce when none is expected (nonce undefined)", () => { const claims = baseClaims(); delete claims.nonce; const token = sign(claims); expect(verifyIdToken(token, { keys: [jwk] }, { issuer: ISSUER, clientId: CLIENT_ID }, now).ok).toBe(true); }); it("rejects an unparseable token (invalid_id_token)", () => { expect(verifyIdToken("not-a-jwt", { keys: [jwk] }, { issuer: ISSUER, clientId: CLIENT_ID }, now)).toMatchObject({ ok: false, reason: "invalid_id_token" }); }); it("rejects a non-RS256 alg (unsupported_alg)", () => { const token = sign(baseClaims(), { alg: "HS256", typ: "JWT", kid: KID }); expect(verifyIdToken(token, { keys: [jwk] }, { issuer: ISSUER, clientId: CLIENT_ID }, now)).toMatchObject({ ok: false, reason: "unsupported_alg" }); }); it("rejects an unknown kid (unknown_key)", () => { const token = sign(baseClaims(), { alg: "RS256", typ: "JWT", kid: "missing-key" }); expect(verifyIdToken(token, { keys: [jwk] }, { issuer: ISSUER, clientId: CLIENT_ID }, now)).toMatchObject({ ok: false, reason: "unknown_key" }); }); it("rejects a token with no kid (unknown_key)", () => { const token = sign(baseClaims(), { alg: "RS256", typ: "JWT" }); expect(verifyIdToken(token, { keys: [jwk] }, { issuer: ISSUER, clientId: CLIENT_ID }, now)).toMatchObject({ ok: false, reason: "unknown_key" }); }); it("rejects a tampered signature (invalid_signature)", () => { const good = sign(baseClaims()); const [h, p, s] = good.split("."); // Flip a char in the signature. const flipped = s.slice(0, -2) + (s.endsWith("ab") ? "cd" : "ab"); expect(verifyIdToken(`${h}.${p}.${flipped}`, { keys: [jwk] }, { issuer: ISSUER, clientId: CLIENT_ID }, now)).toMatchObject({ ok: false, reason: "invalid_signature" }); }); it("rejects a wrong issuer (issuer_mismatch)", () => { const token = sign(baseClaims({ iss: "https://evil.test" })); expect(verifyIdToken(token, { keys: [jwk] }, { issuer: ISSUER, clientId: CLIENT_ID }, now)).toMatchObject({ ok: false, reason: "issuer_mismatch" }); }); it("rejects a wrong audience (audience_mismatch)", () => { const token = sign(baseClaims({ aud: "other-client" })); expect(verifyIdToken(token, { keys: [jwk] }, { issuer: ISSUER, clientId: CLIENT_ID }, now)).toMatchObject({ ok: false, reason: "audience_mismatch" }); }); it("rejects an expired token (expired)", () => { const token = sign(baseClaims({ exp: sec(-10) })); expect(verifyIdToken(token, { keys: [jwk] }, { issuer: ISSUER, clientId: CLIENT_ID }, now)).toMatchObject({ ok: false, reason: "expired" }); }); it("rejects a not-yet-valid token beyond the 60s skew (not_yet_valid)", () => { const token = sign(baseClaims({ nbf: sec(120) })); expect(verifyIdToken(token, { keys: [jwk] }, { issuer: ISSUER, clientId: CLIENT_ID }, now)).toMatchObject({ ok: false, reason: "not_yet_valid" }); }); it("accepts a token whose nbf is within the 60s skew", () => { const token = sign(baseClaims({ nbf: sec(30) })); expect(verifyIdToken(token, { keys: [jwk] }, { issuer: ISSUER, clientId: CLIENT_ID }, now).ok).toBe(true); }); it("rejects a mismatched nonce (nonce_mismatch)", () => { const token = sign(baseClaims({ nonce: "wrong" })); expect(verifyIdToken(token, { keys: [jwk] }, { issuer: ISSUER, clientId: CLIENT_ID, nonce: "n-1" }, now)).toMatchObject({ ok: false, reason: "nonce_mismatch" }); }); }); describe("TASK-0131 OIDC helpers", () => { it("decodeJwtUnverified returns header + claims (and null on garbage)", () => { const token = sign(baseClaims({ email: "x@y.test" })); const d = decodeJwtUnverified(token); expect(d?.header.alg).toBe("RS256"); expect(d?.claims.email).toBe("x@y.test"); expect(decodeJwtUnverified("garbage")).toBeNull(); }); it("pkcePair produces a verifier whose S256 challenge is deterministic", () => { const a = pkcePair(); const b = pkcePair(); expect(a.verifier).not.toBe(b.verifier); // random per call expect(a.verifier.length).toBe(86); // 64 bytes base64url expect(a.challenge.length).toBe(43); // sha256 → 32 bytes base64url // Same verifier → same challenge (SHA-256 is deterministic). const expected = createHash("sha256").update(a.verifier).digest("base64url"); expect(a.challenge).toBe(expected); }); it("randomToken produces the requested byte length (base64url)", () => { expect(randomToken(16).length).toBe(22); // 16 bytes → 22 base64url chars expect(randomToken(32)).not.toBe(randomToken(32)); }); it("resolveClaim walks dotted paths (undefined on a missing hop)", () => { const claims = { profile: { email: "a@b.test" }, flat: "v", list: [1, 2] }; expect(resolveClaim(claims, "profile.email")).toBe("a@b.test"); expect(resolveClaim(claims, "flat")).toBe("v"); expect(resolveClaim(claims, "profile.missing")).toBeUndefined(); expect(resolveClaim(claims, "list.deep")).toBeUndefined(); // array hop expect(resolveClaim(claims, "nope.deep")).toBeUndefined(); }); });