import { authorizationCodeGrant, buildAuthorizationUrl, calculatePKCECodeChallenge, customFetch, discovery, initiateDeviceAuthorization, pollDeviceAuthorizationGrant, type Configuration, type CustomFetch, } from "openid-client"; import { constants, createPublicKey, verify as verifySignature } from "node:crypto"; import { parseConfiguredTransportUrl } from "./url-policy.js"; import { isUsableAuthenticationSecret } from "./secret-policy.js"; export interface OidcIdentity { issuer: string; subject: string; displayName?: string; groups: readonly string[]; tokenExpiresAt: Date; } export interface OidcProtocol { authorizationUrl(input: { state: string; nonce: string; codeVerifier: string }): Promise; callback(input: { currentUrl: URL; state: string; nonce: string; codeVerifier: string }): Promise; diagnose(signal: AbortSignal): Promise; verifyDeviceFlow?(signal: AbortSignal, present: (uri: string, code: string) => void): Promise; } export class OidcProtocolError extends Error { constructor(message = "oidc_protocol_invalid") { super(message); this.name = "OidcProtocolError"; } } /** A safe operational distinction for callers and diagnostics; provider details never cross this boundary. */ export class OidcProviderUnavailableError extends OidcProtocolError { constructor() { super("oidc_provider_unavailable"); this.name = "OidcProviderUnavailableError"; } } /** The discovery document resolved, but its signed-token key set could not be certified. */ export class OidcJwksUnavailableError extends OidcProtocolError { constructor() { super("oidc_jwks_unreachable"); this.name = "OidcJwksUnavailableError"; } } /** Discovery completed with metadata for a different issuer than the configured trust anchor. */ export class OidcIssuerMismatchError extends OidcProtocolError { constructor() { super("oidc_issuer_mismatch"); this.name = "OidcIssuerMismatchError"; } } /** Device authorization is optional OIDC metadata and must never fall back to a browser flow. */ export class OidcDeviceFlowUnavailableError extends OidcProtocolError { constructor() { super("oidc_device_flow_unavailable"); this.name = "OidcDeviceFlowUnavailableError"; } } export interface OidcProtocolOptions { issuer: string; clientId: string; clientSecret: string; callbackUrl: string; scopes: readonly string[]; groupsClaim: string; fetch?: typeof globalThis.fetch; httpTimeoutMs?: number; jwksTimeoutMs?: number; } const MAX_GROUPS = 128; const MAX_GROUP_LENGTH = 256; const MAX_ID_TOKEN_LENGTH = 16 * 1024; const MAX_OIDC_RESPONSE_BYTES = 1024 * 1024; const DEFAULT_HTTP_TIMEOUT_MS = 5_000; const MAX_HTTP_TIMEOUT_MS = 30_000; const DEFAULT_JWKS_TIMEOUT_MS = 5_000; const MAX_JWKS_TIMEOUT_MS = 30_000; const MAX_DEVICE_FLOW_TIMEOUT_MS = 10 * 60_000; const text = (value: unknown, maximum = 2048): value is string => typeof value === "string" && value.length > 0 && value.length <= maximum && !/\p{Cc}/u.test(value); function discoveryStringList(value: unknown): value is readonly string[] { return Array.isArray(value) && value.length > 0 && value.length <= 128 && value.every((item) => text(item, 128)); } function schemaValidDiscoveryMetadata(value: unknown): value is Record & { issuer: string } { if (!value || typeof value !== "object" || Array.isArray(value)) return false; const metadata = value as Record; const issuer = metadata.issuer; const authorizationEndpoint = metadata.authorization_endpoint; const tokenEndpoint = metadata.token_endpoint; const jwksUri = metadata.jwks_uri; if (!text(issuer, 2048) || !text(authorizationEndpoint, 2048) || !text(tokenEndpoint, 2048) || !text(jwksUri, 2048) || !discoveryStringList(metadata.response_types_supported) || !discoveryStringList(metadata.subject_types_supported) || !discoveryStringList(metadata.id_token_signing_alg_values_supported)) return false; try { configuredHttpsUrl(issuer); httpsEndpoint(authorizationEndpoint); httpsEndpoint(tokenEndpoint); httpsEndpoint(jwksUri); return true; } catch { return false; } } function configuredHttpsUrl(value: string): URL { const url = parseConfiguredTransportUrl(value, { allowLoopbackHttp: false }); if (!url) throw new OidcProtocolError(); return url; } function configuredCallbackUrl(value: string): URL { const url = parseConfiguredTransportUrl(value, { allowLoopbackHttp: true }); if (!url) throw new OidcProtocolError(); return url; } function httpsEndpoint(value: unknown): URL { if (!text(value, 2048)) throw new OidcProtocolError(); let url: URL; try { url = new URL(value); } catch { throw new OidcProtocolError(); } if (url.protocol !== "https:" || url.username || url.password || url.hash) throw new OidcProtocolError(); return url; } function groupsFromClaims(claims: Record, name: string): string[] { const indirect = claims._claim_names; if ((indirect && typeof indirect === "object" && !Array.isArray(indirect) && Object.prototype.hasOwnProperty.call(indirect, name)) || claims.hasgroups === true) throw new OidcProtocolError(); const raw = claims[name]; if (!Array.isArray(raw) || raw.length === 0 || raw.length > MAX_GROUPS) throw new OidcProtocolError(); const groups: string[] = []; const unique = new Set(); for (const group of raw) { if (!text(group, MAX_GROUP_LENGTH) || group.trim().length === 0 || unique.has(group)) throw new OidcProtocolError(); unique.add(group); groups.push(group); } return groups; } function identityFromClaims(claims: Record, options: OidcProtocolOptions): OidcIdentity { if (claims.iss !== options.issuer || !text(claims.sub, 512)) throw new OidcProtocolError(); const audience = claims.aud; if (!(audience === options.clientId || (Array.isArray(audience) && audience.includes(options.clientId)))) { throw new OidcProtocolError(); } if (typeof claims.exp !== "number" || !Number.isSafeInteger(claims.exp) || claims.exp * 1000 <= Date.now()) { throw new OidcProtocolError(); } const tokenExpiresAt = new Date(claims.exp * 1000); if (Number.isNaN(tokenExpiresAt.getTime())) throw new OidcProtocolError(); return { issuer: options.issuer, subject: claims.sub, ...(text(claims.name, 256) ? { displayName: claims.name } : {}), groups: groupsFromClaims(claims, options.groupsClaim), tokenExpiresAt, }; } function jsonPart(part: string): Record { if (!/^[A-Za-z0-9_-]+$/.test(part) || part.length > MAX_ID_TOKEN_LENGTH) throw new OidcProtocolError(); try { const value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(Buffer.from(part, "base64url"))); if (!value || typeof value !== "object" || Array.isArray(value)) throw new OidcProtocolError(); return value as Record; } catch { throw new OidcProtocolError(); } } function signatureAlgorithm(algorithm: string): { digest: string | null; pss?: boolean; saltLength?: number; ecdsaPartLength?: number; } { switch (algorithm) { case "RS256": return { digest: "RSA-SHA256" }; case "RS384": return { digest: "RSA-SHA384" }; case "RS512": return { digest: "RSA-SHA512" }; case "PS256": return { digest: "sha256", pss: true, saltLength: 32 }; case "PS384": return { digest: "sha384", pss: true, saltLength: 48 }; case "PS512": return { digest: "sha512", pss: true, saltLength: 64 }; case "ES256": return { digest: "sha256", ecdsaPartLength: 32 }; case "ES384": return { digest: "sha384", ecdsaPartLength: 48 }; case "ES512": return { digest: "sha512", ecdsaPartLength: 66 }; case "EdDSA": return { digest: null }; default: throw new OidcProtocolError(); } } function derLength(length: number): Buffer { if (length < 128) return Buffer.from([length]); if (length < 256) return Buffer.from([0x81, length]); throw new OidcProtocolError(); } function derInteger(raw: Buffer): Buffer { let start = 0; while (start < raw.length - 1 && raw[start] === 0) start += 1; let value = raw.subarray(start); if ((value[0] & 0x80) !== 0) value = Buffer.concat([Buffer.from([0]), value]); return Buffer.concat([Buffer.from([0x02]), derLength(value.length), value]); } function joseEcdsaSignatureToDer(signature: Buffer, partLength: number): Buffer { if (signature.length !== partLength * 2) throw new OidcProtocolError(); const sequence = Buffer.concat([ derInteger(signature.subarray(0, partLength)), derInteger(signature.subarray(partLength)), ]); return Buffer.concat([Buffer.from([0x30]), derLength(sequence.length), sequence]); } class OidcTransportError extends Error { constructor(readonly availability: boolean) { super(availability ? "oidc_provider_unavailable" : "oidc_provider_response_invalid"); this.name = "OidcTransportError"; } } interface BoundedOidcTransport { customFetch: CustomFetch; request( input: RequestInfo | URL, init?: RequestInit, options?: { timeoutMs?: number; requireSuccess?: boolean }, ): Promise; } function cancelReaderBestEffort(reader: ReadableStreamDefaultReader): void { try { void Promise.resolve(reader.cancel()).catch(() => undefined); } catch { // Cancellation is advisory; the deadline and rejection remain authoritative. } } function cancelResponseBestEffort(response: Response): void { if (!response.body) return; try { void Promise.resolve(response.body.cancel()).catch(() => undefined); } catch { // A late response is never allowed to turn an already-bounded request into an unhandled rejection. } } function abortedReason(signal: AbortSignal): unknown { return signal.reason ?? new DOMException("The operation was aborted", "AbortError"); } async function awaitWithAbort( operation: Promise, signal: AbortSignal, onLateResolution?: (value: T) => void, ): Promise { return await new Promise((resolve, reject) => { let settled = signal.aborted; const cleanup = () => signal.removeEventListener("abort", aborted); const aborted = () => { if (settled) return; settled = true; cleanup(); reject(abortedReason(signal)); }; if (signal.aborted) reject(abortedReason(signal)); else signal.addEventListener("abort", aborted, { once: true }); operation.then( (value) => { if (settled) { try { onLateResolution?.(value); } catch { /* best-effort late cleanup only */ } return; } settled = true; cleanup(); resolve(value); }, (error: unknown) => { if (settled) return; settled = true; cleanup(); reject(error); }, ); }); } function providerRequestUrl(input: RequestInfo | URL): URL { const value = input instanceof URL ? input.href : input instanceof Request ? input.url : input; return httpsEndpoint(value); } function declaredResponseLength(response: Response): number | undefined { const declared = response.headers.get("content-length"); if (declared === null) return undefined; if (!/^\d+$/.test(declared)) throw new OidcTransportError(false); const length = Number(declared); if (!Number.isSafeInteger(length) || length > MAX_OIDC_RESPONSE_BYTES) throw new OidcTransportError(false); return length; } function safeBufferedResponse(response: Response, body: Buffer): Response { const noBodyStatus = response.status === 204 || response.status === 205 || response.status === 304; // Node accepts Buffer as a fetch body; the DOM declaration in this project does not model it. return new Response(noBodyStatus ? null : body as unknown as BodyInit, { status: response.status, statusText: response.statusText, headers: response.headers, }); } async function boundedResponse( response: Response, signal: AbortSignal, requireSuccess: boolean, ): Promise { const reader = response.body?.getReader(); const chunks: Buffer[] = []; let total = 0; let completed = false; try { if (response.redirected || response.type === "opaqueredirect" || response.status >= 300 && response.status < 400) { throw new OidcTransportError(false); } if (requireSuccess && !response.ok) throw new OidcTransportError(false); declaredResponseLength(response); if (!reader) { completed = true; return safeBufferedResponse(response, Buffer.alloc(0)); } while (true) { const { done, value } = await awaitWithAbort(reader.read(), signal); if (done) break; if (value.byteLength > MAX_OIDC_RESPONSE_BYTES - total) throw new OidcTransportError(false); total += value.byteLength; chunks.push(Buffer.from(value)); } completed = true; return safeBufferedResponse(response, Buffer.concat(chunks, total)); } finally { try { if (!completed && reader) cancelReaderBestEffort(reader); } finally { try { reader?.releaseLock(); } catch { /* cancellation already made the response unusable */ } } } } function createBoundedOidcTransport( fetchImplementation: typeof globalThis.fetch, defaultTimeoutMs: number, ): BoundedOidcTransport { const request: BoundedOidcTransport["request"] = async (input, init, requestOptions) => { let target: URL; try { target = providerRequestUrl(input); } catch { throw new OidcTransportError(false); } const timeoutMs = requestOptions?.timeoutMs ?? defaultTimeoutMs; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); timeout.unref(); const signal = init?.signal ? AbortSignal.any([init.signal, controller.signal]) : controller.signal; try { const requestInit: RequestInit = { ...init, redirect: "manual", signal }; const response = await awaitWithAbort( Promise.resolve().then(() => fetchImplementation(target, requestInit)), signal, cancelResponseBestEffort, ); return await boundedResponse(response, signal, requestOptions?.requireSuccess === true); } catch (error) { if (error instanceof OidcTransportError) throw error; if (signal.aborted) throw new OidcTransportError(true); throw new OidcTransportError(true); } finally { clearTimeout(timeout); } }; return { request, // openid-client's FetchBody is Fetch-compatible, but comes from a distinct declaration graph. customFetch: (url, options) => request(url, options as unknown as RequestInit), }; } function availabilityFailure(error: unknown): boolean { let current = error; const seen = new Set(); for (let depth = 0; depth < 8; depth += 1) { if (current instanceof OidcTransportError) return current.availability; if (!current || typeof current !== "object" || seen.has(current)) return false; seen.add(current); current = (current as { cause?: unknown }).cause; } return false; } function protocolFailure(error: unknown): OidcProtocolError { let current = error; const seen = new Set(); for (let depth = 0; depth < 8; depth += 1) { if (current instanceof OidcProtocolError) return current; if (!current || typeof current !== "object" || seen.has(current)) break; seen.add(current); current = (current as { cause?: unknown }).cause; } return availabilityFailure(error) ? new OidcProviderUnavailableError() : new OidcProtocolError(); } async function verifyIdTokenSignature( idToken: unknown, config: Configuration, transport: BoundedOidcTransport, jwksTimeoutMs: number, ): Promise { if (!text(idToken, MAX_ID_TOKEN_LENGTH)) throw new OidcProtocolError(); const [protectedPart, payloadPart, signaturePart, extra] = idToken.split("."); if (!protectedPart || !payloadPart || !signaturePart || extra) throw new OidcProtocolError(); const header = jsonPart(protectedPart); if (!text(header.alg, 16) || !text(header.kid, 256)) throw new OidcProtocolError(); const metadata = config.serverMetadata(); if (!Array.isArray(metadata.id_token_signing_alg_values_supported) || !metadata.id_token_signing_alg_values_supported.includes(header.alg) || !text(metadata.jwks_uri, 2048)) throw new OidcProtocolError(); const jwksUrl = httpsEndpoint(metadata.jwks_uri); try { const response = await transport.request( jwksUrl, { headers: { accept: "application/json" }, redirect: "manual" }, { timeoutMs: jwksTimeoutMs, requireSuccess: true }, ); const body = new TextDecoder("utf-8", { fatal: true }).decode(await response.arrayBuffer()); const parsed = JSON.parse(body) as { keys?: unknown }; if (!Array.isArray(parsed.keys) || parsed.keys.length === 0 || parsed.keys.length > 16) throw new OidcProtocolError(); const matching = parsed.keys.filter((key): key is Record => Boolean(key) && typeof key === "object" && !Array.isArray(key) && key.kid === header.kid); if (matching.length !== 1) throw new OidcProtocolError(); const key = matching[0]; if (key.use !== undefined && key.use !== "sig") throw new OidcProtocolError(); if (key.alg !== undefined && key.alg !== header.alg) throw new OidcProtocolError(); const algorithm = signatureAlgorithm(header.alg); if (!/^[A-Za-z0-9_-]+$/.test(signaturePart)) throw new OidcProtocolError(); const signature = Buffer.from(signaturePart, "base64url"); if (signature.length === 0) throw new OidcProtocolError(); const publicKey = createPublicKey({ key: key as never, format: "jwk" }); const normalizedSignature = algorithm.ecdsaPartLength ? joseEcdsaSignatureToDer(signature, algorithm.ecdsaPartLength) : signature; const verified = algorithm.pss ? verifySignature(algorithm.digest, Buffer.from(`${protectedPart}.${payloadPart}`), { key: publicKey, padding: constants.RSA_PKCS1_PSS_PADDING, saltLength: algorithm.saltLength, }, normalizedSignature) : verifySignature(algorithm.digest, Buffer.from(`${protectedPart}.${payloadPart}`), publicKey, normalizedSignature); if (!verified) throw new OidcProtocolError(); } catch (error) { throw protocolFailure(error); } } async function verifyJwksAvailability( config: Configuration, transport: BoundedOidcTransport, jwksTimeoutMs: number, signal: AbortSignal, ): Promise { const metadata = config.serverMetadata(); if (!text(metadata.jwks_uri, 2048)) throw new OidcProtocolError(); const response = await transport.request( httpsEndpoint(metadata.jwks_uri), { headers: { accept: "application/json" }, redirect: "manual", signal }, { timeoutMs: jwksTimeoutMs, requireSuccess: true }, ); const parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(await response.arrayBuffer())); if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || !Array.isArray((parsed as { keys?: unknown }).keys)) throw new OidcProtocolError(); } export function createOidcProtocol(options: OidcProtocolOptions): OidcProtocol { const issuerUrl = configuredHttpsUrl(options.issuer); const callbackUrl = configuredCallbackUrl(options.callbackUrl); if (!text(options.clientId, 512) || !isUsableAuthenticationSecret("THT_OIDC_CLIENT_SECRET", options.clientSecret) || !text(options.groupsClaim, 128) || options.scopes.length === 0 || options.scopes.length > 16 || options.scopes.some((scope) => !text(scope, 128)) || (options.httpTimeoutMs !== undefined && (!Number.isSafeInteger(options.httpTimeoutMs) || options.httpTimeoutMs < 1 || options.httpTimeoutMs > MAX_HTTP_TIMEOUT_MS)) || (options.jwksTimeoutMs !== undefined && (!Number.isSafeInteger(options.jwksTimeoutMs) || options.jwksTimeoutMs < 1 || options.jwksTimeoutMs > MAX_JWKS_TIMEOUT_MS))) throw new OidcProtocolError(); const httpTimeoutMs = options.httpTimeoutMs ?? DEFAULT_HTTP_TIMEOUT_MS; const jwksTimeoutMs = options.jwksTimeoutMs ?? DEFAULT_JWKS_TIMEOUT_MS; const transport = createBoundedOidcTransport(options.fetch ?? globalThis.fetch, httpTimeoutMs); let discovered: Promise | undefined; const configuration = async (): Promise => { if (!discovered) { discovered = (async () => { let certifiedIssuerMismatch = false; const issuerCheckingFetch: CustomFetch = async (input, init) => { const response = await transport.customFetch(input, init); if (!response.ok) return response; try { const metadata: unknown = await response.clone().json(); if (schemaValidDiscoveryMetadata(metadata) && metadata.issuer !== options.issuer) { certifiedIssuerMismatch = true; const headers = new Headers(response.headers); headers.delete("content-length"); return new Response(JSON.stringify({ ...metadata, issuer: options.issuer }), { status: response.status, statusText: response.statusText, headers, }); } } catch { // The OIDC library owns malformed discovery-document classification. } return response; }; try { const config = await discovery( issuerUrl, options.clientId, { client_secret: options.clientSecret, redirect_uris: [callbackUrl.href], response_types: ["code"] }, undefined, { [customFetch]: issuerCheckingFetch, timeout: httpTimeoutMs / 1000 }, ); const metadata = config.serverMetadata(); if (metadata.issuer !== options.issuer) throw new OidcProtocolError(); httpsEndpoint(metadata.authorization_endpoint); httpsEndpoint(metadata.token_endpoint); httpsEndpoint(metadata.jwks_uri); if (certifiedIssuerMismatch) throw new OidcIssuerMismatchError(); return config; } catch (error) { throw protocolFailure(error); } })(); } return await discovered; }; return { async authorizationUrl(input) { try { const challenge = await calculatePKCECodeChallenge(input.codeVerifier); return buildAuthorizationUrl(await configuration(), { response_type: "code", redirect_uri: callbackUrl.href, scope: options.scopes.join(" "), state: input.state, nonce: input.nonce, code_challenge: challenge, code_challenge_method: "S256", }); } catch (error) { throw protocolFailure(error); } }, async callback(input) { if (input.currentUrl.origin !== callbackUrl.origin || input.currentUrl.pathname !== callbackUrl.pathname) { throw new OidcProtocolError(); } try { const config = await configuration(); const tokens = await authorizationCodeGrant(config, input.currentUrl, { expectedState: input.state, expectedNonce: input.nonce, pkceCodeVerifier: input.codeVerifier, idTokenExpected: true, }); await verifyIdTokenSignature(tokens.id_token, config, transport, jwksTimeoutMs); const claims = tokens.claims(); if (!claims || Array.isArray(claims)) throw new OidcProtocolError(); return identityFromClaims(claims as Record, options); } catch (error) { throw protocolFailure(error); } }, async diagnose(signal) { signal.throwIfAborted(); const config = await configuration(); signal.throwIfAborted(); try { await verifyJwksAvailability(config, transport, jwksTimeoutMs, signal); } catch { throw new OidcJwksUnavailableError(); } signal.throwIfAborted(); }, async verifyDeviceFlow(signal, present) { const maximumDeadline = AbortSignal.timeout(MAX_DEVICE_FLOW_TIMEOUT_MS); const operationSignal = AbortSignal.any([signal, maximumDeadline]); let config: Configuration; try { operationSignal.throwIfAborted(); config = await configuration(); operationSignal.throwIfAborted(); const endpoint = config.serverMetadata().device_authorization_endpoint; httpsEndpoint(endpoint); } catch (error) { if (error instanceof OidcIssuerMismatchError || error instanceof OidcProviderUnavailableError) throw error; throw new OidcDeviceFlowUnavailableError(); } try { operationSignal.throwIfAborted(); const device = await awaitWithAbort( initiateDeviceAuthorization(config, { scope: options.scopes.join(" ") }), operationSignal, ); if (!text(device.verification_uri, 2048) || !text(device.user_code, 256)) { throw new OidcDeviceFlowUnavailableError(); } const providerLifetimeMs = device.expires_in * 1000; if (!Number.isSafeInteger(providerLifetimeMs) || providerLifetimeMs <= 0) { throw new OidcDeviceFlowUnavailableError(); } const deviceSignal = AbortSignal.any([ signal, maximumDeadline, AbortSignal.timeout(Math.min(providerLifetimeMs, MAX_DEVICE_FLOW_TIMEOUT_MS)), ]); const verificationUri = httpsEndpoint(device.verification_uri); present(verificationUri.href, device.user_code); const tokens = await pollDeviceAuthorizationGrant(config, device, undefined, { signal: deviceSignal }); await verifyIdTokenSignature(tokens.id_token, config, transport, jwksTimeoutMs); const claims = tokens.claims(); if (!claims || Array.isArray(claims)) throw new OidcProtocolError(); return identityFromClaims(claims as Record, options); } catch (error) { if (error instanceof OidcDeviceFlowUnavailableError) throw error; throw protocolFailure(error); } }, }; }