180 lines
6.4 KiB
TypeScript
180 lines
6.4 KiB
TypeScript
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<CryptoKey>;
|
|
refresh(): Promise<void>;
|
|
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<CryptoKey> };
|
|
|
|
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<string, StoredKey>();
|
|
let expiresAt = 0;
|
|
let etag: string | undefined;
|
|
let refreshing: Promise<void> | undefined;
|
|
let fetches = 0;
|
|
let hits = 0;
|
|
|
|
const refresh = async (): Promise<void> => {
|
|
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<string, StoredKey>();
|
|
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<T extends JwtClaims = JwtClaims>(
|
|
token: string,
|
|
jwks: RemoteJwks,
|
|
options: VerifyOptions = {},
|
|
): Promise<T> {
|
|
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;
|
|
}
|