{"version":3,"file":"jwt.mjs","names":[],"sources":["../src/jwt.ts"],"sourcesContent":["/**\n * @file lib/jwt.ts\n *\n * # JWT Verification\n *\n * Stateless JWT verification via JWKS, with in-process key caching.\n *\n * ## Anatomy of a JWT\n *\n * A compact JWT (JWS compact serialisation) is three Base64URL-encoded parts\n * joined by dots:\n *\n * ```\n * <header>.<payload>.<signature>\n * ```\n *\n * - **Header** — metadata about the token: signing algorithm, key ID, token type.\n * - **Payload** — the claims: user identity, expiry, issuer, audience, etc.\n * - **Signature** — `SIGN(privateKey, \"<header>.<payload>\")`. Any party with the\n *   matching public key can verify it without contacting the auth server.\n *\n * A valid signature proves two things:\n *\n * 1. **Authenticity** — the token was produced by the holder of the private key.\n * 2. **Integrity** — neither the header nor the payload has been altered since signing.\n *\n * Signature verification alone is not sufficient. The claims inside the payload\n * must also be checked (expiry, issuer, audience, …). Both steps are performed\n * by {@link verifyJwt}.\n *\n * ---\n *\n * ## JWT Header fields\n *\n * The header is a JSON object Base64URL-encoded into the first segment.\n *\n * | Field | Purpose | Verified |\n * |-------|---------|---------|\n * | `alg` | Signing algorithm (`RS256`, `ES256`, …) | Checked against {@link VerifyJwtOptions.allowedAlgorithms} before JWKS is fetched |\n * | `kid` | Key ID — selects the matching public key from the JWKS | Used for key lookup; falls back to the first JWKS key when absent |\n * | `typ` | Token type (`JWT`, `at+JWT`, …) | Checked against {@link VerifyJwtOptions.allowedTokenTypes} (case-insensitive) |\n * | ~~`enc`~~ | ~~Encryption algorithm — present in JWE (encrypted) tokens only~~ | ~~Not applicable — we verify JWS (signed) tokens, not JWE (encrypted)~~ |\n * | ~~`cty`~~ | ~~Content type — describes the secured content media type~~ | ~~Not applicable — not set by standard OIDC/OAuth auth servers~~ |\n *\n * ---\n *\n * ## JWT Payload claims\n *\n * The payload is a JSON object Base64URL-encoded into the second segment.\n *\n * ### Validated claims\n *\n * | Claim | Purpose | How |\n * |-------|---------|-----|\n * | `iss` | Issuer — the URL of the auth server that issued the token | Must be present and must equal {@link VerifyJwtOptions.issuerUrl} |\n * | `exp` | Expiration time — Unix timestamp after which the token is invalid | Must be present and in the future, allowing {@link VerifyJwtOptions.clockSkewMs} tolerance |\n * | `nbf` | Not before — Unix timestamp before which the token must not be accepted | Must be in the past, allowing {@link VerifyJwtOptions.clockSkewMs} tolerance |\n * | `iat` | Issued at — Unix timestamp when the token was created | Must not be in the future beyond {@link VerifyJwtOptions.clockSkewMs} |\n * | `aud` | Audience — the intended recipient(s) of the token | Checked against {@link VerifyJwtOptions.audience} when that option is provided |\n *\n * ### Read-only claims (extracted into the session, not further validated)\n *\n * | Claim | Purpose |\n * |-------|---------|\n * | `sub` | Subject — the user's unique identifier |\n * | `email` | User's email address |\n * | `name` | User's display name |\n *\n * ### Not validated\n *\n * | Claim | Purpose | Why skipped |\n * |-------|---------|-------------|\n * | ~~`jti`~~ | ~~JWT ID — a unique identifier for this specific token instance~~ | ~~Revocation requires a server-side blocklist; not feasible in stateless edge middleware~~ |\n * | ~~`azp`~~ | ~~Authorized party — the OAuth 2.0 client that requested the token~~ | ~~The proxy architecture means the browser never contacts the auth server directly, making cross-origin token replay a non-threat~~ |\n * | ~~`nonce`~~ | ~~OIDC nonce — binds a token to a specific authorisation request~~ | ~~Not applicable in our password/cookie flow (no OIDC redirect round-trip)~~ |\n * | ~~`acr`~~ | ~~Authentication class reference — describes the strength of the authentication~~ | ~~Not validated (future: require MFA for sensitive routes)~~ |\n * | ~~`amr`~~ | ~~Authentication methods reference — list of methods used to authenticate~~ | ~~Not validated~~ |\n * | ~~`scope`~~ | ~~OAuth 2.0 scopes granted to the token~~ | ~~Not validated (future: per-route scope enforcement)~~ |\n *\n * ---\n *\n * ## JWKS caching\n *\n * Each issuer's whole JWKS document is cached in-process, keyed by its JWKS URI,\n * for {@link JWKS_TTL_MS} milliseconds (default 5 minutes); keys are imported\n * once and reused. Keying by the URI keeps two issuers that publish the same\n * `kid` apart.\n *\n * The document is fetched from `{issuerUrl}/auth/keys` on first use and after\n * the TTL. A token naming a `kid` the cached document lacks refreshes it — how\n * a rotated key is picked up — but no sooner than {@link JWKS_MIN_REFRESH_MS}\n * after the last fetch. The `kid` header is attacker-controlled, so a per-`kid`\n * cache would let any request with a made-up `kid` cost a JWKS fetch.\n *\n * > **Note (serverless):** the cache is module-level. In serverless environments\n * > each cold start gets a fresh cache; warm instances share it across requests.\n *\n * ---\n *\n * ## Runtime note\n *\n * This file is the **shared implementation** used by both `sdk-next` (Edge\n * runtime) and `sdk-nuxt` (Node.js/Nitro). `base64UrlDecode` uses `atob`,\n * which is available in both environments (Edge natively; Node.js since v16).\n * The previous per-SDK copies (one using `atob`, one using `Buffer`) have\n * been consolidated here.\n */\n\nimport { joinURL, withoutTrailingSlash } from \"ufo\";\n\n// ─── Constants ───────────────────────────────────────────────────────────────\n\n/** How long a cached JWKS document is considered fresh before re-fetching. */\nexport const JWKS_TTL_MS = 5 * 60 * 1_000;\n\n/**\n * The least time between two JWKS fetches for one issuer. A token naming a\n * `kid` the cached document lacks triggers a refresh — that is how a rotated\n * key is picked up — but at most this often, so a stream of tokens with random\n * `kid` values cannot turn every request into a JWKS fetch.\n */\nexport const JWKS_MIN_REFRESH_MS = 30 * 1_000;\n\n// ─── Types ────────────────────────────────────────────────────────────────────\n\n/**\n * The subset of standard JWT payload claims this module reads or validates.\n *\n * All fields are `readonly`. Additional custom claims are preserved under\n * the index signature and accessible via `payload[\"my-claim\"]`.\n *\n * Refer to the module-level documentation for the full verification matrix.\n */\nexport interface JwtPayload {\n  /** Issuer (`iss`) — identifies the auth server that created the token. */\n  readonly iss?: string;\n  /** Subject (`sub`) — the user's unique identifier. */\n  readonly sub?: string;\n  /**\n   * Audience (`aud`) — the intended recipient(s).\n   * A single string or an array of strings per RFC 7519 §4.1.3.\n   */\n  readonly aud?: string | readonly string[];\n  /** Expiration time (`exp`) — seconds since Unix epoch. */\n  readonly exp?: number;\n  /** Not before (`nbf`) — seconds since Unix epoch. */\n  readonly nbf?: number;\n  /** Issued at (`iat`) — seconds since Unix epoch. */\n  readonly iat?: number;\n  /** User's email address (custom claim). */\n  readonly email?: string;\n  /** User's display name (custom claim). */\n  readonly name?: string;\n  readonly [key: string]: unknown;\n}\n\n/**\n * The typed representation of a JWT header.\n *\n * The three fields below are the only ones this library reads. Additional\n * header parameters are preserved under the index signature.\n */\nexport interface JwtHeader {\n  /** Signing algorithm (`alg`) — e.g. `\"RS256\"`, `\"ES256\"`. */\n  readonly alg?: string;\n  /** Token type (`typ`) — e.g. `\"JWT\"`, `\"at+JWT\"`. */\n  readonly typ?: string;\n  /** Key ID (`kid`) — selects the matching public key from the JWKS. */\n  readonly kid?: string;\n  readonly [key: string]: unknown;\n}\n\n/**\n * The decoded, unverified parts of a compact-serialised JWT.\n *\n * **Do not act on these values before signature verification.**\n * This type is returned by {@link decodeJwt} purely to carry the\n * raw data into the next step.\n */\nexport interface DecodedJwt {\n  readonly header: JwtHeader;\n  readonly payload: JwtPayload;\n}\n\n/** A public key as published in a JWKS document. */\ntype Jwk = JsonWebKey & { readonly kid?: string; readonly alg?: string };\n\n/**\n * The cached JWKS document of one issuer. The whole key set is cached rather\n * than one key per `kid`, so a token naming an unknown `kid` is answered from\n * the cache instead of costing a JWKS fetch.\n */\ninterface JwksCacheEntry {\n  readonly keys: readonly Jwk[];\n  readonly fetchedAt: number;\n  /** Imported keys, by `kid` and algorithm, so each is imported once. */\n  readonly imported: Map<string, CryptoKey>;\n}\n\n/**\n * Options for {@link verifyJwt}.\n *\n * All fields are `readonly` — this object is never mutated after construction.\n */\nexport interface VerifyJwtOptions {\n  /**\n   * Base URL of the auth backend.\n   *\n   * Serves two purposes:\n   * 1. Determines the JWKS endpoint: `{issuerUrl}/auth/keys`\n   * 2. Is compared against the `iss` claim when present in the token.\n   */\n  readonly issuerUrl: string;\n\n  /**\n   * Restrict accepted `alg` header values. Tokens whose algorithm is not in\n   * this list are rejected before the JWKS is fetched.\n   * When `undefined`, all algorithms are accepted (the middleware layer\n   * defaults to `[\"RS256\", \"ES256\"]` before passing options here).\n   */\n  readonly allowedAlgorithms?: readonly string[];\n\n  /**\n   * Clock-skew tolerance in milliseconds, applied symmetrically to `exp`,\n   * `nbf`, and `iat` checks to accommodate minor differences between clocks.\n   */\n  readonly clockSkewMs: number;\n\n  /**\n   * Expected `aud` claim value(s). When `undefined`, audience is not\n   * validated. Provide a string or array when the auth server sets `aud`\n   * and you want to ensure the token was issued specifically for your app.\n   */\n  readonly audience?: string | readonly string[];\n\n  /**\n   * Accepted `typ` header values (case-insensitive).\n   *\n   * Guards against using a refresh token or ID token where an access token\n   * is expected. Set to an empty array (`[]`) to disable this check.\n   *\n   * @default [\"JWT\", \"at+JWT\"]\n   */\n  readonly allowedTokenTypes: readonly string[];\n\n  /**\n   * Timeout in milliseconds for JWKS endpoint requests.\n   * Requests that do not complete within this window are aborted and the\n   * token is rejected (function returns `null`).\n   * @default 5000\n   */\n  readonly jwksTimeoutMs?: number;\n}\n\n// ─── JWKS cache ───────────────────────────────────────────────────────────────\n\n/**\n * In-memory JWKS cache: one document per JWKS URI. Keying by the full URI keeps\n * two issuers that publish the same `kid` apart. It holds one entry per issuer\n * the process verifies against, each with the keys that issuer published, so it\n * cannot be grown by the `kid` values tokens claim.\n */\nconst jwksCache = new Map<string, JwksCacheEntry>();\n\n/** In-flight JWKS fetches by URI, so concurrent misses share one request. */\nconst jwksInFlight = new Map<string, Promise<JwksCacheEntry | null>>();\n\n/**\n * When each URI was last fetched, successfully or not. The refresh throttle\n * counts from here, not from the last document, so a failing endpoint is not\n * retried on every request.\n */\nconst jwksAttemptedAt = new Map<string, number>();\n\n/**\n * Bumped by {@link clearJwksCache}. A fetch that started before a clear must not\n * write its answer into the cache or the in-flight table afterwards.\n */\nlet jwksGeneration = 0;\n\n/**\n * Forgets every cached JWKS document, so the next verification fetches the key\n * set anew. Use it to drop a compromised key at once instead of waiting out\n * {@link JWKS_TTL_MS}, and in tests that publish different keys per case.\n */\nexport function clearJwksCache(): void {\n  jwksGeneration++;\n  jwksCache.clear();\n  jwksInFlight.clear();\n  jwksAttemptedAt.clear();\n}\n\nconst DECODER = new TextDecoder();\nconst ENCODER = new TextEncoder();\n\n// ─── Base64URL ────────────────────────────────────────────────────────────────\n\n/**\n * Decodes a Base64URL-encoded string into a `Uint8Array`.\n *\n * The Edge runtime has no `Buffer`, so this uses `atob` after translating the\n * Base64URL alphabet (`-` → `+`, `_` → `/`) and padding to a multiple of four.\n *\n * @param input - A Base64URL-encoded string (padding is optional).\n * @returns The decoded bytes.\n */\nexport function base64UrlDecode(input: string): Uint8Array<ArrayBuffer> {\n  const base64 = input.replace(/-/g, \"+\").replace(/_/g, \"/\");\n  const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), \"=\");\n  const binary = atob(padded);\n  return Uint8Array.from(binary, (c) => c.charCodeAt(0));\n}\n\n// ─── JWT splitting ────────────────────────────────────────────────────────────\n\n/**\n * Splits a compact JWT string into its three raw Base64URL segments without\n * decoding or verifying any of them.\n *\n * Tokens with more than three segments (e.g. nested JWTs) are accepted; only\n * the first three parts are returned. Tokens with fewer than three segments are\n * structurally invalid compact JWTs and cannot be verified.\n *\n * @param token - The compact JWT string.\n * @returns A `[header, payload, signature]` tuple, or `null` when the token\n *   has fewer than three dot-separated segments.\n */\nfunction splitToken(token: string): readonly [string, string, string] | null {\n  const [h, p, s] = token.split(\".\");\n  if (!h || !p || !s) {\n    return null;\n  }\n  return [h, p, s] as const;\n}\n\n// ─── JWT decode ───────────────────────────────────────────────────────────────\n\n/**\n * Decodes a compact-serialised JWT into its header and payload **without**\n * verifying the signature.\n *\n * **Do not trust the result of this function alone.** Use it only as the first\n * step of a full verification pipeline — the signature check in {@link verifyJwt}\n * must follow before any claim can be trusted.\n *\n * @param token - A compact JWT string (`header.payload.signature`).\n * @returns The decoded {@link DecodedJwt}.\n * @throws {TypeError} When `token` has fewer than three dot-separated segments.\n */\nexport function decodeJwt(token: string): DecodedJwt {\n  const segments = splitToken(token);\n  if (!segments) {\n    throw new TypeError(\n      `Invalid JWT: expected at least three dot-separated segments, received ${token.split(\".\").length}.`,\n    );\n  }\n  const [h, p] = segments;\n  return {\n    header: JSON.parse(DECODER.decode(base64UrlDecode(h))) as JwtHeader,\n    payload: JSON.parse(DECODER.decode(base64UrlDecode(p))) as JwtPayload,\n  };\n}\n\n// ─── JWT shape detection ──────────────────────────────────────────────────────\n\n/**\n * Returns `true` when `token` looks structurally like a signed JWT (JWS) —\n * NOT an encrypted token (JWE).\n *\n * JWS compact: 3 dot-separated segments; header has `alg` but NOT `enc`.\n * JWE compact: 5 dot-separated segments; header has BOTH `alg` and `enc`.\n *\n * The Zitadel backend issues JWE tokens (AES-256-GCM via go-jose\n * `A256GCMKW/A256GCM`), whose compact form is\n * `header.encrypted_key.iv.ciphertext.tag`. The header decodes to JSON with\n * `\"alg\":\"A256GCMKW\",\"enc\":\"A256GCM\"`. Checking for the absence of `enc`\n * correctly identifies signed JWTs without false-positives on JWE tokens.\n *\n * This is a structural check only — not a security check. Use it to route a\n * token to the correct validation path ({@link verifyJwt} for JWS, a backend\n * session lookup for everything else), never to trust one.\n */\nexport function isJwtShaped(token: string): boolean {\n  const parts = token.split(\".\");\n  if (parts.length < 3 || !parts[0]) return false;\n  try {\n    const header = JSON.parse(DECODER.decode(base64UrlDecode(parts[0]))) as Record<string, unknown>;\n    return typeof header?.alg === \"string\" && !(\"enc\" in header);\n  } catch {\n    return false;\n  }\n}\n\n// ─── Algorithm helpers ────────────────────────────────────────────────────────\n\n/**\n * Web Crypto parameters for importing a public key from JWKS, keyed by JWT\n * `alg` header value. The keys of this object are the only supported algorithms;\n * anything not present throws {@link TypeError}.\n */\nconst IMPORT_PARAMS = {\n  RS256: { name: \"RSASSA-PKCS1-v1_5\", hash: \"SHA-256\" },\n  RS384: { name: \"RSASSA-PKCS1-v1_5\", hash: \"SHA-384\" },\n  RS512: { name: \"RSASSA-PKCS1-v1_5\", hash: \"SHA-512\" },\n  ES256: { name: \"ECDSA\", namedCurve: \"P-256\" },\n  ES384: { name: \"ECDSA\", namedCurve: \"P-384\" },\n  ES512: { name: \"ECDSA\", namedCurve: \"P-521\" },\n} satisfies Record<string, RsaHashedImportParams | EcKeyImportParams>;\n\n/**\n * Web Crypto parameters for verifying a JWT signature, keyed by JWT `alg`\n * header value.\n */\nconst VERIFY_PARAMS = {\n  RS256: { name: \"RSASSA-PKCS1-v1_5\" },\n  RS384: { name: \"RSASSA-PKCS1-v1_5\" },\n  RS512: { name: \"RSASSA-PKCS1-v1_5\" },\n  ES256: { name: \"ECDSA\", hash: \"SHA-256\" },\n  ES384: { name: \"ECDSA\", hash: \"SHA-384\" },\n  ES512: { name: \"ECDSA\", hash: \"SHA-512\" },\n} satisfies Record<string, EcdsaParams | Algorithm>;\n\nfunction assertSupportedAlgorithm(alg: string): asserts alg is keyof typeof IMPORT_PARAMS {\n  if (!Object.hasOwn(IMPORT_PARAMS, alg)) {\n    throw new TypeError(\n      `Unsupported JWT algorithm: \"${alg}\". Supported algorithms are ${Object.keys(IMPORT_PARAMS).join(\", \")}.`,\n    );\n  }\n}\n\n/** The JWT `alg` an EC key's curve signs with (RFC 7518 §3.4). */\nconst CURVE_ALGORITHMS: Readonly<Record<string, string>> = {\n  \"P-256\": \"ES256\",\n  \"P-384\": \"ES384\",\n  \"P-521\": \"ES512\",\n};\n\n/**\n * The Web Crypto import parameters for verifying a token signed with `alg`\n * under `jwk`, or `null` when the key cannot have produced it.\n *\n * RFC 7517 §4.4 makes a JWK's `alg` optional, so the key type decides: an EC\n * key's curve fixes its algorithm, and an RSA key fits any `RS*` algorithm.\n * When the JWK names an `alg`, the token must use that one. A mismatch is a\n * token that cannot verify against this key, not an error worth logging.\n */\nfunction importParams(jwk: Jwk, alg: string): RsaHashedImportParams | EcKeyImportParams | null {\n  if (!Object.hasOwn(IMPORT_PARAMS, alg)) {\n    return null;\n  }\n  if (jwk.alg !== undefined && jwk.alg !== alg) {\n    return null;\n  }\n  const fits =\n    jwk.kty === \"RSA\"\n      ? alg.startsWith(\"RS\")\n      : jwk.kty === \"EC\" && CURVE_ALGORITHMS[jwk.crv ?? \"\"] === alg;\n  return fits ? IMPORT_PARAMS[alg as keyof typeof IMPORT_PARAMS] : null;\n}\n\nfunction resolveVerifyAlgorithm(alg: string): EcdsaParams | Algorithm {\n  assertSupportedAlgorithm(alg);\n  return VERIFY_PARAMS[alg];\n}\n\n// ─── JWKS fetch ───────────────────────────────────────────────────────────────\n\n/** Resolves to `null` if `promise` has not settled within `timeoutMs`. */\nfunction withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T | null> {\n  let timer: ReturnType<typeof setTimeout> | undefined;\n  const expired = new Promise<null>((resolve) => {\n    timer = setTimeout(() => {\n      resolve(null);\n    }, timeoutMs);\n  });\n  return Promise.race([promise, expired]).finally(() => {\n    clearTimeout(timer);\n  });\n}\n\n/**\n * Fetches the JWKS document at `jwksUri`, coalescing concurrent fetches, and\n * caches it. Returns `null` when the endpoint fails or answers without a key\n * set, and also, without fetching, when the URI was fetched less than\n * {@link JWKS_MIN_REFRESH_MS} ago: a failed fetch counts, so a failing endpoint\n * is not retried on every request. A failure leaves any previously cached\n * document in place, but {@link resolveJwk} does not use it once it has expired.\n */\nfunction refreshJwks(jwksUri: string, timeoutMs: number): Promise<JwksCacheEntry | null> {\n  const pending = jwksInFlight.get(jwksUri);\n  if (pending) {\n    // The fetch in flight runs on the timeout of whoever started it. A caller\n    // that joins it waits no longer than its own.\n    return withTimeout(pending, timeoutMs);\n  }\n  const attemptedAt = jwksAttemptedAt.get(jwksUri);\n  if (attemptedAt !== undefined && Date.now() - attemptedAt < JWKS_MIN_REFRESH_MS) {\n    return Promise.resolve(null);\n  }\n  const generation = jwksGeneration;\n  jwksAttemptedAt.set(jwksUri, Date.now());\n  const request: Promise<JwksCacheEntry | null> = (async () => {\n    const res = await fetch(jwksUri, { signal: AbortSignal.timeout(timeoutMs) });\n    if (!res.ok) {\n      return null;\n    }\n    const json = (await res.json()) as { keys?: unknown };\n    if (!Array.isArray(json.keys)) {\n      return null;\n    }\n    const entry: JwksCacheEntry = {\n      keys: json.keys as Jwk[],\n      fetchedAt: Date.now(),\n      imported: new Map(),\n    };\n    if (generation === jwksGeneration) {\n      jwksCache.set(jwksUri, entry);\n    }\n    return entry;\n  })().finally(() => {\n    // Only the request that is still the registered one is removed: a clear can\n    // have replaced it with a newer fetch that must stay shared.\n    if (jwksInFlight.get(jwksUri) === request) {\n      jwksInFlight.delete(jwksUri);\n    }\n  });\n  jwksInFlight.set(jwksUri, request);\n  return request;\n}\n\n/** The key a token names: the one with its `kid`, or the first without one. */\nfunction findKey(entry: JwksCacheEntry, kid: string | undefined): Jwk | undefined {\n  return kid === undefined ? entry.keys[0] : entry.keys.find((key) => key.kid === kid);\n}\n\n/**\n * Resolves the public key for a token from the issuer's JWKS, imported for\n * `alg`.\n *\n * The JWKS document is cached for {@link JWKS_TTL_MS}. A `kid` the cached\n * document lacks refreshes it — a rotated key — but no sooner than\n * {@link JWKS_MIN_REFRESH_MS} after the last fetch, so tokens with made-up\n * `kid` values are answered from the cache. An expired document is never used\n * when its refresh fails. Returns `null` when no key fits: the endpoint failed,\n * no key has that `kid`, or the key cannot verify `alg`.\n *\n * @param jwksUri - Full URL of the JWKS endpoint.\n * @param kid     - The `kid` header value from the JWT, or `undefined`.\n * @param alg     - The token's (already allowlisted) `alg` header value.\n */\nasync function resolveJwk(\n  jwksUri: string,\n  kid: string | undefined,\n  alg: string,\n  timeoutMs: number,\n): Promise<CryptoKey | null> {\n  const now = Date.now();\n  let entry = jwksCache.get(jwksUri);\n  if (!entry || now - entry.fetchedAt >= JWKS_TTL_MS) {\n    // An expired document is never used: a key the issuer has removed, or one\n    // that was compromised, must stop verifying once the TTL has passed even if\n    // the endpoint is down.\n    entry = (await refreshJwks(jwksUri, timeoutMs)) ?? undefined;\n  }\n  if (!entry) {\n    return null;\n  }\n\n  let jwk = findKey(entry, kid);\n  if (!jwk && now - entry.fetchedAt >= JWKS_MIN_REFRESH_MS) {\n    const refreshed = await refreshJwks(jwksUri, timeoutMs);\n    if (refreshed) {\n      entry = refreshed;\n      jwk = findKey(entry, kid);\n    }\n  }\n  if (!jwk) {\n    return null;\n  }\n\n  const params = importParams(jwk, alg);\n  if (!params) {\n    return null;\n  }\n  const importedKey = `${kid ?? \"__default__\"}:${alg}`;\n  const cached = entry.imported.get(importedKey);\n  if (cached) {\n    return cached;\n  }\n  const cryptoKey = await crypto.subtle.importKey(\"jwk\", jwk, params, false, [\"verify\"]);\n  entry.imported.set(importedKey, cryptoKey);\n  return cryptoKey;\n}\n\n// ─── Verification ─────────────────────────────────────────────────────────────\n\n/**\n * Verifies a compact-serialised JWT and returns its validated payload.\n *\n * ## Verification steps (in order)\n *\n * 1. Split the token — returns `null` immediately when fewer than three\n *    dot-separated segments are present.\n * 2. Reject if `alg` is not in {@link VerifyJwtOptions.allowedAlgorithms} (when set).\n * 3. Reject if `typ` is not in {@link VerifyJwtOptions.allowedTokenTypes} (when non-empty).\n * 4. Fetch (or retrieve from cache) the matching public key from the JWKS endpoint.\n * 5. Verify the cryptographic signature over `header.payload`.\n * 6. Reject if `iss` is absent or does not equal {@link VerifyJwtOptions.issuerUrl}.\n * 7. Reject if {@link VerifyJwtOptions.audience} is set and `aud` does not contain it.\n * 8. Reject if `exp` is absent or is in the past (minus clock-skew tolerance).\n * 9. Reject if `nbf` is in the future (plus clock-skew tolerance).\n * 10. Reject if `iat` is in the future (plus clock-skew tolerance).\n *\n * Returns `null` on any failure rather than throwing, so callers can treat a\n * bad token identically to a missing one without try/catch at the call site.\n *\n * @param token - The raw compact-serialised JWT string.\n * @param opts  - Verification options; see {@link VerifyJwtOptions}.\n * @returns The verified {@link JwtPayload}, or `null` if verification fails.\n */\nexport async function verifyJwt(token: string, opts: VerifyJwtOptions): Promise<JwtPayload | null> {\n  try {\n    const {\n      issuerUrl,\n      allowedAlgorithms,\n      clockSkewMs,\n      audience,\n      allowedTokenTypes,\n      jwksTimeoutMs = 5000,\n    } = opts;\n\n    const segments = splitToken(token);\n    if (!segments) {\n      return null;\n    }\n    const [rawHeader, rawPayload, rawSig] = segments;\n\n    const header = JSON.parse(DECODER.decode(base64UrlDecode(rawHeader))) as JwtHeader;\n    const payload = JSON.parse(DECODER.decode(base64UrlDecode(rawPayload))) as JwtPayload;\n\n    // RFC 7515 §4.1.1 requires 'alg' in the JWT header. A token that omits it\n    // is structurally invalid — we reject it rather than guessing an algorithm.\n    const alg = header.alg;\n    if (!alg) {\n      return null;\n    }\n    // Unconditionally reject the 'none' algorithm regardless of the allowlist.\n    // 'none' means the token is unsigned; accepting it would allow anyone to\n    // forge arbitrary claims without a key. The comparison is case-insensitive\n    // so that \"None\", \"NONE\", or any other casing variant is also rejected.\n    if (alg.toLowerCase() === \"none\") {\n      return null;\n    }\n    if (allowedAlgorithms?.length && !allowedAlgorithms.includes(alg)) {\n      return null;\n    }\n\n    if (allowedTokenTypes.length > 0) {\n      const typ = header.typ ?? \"\";\n      if (!allowedTokenTypes.some((t) => t.toLowerCase() === typ.toLowerCase())) {\n        return null;\n      }\n    }\n\n    const kid = header.kid;\n    const cryptoKey = await resolveJwk(joinURL(issuerUrl, \"/auth/keys\"), kid, alg, jwksTimeoutMs);\n    if (!cryptoKey) {\n      return null;\n    }\n\n    const valid = await crypto.subtle.verify(\n      resolveVerifyAlgorithm(alg),\n      cryptoKey,\n      base64UrlDecode(rawSig),\n      ENCODER.encode(`${rawHeader}.${rawPayload}`),\n    );\n    if (!valid) {\n      return null;\n    }\n\n    // Strict equality is intentional: the issuer URL must match exactly as\n    // configured, apart from a trailing slash on the configured address, which\n    // is not part of an issuer. We deliberately do not normalise case or\n    // surrounding whitespace — any difference is either a misconfiguration or\n    // a token issued by a different server and must be rejected.\n    if (payload.iss !== withoutTrailingSlash(issuerUrl)) {\n      return null;\n    }\n\n    if (audience !== undefined) {\n      const audList = payload.aud !== undefined ? [payload.aud].flat() : [];\n      const expectedList = [audience].flat();\n      if (!expectedList.some((a) => audList.includes(a))) {\n        return null;\n      }\n    }\n\n    const now = Date.now();\n    if (payload.exp === undefined || payload.exp * 1000 < now - clockSkewMs) {\n      return null;\n    }\n    if (payload.nbf !== undefined && payload.nbf * 1000 > now + clockSkewMs) {\n      return null;\n    }\n    if (payload.iat !== undefined && payload.iat * 1000 > now + clockSkewMs) {\n      return null;\n    }\n\n    return payload;\n  } catch (err) {\n    // SyntaxError from JSON.parse means the token segments are not JSON —\n    // this is an opaque (non-JWT) token. Return null silently; the middleware\n    // will fall back to opaque token validation via the backend.\n    if (err instanceof SyntaxError) {\n      return null;\n    }\n    // Any other unexpected error (network failure, malformed JWKS, Web Crypto\n    // exception) is worth logging so operators can distinguish infrastructure\n    // failures from genuine bad tokens.\n    console.error(\"[nextgen] verifyJwt unexpected error:\", err);\n    return null;\n  }\n}\n"],"mappings":"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"}