import { decodeJwt, type JwtClaims } from "./index.ts"; import { JwtError, type VerifyOptions } from "./index.ts"; export interface RemoteJwksOptions { fetch?: typeof fetch; cacheTtlMs?: number; maxKeys?: number; maxBytes?: number; now?: () => number; } export interface RemoteJwks { resolve(kid: string, alg: string): Promise; refresh(): Promise; clear(): void; stats(): { fetches: number; hits: number; keys: number; expiresAt: number }; } type JwksKey = JsonWebKey & { kid?: string; alg?: string; use?: string }; type StoredKey = { jwk: JwksKey; key?: Promise }; function decodeBase64Url(value: string): Uint8Array { const padding = value.length % 4 === 0 ? "" : "=".repeat(4 - (value.length % 4)); const binary = atob(value.replace(/-/g, "+").replace(/_/g, "/") + padding); return Uint8Array.from(binary, (character) => character.charCodeAt(0)); } export function createRemoteJwks(url: string, options: RemoteJwksOptions = {}): RemoteJwks { const endpoint = new URL(url); if (endpoint.protocol !== "https:") throw new TypeError("JWKS URL must use HTTPS"); const fetchImpl = options.fetch ?? fetch; const cacheTtlMs = options.cacheTtlMs ?? 5 * 60_000; const maxKeys = options.maxKeys ?? 32; const maxBytes = options.maxBytes ?? 256 * 1024; if (!Number.isFinite(cacheTtlMs) || cacheTtlMs < 0) throw new RangeError("JWKS cacheTtlMs must be non-negative"); if (!Number.isInteger(maxKeys) || maxKeys < 1) throw new RangeError("JWKS maxKeys must be positive"); if (!Number.isInteger(maxBytes) || maxBytes < 1) throw new RangeError("JWKS maxBytes must be positive"); const now = options.now ?? Date.now; const keys = new Map(); let expiresAt = 0; let etag: string | undefined; let refreshing: Promise | undefined; let fetches = 0; let hits = 0; const refresh = async (): Promise => { if (refreshing) return refreshing; refreshing = (async () => { fetches++; const response = await fetchImpl(endpoint, { headers: etag ? { accept: "application/json", "if-none-match": etag } : { accept: "application/json" }, }); if (response.status === 304) { expiresAt = now() + cacheTtlMs; return; } if (!response.ok) throw new JwtError(`JWKS fetch failed (${response.status})`); const declared = Number(response.headers.get("content-length")); if (Number.isFinite(declared) && declared > maxBytes) throw new JwtError("JWKS response is too large"); const text = await response.text(); if (new TextEncoder().encode(text).byteLength > maxBytes) throw new JwtError("JWKS response is too large"); let document: { keys?: JwksKey[] }; try { document = JSON.parse(text) as { keys?: JwksKey[] }; } catch { throw new JwtError("JWKS response is invalid JSON"); } if (!Array.isArray(document.keys) || document.keys.length > maxKeys) throw new JwtError("JWKS response has an invalid key set"); const next = new Map(); for (const jwk of document.keys) { if ( typeof jwk.kid !== "string" || !jwk.kid || jwk.kty !== "RSA" || (jwk.use !== undefined && jwk.use !== "sig") || (jwk.alg !== undefined && jwk.alg !== "RS256") || typeof jwk.n !== "string" || typeof jwk.e !== "string" ) { continue; } if (next.has(jwk.kid)) throw new JwtError(`JWKS contains duplicate kid: ${jwk.kid}`); next.set(jwk.kid, { jwk }); } keys.clear(); for (const [kid, key] of next) keys.set(kid, key); etag = response.headers.get("etag") ?? undefined; expiresAt = now() + cacheTtlMs; })().finally(() => { refreshing = undefined; }); return refreshing; }; return { async resolve(kid, alg) { if (!kid) throw new JwtError("Token has no key id"); if (alg !== "RS256") throw new JwtError(`Unsupported token algorithm: ${alg}`); if (now() >= expiresAt) await refresh(); let stored = keys.get(kid); if (!stored) { await refresh(); stored = keys.get(kid); } if (!stored) throw new JwtError(`Unknown key id: ${kid}`); hits++; stored.key ??= crypto.subtle.importKey( "jwk", stored.jwk, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, false, ["verify"], ); return stored.key; }, refresh, clear() { keys.clear(); expiresAt = 0; etag = undefined; }, stats: () => ({ fetches, hits, keys: keys.size, expiresAt }), }; } export async function verifyJwtWithJwks( token: string, jwks: RemoteJwks, options: VerifyOptions = {}, ): Promise { const parts = token.split("."); if (parts.length !== 3) throw new JwtError("Malformed token"); const decoded = decodeJwt(token); const kid = typeof decoded.header.kid === "string" ? decoded.header.kid : ""; const alg = typeof decoded.header.alg === "string" ? decoded.header.alg : ""; const key = await jwks.resolve(kid, alg); const valid = await crypto.subtle.verify( "RSASSA-PKCS1-v1_5", key, decodeBase64Url(parts[2]!) as BufferSource, new TextEncoder().encode(`${parts[0]}.${parts[1]}`) as BufferSource, ); if (!valid) throw new JwtError("Invalid signature"); const claims = decoded.claims as T; const now = options.now ?? Math.floor(Date.now() / 1000); const tolerance = Math.max(0, options.clockTolerance ?? 0); if (typeof claims.exp === "number" && now - tolerance >= claims.exp) throw new JwtError("Token expired"); if (typeof claims.nbf === "number" && now + tolerance < claims.nbf) throw new JwtError("Token not yet valid"); if (options.issuer !== undefined && claims.iss !== options.issuer) throw new JwtError("Invalid issuer"); if (options.audience !== undefined) { const expected = Array.isArray(options.audience) ? options.audience : [options.audience]; const actual = Array.isArray(claims.aud) ? claims.aud : typeof claims.aud === "string" ? [claims.aud] : []; if (!expected.some((audience) => actual.includes(audience))) throw new JwtError("Invalid audience"); } if ( options.maxAge !== undefined && typeof claims.iat === "number" && now - claims.iat > options.maxAge + tolerance ) { throw new JwtError("Token is too old"); } return claims; }