import type { ZodSchemaLike } from "../tools/ZodSchemaLike"; import type { DecodedAccessToken_RFC9068, OidcSpaUtils, ParamsOfBootstrap, ValidateAndDecodeAccessToken } from "./types"; import { Deferred } from "../tools/Deferred"; import { decodeProtectedHeader, jwtVerify, createLocalJWKSet, errors, importJWK, calculateJwkThumbprint, base64url } from "../vendor/server/jose"; import { assert, isAmong, id, type Equals, is, Reflect } from "../vendor/server/tsafe"; import { z } from "../vendor/server/zod"; import { Evt, throttleTime } from "../vendor/server/evt"; import { decodeJwt } from "../tools/decodeJwt"; export function createOidcSpaUtils>(params: { decodedAccessTokenSchema: ZodSchemaLike | undefined; }): OidcSpaUtils { const { decodedAccessTokenSchema } = params; const dParamsOfBootstrap = new Deferred>(); const evtPublicSigningKeys = Evt.create(undefined); const evtInvalidSignature = Evt.create(); evtInvalidSignature.pipe(throttleTime(3600_000)).attach(async () => { const publicSigningKeys_new = await (async function callee( count: number ): Promise { const paramsOfBootstrap = await dParamsOfBootstrap.pr; assert(paramsOfBootstrap.implementation === "real", "22933023"); const { issuerUri } = paramsOfBootstrap; let wrap: PublicSigningKeys | undefined; try { wrap = await fetchPublicSigningKeys({ issuerUri }); } catch (error) { if (count === 9) { console.warn( `Failed to refresh public key and signing algorithm after ${count + 1} attempts` ); return undefined; } const delayMs = 1000 * Math.pow(2, count); console.warn( `Failed to refresh public key and signing algorithm: ${String( error )}, retrying in ${delayMs}ms` ); await new Promise(resolve => setTimeout(resolve, delayMs)); return callee(count + 1); } return wrap; })(0); if (publicSigningKeys_new === undefined) { return; } evtPublicSigningKeys.state = publicSigningKeys_new; }); let bootstrapAuth_prResolved: Promise | undefined = undefined; type Out = OidcSpaUtils; const bootstrapAuth: Out["bootstrapAuth"] = paramsOfBootstrap => { if (bootstrapAuth_prResolved !== undefined) { return bootstrapAuth_prResolved; } return (bootstrapAuth_prResolved = (async () => { if (paramsOfBootstrap.implementation === "real") { evtPublicSigningKeys.state = await fetchPublicSigningKeys({ issuerUri: paramsOfBootstrap.issuerUri }); } dParamsOfBootstrap.resolve(paramsOfBootstrap); })()); }; const { getIsDpopPoofSeenRecordIfNotSeen } = (() => { const timeSeenByDpopProofId = new Map(); const evtDpopProofAdded = Evt.create(); evtDpopProofAdded.pipe(throttleTime(40_000)).attach(async () => { await Promise.resolve(); const now = Date.now(); for (const [dpopProofId, timeSeen] of timeSeenByDpopProofId) { if (now - timeSeen > 40_000) { timeSeenByDpopProofId.delete(dpopProofId); } else { // NOTE: All entries added after are more recent. break; } } }); function getIsDpopPoofSeenRecordIfNotSeen(params: { jkt: string; jti: string }): boolean { const { jkt, jti } = params; const dpopProofId = `${jkt}:${jti}`; if (timeSeenByDpopProofId.has(dpopProofId)) { return true; } { timeSeenByDpopProofId.set(dpopProofId, Date.now()); if (timeSeenByDpopProofId.size > 50_000) { const firstEntry = timeSeenByDpopProofId[Symbol.iterator]().next().value; assert(firstEntry !== undefined, "3922304"); const [key] = firstEntry; timeSeenByDpopProofId.delete(key); } evtDpopProofAdded.post(); } return false; } return { getIsDpopPoofSeenRecordIfNotSeen }; })(); const validateAndDecodeAccessToken: Out["validateAndDecodeAccessToken"] = async params => { const paramsOfBootstrap = await dParamsOfBootstrap.pr; if ( paramsOfBootstrap.implementation === "mock" && paramsOfBootstrap.behavior === "use static identity" ) { return id>({ isSuccess: true, decodedAccessToken: paramsOfBootstrap.decodedAccessToken_mock, get accessToken() { if (paramsOfBootstrap.accessToken_mock === undefined) { throw new Error( [ "oidc-spa: No mock provided for accessToken.", "Provide accessToken_mock to bootstrapAuth" ].join(" ") ); } return paramsOfBootstrap.accessToken_mock; }, get decodedAccessToken_original() { if (paramsOfBootstrap.decodedAccessToken_original_mock === undefined) { throw new Error( [ "oidc-spa: No mock provided for decodedAccessToken_original.", "Provide decodedAccessToken_original_mock to bootstrapAuth" ].join(" ") ); } return paramsOfBootstrap.decodedAccessToken_original_mock; } }); } let decodedAccessToken_original: unknown; validation: { if (paramsOfBootstrap.implementation === "mock") { assert>; decodedAccessToken_original = decodeJwt(params.accessToken); try { zDecodedAccessToken_RFC9068.parse(decodedAccessToken_original); } catch (error) { assert(error instanceof Error, "38292332"); return id({ isSuccess: false, errorCause: "validation error", debugErrorMessage: [ `The decoded access token does not satisfies`, `the shape mandated by RFC9068: ${error.message}` ].join(" ") }); } assert(is(decodedAccessToken_original)); break validation; } let kid: string; let alg: string; { let header: ReturnType; try { header = decodeProtectedHeader(params.accessToken); } catch { return id({ isSuccess: false, errorCause: "validation error", debugErrorMessage: "Failed to decode the JWT header" }); } const { kid: kidFromHeader, alg: algFromHeader } = header; if (typeof kidFromHeader !== "string" || kidFromHeader.length === 0) { return id({ isSuccess: false, errorCause: "validation error", debugErrorMessage: "The decoded JWT header does not have a kid property" }); } if (typeof algFromHeader !== "string") { return id({ isSuccess: false, errorCause: "validation error", debugErrorMessage: "The decoded JWT header does not specify an algorithm" }); } if ( !isAmong( [ "RS256", "RS384", "RS512", "ES256", "ES384", "ES512", "PS256", "PS384", "PS512" ], algFromHeader ) ) { return id({ isSuccess: false, errorCause: "validation error", debugErrorMessage: `Unsupported or too weak algorithm ${algFromHeader}` }); } kid = kidFromHeader; alg = algFromHeader; } const publicSigningKeys = evtPublicSigningKeys.state; assert(publicSigningKeys !== undefined, "3304483302"); if (!publicSigningKeys.kidSet.has(kid)) { return id({ isSuccess: false, errorCause: "validation error", debugErrorMessage: `No public signing key found with kid ${kid}` }); } try { const verification = await jwtVerify(params.accessToken, publicSigningKeys.keyResolver, { algorithms: [alg] }); decodedAccessToken_original = verification.payload; } catch (error) { assert(error instanceof Error, "3922843"); if (error instanceof errors.JWTExpired) { return id({ isSuccess: false, errorCause: "validation error - access token expired", debugErrorMessage: error.message }); } evtInvalidSignature.post(); return id({ isSuccess: false, errorCause: "validation error - invalid signature", debugErrorMessage: error.message }); } try { zDecodedAccessToken_RFC9068.parse(decodedAccessToken_original); } catch (error) { assert(error instanceof Error, "382923"); return id({ isSuccess: false, errorCause: "validation error", debugErrorMessage: [ `The decoded access token does not satisfies`, `the shape mandated by RFC9068: ${error.message}` ].join(" ") }); } assert(is(decodedAccessToken_original)); // Validate issuer { const { issuerUri } = paramsOfBootstrap; const normalize = (url: string) => url.replace(/\/$/, ""); if (normalize(decodedAccessToken_original.iss) !== normalize(issuerUri)) { return id({ isSuccess: false, errorCause: "validation error", debugErrorMessage: [ `iss claim in access token payload "${decodedAccessToken_original.iss}"`, `does not match the issuerUri "${issuerUri}".` ].join(" ") }); } } validate_audience: { const { expectedAudience } = paramsOfBootstrap; if (expectedAudience === undefined) { break validate_audience; } const audiences = decodedAccessToken_original.aud instanceof Array ? decodedAccessToken_original.aud : [decodedAccessToken_original.aud]; if (!audiences.includes(expectedAudience)) { return id({ isSuccess: false, errorCause: "validation error", debugErrorMessage: [ `Not expected audience, got aud claim ${JSON.stringify( decodedAccessToken_original.aud )}`, `but expected "${expectedAudience}".` ].join(" ") }); } } validate_DPoP: { const cnf_jkt = decodedAccessToken_original.cnf === undefined ? undefined : decodedAccessToken_original.cnf.jkt; if (cnf_jkt !== undefined && typeof cnf_jkt !== "string") { return id({ isSuccess: false, errorCause: "validation error", debugErrorMessage: "cnf.jkt claim is expected to be a string" }); } if (params.scheme === "Bearer") { if (!params.rejectIfAccessTokenDPoPBound) { if (process.env.NODE_ENV === "development") { console.warn( [ "oidc-spa: Accepting a DPoP bound token without", "validating the DPoP proof because rejectIfAccessTokenDPoPBound was explicitly", "set to false" ].join(" ") ); } break validate_DPoP; } if (cnf_jkt !== undefined) { return id({ isSuccess: false, errorCause: "validation error", debugErrorMessage: [ "access token is DPoP bound (cnf.jkt claim present)", "but used with bearer scheme" ].join(" ") }); } break validate_DPoP; } assert>; if (cnf_jkt === undefined) { return id({ isSuccess: false, errorCause: "validation error", debugErrorMessage: [ "DPoP validation error, missing cnf.jtk claim", "in the access token payload" ].join(" ") }); } let dpopHeader: ReturnType; try { dpopHeader = decodeProtectedHeader(params.dpopProof); } catch { return id({ isSuccess: false, errorCause: "validation error", debugErrorMessage: "Failed to decode DPoP proof header" }); } const { jwk, alg: dpopAlg, typ: dpopTyp } = dpopHeader; if (dpopAlg === undefined) { return id({ isSuccess: false, errorCause: "validation error", debugErrorMessage: "DPoP proof header missing alg" }); } if ( !isAmong( [ "RS256", "RS384", "RS512", "ES256", "ES384", "ES512", "PS256", "PS384", "PS512" ], dpopAlg ) ) { return id({ isSuccess: false, errorCause: "validation error", debugErrorMessage: `Unsupported or too weak DPoP algorithm ${dpopAlg}` }); } if (dpopTyp === undefined || dpopTyp.toLowerCase() !== "dpop+jwt") { return id({ isSuccess: false, errorCause: "validation error", debugErrorMessage: "DPoP proof header typ must be dpop+jwt" }); } if (jwk === undefined) { return id({ isSuccess: false, errorCause: "validation error", debugErrorMessage: "DPoP proof header missing jwk" }); } let jkt_calculated: string; try { jkt_calculated = await calculateJwkThumbprint(jwk); } catch (error) { return id({ isSuccess: false, errorCause: "validation error", debugErrorMessage: `Failed to calculate DPoP jwk thumbprint: ${String(error)}` }); } if (jkt_calculated !== cnf_jkt) { return id({ isSuccess: false, errorCause: "validation error", debugErrorMessage: "DPoP jwk thumbprint does not match cnf.jkt claim" }); } let dpopPayload: Awaited>["payload"]; try { const key = await importJWK(jwk, dpopAlg); const verification = await jwtVerify(params.dpopProof, key, { algorithms: [dpopAlg], typ: "dpop+jwt" }); dpopPayload = verification.payload; } catch (error) { assert(error instanceof Error, "34022849313"); return id({ isSuccess: false, errorCause: "validation error", debugErrorMessage: `DPoP proof signature/structure invalid: ${error.message}` }); } const { htm, htu, ath, iat, jti } = dpopPayload; { if (iat === undefined) { return id({ isSuccess: false, errorCause: "validation error", debugErrorMessage: "DPoP proof missing or invalid iat claim" }); } const now = Math.floor(Date.now() / 1000); const maxAgeSeconds = 40; const maxFutureSkewSeconds = 3; if (iat - now > maxFutureSkewSeconds) { return id({ isSuccess: false, errorCause: "validation error", debugErrorMessage: "DPoP proof iat is in the future" }); } if (now - iat > maxAgeSeconds) { return id({ isSuccess: false, errorCause: "validation error", debugErrorMessage: "DPoP proof iat too old" }); } } check_htm: { const errored = id({ isSuccess: false, errorCause: "validation error", debugErrorMessage: [ "DPoP proof htm claim does not match request method.", `htm: ${htm}, expected htm: ${params.expectedHtm}` ].join(" ") }); if (typeof htm !== "string") { if (!htm && params.expectedHtm === undefined) { break check_htm; } return errored; } if (params.expectedHtm === undefined) { return errored; } if (htm.toUpperCase() !== params.expectedHtm.toUpperCase()) { return errored; } } check_htu: { const errored = id({ isSuccess: false, errorCause: "validation error", debugErrorMessage: [ "DPoP proof htu claim does not match request url.", `htu: ${htu}, expected htu: ${params.expectedHtu}` ].join(" ") }); if (typeof htu !== "string") { if (!htu && params.expectedHtu === undefined) { break check_htu; } return errored; } if (params.expectedHtu === undefined) { return errored; } if (htu !== params.expectedHtu) { return errored; } } if (typeof ath !== "string") { return id({ isSuccess: false, errorCause: "validation error", debugErrorMessage: "DPoP proof missing ath claim" }); } const expectedAth = base64url.encode( new Uint8Array( await globalThis.crypto.subtle.digest( "SHA-256", new TextEncoder().encode(params.accessToken) ) ) ); if (ath !== expectedAth) { return id({ isSuccess: false, errorCause: "validation error", debugErrorMessage: "DPoP proof ath claim does not match access token" }); } if (jti === undefined) { return id({ isSuccess: false, errorCause: "validation error", debugErrorMessage: "DPoP proof missing jti claim" }); } if (getIsDpopPoofSeenRecordIfNotSeen({ jkt: cnf_jkt, jti })) { return id({ isSuccess: false, errorCause: "validation error", debugErrorMessage: "DPoP proof replayed" }); } } } let decodedAccessToken: DecodedAccessToken; if (decodedAccessTokenSchema === undefined) { // @ts-expect-error: We know it will match because DecodedAccessToken will default to DecodedAccessToken_RFC9068 decodedAccessToken = decodedAccessToken_original; } else { try { decodedAccessToken = decodedAccessTokenSchema.parse(decodedAccessToken_original); } catch (error) { assert(error instanceof Error, "887302013"); return id({ isSuccess: false, errorCause: "validation error", debugErrorMessage: [ `The decoded access token does not satisfies`, `the shape that the application expects: ${error.message}` ].join(" ") }); } } return id>({ isSuccess: true, decodedAccessToken, decodedAccessToken_original, accessToken: params.accessToken }); }; return { bootstrapAuth, validateAndDecodeAccessToken, ofTypeDecodedAccessToken: Reflect() }; } type PublicSigningKeys = { keyResolver: ReturnType; kidSet: Set; }; async function fetchPublicSigningKeys(params: { issuerUri: string }): Promise { const { issuerUri } = params; const { jwks_uri } = await (async () => { const url = `${issuerUri.replace(/\/$/, "")}/.well-known/openid-configuration`; const response = await fetch(url).catch(error => { assert(error instanceof Error); return error; }); if (response instanceof Error || !response.ok) { throw new Error( `Failed to fetch openid configuration of the issuerUri: ${issuerUri} (${url}): ${ response instanceof Error ? response.message : response.statusText }` ); } let data: unknown; try { data = await response.json(); } catch (error) { throw new Error(`Failed to parse json from ${url}: ${String(error)}`); } { type WellKnownConfiguration = { jwks_uri: string; }; const zWellKnownConfiguration = z.object({ jwks_uri: z.string() }); assert>>; try { zWellKnownConfiguration.parse(data); } catch { throw new Error(`${url} does not have a jwks_uri property`); } assert(is(data)); } const { jwks_uri } = data; return { jwks_uri }; })(); const { jwks } = await (async () => { const response = await fetch(jwks_uri); if (!response.ok) { throw new Error( `Failed to fetch public key and algorithm from ${jwks_uri}: ${response.statusText}` ); } let jwks: unknown; try { jwks = await response.json(); } catch (error) { throw new Error(`Failed to parse json from ${jwks_uri}: ${String(error)}`); } { type Jwks = { keys: { kid: string; kty: string; use?: string; alg?: string; }[]; }; const zJwks = z.object({ keys: z.array( z.object({ kid: z.string(), kty: z.string(), use: z.string().optional(), alg: z.string().optional() }) ) }); assert>>; try { zJwks.parse(jwks); } catch { throw new Error(`${jwks_uri} does not have the expected shape`); } assert(is(jwks)); } return { jwks }; })(); //const signatureKeys = jwks.keys.filter((key): key is JWKS["keys"][number] & { kid: string } => { const signatureKeys = jwks.keys.filter(key => { if (typeof key.kid !== "string" || key.kid.length === 0) { return false; } if (key.use !== undefined && key.use !== "sig") { return false; } const supportedKty = ["RSA", "EC"] as const; if (!supportedKty.includes(key.kty as (typeof supportedKty)[number])) { return false; } return true; }); assert( signatureKeys.length !== 0, `No public signing key found at ${jwks_uri}, ${JSON.stringify(jwks, null, 2)}` ); const kidSet = new Set(signatureKeys.map(({ kid }) => kid)); const keyResolver = createLocalJWKSet({ keys: signatureKeys }); return { keyResolver, kidSet }; } const zDecodedAccessToken_RFC9068 = (() => { type TargetType = DecodedAccessToken_RFC9068; const zTargetType = z .object({ iss: z.string(), sub: z.string(), aud: z.union([z.string(), z.array(z.string())]), exp: z.number(), iat: z.number(), client_id: z.string().optional(), scope: z.string().optional(), jti: z.string().optional(), nbf: z.number().optional(), auth_time: z.number().optional(), cnf: z.record(z.string(), z.unknown()).optional() }) .catchall(z.unknown()); type InferredType = z.infer; assert>; return id>(zTargetType); })();