1
9import { beforeAll, describe, expect, it } from "vitest";
10import { createHash, createSign, generateKeyPairSync, type KeyObject } from "node:crypto";
11import {
12 base64urlEncode,
13 decodeJwtUnverified,
14 pkcePair,
15 randomToken,
16 resolveClaim,
17 verifyIdToken,
18 type Jwk
19} from "../oidc.js";
20
21const ISSUER = "https://idp.acme.test";
22const CLIENT_ID = "factory-client";
23const KID = "unit-key-1";
24
25let jwk: Jwk;
26let signKey: KeyObject;
27
28beforeAll(() => {
29 const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
30 signKey = privateKey;
31 const j = (publicKey as KeyObject).export({ format: "jwk" }) as { n: string; e: string };
32 jwk = { kty: "RSA", n: j.n, e: j.e, kid: KID, alg: "RS256", use: "sig" };
33});
34
35const now = new Date();
36const sec = (offset: number) => Math.floor(now.getTime() / 1000) + offset;
37
38function sign(claims: Record<string, unknown>, header: Record<string, unknown> = { alg: "RS256", typ: "JWT", kid: KID }): string {
39 const h = base64urlEncode(Buffer.from(JSON.stringify(header)));
40 const p = base64urlEncode(Buffer.from(JSON.stringify(claims)));
41 const sig = createSign("RSA-SHA256")
42 .update(`${h}.${p}`)
43 .sign(signKey, "base64");
44 return `${h}.${p}.${sig}`;
45}
46
47const baseClaims = (over: Record<string, unknown> = {}): Record<string, unknown> => ({
48 iss: ISSUER,
49 aud: CLIENT_ID,
50 sub: "user-42",
51 email: "alice@acme.test",
52 email_verified: true,
53 name: "Alice",
54 nonce: "n-1",
55 iat: sec(-10),
56 nbf: sec(-10),
57 exp: sec(3600),
58 ...over
59});
60
61describe("TASK-0131 OIDC verifyIdToken matrix", () => {
62 it("accepts a well-formed RS256 ID token (happy path)", () => {
63 const token = sign(baseClaims());
64 const res = verifyIdToken(token, { keys: [jwk] }, { issuer: ISSUER, clientId: CLIENT_ID, nonce: "n-1" }, now);
65 expect(res.ok).toBe(true);
66 if (res.ok) {
67 expect(res.claims.sub).toBe("user-42");
68 expect(res.claims.email).toBe("alice@acme.test");
69 }
70 });
71
72 it("accepts `aud` as an array containing the client id", () => {
73 const token = sign(baseClaims({ aud: [CLIENT_ID, "other-client"] }));
74 expect(verifyIdToken(token, { keys: [jwk] }, { issuer: ISSUER, clientId: CLIENT_ID, nonce: "n-1" }, now).ok).toBe(true);
75 });
76
77 it("accepts a token without a nonce when none is expected (nonce undefined)", () => {
78 const claims = baseClaims();
79 delete claims.nonce;
80 const token = sign(claims);
81 expect(verifyIdToken(token, { keys: [jwk] }, { issuer: ISSUER, clientId: CLIENT_ID }, now).ok).toBe(true);
82 });
83
84 it("rejects an unparseable token (invalid_id_token)", () => {
85 expect(verifyIdToken("not-a-jwt", { keys: [jwk] }, { issuer: ISSUER, clientId: CLIENT_ID }, now)).toMatchObject({
86 ok: false,
87 reason: "invalid_id_token"
88 });
89 });
90
91 it("rejects a non-RS256 alg (unsupported_alg)", () => {
92 const token = sign(baseClaims(), { alg: "HS256", typ: "JWT", kid: KID });
93 expect(verifyIdToken(token, { keys: [jwk] }, { issuer: ISSUER, clientId: CLIENT_ID }, now)).toMatchObject({
94 ok: false,
95 reason: "unsupported_alg"
96 });
97 });
98
99 it("rejects an unknown kid (unknown_key)", () => {
100 const token = sign(baseClaims(), { alg: "RS256", typ: "JWT", kid: "missing-key" });
101 expect(verifyIdToken(token, { keys: [jwk] }, { issuer: ISSUER, clientId: CLIENT_ID }, now)).toMatchObject({
102 ok: false,
103 reason: "unknown_key"
104 });
105 });
106
107 it("rejects a token with no kid (unknown_key)", () => {
108 const token = sign(baseClaims(), { alg: "RS256", typ: "JWT" });
109 expect(verifyIdToken(token, { keys: [jwk] }, { issuer: ISSUER, clientId: CLIENT_ID }, now)).toMatchObject({
110 ok: false,
111 reason: "unknown_key"
112 });
113 });
114
115 it("rejects a tampered signature (invalid_signature)", () => {
116 const good = sign(baseClaims());
117 const [h, p, s] = good.split(".");
118
119 const flipped = s.slice(0, -2) + (s.endsWith("ab") ? "cd" : "ab");
120 expect(verifyIdToken(`${h}.${p}.${flipped}`, { keys: [jwk] }, { issuer: ISSUER, clientId: CLIENT_ID }, now)).toMatchObject({
121 ok: false,
122 reason: "invalid_signature"
123 });
124 });
125
126 it("rejects a wrong issuer (issuer_mismatch)", () => {
127 const token = sign(baseClaims({ iss: "https://evil.test" }));
128 expect(verifyIdToken(token, { keys: [jwk] }, { issuer: ISSUER, clientId: CLIENT_ID }, now)).toMatchObject({
129 ok: false,
130 reason: "issuer_mismatch"
131 });
132 });
133
134 it("rejects a wrong audience (audience_mismatch)", () => {
135 const token = sign(baseClaims({ aud: "other-client" }));
136 expect(verifyIdToken(token, { keys: [jwk] }, { issuer: ISSUER, clientId: CLIENT_ID }, now)).toMatchObject({
137 ok: false,
138 reason: "audience_mismatch"
139 });
140 });
141
142 it("rejects an expired token (expired)", () => {
143 const token = sign(baseClaims({ exp: sec(-10) }));
144 expect(verifyIdToken(token, { keys: [jwk] }, { issuer: ISSUER, clientId: CLIENT_ID }, now)).toMatchObject({
145 ok: false,
146 reason: "expired"
147 });
148 });
149
150 it("rejects a not-yet-valid token beyond the 60s skew (not_yet_valid)", () => {
151 const token = sign(baseClaims({ nbf: sec(120) }));
152 expect(verifyIdToken(token, { keys: [jwk] }, { issuer: ISSUER, clientId: CLIENT_ID }, now)).toMatchObject({
153 ok: false,
154 reason: "not_yet_valid"
155 });
156 });
157
158 it("accepts a token whose nbf is within the 60s skew", () => {
159 const token = sign(baseClaims({ nbf: sec(30) }));
160 expect(verifyIdToken(token, { keys: [jwk] }, { issuer: ISSUER, clientId: CLIENT_ID }, now).ok).toBe(true);
161 });
162
163 it("rejects a mismatched nonce (nonce_mismatch)", () => {
164 const token = sign(baseClaims({ nonce: "wrong" }));
165 expect(verifyIdToken(token, { keys: [jwk] }, { issuer: ISSUER, clientId: CLIENT_ID, nonce: "n-1" }, now)).toMatchObject({
166 ok: false,
167 reason: "nonce_mismatch"
168 });
169 });
170});
171
172describe("TASK-0131 OIDC helpers", () => {
173 it("decodeJwtUnverified returns header + claims (and null on garbage)", () => {
174 const token = sign(baseClaims({ email: "x@y.test" }));
175 const d = decodeJwtUnverified(token);
176 expect(d?.header.alg).toBe("RS256");
177 expect(d?.claims.email).toBe("x@y.test");
178 expect(decodeJwtUnverified("garbage")).toBeNull();
179 });
180
181 it("pkcePair produces a verifier whose S256 challenge is deterministic", () => {
182 const a = pkcePair();
183 const b = pkcePair();
184 expect(a.verifier).not.toBe(b.verifier);
185 expect(a.verifier.length).toBe(86);
186 expect(a.challenge.length).toBe(43);
187
188 const expected = createHash("sha256").update(a.verifier).digest("base64url");
189 expect(a.challenge).toBe(expected);
190 });
191
192 it("randomToken produces the requested byte length (base64url)", () => {
193 expect(randomToken(16).length).toBe(22);
194 expect(randomToken(32)).not.toBe(randomToken(32));
195 });
196
197 it("resolveClaim walks dotted paths (undefined on a missing hop)", () => {
198 const claims = { profile: { email: "a@b.test" }, flat: "v", list: [1, 2] };
199 expect(resolveClaim(claims, "profile.email")).toBe("a@b.test");
200 expect(resolveClaim(claims, "flat")).toBe("v");
201 expect(resolveClaim(claims, "profile.missing")).toBeUndefined();
202 expect(resolveClaim(claims, "list.deep")).toBeUndefined();
203 expect(resolveClaim(claims, "nope.deep")).toBeUndefined();
204 });
205});
206