1
19import { createHash, createHmac, createPublicKey, createVerify, randomBytes } from "node:crypto";
20import type { KeyLike } from "node:crypto";
21
22export interface Jwk {
23 kty: string;
24 kid?: string;
25 use?: string;
26 alg?: string;
27 n?: string;
28 e?: string;
29 x5c?: string[];
30 [key: string]: unknown;
31}
32
33export interface JwtHeader {
34 alg: string;
35 kid?: string;
36 typ?: string;
37 [key: string]: unknown;
38}
39
40export type OidcRejectReason =
41 | "state_invalid"
42 | "state_replayed"
43 | "missing_code"
44 | "token_exchange_failed"
45 | "invalid_id_token"
46 | "unsupported_alg"
47 | "unknown_key"
48 | "invalid_signature"
49 | "issuer_mismatch"
50 | "audience_mismatch"
51 | "expired"
52 | "not_yet_valid"
53 | "nonce_mismatch"
54 | "missing_claims";
55
56export const base64urlEncode = (buf: Buffer): string => buf.toString("base64url");
57export const base64urlDecode = (s: string): Buffer => Buffer.from(s, "base64url");
58
59
60export const randomToken = (bytes = 32): string => base64urlEncode(randomBytes(bytes));
61
62
66export function pkcePair(): { verifier: string; challenge: string } {
67 const verifier = base64urlEncode(randomBytes(64));
68 const challenge = base64urlEncode(createHash("sha256").update(verifier).digest());
69 return { verifier, challenge };
70}
71
72
73export function decodeJwtUnverified(token: string): { header: JwtHeader; claims: Record<string, unknown> } | null {
74 const parts = token.split(".");
75 if (parts.length !== 3) return null;
76 try {
77 const header = JSON.parse(base64urlDecode(parts[0]).toString("utf8")) as JwtHeader;
78 const claims = JSON.parse(base64urlDecode(parts[1]).toString("utf8")) as Record<string, unknown>;
79 return { header, claims };
80 } catch {
81 return null;
82 }
83}
84
85
90export function verifyIdToken(
91 token: string,
92 jwks: { keys: Jwk[] },
93 expected: { issuer: string; clientId: string; nonce?: string },
94 now: Date = new Date()
95): { ok: true; claims: Record<string, unknown> } | { ok: false; reason: OidcRejectReason; detail?: string } {
96 const decoded = decodeJwtUnverified(token);
97 if (!decoded) return { ok: false, reason: "invalid_id_token" };
98 const { header, claims } = decoded;
99
100 if (header.alg !== "RS256") return { ok: false, reason: "unsupported_alg", detail: `alg=${header.alg}` };
101 if (!header.kid) return { ok: false, reason: "unknown_key", detail: "no kid in header" };
102 const jwk = jwks.keys.find((k) => k.kid === header.kid && k.kty === "RSA");
103 if (!jwk) return { ok: false, reason: "unknown_key", detail: `kid=${header.kid}` };
104
105 let key: KeyLike;
106 try {
107 key = createPublicKey({ key: jwk as unknown as { kty: "RSA"; n: string; e: string }, format: "jwk" });
108 } catch {
109 return { ok: false, reason: "unknown_key", detail: "JWK does not import" };
110 }
111
112 const signingInput = `${token.split(".")[0]}.${token.split(".")[1]}`;
113 let valid = false;
114 try {
115 const verifier = createVerify("RSA-SHA256");
116 verifier.update(signingInput);
117 valid = verifier.verify(key, base64urlDecode(token.split(".")[2]));
118 } catch {
119 valid = false;
120 }
121 if (!valid) return { ok: false, reason: "invalid_signature" };
122
123 if (claims.iss !== expected.issuer) return { ok: false, reason: "issuer_mismatch" };
124
125 const aud: unknown = claims.aud;
126 const audOk = Array.isArray(aud) ? aud.includes(expected.clientId) : aud === expected.clientId;
127 if (!audOk) return { ok: false, reason: "audience_mismatch" };
128
129 const nowSec = Math.floor(now.getTime() / 1000);
130 const exp = claims.exp;
131 if (typeof exp === "number" && exp < nowSec) return { ok: false, reason: "expired" };
132 const nbf = claims.nbf;
133 if (typeof nbf === "number" && nbf > nowSec + 60) return { ok: false, reason: "not_yet_valid" };
134
135 if (expected.nonce !== undefined && claims.nonce !== undefined && claims.nonce !== expected.nonce) {
136 return { ok: false, reason: "nonce_mismatch" };
137 }
138
139 return { ok: true, claims };
140}
141
142
146export function resolveClaim(claims: Record<string, unknown>, path: string): unknown {
147 let cur: unknown = claims;
148 for (const part of path.split(".")) {
149 if (cur === null || typeof cur !== "object" || Array.isArray(cur)) return undefined;
150 cur = (cur as Record<string, unknown>)[part];
151 }
152 return cur;
153}
154
155
156export const hmacSha256 = (key: string, data: string): string =>
157 createHmac("sha256", key).update(data).digest("hex");
158