import { createLocalJWKSet, createRemoteJWKSet, decodeProtectedHeader, jwtVerify } from 'jose'; import type { JWTPayload, JWTVerifyOptions, RemoteJWKSetOptions } from 'jose'; import { isEmpty } from 'lodash'; import { UnauthorizedError } from '../errors/index.js'; /** * Verifies JWTs using a JWKS endpoint to resolve signing keys on demand. */ export class JwtVerifier { private readonly options: JwtVerifierOptions; // Memoize the remote JWKS loader; without this, we'd recreate it on each verification and jose's own cache would never persist. private remoteJwkSet?: ReturnType; constructor(options: JwtVerifierOptions) { this.options = options; } async verify(token: string, options?: JWTVerifyOptions): Promise { let header: ReturnType; try { header = decodeProtectedHeader(token); } catch { // Preserve legacy error message relied upon by tests throw new Error('Invalid token specified'); } if (isEmpty(header.kid)) { throw new UnauthorizedError('Invalid JWT as kid header is missing.'); } // Fail closed: require an explicit algorithm allowlist and reject a // disallowed `alg` before resolving keys, so a forged `alg` header can't // select an unintended verification path or trigger a JWKS fetch. if (!options?.algorithms || options.algorithms.length === 0) { throw new UnauthorizedError('JWT could not be verified as no signing algorithm allowlist was configured.'); } if (!header.alg || !options.algorithms.includes(header.alg)) { throw new UnauthorizedError('JWT could not be verified as its algorithm is not allowed.'); } const jwkSet = await this.resolveJwkSet(); try { const result = await jwtVerify(token, jwkSet, options); return result.payload; } catch (err) { throw new UnauthorizedError(`JWT verification failed: ${(err as Error).message}`); } } private async resolveJwkSet(): Promise | ReturnType> { // Prefer interceptor-provided keys for explicit testability and control if (this.options.getKeysInterceptor) { const keys = await this.options.getKeysInterceptor(); if (isEmpty(keys)) { throw new UnauthorizedError('JWT could not be verified as no corresponding public key was found.'); } return createLocalJWKSet({ keys }); } // Allow custom fetcher to provide JWKS out-of-band if (this.options.fetcher) { const jwks = await this.options.fetcher(this.options.jwksUri); const keys = jwks.keys; if (!Array.isArray(keys) || isEmpty(keys)) { throw new UnauthorizedError('JWT could not be verified as no corresponding public key was found.'); } return createLocalJWKSet({ keys }); } // Default to remote JWKS. jose v5 supports headers/timeout; pass when provided. const url = new URL(this.options.jwksUri); const opts: RemoteJWKSetOptions = {}; if (!isEmpty(this.options.requestHeaders)) { opts.headers = this.options.requestHeaders; } if (!isEmpty(this.options.timeoutMs)) { opts.timeoutDuration = this.options.timeoutMs; } if (!this.remoteJwkSet) { this.remoteJwkSet = createRemoteJWKSet(url, opts); } return this.remoteJwkSet; } } /** * Options that configure how the verifier fetches and caches JWKS signing keys. */ export interface JwtVerifierOptions { jwksUri: string; rateLimit?: boolean; cache?: boolean; cacheMaxEntries?: number; cacheMaxAge?: number; jwksRequestsPerMinute?: number; proxy?: string; requestHeaders?: Headers; timeoutMs?: number; fetcher?(jwksUri: string): Promise<{ keys: unknown }>; getKeysInterceptor?(): Promise; } type Headers = Record; export interface JwtKey { kty: string; kid: string; alg: string; [key: string]: unknown; }