import { createHash, createPublicKey, randomBytes, verify } from "node:crypto"; import { createRemoteJWKSet, errors as joseErrors, jwtVerify } from "jose"; import type { JWSHeaderParameters, JWTPayload, FlattenedJWSInput } from "jose"; import { type Result, err, ok } from "~/server/result"; import log from "~/utils/log"; export interface OidcConfig { issuer: string; clientId: string; clientSecret: string; baseUrl: string; authorizationEndpoint?: string; tokenEndpoint?: string; userinfoEndpoint?: string; endSessionEndpoint?: string; jwksUri?: string; tokenEndpointAuthMethod?: "client_secret_basic" | "client_secret_post"; usePkce?: boolean; scope?: string; subjectClaims?: string[]; roleClaim?: string; allowWeakRsaKeys?: boolean; extraParams?: Record; profilePictureSource?: "oidc" | "gravatar"; postLogoutRedirectUri?: string; } export interface ResolvedEndpoints { authorizationEndpoint: string; tokenEndpoint: string; jwksUri: string; userinfoEndpoint?: string; endSessionEndpoint?: string; } export interface OidcFlowState { state: string; nonce: string; codeVerifier: string; redirectUri: string; } export interface OidcIdentity { issuer: string; subject: string; name: string; username: string; email?: string; picture?: string; role?: string; idToken?: string; } export type OidcErrorCode = | "discovery_failed" | "missing_endpoints" | "invalid_api_key" | "state_mismatch" | "nonce_mismatch" | "token_exchange_failed" | "invalid_client" | "pkce_error" | "invalid_id_token" | "missing_sub" | "userinfo_failed"; export interface OidcError { code: OidcErrorCode; message: string; hint?: string; } export function logOidcError(context: string, error: OidcError): void { log.error("auth", "%s [%s]: %s", context, error.code, error.message); if (error.hint) { log.error("auth", "Hint: %s", error.hint); } } type JwksResolver = ( protectedHeader?: JWSHeaderParameters, token?: FlattenedJWSInput, ) => Promise; export interface OidcService { status(): | { state: "ready"; endpoints: ResolvedEndpoints } | { state: "pending" } | { state: "error"; error: OidcError }; discover(): Promise>; startFlow(): Promise>; handleCallback( callbackParams: URLSearchParams, flowState: OidcFlowState, ): Promise>; buildEndSessionUrl(idToken?: string): string | undefined; invalidate(): void; reload(config: OidcConfig): void; } interface OidcClaims extends JWTPayload { nonce?: string; name?: string; given_name?: string; family_name?: string; preferred_username?: string; email?: string; picture?: string; [claim: string]: unknown; } interface TokenResponse { access_token: string; id_token?: string; token_type?: string; expires_in?: number; refresh_token?: string; } interface TokenErrorResponse { error: string; error_description?: string; } interface JwksResponse { keys?: Array; } interface DecodedJwtParts { header: Record; payload: OidcClaims; signature: Buffer; signingInput: string; } interface WeakRsaContext { alg: "RS256" | "RS384" | "RS512"; decoded: DecodedJwtParts; candidateKeys: Array; } export function createOidcService(initialConfig: OidcConfig): OidcService { let config = Object.freeze({ ...initialConfig }); let endpoints: ResolvedEndpoints | undefined; let lastError: OidcError | undefined; let jwks: JwksResolver | undefined; let resolvedAuthMethod: "client_secret_basic" | "client_secret_post" | undefined = initialConfig.tokenEndpointAuthMethod; const weakJwksCache = new Map< string, { expiresAt: number; keys: Array } >(); let hasWarnedWeakKeyMode = false; maybeWarnWeakRsaMode(config); function status(): ReturnType { if (lastError) { return { state: "error", error: lastError }; } if (endpoints) { return { state: "ready", endpoints }; } return { state: "pending" }; } async function discover(): Promise> { if (endpoints) { return ok(endpoints); } const fullManual = config.authorizationEndpoint && config.tokenEndpoint && config.jwksUri; if (fullManual) { endpoints = { authorizationEndpoint: config.authorizationEndpoint!, tokenEndpoint: config.tokenEndpoint!, jwksUri: config.jwksUri!, userinfoEndpoint: config.userinfoEndpoint, endSessionEndpoint: config.endSessionEndpoint, }; lastError = undefined; jwks = createRemoteJWKSet(new URL(endpoints.jwksUri)); log.debug("auth", "OIDC endpoints configured manually, skipping discovery"); return ok(endpoints); } let discoveryUrl: string; try { const issuerUrl = new URL(config.issuer); if (issuerUrl.pathname === "/" || issuerUrl.pathname === "") { discoveryUrl = new URL("/.well-known/openid-configuration", issuerUrl).href; } else { discoveryUrl = new URL( `${issuerUrl.pathname.replace(/\/$/, "")}/.well-known/openid-configuration`, issuerUrl, ).href; } } catch { const error: OidcError = { code: "discovery_failed", message: `Invalid issuer URL: ${config.issuer}`, }; lastError = error; return err(error); } let metadata: Record; try { const response = await fetch(discoveryUrl, { headers: { Accept: "application/json" }, signal: AbortSignal.timeout(10_000), }); if (!response.ok) { const error: OidcError = { code: "discovery_failed", message: `Discovery endpoint returned ${response.status}: ${discoveryUrl}`, hint: "Check that your issuer URL is correct and that the identity provider is online.", }; lastError = error; return err(error); } metadata = (await response.json()) as Record; } catch (cause) { const error: OidcError = { code: "discovery_failed", message: `Failed to reach OIDC discovery endpoint: ${cause instanceof Error ? cause.message : String(cause)}`, hint: "Unable to reach your identity provider. SSO will automatically retry on the next login attempt.", }; lastError = error; return err(error); } if (typeof metadata.issuer === "string" && metadata.issuer !== config.issuer) { log.debug( "auth", "Discovery issuer %s does not match configured issuer %s", metadata.issuer, config.issuer, ); } const authorizationEndpoint = config.authorizationEndpoint ?? (metadata.authorization_endpoint as string | undefined); const tokenEndpoint = config.tokenEndpoint ?? (metadata.token_endpoint as string | undefined); const jwksUri = config.jwksUri ?? (metadata.jwks_uri as string | undefined); const userinfoEndpoint = config.userinfoEndpoint ?? (metadata.userinfo_endpoint as string | undefined); const endSessionEndpoint = config.endSessionEndpoint ?? (metadata.end_session_endpoint as string | undefined); if (!authorizationEndpoint || !tokenEndpoint || !jwksUri) { const missing: string[] = []; if (!authorizationEndpoint) missing.push("authorization_endpoint"); if (!tokenEndpoint) missing.push("token_endpoint"); if (!jwksUri) missing.push("jwks_uri"); const error: OidcError = { code: "missing_endpoints", message: `Discovery is missing required endpoints: ${missing.join(", ")}`, hint: "Your identity provider did not return all required endpoints. You can set them manually in your Headplane config.", }; lastError = error; return err(error); } endpoints = { authorizationEndpoint, tokenEndpoint, jwksUri, userinfoEndpoint, endSessionEndpoint, }; lastError = undefined; jwks = createRemoteJWKSet(new URL(endpoints.jwksUri)); log.debug("auth", "OIDC discovery completed successfully"); return ok(endpoints); } async function startFlow(): Promise< Result<{ url: string; flowState: OidcFlowState }, OidcError> > { const resolved = await discover(); if (!resolved.ok) { return resolved; } const usePkce = config.usePkce !== false; const scope = config.scope ?? "openid email profile"; const redirectUri = new URL(`${__PREFIX__}/oidc/callback`, config.baseUrl).href; const state = generateRandom(); const nonce = generateRandom(); const codeVerifier = generateRandom(64); const params = new URLSearchParams({ response_type: "code", client_id: config.clientId, redirect_uri: redirectUri, scope, state, nonce, }); if (usePkce) { const codeChallenge = computeS256Challenge(codeVerifier); params.set("code_challenge", codeChallenge); params.set("code_challenge_method", "S256"); } if (config.extraParams) { for (const [key, value] of Object.entries(config.extraParams)) { params.set(key, value); } } const url = `${resolved.value.authorizationEndpoint}?${params.toString()}`; const flowState: OidcFlowState = { state, nonce, codeVerifier, redirectUri }; return ok({ url, flowState }); } async function handleCallback( callbackParams: URLSearchParams, flowState: OidcFlowState, ): Promise> { const resolved = await discover(); if (!resolved.ok) { return resolved; } const callbackError = callbackParams.get("error"); if (callbackError) { const desc = callbackParams.get("error_description") ?? ""; return err({ code: "token_exchange_failed", message: `Provider returned error: ${callbackError} — ${desc}`, hint: desc || undefined, }); } const code = callbackParams.get("code"); if (!code) { return err({ code: "token_exchange_failed", message: "Callback is missing the authorization code", }); } const returnedState = callbackParams.get("state"); if (returnedState !== flowState.state) { return err({ code: "state_mismatch", message: `State mismatch: expected ${flowState.state}, got ${returnedState}`, hint: "Please try signing in again. If this keeps happening, your reverse proxy may be interfering with cookies.", }); } // Token exchange with auth method retry, hopefully this stops new GitHub issues about this const tokenResult = await exchangeCode(resolved.value, code, flowState); if (!tokenResult.ok) { return tokenResult; } const tokens = tokenResult.value; if (!tokens.id_token) { return err({ code: "token_exchange_failed", message: "Token response is missing id_token", hint: "Your identity provider did not return an ID token. Make sure the 'openid' scope is included in your OIDC client configuration.", }); } // ID token verification const verifyResult = await verifyIdToken(tokens.id_token, flowState.nonce); if (!verifyResult.ok) { return verifyResult; } const claims = verifyResult.value; const enriched = await enrichWithUserInfo(resolved.value, tokens.access_token, claims); if (!resolveSubject(enriched)) { return err({ code: "missing_sub", message: "ID token and userinfo response are missing all configured subject claims", hint: `Your identity provider did not return a stable user identifier. Configure oidc.subject_claims or ensure one of these claims is present: ${getSubjectClaimOrder().join(", ")}.`, }); } return ok(buildIdentity(enriched, tokens.id_token)); } async function exchangeCode( ep: ResolvedEndpoints, code: string, flowState: OidcFlowState, ): Promise> { const usePkce = config.usePkce !== false; const body = new URLSearchParams({ grant_type: "authorization_code", code, redirect_uri: flowState.redirectUri, ...(usePkce ? { code_verifier: flowState.codeVerifier } : {}), }); const methodToTry = resolvedAuthMethod ?? "client_secret_post"; const result = await fetchToken(ep.tokenEndpoint, body, methodToTry); if (!result.ok && !resolvedAuthMethod) { const isClientError = result.error.code === "invalid_client" || (result.error.code === "token_exchange_failed" && result.error.message.includes("invalid_client")); if (isClientError) { const fallback = methodToTry === "client_secret_post" ? ("client_secret_basic" as const) : ("client_secret_post" as const); log.debug("auth", "Token exchange failed with %s, retrying with %s", methodToTry, fallback); const retryResult = await fetchToken(ep.tokenEndpoint, body, fallback); if (retryResult.ok) { resolvedAuthMethod = fallback; log.debug("auth", "Auth method %s succeeded, caching for future requests", fallback); } return retryResult; } } if (result.ok && !resolvedAuthMethod) { resolvedAuthMethod = methodToTry; } return result; } async function fetchToken( tokenEndpoint: string, body: URLSearchParams, method: "client_secret_basic" | "client_secret_post", ): Promise> { const requestBody = new URLSearchParams(body); const headers: Record = { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json", }; if (method === "client_secret_post") { requestBody.set("client_id", config.clientId); requestBody.set("client_secret", config.clientSecret); } else { const credentials = btoa( `${encodeURIComponent(config.clientId)}:${encodeURIComponent(config.clientSecret)}`, ); headers.Authorization = `Basic ${credentials}`; } let response: Response; try { response = await fetch(tokenEndpoint, { method: "POST", headers, body: requestBody.toString(), signal: AbortSignal.timeout(10_000), }); } catch (cause) { return err({ code: "token_exchange_failed", message: `Failed to reach token endpoint: ${cause instanceof Error ? cause.message : String(cause)}`, }); } let json: unknown; try { json = await response.json(); } catch { return err({ code: "token_exchange_failed", message: `Token endpoint returned non-JSON response (status ${response.status})`, }); } const responseBody = json as Record; if (!response.ok || typeof responseBody.error === "string") { const tokenError = responseBody as unknown as TokenErrorResponse; const errorDesc = tokenError.error_description ?? ""; if (tokenError.error === "invalid_client") { return err({ code: "invalid_client", message: `invalid_client: ${errorDesc}`, hint: "Your identity provider rejected the client credentials. Try setting oidc.token_endpoint_auth_method to 'client_secret_post' or 'client_secret_basic' in your config.", }); } // Praying on hopes and dreams, but this *might* help (MAYBE) const isPkceError = tokenError.error.toLowerCase().includes("pkce") || tokenError.error.toLowerCase().includes("code_verifier") || tokenError.error.toLowerCase().includes("code verifier") || errorDesc.toLowerCase().includes("pkce") || errorDesc.toLowerCase().includes("code_verifier") || errorDesc.toLowerCase().includes("code verifier"); if (isPkceError) { const usePkce = config.usePkce !== false; return err({ code: "pkce_error", message: `PKCE error: ${tokenError.error} — ${errorDesc}. Current use_pkce=${usePkce}`, hint: usePkce ? "Your identity provider may not support PKCE. Try setting oidc.use_pkce to false in your config." : "Your identity provider may require PKCE. Try setting oidc.use_pkce to true in your config.", }); } return err({ code: "token_exchange_failed", message: `Token exchange error: ${tokenError.error} — ${errorDesc}`, }); } if (typeof responseBody.access_token !== "string") { return err({ code: "token_exchange_failed", message: "Token response is missing access_token", }); } return ok({ access_token: responseBody.access_token as string, id_token: responseBody.id_token as string | undefined, token_type: responseBody.token_type as string | undefined, expires_in: responseBody.expires_in as number | undefined, refresh_token: responseBody.refresh_token as string | undefined, }); } async function verifyIdToken( idToken: string, expectedNonce: string, ): Promise> { if (!jwks) { return err({ code: "invalid_id_token", message: "JWKS resolver is not initialized — endpoints must be resolved first", }); } try { const { payload } = await jwtVerify(idToken, jwks, { issuer: config.issuer, audience: config.clientId, clockTolerance: 60, }); if (payload.nonce !== expectedNonce) { return err({ code: "nonce_mismatch", message: `Nonce mismatch: expected ${expectedNonce}, got ${payload.nonce}`, hint: "Please try signing in again. This can happen with stale browser sessions.", }); } return ok(payload); } catch (cause) { if (cause instanceof joseErrors.JWTClaimValidationFailed) { return err({ code: "invalid_id_token", message: `JWT claim validation failed: ${cause.claim} — ${cause.reason}`, }); } if (cause instanceof joseErrors.JWTExpired) { return err({ code: "invalid_id_token", message: "ID token is expired", }); } let weakRsaContext: WeakRsaContext | undefined; try { weakRsaContext = await getWeakRsaContext(idToken); } catch (weakRsaCause) { return err({ code: "invalid_id_token", message: `ID token verification failed: ${weakRsaCause instanceof Error ? weakRsaCause.message : String(weakRsaCause)}`, }); } if (weakRsaContext) { if (!config.allowWeakRsaKeys) { return err({ code: "invalid_id_token", message: "ID token was signed with a weak RSA key that Headplane rejects by default", hint: "If your provider cannot rotate to a 2048-bit-or-larger RSA signing key, set oidc.allow_weak_rsa_keys to true as a temporary compatibility fallback.", }); } return verifyIdTokenWithWeakRsa(weakRsaContext, expectedNonce); } if (cause instanceof joseErrors.JWSSignatureVerificationFailed) { return err({ code: "invalid_id_token", message: "ID token signature verification failed", hint: "The identity provider's signing keys may have changed. Try restarting Headplane to refresh the key cache.", }); } return err({ code: "invalid_id_token", message: `ID token verification failed: ${cause instanceof Error ? cause.message : String(cause)}`, }); } } async function verifyIdTokenWithWeakRsa( weakRsaContext: WeakRsaContext, expectedNonce: string, ): Promise> { for (const jwk of weakRsaContext.candidateKeys) { try { const key = createPublicKey({ key: jwk, format: "jwk" }); const isValid = verify( getNodeVerifyAlgorithm(weakRsaContext.alg), Buffer.from(weakRsaContext.decoded.signingInput), key, weakRsaContext.decoded.signature, ); if (!isValid) { continue; } } catch (cause) { return err({ code: "invalid_id_token", message: `ID token verification failed: ${cause instanceof Error ? cause.message : String(cause)}`, }); } if (!hasWarnedWeakKeyMode) { hasWarnedWeakKeyMode = true; log.warn( "auth", "OIDC issuer %s is using a weak RSA signing key. Accepting it only because oidc.allow_weak_rsa_keys=true.", config.issuer, ); } const claimError = validateOidcClaims( weakRsaContext.decoded.payload, config.issuer, config.clientId, 60, ); if (claimError) { return err(claimError); } if (weakRsaContext.decoded.payload.nonce !== expectedNonce) { return err({ code: "nonce_mismatch", message: `Nonce mismatch: expected ${expectedNonce}, got ${weakRsaContext.decoded.payload.nonce}`, hint: "Please try signing in again. This can happen with stale browser sessions.", }); } return ok(weakRsaContext.decoded.payload); } return err({ code: "invalid_id_token", message: "ID token signature verification failed", hint: "The identity provider's signing keys may have changed. Try restarting Headplane to refresh the key cache.", }); } async function enrichWithUserInfo( ep: ResolvedEndpoints, accessToken: string, claims: OidcClaims, ): Promise { const needsEnrichment = !claims.name && !claims.email && !claims.picture && !!resolveSubject(claims); const needsSubjectEnrichment = !resolveSubject(claims); const needsRoleEnrichment = !!config.roleClaim && claims[config.roleClaim] === undefined; if ( (!needsEnrichment && !needsSubjectEnrichment && !needsRoleEnrichment) || !ep.userinfoEndpoint ) { return claims; } try { const response = await fetch(ep.userinfoEndpoint, { headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/json", }, signal: AbortSignal.timeout(10_000), }); if (!response.ok) { log.debug("auth", "UserInfo endpoint returned %d, skipping enrichment", response.status); return claims; } const userInfo = (await response.json()) as Record; const roleClaimValue = config.roleClaim ? (claims[config.roleClaim] ?? userInfo[config.roleClaim]) : undefined; const subjectClaimValues = Object.fromEntries( getSubjectClaimOrder() .filter((claim) => claim !== "sub") .map((claim) => [ claim, readClaimAsString(claims, claim) ?? readClaimAsString(userInfo, claim), ]), ); return { ...claims, ...subjectClaimValues, ...(config.roleClaim && roleClaimValue !== undefined ? { [config.roleClaim]: roleClaimValue } : {}), name: claims.name ?? (userInfo.name as string | undefined), given_name: claims.given_name ?? (userInfo.given_name as string | undefined), family_name: claims.family_name ?? (userInfo.family_name as string | undefined), preferred_username: claims.preferred_username ?? (userInfo.preferred_username as string | undefined), email: claims.email ?? (userInfo.email as string | undefined), picture: claims.picture ?? (userInfo.picture as string | undefined), sub: claims.sub ?? readClaimAsString(userInfo, "sub"), }; } catch (cause) { log.debug( "auth", "UserInfo fetch failed (non-fatal): %s", cause instanceof Error ? cause.message : String(cause), ); return claims; } } function buildIdentity(claims: OidcClaims, idToken?: string): OidcIdentity { const subject = resolveSubject(claims); if (!subject) { throw new Error("OIDC subject was not resolved before identity construction"); } const name = claims.name ?? (claims.given_name && claims.family_name ? `${claims.given_name} ${claims.family_name}` : (claims.preferred_username ?? "SSO User")); const username = claims.preferred_username ?? claims.email?.split("@")[0] ?? "user"; let picture: string | undefined; if (config.profilePictureSource === "gravatar") { if (claims.email) { const hash = createHash("sha256").update(claims.email.trim().toLowerCase()).digest("hex"); picture = `https://www.gravatar.com/avatar/${hash}?s=200&d=identicon&r=x`; } } else { picture = claims.picture; } return { issuer: config.issuer, subject, name, username, email: claims.email, picture, role: config.roleClaim ? resolveRoleClaim(claims, config.roleClaim) : undefined, idToken, }; } function buildEndSessionUrl(idToken?: string): string | undefined { if (!endpoints?.endSessionEndpoint) { return undefined; } const params = new URLSearchParams(); if (idToken) { params.set("id_token_hint", idToken); } params.set("client_id", config.clientId); const postLogoutRedirectUri = config.postLogoutRedirectUri ?? new URL(`${__PREFIX__}/login?s=logout`, config.baseUrl).href; params.set("post_logout_redirect_uri", postLogoutRedirectUri); return `${endpoints.endSessionEndpoint}?${params.toString()}`; } function invalidate(): void { endpoints = undefined; lastError = undefined; jwks = undefined; resolvedAuthMethod = config.tokenEndpointAuthMethod; } function reload(newConfig: OidcConfig): void { config = Object.freeze({ ...newConfig }); maybeWarnWeakRsaMode(config); invalidate(); } function getSubjectClaimOrder(): string[] { return [ "sub", ...normalizeSubjectClaims(config.subjectClaims).filter((claim) => claim !== "sub"), ]; } function resolveSubject(claims: OidcClaims): string | undefined { for (const claim of getSubjectClaimOrder()) { const value = readClaimAsString(claims, claim); if (value) { return value; } } return undefined; } function resolveRoleClaim(claims: OidcClaims, claimName: string): string | undefined { const value = claims[claimName]; if (typeof value === "string") { return value.trim() || undefined; } if (Array.isArray(value)) { const roles = new Set(value.filter((v): v is string => typeof v === "string")); for (const role of ["admin", "network_admin", "it_admin", "auditor", "viewer", "member"]) { if (roles.has(role)) { return role; } } } return undefined; } return { status, discover, startFlow, handleCallback, buildEndSessionUrl, invalidate, reload, }; function maybeWarnWeakRsaMode(currentConfig: OidcConfig): void { if (!currentConfig.allowWeakRsaKeys) { return; } log.warn( "auth", "OIDC weak RSA compatibility mode is enabled for issuer %s. This lowers token verification security and should only be used as a temporary workaround.", currentConfig.issuer, ); } async function getWeakRsaContext(idToken: string): Promise { if (!endpoints?.jwksUri) { return undefined; } const decoded = decodeJwtParts(idToken); const alg = decoded.header.alg; if (alg !== "RS256" && alg !== "RS384" && alg !== "RS512") { return undefined; } const keys = await fetchSigningJwks(endpoints.jwksUri); const candidateKeys = selectCandidateSigningKeys(keys, decoded.header.kid).filter((jwk) => isWeakRsaKey(jwk), ); if (candidateKeys.length === 0) { return undefined; } return { alg, decoded, candidateKeys }; } async function fetchSigningJwks(jwksUri: string): Promise> { const cached = weakJwksCache.get(jwksUri); const now = Date.now(); if (cached && cached.expiresAt > now) { return cached.keys; } const response = await fetch(jwksUri, { headers: { Accept: "application/json" }, signal: AbortSignal.timeout(10_000), }); if (!response.ok) { throw new Error(`JWKS endpoint returned ${response.status}: ${jwksUri}`); } const json = (await response.json()) as JwksResponse; const keys = Array.isArray(json.keys) ? json.keys : []; if (keys.length === 0) { throw new Error("JWKS response did not contain any keys"); } weakJwksCache.set(jwksUri, { expiresAt: now + 60_000, keys, }); return keys; } } function readClaimAsString(claims: Record, claimName: string): string | undefined { const value = claims[claimName]; if (typeof value !== "string") { return undefined; } const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : undefined; } function generateRandom(bytes = 32): string { return randomBytes(bytes).toString("base64url"); } function computeS256Challenge(verifier: string): string { return createHash("sha256").update(verifier).digest("base64url"); } function decodeJwtParts(token: string): DecodedJwtParts { const parts = token.split("."); if (parts.length !== 3) { throw new Error("JWT must have exactly 3 parts"); } const [encodedHeader, encodedPayload, encodedSignature] = parts; const header = JSON.parse(Buffer.from(encodedHeader, "base64url").toString("utf8")) as Record< string, unknown >; const payload = JSON.parse( Buffer.from(encodedPayload, "base64url").toString("utf8"), ) as OidcClaims; const signature = Buffer.from(encodedSignature, "base64url"); return { header, payload, signature, signingInput: `${encodedHeader}.${encodedPayload}`, }; } function selectCandidateSigningKeys( keys: Array, expectedKid: unknown, ): Array { const kid = typeof expectedKid === "string" ? expectedKid : undefined; const rsaKeys = keys.filter((key) => key.kty === "RSA"); if (kid) { const matchingKey = rsaKeys.find((key) => key.kid === kid); if (matchingKey) { return [matchingKey]; } return []; } return rsaKeys; } function getNodeVerifyAlgorithm(alg: string): "RSA-SHA256" | "RSA-SHA384" | "RSA-SHA512" { switch (alg) { case "RS256": return "RSA-SHA256"; case "RS384": return "RSA-SHA384"; case "RS512": return "RSA-SHA512"; default: throw new Error(`Unsupported RSA verification algorithm: ${alg}`); } } function isWeakRsaKey(jwk: JsonWebKey): boolean { if (jwk.kty !== "RSA" || typeof jwk.n !== "string") { return false; } return getRsaModulusBitLength(jwk.n) < 2048; } function getRsaModulusBitLength(base64UrlModulus: string): number { const modulus = Buffer.from(base64UrlModulus, "base64url"); if (modulus.length === 0) { return 0; } let leadingZeroBits = 0; let currentByte = modulus[0]; while ((currentByte & 0x80) === 0 && leadingZeroBits < 8) { leadingZeroBits++; currentByte <<= 1; } return modulus.length * 8 - leadingZeroBits; } function normalizeSubjectClaims(subjectClaims?: string[]): string[] { const seen = new Set(); const normalized: string[] = []; for (const claim of subjectClaims ?? []) { const trimmed = claim.trim(); if (trimmed.length === 0 || seen.has(trimmed)) { continue; } seen.add(trimmed); normalized.push(trimmed); } return normalized; } function validateOidcClaims( payload: OidcClaims, expectedIssuer: string, expectedAudience: string, clockToleranceSeconds: number, ): OidcError | undefined { if (payload.iss !== expectedIssuer) { return { code: "invalid_id_token", message: 'JWT claim validation failed: iss — unexpected "iss" claim value', }; } const audiences = Array.isArray(payload.aud) ? payload.aud : [payload.aud]; if (!audiences.includes(expectedAudience)) { return { code: "invalid_id_token", message: 'JWT claim validation failed: aud — unexpected "aud" claim value', }; } const now = Math.floor(Date.now() / 1000); if (typeof payload.exp === "number" && now - clockToleranceSeconds >= payload.exp) { return { code: "invalid_id_token", message: "ID token is expired", }; } if (typeof payload.nbf === "number" && now + clockToleranceSeconds < payload.nbf) { return { code: "invalid_id_token", message: "JWT claim validation failed: nbf — token is not active yet", }; } return undefined; }