From 301770e26e23f5fd9f17fd61eda2ee0e432382ce Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Fri, 10 Jul 2026 20:30:00 -0400 Subject: [PATCH 01/11] fix(identity-webhook): resolve JWKS via OIDC discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the incorrect default {issuer}/.well-known/jwks.json with issuer-relative OIDC discovery (openid-configuration → jwks_uri). Validate discovery issuer, wrap JWKS fetch errors with the URL, and keep OIDC_JWKS_URI as an explicit override. Unit tests cover discovery and leave api_key mode unchanged. --- .env.example | 2 +- README.md | 2 +- identity-webhook/verifiers.mjs | 122 +++++++++++++++++++++++-- identity-webhook/verifiers.test.mjs | 137 ++++++++++++++++++++++++++++ 4 files changed, 253 insertions(+), 10 deletions(-) diff --git a/.env.example b/.env.example index 8fdb7e0..82e182f 100644 --- a/.env.example +++ b/.env.example @@ -39,7 +39,7 @@ USAGE_SUBJECT_TYPE=api_key_user # Generic OIDC (raw IdP access tokens with azp/sub): # OIDC_ISSUER=https://your-tenant.auth0.com/ # OIDC_AUDIENCE=https://api.your-platform.com -# OIDC_JWKS_URI= # defaults to ${OIDC_ISSUER}/.well-known/jwks.json +# OIDC_JWKS_URI= # optional override; else OIDC Discovery → jwks_uri # OIDC_CLIENT_CLAIM=azp # OIDC_SUBJECT_CLAIM=sub # OIDC_SUBJECT_TYPE=oidc_user diff --git a/README.md b/README.md index 81df48d..6c2a1a8 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,7 @@ Shared keys (`WEBHOOK_SECRET`, `KAFKA_BROKERS`, `KAFKA_GATEWAY_TOPIC`) are liste | `API_KEY_PREFIX` | `api_key`, optional | Default `sk_` | | `OIDC_ISSUER` | `oidc` | JWT issuer / JWKS host | | `OIDC_AUDIENCE` | `oidc` | Expected JWT audience | -| `OIDC_JWKS_URI` | `oidc`, optional | Defaults to `${OIDC_ISSUER}/.well-known/jwks.json` | +| `OIDC_JWKS_URI` | `oidc`, optional | Explicit JWKS override; otherwise resolved via OIDC Discovery (`${OIDC_ISSUER}/.well-known/openid-configuration` → `jwks_uri`) | | `OIDC_CLIENT_CLAIM` | `oidc`, optional | Tenant claim (default `azp`; Auth0 clearinghouse uses `app_client_id`) | | `OIDC_SUBJECT_CLAIM` | `oidc`, optional | End-user claim (default `sub`; Auth0 clearinghouse uses `external_user_id`) | | `OIDC_SUBJECT_TYPE` | `oidc`, optional | Default `oidc_user`; Auth0 clearinghouse uses `external_user_id` | diff --git a/identity-webhook/verifiers.mjs b/identity-webhook/verifiers.mjs index 968c6bc..53f2859 100644 --- a/identity-webhook/verifiers.mjs +++ b/identity-webhook/verifiers.mjs @@ -9,7 +9,7 @@ * - createOidcVerifier: verifies a JWT bearer against an OIDC issuer's JWKS (jose). * - createEndUserVerifierFromEnv: picks exactly one verifier via IDENTITY_AUTH_MODE. */ -import { createRemoteJWKSet, jwtVerify } from "jose"; +import { createLocalJWKSet, createRemoteJWKSet, jwtVerify } from "jose"; import { bearerToken, WebhookError } from "./protocol.mjs"; import { loadApiKeyStore } from "./keys.mjs"; @@ -59,10 +59,114 @@ export function createApiKeyVerifier({ }; } +/** + * Resolve `jwks_uri` via OIDC Discovery (issuer-relative + * `/.well-known/openid-configuration`), matching oauth4webapi / builder-sdk. + * + * Example: issuer `https://staging.pymthouse.com/api/v1/oidc` → + * discovery advertises `jwks_uri` `https://staging.pymthouse.com/api/v1/oidc/jwks`. + * + * @param {string} jwtIssuer + * @param {{ fetchImpl?: typeof fetch }} [options] + * @returns {Promise} + */ +function normalizeIssuer(issuer) { + return String(issuer ?? "").replace(/\/$/, ""); +} + +export async function discoverJwksUri(jwtIssuer, options = {}) { + const fetchImpl = options.fetchImpl ?? fetch; + const base = normalizeIssuer(jwtIssuer); + const url = `${base}/.well-known/openid-configuration`; + let response; + try { + response = await fetchImpl(url); + } catch (err) { + throw new Error( + `OIDC discovery request failed (${url}): ${err instanceof Error ? err.message : err}`, + ); + } + if (!response.ok) { + throw new Error(`OIDC discovery failed: expected 200 from ${url}, got ${response.status}`); + } + let doc; + try { + doc = await response.json(); + } catch { + throw new Error(`OIDC discovery response is not JSON (${url})`); + } + if (!doc || typeof doc.issuer !== "string" || !doc.issuer.trim()) { + throw new Error(`OIDC discovery document missing issuer (${url})`); + } + if (normalizeIssuer(doc.issuer) !== base) { + throw new Error( + `OIDC discovery issuer mismatch (${url}): expected ${base}, got ${doc.issuer.trim()}`, + ); + } + if (typeof doc.jwks_uri !== "string" || !doc.jwks_uri.trim()) { + throw new Error(`OIDC discovery document missing jwks_uri (${url})`); + } + return doc.jwks_uri.trim(); +} + +/** + * Build a jose key resolver: explicit `jwks`, explicit `jwksUri`, or lazy OIDC + * discovery of `jwks_uri` from `{issuer}/.well-known/openid-configuration`. + * + * When `fetchImpl` is set (tests / custom HTTP), JWKS is loaded through it into + * a local keyset. Otherwise jose's createRemoteJWKSet fetches and caches. + */ +function createOidcKeyResolver({ jwks, jwksUri, jwtIssuer, fetchImpl }) { + if (jwks) { + return jwks; + } + + let remote; + let resolving; + return async (protectedHeader, token) => { + if (!remote) { + resolving ??= (async () => { + const uri = jwksUri ?? (await discoverJwksUri(jwtIssuer, { fetchImpl })); + if (fetchImpl) { + let response; + try { + response = await fetchImpl(uri); + } catch (err) { + throw new Error( + `JWKS request failed (${uri}): ${err instanceof Error ? err.message : err}`, + ); + } + if (!response.ok) { + throw new Error( + `Expected 200 OK from the JSON Web Key Set HTTP response (${uri}) [${response.status}]`, + ); + } + let doc; + try { + doc = await response.json(); + } catch { + throw new Error(`JWKS response is not JSON (${uri})`); + } + return createLocalJWKSet(doc); + } + return createRemoteJWKSet(new URL(uri)); + })(); + remote = await resolving; + } + return remote(protectedHeader, token); + }; +} + /** * OIDC/JWT verifier (bring-your-own OAuth). Validates a `Bearer ` against - * `jwtIssuer`/`jwtAudience` using the issuer's JWKS (auto-cached & refreshed by - * jose's createRemoteJWKSet). `jwks` may be injected for testing. + * `jwtIssuer`/`jwtAudience` using the issuer's JWKS. By default jose's + * `createRemoteJWKSet` fetches, caches, and refreshes keys. When `fetchImpl` is + * provided (tests / custom HTTP), JWKS is fetched once into a local keyset. + * + * JWKS resolution order: + * 1. `jwks` (injected keyset, for tests) + * 2. `jwksUri` (explicit override, e.g. OIDC_JWKS_URI) + * 3. OIDC Discovery: `{jwtIssuer}/.well-known/openid-configuration` → `jwks_uri` */ export function createOidcVerifier({ jwtIssuer, @@ -74,6 +178,7 @@ export function createOidcVerifier({ subjectClaim = "sub", subjectTypeValue = "oidc_user", requiredScopes = [], + fetchImpl, }) { if (!jwtIssuer) { throw new Error("createOidcVerifier: jwtIssuer is required"); @@ -81,11 +186,12 @@ export function createOidcVerifier({ if (!jwtAudience) { throw new Error("createOidcVerifier: jwtAudience is required"); } - const keyset = - jwks ?? - createRemoteJWKSet( - new URL(jwksUri ?? `${jwtIssuer.replace(/\/$/, "")}/.well-known/jwks.json`), - ); + const keyset = createOidcKeyResolver({ + jwks, + jwksUri, + jwtIssuer, + fetchImpl, + }); const identityIssuer = issuer ?? jwtIssuer; return { diff --git a/identity-webhook/verifiers.test.mjs b/identity-webhook/verifiers.test.mjs index 3993ae7..ca19a46 100644 --- a/identity-webhook/verifiers.test.mjs +++ b/identity-webhook/verifiers.test.mjs @@ -5,10 +5,147 @@ import { createApiKeyVerifier, createEndUserVerifierFromEnv, createOidcVerifier, + discoverJwksUri, } from "./verifiers.mjs"; const ISSUER = "http://identity-webhook:8090"; +describe("discoverJwksUri", () => { + it("reads jwks_uri from issuer-relative openid-configuration", async () => { + const seen = []; + const fetchImpl = async (input) => { + seen.push(String(input)); + return Response.json({ + issuer: "https://staging.pymthouse.com/api/v1/oidc", + jwks_uri: "https://staging.pymthouse.com/api/v1/oidc/jwks", + }); + }; + const uri = await discoverJwksUri("https://staging.pymthouse.com/api/v1/oidc", { + fetchImpl, + }); + assert.equal(uri, "https://staging.pymthouse.com/api/v1/oidc/jwks"); + assert.deepEqual(seen, [ + "https://staging.pymthouse.com/api/v1/oidc/.well-known/openid-configuration", + ]); + }); + + it("rejects discovery without jwks_uri", async () => { + await assert.rejects( + () => + discoverJwksUri("https://idp.test", { + fetchImpl: async () => Response.json({ issuer: "https://idp.test" }), + }), + /missing jwks_uri/, + ); + }); + + it("rejects discovery when issuer does not match jwtIssuer", async () => { + await assert.rejects( + () => + discoverJwksUri("https://idp.test/api/v1/oidc", { + fetchImpl: async () => + Response.json({ + issuer: "https://other.example/oidc", + jwks_uri: "https://other.example/oidc/jwks", + }), + }), + /issuer mismatch/, + ); + }); + + it("accepts discovery issuer that differs only by trailing slash", async () => { + const uri = await discoverJwksUri("https://idp.test/api/v1/oidc/", { + fetchImpl: async () => + Response.json({ + issuer: "https://idp.test/api/v1/oidc", + jwks_uri: "https://idp.test/api/v1/oidc/jwks", + }), + }); + assert.equal(uri, "https://idp.test/api/v1/oidc/jwks"); + }); +}); + +describe("createOidcVerifier discovery", () => { + it("discovers JWKS via openid-configuration then verifies", async () => { + const { publicKey, privateKey } = await generateKeyPair("RS256"); + const jwk = await exportJWK(publicKey); + jwk.kid = "disc-key"; + jwk.alg = "RS256"; + jwk.use = "sig"; + const issuer = "https://idp.test/api/v1/oidc"; + const jwksUri = `${issuer}/jwks`; + const seen = []; + const fetchImpl = async (input) => { + const url = String(input); + seen.push(url); + if (url.endsWith("/.well-known/openid-configuration")) { + return Response.json({ issuer, jwks_uri: jwksUri }); + } + if (url.startsWith(jwksUri)) { + return Response.json({ keys: [jwk] }); + } + return new Response("not found", { status: 404 }); + }; + + const token = await new SignJWT({ sub: "user-b", azp: "app-b" }) + .setProtectedHeader({ alg: "RS256", kid: "disc-key" }) + .setIssuer(issuer) + .setAudience("clearinghouse") + .setIssuedAt() + .setExpirationTime("5m") + .sign(privateKey); + + const verifier = createOidcVerifier({ + jwtIssuer: issuer, + jwtAudience: "clearinghouse", + fetchImpl, + }); + const { identity } = await verifier.verify({ authorization: `Bearer ${token}` }); + assert.equal(identity.usage_subject, "user-b"); + assert.ok(seen.some((u) => u.endsWith("/.well-known/openid-configuration"))); + assert.ok(seen.some((u) => u.startsWith(jwksUri))); + assert.ok(!seen.some((u) => u.includes(".well-known/jwks.json"))); + }); + + it("wraps JWKS fetch failures with the JWKS URL", async () => { + const issuer = "https://idp.test/api/v1/oidc"; + const jwksUri = `${issuer}/jwks`; + const fetchImpl = async (input) => { + const url = String(input); + if (url.endsWith("/.well-known/openid-configuration")) { + return Response.json({ issuer, jwks_uri: jwksUri }); + } + if (url.startsWith(jwksUri)) { + throw new Error("network down"); + } + return new Response("not found", { status: 404 }); + }; + + const warnings = []; + const origWarn = console.warn; + console.warn = (...args) => warnings.push(args.join(" ")); + try { + const verifier = createOidcVerifier({ + jwtIssuer: issuer, + jwtAudience: "clearinghouse", + fetchImpl, + }); + await assert.rejects( + () => verifier.verify({ authorization: "Bearer eyJhbGciOiJSUzI1NiJ9.e30.sig" }), + /oidc verification failed/, + ); + } finally { + console.warn = origWarn; + } + assert.ok( + warnings.some((w) => + w.includes(`JWKS request failed (${jwksUri}): network down`), + ), + `expected JWKS URL in warn log, got: ${JSON.stringify(warnings)}`, + ); + }); +}); + describe("createApiKeyVerifier", () => { const store = new Map([ ["sk_demo", { userId: "demo-user", clientId: "demo-client", usageSubjectType: "api_key_user" }], From 216cb5a2d66d388f92ded8352b9ed0bf8fcdbc79 Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Sat, 11 Jul 2026 17:43:28 -0400 Subject: [PATCH 02/11] feat(identity-webhook): exchange composite app_*_* in OIDC mode Accept Bearer app_<24hex>_ when OIDC_TOKEN_EXCHANGE_BASE_URL is set: RFC 8693 exchange at the app-scoped token endpoint, then jwtVerify the minted access token (same JWKS discovery path as #62). Secret segment is opaque (operator prefixes allowed); reject cs_ / _cs_ client-secret shapes. Harden cache keys (salted HKDF) and log sanitization. Document exchange env vars. --- .env.example | 3 + README.md | 2 + identity-webhook/verifiers.mjs | 65 ++++++++++----- identity-webhook/verifiers.test.mjs | 120 +++++++++++++++++++++++----- 4 files changed, 148 insertions(+), 42 deletions(-) diff --git a/.env.example b/.env.example index 8fdb7e0..f0b2f87 100644 --- a/.env.example +++ b/.env.example @@ -44,6 +44,9 @@ USAGE_SUBJECT_TYPE=api_key_user # OIDC_SUBJECT_CLAIM=sub # OIDC_SUBJECT_TYPE=oidc_user # OIDC_REQUIRED_SCOPES= +# OIDC_TOKEN_EXCHANGE_BASE_URL= # optional; enables app_<24hex>_ composite key exchange +# OIDC_EXCHANGE_M2M_CLIENT_ID= +# OIDC_EXCHANGE_M2M_CLIENT_SECRET= # Reference only — not read by Compose services; for minting test JWTs. # Canonical copy: auth0-provisioner/provision/.env.livepeer diff --git a/README.md b/README.md index 917b802..a09ce94 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,8 @@ Shared keys (`WEBHOOK_SECRET`, `KAFKA_BROKERS`, `KAFKA_GATEWAY_TOPIC`) are liste | `OIDC_SUBJECT_CLAIM` | `oidc`, optional | End-user claim (default `sub`; Auth0 clearinghouse uses `external_user_id`) | | `OIDC_SUBJECT_TYPE` | `oidc`, optional | Default `oidc_user`; Auth0 clearinghouse uses `external_user_id` | | `OIDC_REQUIRED_SCOPES` | `oidc`, optional | Space- or comma-separated required scopes (e.g. `sign:job`) | +| `OIDC_TOKEN_EXCHANGE_BASE_URL` | `oidc`, optional | Enables composite `Bearer app_<24hex>_`: RFC 8693 exchange at `/api/v1/apps/{clientId}/oidc/token`, then JWT verify | +| `OIDC_EXCHANGE_M2M_CLIENT_ID` / `OIDC_EXCHANGE_M2M_CLIENT_SECRET` | `oidc`, optional | Optional client credentials for the exchange request (both or neither) | `PORT` is set by Compose (`8090`), not `.env`. See the `identity-webhook` block in [`.env.example`](.env.example) for Auth0 vs generic OIDC examples. diff --git a/identity-webhook/verifiers.mjs b/identity-webhook/verifiers.mjs index dbc3fee..7148c60 100644 --- a/identity-webhook/verifiers.mjs +++ b/identity-webhook/verifiers.mjs @@ -7,7 +7,7 @@ * * - createApiKeyVerifier: resolves `sk_…` keys via a caller-supplied lookup. * - createOidcVerifier: verifies a JWT bearer against an OIDC issuer's JWKS (jose), - * or exchanges a composite `app_*.pmth_*` API key via RFC 8693 then verifies. + * or exchanges a composite `app_<24hex>_` API key via RFC 8693 then verifies. * - createEndUserVerifierFromEnv: picks exactly one verifier via IDENTITY_AUTH_MODE. */ import { hkdfSync, randomBytes } from "node:crypto"; @@ -21,7 +21,8 @@ const GRANT_TYPE_TOKEN_EXCHANGE = "urn:ietf:params:oauth:grant-type:token-exchange"; const SUBJECT_ACCESS_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token"; -const COMPOSITE_CLIENT_ID_RE = /^app_[a-z0-9]+$/; +/** Underscore composite: `app_<24hex>_` (no `.` — better copy/select UX). */ +const COMPOSITE_API_KEY_RE = /^(app_[a-f0-9]{24})_(.+)$/; const COMPOSITE_CACHE_MAX_TTL_SECONDS = 60; const ALLOWED_JWT_ALGS = ["RS256"]; @@ -30,20 +31,19 @@ function nowSeconds() { } /** - * Split `app_.pmth_` into parts. Returns null when not composite. + * Split `app_<24hex>_` into parts. Returns null when not composite. + * The secret segment is opaque issuer key material. Client-secret shaped + * segments (`cs_…`) are rejected. */ export function splitCompositeApiKey(token) { const trimmed = (token ?? "").trim(); - const dot = trimmed.indexOf("."); - if (dot <= 0 || trimmed.includes(".", dot + 1)) { + const match = COMPOSITE_API_KEY_RE.exec(trimmed); + if (!match) { return null; } - const publicClientId = trimmed.slice(0, dot); - const apiKey = trimmed.slice(dot + 1); - if (!COMPOSITE_CLIENT_ID_RE.test(publicClientId)) { - return null; - } - if (!apiKey.startsWith("pmth_") || apiKey.startsWith("pmth_cs_")) { + const publicClientId = match[1]; + const apiKey = match[2]; + if (!apiKey || apiKey.startsWith("cs_") || apiKey.includes("_cs_")) { return null; } return { publicClientId, apiKey }; @@ -148,16 +148,20 @@ export function createApiKeyVerifier({ * Resolve `jwks_uri` via OIDC Discovery (issuer-relative * `/.well-known/openid-configuration`), matching oauth4webapi / builder-sdk. * - * Example: issuer `https://staging.pymthouse.com/api/v1/oidc` → - * discovery advertises `jwks_uri` `https://staging.pymthouse.com/api/v1/oidc/jwks`. + * Example: issuer `https://issuer.example/api/v1/oidc` → + * discovery advertises `jwks_uri` `https://issuer.example/api/v1/oidc/jwks`. * * @param {string} jwtIssuer * @param {{ fetchImpl?: typeof fetch }} [options] * @returns {Promise} */ +function normalizeIssuer(issuer) { + return String(issuer ?? "").replace(/\/$/, ""); +} + export async function discoverJwksUri(jwtIssuer, options = {}) { const fetchImpl = options.fetchImpl ?? fetch; - const base = jwtIssuer.replace(/\/$/, ""); + const base = normalizeIssuer(jwtIssuer); const url = `${base}/.well-known/openid-configuration`; let response; try { @@ -176,7 +180,15 @@ export async function discoverJwksUri(jwtIssuer, options = {}) { } catch { throw new Error(`OIDC discovery response is not JSON (${url})`); } - if (!doc || typeof doc.jwks_uri !== "string" || !doc.jwks_uri.trim()) { + if (!doc || typeof doc.issuer !== "string" || !doc.issuer.trim()) { + throw new Error(`OIDC discovery document missing issuer (${url})`); + } + if (normalizeIssuer(doc.issuer) !== base) { + throw new Error( + `OIDC discovery issuer mismatch (${url}): expected ${base}, got ${doc.issuer.trim()}`, + ); + } + if (typeof doc.jwks_uri !== "string" || !doc.jwks_uri.trim()) { throw new Error(`OIDC discovery document missing jwks_uri (${url})`); } return doc.jwks_uri.trim(); @@ -201,13 +213,25 @@ function createOidcKeyResolver({ jwks, jwksUri, jwtIssuer, fetchImpl }) { resolving ??= (async () => { const uri = jwksUri ?? (await discoverJwksUri(jwtIssuer, { fetchImpl })); if (fetchImpl) { - const response = await fetchImpl(uri); + let response; + try { + response = await fetchImpl(uri); + } catch (err) { + throw new Error( + `JWKS request failed (${uri}): ${err instanceof Error ? err.message : err}`, + ); + } if (!response.ok) { throw new Error( `Expected 200 OK from the JSON Web Key Set HTTP response (${uri}) [${response.status}]`, ); } - const doc = await response.json(); + let doc; + try { + doc = await response.json(); + } catch { + throw new Error(`JWKS response is not JSON (${uri})`); + } return createLocalJWKSet(doc); } return createRemoteJWKSet(new URL(uri)); @@ -409,10 +433,11 @@ async function exchangeCompositeApiKey({ /** * OIDC/JWT verifier (bring-your-own OAuth). Validates a `Bearer ` against - * `jwtIssuer`/`jwtAudience` using the issuer's JWKS (auto-cached & refreshed by - * jose's createRemoteJWKSet). + * `jwtIssuer`/`jwtAudience` using the issuer's JWKS. By default jose's + * `createRemoteJWKSet` fetches, caches, and refreshes keys. When `fetchImpl` is + * provided (tests / custom HTTP), JWKS is fetched once into a local keyset. * - * Also accepts composite `Bearer app_.pmth_` when + * Also accepts composite `Bearer app_<24hex>_` when * `tokenExchangeBaseUrl` is configured: RFC 8693 exchange at * `/api/v1/apps/{clientId}/oidc/token`, then the same JWT verification path. * diff --git a/identity-webhook/verifiers.test.mjs b/identity-webhook/verifiers.test.mjs index a30d200..5ee1d97 100644 --- a/identity-webhook/verifiers.test.mjs +++ b/identity-webhook/verifiers.test.mjs @@ -18,16 +18,16 @@ describe("discoverJwksUri", () => { const fetchImpl = async (input) => { seen.push(String(input)); return Response.json({ - issuer: "https://staging.pymthouse.com/api/v1/oidc", - jwks_uri: "https://staging.pymthouse.com/api/v1/oidc/jwks", + issuer: "https://issuer.example/api/v1/oidc", + jwks_uri: "https://issuer.example/api/v1/oidc/jwks", }); }; - const uri = await discoverJwksUri("https://staging.pymthouse.com/api/v1/oidc", { + const uri = await discoverJwksUri("https://issuer.example/api/v1/oidc", { fetchImpl, }); - assert.equal(uri, "https://staging.pymthouse.com/api/v1/oidc/jwks"); + assert.equal(uri, "https://issuer.example/api/v1/oidc/jwks"); assert.deepEqual(seen, [ - "https://staging.pymthouse.com/api/v1/oidc/.well-known/openid-configuration", + "https://issuer.example/api/v1/oidc/.well-known/openid-configuration", ]); }); @@ -40,6 +40,31 @@ describe("discoverJwksUri", () => { /missing jwks_uri/, ); }); + + it("rejects discovery when issuer does not match jwtIssuer", async () => { + await assert.rejects( + () => + discoverJwksUri("https://idp.test/api/v1/oidc", { + fetchImpl: async () => + Response.json({ + issuer: "https://other.example/oidc", + jwks_uri: "https://other.example/oidc/jwks", + }), + }), + /issuer mismatch/, + ); + }); + + it("accepts discovery issuer that differs only by trailing slash", async () => { + const uri = await discoverJwksUri("https://idp.test/api/v1/oidc/", { + fetchImpl: async () => + Response.json({ + issuer: "https://idp.test/api/v1/oidc", + jwks_uri: "https://idp.test/api/v1/oidc/jwks", + }), + }); + assert.equal(uri, "https://idp.test/api/v1/oidc/jwks"); + }); }); describe("createOidcVerifier discovery", () => { @@ -83,6 +108,44 @@ describe("createOidcVerifier discovery", () => { assert.ok(seen.some((u) => u.startsWith(jwksUri))); assert.ok(!seen.some((u) => u.includes(".well-known/jwks.json"))); }); + + it("wraps JWKS fetch failures with the JWKS URL", async () => { + const issuer = "https://idp.test/api/v1/oidc"; + const jwksUri = `${issuer}/jwks`; + const fetchImpl = async (input) => { + const url = String(input); + if (url.endsWith("/.well-known/openid-configuration")) { + return Response.json({ issuer, jwks_uri: jwksUri }); + } + if (url.startsWith(jwksUri)) { + throw new Error("network down"); + } + return new Response("not found", { status: 404 }); + }; + + const warnings = []; + const origWarn = console.warn; + console.warn = (...args) => warnings.push(args.join(" ")); + try { + const verifier = createOidcVerifier({ + jwtIssuer: issuer, + jwtAudience: "clearinghouse", + fetchImpl, + }); + await assert.rejects( + () => verifier.verify({ authorization: "Bearer eyJhbGciOiJSUzI1NiJ9.e30.sig" }), + /oidc verification failed/, + ); + } finally { + console.warn = origWarn; + } + assert.ok( + warnings.some((w) => + w.includes(`JWKS request failed (${jwksUri}): network down`), + ), + `expected JWKS URL in warn log, got: ${JSON.stringify(warnings)}`, + ); + }); }); describe("createApiKeyVerifier", () => { @@ -251,7 +314,7 @@ describe("createEndUserVerifierFromEnv", () => { ); }); - it("oidc mode rejects sk_ / bare pmth_* / pmth_cs_*", async () => { + it("oidc mode rejects bare secrets and non-composite tokens", async () => { const verifier = createEndUserVerifierFromEnv({ IDENTITY_ISSUER: ISSUER, IDENTITY_AUTH_MODE: "oidc", @@ -266,37 +329,48 @@ describe("createEndUserVerifierFromEnv", () => { /not a JWT/, ); await assert.rejects( - () => verifier.verify({ authorization: "Bearer pmth_barekey" }), + () => verifier.verify({ authorization: "Bearer deadbeefsecret" }), /not a JWT/, ); await assert.rejects( - () => verifier.verify({ authorization: "Bearer app_abc.pmth_cs_secret" }), + () => + verifier.verify({ + authorization: "Bearer app_3b386c81a1db1169fd2c3986_cs_secret", + }), /not a JWT/, ); }); }); describe("splitCompositeApiKey / normalizeTokenExchangeBaseUrl", () => { + const clientId = "app_3b386c81a1db1169fd2c3986"; + it("parses composite credentials", () => { - assert.deepEqual(splitCompositeApiKey("app_abc123.pmth_deadbeef"), { - publicClientId: "app_abc123", - apiKey: "pmth_deadbeef", + assert.deepEqual(splitCompositeApiKey(`${clientId}_deadbeef`), { + publicClientId: clientId, + apiKey: "deadbeef", + }); + assert.deepEqual(splitCompositeApiKey(`${clientId}_key_deadbeef`), { + publicClientId: clientId, + apiKey: "key_deadbeef", }); - assert.equal(splitCompositeApiKey("pmth_deadbeef"), null); - assert.equal(splitCompositeApiKey("app_abc:pmth_x"), null); + assert.equal(splitCompositeApiKey("deadbeef"), null); + assert.equal(splitCompositeApiKey(`${clientId}.deadbeef`), null); + assert.equal(splitCompositeApiKey(`${clientId}_cs_secret`), null); + assert.equal(splitCompositeApiKey("app_abc_short"), null); }); it("requires https except loopback", () => { assert.equal( - normalizeTokenExchangeBaseUrl("https://staging.pymthouse.com/"), - "https://staging.pymthouse.com", + normalizeTokenExchangeBaseUrl("https://billing.example.com/"), + "https://billing.example.com", ); assert.equal( normalizeTokenExchangeBaseUrl("http://localhost:3000"), "http://localhost:3000", ); assert.throws( - () => normalizeTokenExchangeBaseUrl("http://staging.pymthouse.com"), + () => normalizeTokenExchangeBaseUrl("http://billing.example.com"), /must be https/, ); }); @@ -313,11 +387,12 @@ describe("createOidcVerifier composite API key exchange", () => { return { privateKey, jwks }; } - it("exchanges app_*.pmth_* then verifies the minted JWT", async () => { + it("exchanges app_*_* then verifies the minted JWT", async () => { const { privateKey, jwks } = await setup(); const issuer = "https://idp.test/api/v1/oidc"; const audience = issuer; - const clientId = "app_abc123"; + const clientId = "app_3b386c81a1db1169fd2c3986"; + const composite = `${clientId}_deadbeef`; const minted = await new SignJWT({ client_id: clientId, external_user_id: "user-1", @@ -337,7 +412,7 @@ describe("createOidcVerifier composite API key exchange", () => { assert.equal(url, `https://billing.test/api/v1/apps/${clientId}/oidc/token`); assert.equal(init?.method, "POST"); const form = new URLSearchParams(String(init?.body ?? "")); - assert.equal(form.get("subject_token"), "pmth_deadbeef"); + assert.equal(form.get("subject_token"), "deadbeef"); assert.equal( form.get("grant_type"), "urn:ietf:params:oauth:grant-type:token-exchange", @@ -358,19 +433,20 @@ describe("createOidcVerifier composite API key exchange", () => { }); const { identity } = await verifier.verify({ - authorization: `Bearer ${clientId}.pmth_deadbeef`, + authorization: `Bearer ${composite}`, }); assert.equal(identity.client_id, clientId); assert.equal(identity.usage_subject, "user-1"); assert.equal(seen.length, 1); // Second call hits cache — no extra exchange. - await verifier.verify({ authorization: `Bearer ${clientId}.pmth_deadbeef` }); + await verifier.verify({ authorization: `Bearer ${composite}` }); assert.equal(seen.length, 1); }); it("rejects when exchange returns 401", async () => { const { jwks } = await setup(); + const clientId = "app_3b386c81a1db1169fd2c3986"; const fetchImpl = async () => Response.json({ error: "invalid_grant", correlation_id: "c1" }, { status: 401 }); const verifier = createOidcVerifier({ @@ -381,7 +457,7 @@ describe("createOidcVerifier composite API key exchange", () => { fetchImpl, }); await assert.rejects( - () => verifier.verify({ authorization: "Bearer app_abc.pmth_bad" }), + () => verifier.verify({ authorization: `Bearer ${clientId}_bad` }), /token exchange failed/, ); }); From c627449bb7c55c3dcbc0e35ec3bf4e83b560b469 Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Sat, 11 Jul 2026 17:50:32 -0400 Subject: [PATCH 03/11] test(identity-webhook): update legacy-env composite fixture to app_*_* --- identity-webhook/legacy-env.test.mjs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/identity-webhook/legacy-env.test.mjs b/identity-webhook/legacy-env.test.mjs index 1297992..d32644d 100644 --- a/identity-webhook/legacy-env.test.mjs +++ b/identity-webhook/legacy-env.test.mjs @@ -173,10 +173,10 @@ describe("pymthouse embedded flow (handleAuthorize + legacy config)", () => { assert.equal(body.identity.usage_subject_type, "external_user_id"); }); - it("authorizes a composite app_*.pmth_* via mocked exchange", async () => { + it("authorizes a composite app_*_* via mocked exchange", async () => { const { token, jwks } = await setupPymthouseJwt(); const { createOidcVerifier } = await import("./verifiers.mjs"); - const clientId = "app_abc123"; + const clientId = "app_abc123abc123abc123abcdef"; const fetchImpl = async (input) => { const url = String(input); assert.ok(url.includes(`/api/v1/apps/${clientId}/oidc/token`)); @@ -205,7 +205,9 @@ describe("pymthouse embedded flow (handleAuthorize + legacy config)", () => { "content-type": "application/json", }, body: JSON.stringify({ - headers: { Authorization: [`Bearer ${clientId}.pmth_deadbeef`] }, + headers: { + Authorization: [`Bearer ${clientId}_pmth_deadbeef`], + }, }), }); From 0d29aeec930118e6096e2bc6593951d642aea397 Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Sat, 11 Jul 2026 17:50:49 -0400 Subject: [PATCH 04/11] test(identity-webhook): align composite JWT client_id with app_*_* prefix --- identity-webhook/legacy-env.test.mjs | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/identity-webhook/legacy-env.test.mjs b/identity-webhook/legacy-env.test.mjs index d32644d..13b30c3 100644 --- a/identity-webhook/legacy-env.test.mjs +++ b/identity-webhook/legacy-env.test.mjs @@ -174,9 +174,26 @@ describe("pymthouse embedded flow (handleAuthorize + legacy config)", () => { }); it("authorizes a composite app_*_* via mocked exchange", async () => { - const { token, jwks } = await setupPymthouseJwt(); - const { createOidcVerifier } = await import("./verifiers.mjs"); + const { publicKey, privateKey } = await generateKeyPair("RS256"); + const jwk = await exportJWK(publicKey); + jwk.kid = "pymthouse-test"; + jwk.alg = "RS256"; + jwk.use = "sig"; + const jwks = createLocalJWKSet({ keys: [jwk] }); const clientId = "app_abc123abc123abc123abcdef"; + const token = await new SignJWT({ + client_id: clientId, + external_user_id: "user-456", + scope: "sign:job", + }) + .setProtectedHeader({ alg: "RS256", kid: "pymthouse-test" }) + .setIssuer(ISSUER) + .setAudience(defaultSignerWebhookJwtAudience(ISSUER)) + .setIssuedAt() + .setExpirationTime("5m") + .sign(privateKey); + + const { createOidcVerifier } = await import("./verifiers.mjs"); const fetchImpl = async (input) => { const url = String(input); assert.ok(url.includes(`/api/v1/apps/${clientId}/oidc/token`)); @@ -215,6 +232,6 @@ describe("pymthouse embedded flow (handleAuthorize + legacy config)", () => { assert.equal(response.status, 200); const body = await response.json(); assert.equal(body.status, 200); - assert.equal(body.auth_id, "app_abc123:user-456"); + assert.equal(body.auth_id, `${clientId}:user-456`); }); }); From 0326885983dd543bb9c5f1297cfc01fdfbd99ed8 Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Mon, 13 Jul 2026 14:12:15 -0400 Subject: [PATCH 05/11] fix(identity-webhook): retry JWKS resolve after transient failures Clear the in-flight discovery promise on failure so later verifies can retry, and wrap createLocalJWKSet errors with the JWKS URL. --- identity-webhook/verifiers.mjs | 16 +++++- identity-webhook/verifiers.test.mjs | 84 +++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 2 deletions(-) diff --git a/identity-webhook/verifiers.mjs b/identity-webhook/verifiers.mjs index 53f2859..4f9826c 100644 --- a/identity-webhook/verifiers.mjs +++ b/identity-webhook/verifiers.mjs @@ -147,11 +147,23 @@ function createOidcKeyResolver({ jwks, jwksUri, jwtIssuer, fetchImpl }) { } catch { throw new Error(`JWKS response is not JSON (${uri})`); } - return createLocalJWKSet(doc); + try { + return createLocalJWKSet(doc); + } catch (err) { + throw new Error( + `JWKS is invalid (${uri}): ${err instanceof Error ? err.message : err}`, + ); + } } return createRemoteJWKSet(new URL(uri)); })(); - remote = await resolving; + try { + remote = await resolving; + } catch (err) { + // Clear so a later verify can retry after transient discovery/JWKS failures. + resolving = undefined; + throw err; + } } return remote(protectedHeader, token); }; diff --git a/identity-webhook/verifiers.test.mjs b/identity-webhook/verifiers.test.mjs index ca19a46..dd16f4f 100644 --- a/identity-webhook/verifiers.test.mjs +++ b/identity-webhook/verifiers.test.mjs @@ -144,6 +144,90 @@ describe("createOidcVerifier discovery", () => { `expected JWKS URL in warn log, got: ${JSON.stringify(warnings)}`, ); }); + + it("wraps invalid JWKS documents with the JWKS URL", async () => { + const issuer = "https://idp.test/api/v1/oidc"; + const jwksUri = `${issuer}/jwks`; + const fetchImpl = async (input) => { + const url = String(input); + if (url.endsWith("/.well-known/openid-configuration")) { + return Response.json({ issuer, jwks_uri: jwksUri }); + } + if (url.startsWith(jwksUri)) { + return Response.json({ keys: "not-an-array" }); + } + return new Response("not found", { status: 404 }); + }; + + const warnings = []; + const origWarn = console.warn; + console.warn = (...args) => warnings.push(args.join(" ")); + try { + const verifier = createOidcVerifier({ + jwtIssuer: issuer, + jwtAudience: "clearinghouse", + fetchImpl, + }); + await assert.rejects( + () => verifier.verify({ authorization: "Bearer eyJhbGciOiJSUzI1NiJ9.e30.sig" }), + /oidc verification failed/, + ); + } finally { + console.warn = origWarn; + } + assert.ok( + warnings.some((w) => w.includes(`JWKS is invalid (${jwksUri})`)), + `expected JWKS URL in warn log, got: ${JSON.stringify(warnings)}`, + ); + }); + + it("retries discovery after a transient failure", async () => { + const { publicKey, privateKey } = await generateKeyPair("RS256"); + const jwk = await exportJWK(publicKey); + jwk.kid = "retry-key"; + jwk.alg = "RS256"; + jwk.use = "sig"; + const issuer = "https://idp.test/api/v1/oidc"; + const jwksUri = `${issuer}/jwks`; + let discoveryAttempts = 0; + const fetchImpl = async (input) => { + const url = String(input); + if (url.endsWith("/.well-known/openid-configuration")) { + discoveryAttempts += 1; + if (discoveryAttempts === 1) { + throw new Error("temporary outage"); + } + return Response.json({ issuer, jwks_uri: jwksUri }); + } + if (url.startsWith(jwksUri)) { + return Response.json({ keys: [jwk] }); + } + return new Response("not found", { status: 404 }); + }; + + const token = await new SignJWT({ sub: "user-retry", azp: "app-retry" }) + .setProtectedHeader({ alg: "RS256", kid: "retry-key" }) + .setIssuer(issuer) + .setAudience("clearinghouse") + .setIssuedAt() + .setExpirationTime("5m") + .sign(privateKey); + + const verifier = createOidcVerifier({ + jwtIssuer: issuer, + jwtAudience: "clearinghouse", + fetchImpl, + }); + + await assert.rejects( + () => verifier.verify({ authorization: `Bearer ${token}` }), + /oidc verification failed/, + ); + + const { identity } = await verifier.verify({ authorization: `Bearer ${token}` }); + assert.equal(identity.usage_subject, "user-retry"); + assert.equal(discoveryAttempts, 2); + }); }); describe("createApiKeyVerifier", () => { From acde9e8e18697d6c3554288086af6e6406568c19 Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Mon, 13 Jul 2026 14:16:35 -0400 Subject: [PATCH 06/11] fix(identity-webhook): attach discovery JSDoc to discoverJwksUri Move the OIDC discovery docs off normalizeIssuer and align the non-ok HTTP error message with response.ok (2xx). --- identity-webhook/verifiers.mjs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/identity-webhook/verifiers.mjs b/identity-webhook/verifiers.mjs index 4f9826c..864a667 100644 --- a/identity-webhook/verifiers.mjs +++ b/identity-webhook/verifiers.mjs @@ -59,6 +59,11 @@ export function createApiKeyVerifier({ }; } +/** Strip a trailing slash so issuer comparisons are stable. */ +function normalizeIssuer(issuer) { + return String(issuer ?? "").replace(/\/$/, ""); +} + /** * Resolve `jwks_uri` via OIDC Discovery (issuer-relative * `/.well-known/openid-configuration`), matching oauth4webapi / builder-sdk. @@ -70,10 +75,6 @@ export function createApiKeyVerifier({ * @param {{ fetchImpl?: typeof fetch }} [options] * @returns {Promise} */ -function normalizeIssuer(issuer) { - return String(issuer ?? "").replace(/\/$/, ""); -} - export async function discoverJwksUri(jwtIssuer, options = {}) { const fetchImpl = options.fetchImpl ?? fetch; const base = normalizeIssuer(jwtIssuer); @@ -87,7 +88,7 @@ export async function discoverJwksUri(jwtIssuer, options = {}) { ); } if (!response.ok) { - throw new Error(`OIDC discovery failed: expected 200 from ${url}, got ${response.status}`); + throw new Error(`OIDC discovery failed (${url}): HTTP ${response.status}`); } let doc; try { From 26dfa6bf15311a70c24df357fb4251fd43bceb26 Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Mon, 13 Jul 2026 14:21:04 -0400 Subject: [PATCH 07/11] fix(identity-webhook): harden OIDC discovery inputs per review Trim issuers, reject blank jwtIssuer, require jwks to be a jose key function, and align JWKS non-ok HTTP errors with response.ok. --- identity-webhook/verifiers.mjs | 16 +++++--- identity-webhook/verifiers.test.mjs | 64 +++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 5 deletions(-) diff --git a/identity-webhook/verifiers.mjs b/identity-webhook/verifiers.mjs index 864a667..1c4de09 100644 --- a/identity-webhook/verifiers.mjs +++ b/identity-webhook/verifiers.mjs @@ -59,9 +59,9 @@ export function createApiKeyVerifier({ }; } -/** Strip a trailing slash so issuer comparisons are stable. */ +/** Trim and strip a trailing slash so issuer comparisons are stable. */ function normalizeIssuer(issuer) { - return String(issuer ?? "").replace(/\/$/, ""); + return String(issuer ?? "").trim().replace(/\/$/, ""); } /** @@ -78,6 +78,9 @@ function normalizeIssuer(issuer) { export async function discoverJwksUri(jwtIssuer, options = {}) { const fetchImpl = options.fetchImpl ?? fetch; const base = normalizeIssuer(jwtIssuer); + if (!base) { + throw new Error("discoverJwksUri: jwtIssuer is required"); + } const url = `${base}/.well-known/openid-configuration`; let response; try { @@ -119,6 +122,11 @@ export async function discoverJwksUri(jwtIssuer, options = {}) { */ function createOidcKeyResolver({ jwks, jwksUri, jwtIssuer, fetchImpl }) { if (jwks) { + if (typeof jwks !== "function") { + throw new Error( + "createOidcVerifier: jwks must be a jose key resolver function (e.g. createLocalJWKSet(...))", + ); + } return jwks; } @@ -138,9 +146,7 @@ function createOidcKeyResolver({ jwks, jwksUri, jwtIssuer, fetchImpl }) { ); } if (!response.ok) { - throw new Error( - `Expected 200 OK from the JSON Web Key Set HTTP response (${uri}) [${response.status}]`, - ); + throw new Error(`JWKS request failed (${uri}): HTTP ${response.status}`); } let doc; try { diff --git a/identity-webhook/verifiers.test.mjs b/identity-webhook/verifiers.test.mjs index dd16f4f..056e4aa 100644 --- a/identity-webhook/verifiers.test.mjs +++ b/identity-webhook/verifiers.test.mjs @@ -63,6 +63,22 @@ describe("discoverJwksUri", () => { }); assert.equal(uri, "https://idp.test/api/v1/oidc/jwks"); }); + + it("trims surrounding whitespace on jwtIssuer and discovery issuer", async () => { + const uri = await discoverJwksUri(" https://idp.test/api/v1/oidc/ ", { + fetchImpl: async () => + Response.json({ + issuer: " https://idp.test/api/v1/oidc ", + jwks_uri: "https://idp.test/api/v1/oidc/jwks", + }), + }); + assert.equal(uri, "https://idp.test/api/v1/oidc/jwks"); + }); + + it("rejects a blank jwtIssuer", async () => { + await assert.rejects(() => discoverJwksUri(" "), /jwtIssuer is required/); + await assert.rejects(() => discoverJwksUri(""), /jwtIssuer is required/); + }); }); describe("createOidcVerifier discovery", () => { @@ -145,6 +161,42 @@ describe("createOidcVerifier discovery", () => { ); }); + it("wraps non-ok JWKS HTTP responses with the JWKS URL", async () => { + const issuer = "https://idp.test/api/v1/oidc"; + const jwksUri = `${issuer}/jwks`; + const fetchImpl = async (input) => { + const url = String(input); + if (url.endsWith("/.well-known/openid-configuration")) { + return Response.json({ issuer, jwks_uri: jwksUri }); + } + if (url.startsWith(jwksUri)) { + return new Response("gone", { status: 503 }); + } + return new Response("not found", { status: 404 }); + }; + + const warnings = []; + const origWarn = console.warn; + console.warn = (...args) => warnings.push(args.join(" ")); + try { + const verifier = createOidcVerifier({ + jwtIssuer: issuer, + jwtAudience: "clearinghouse", + fetchImpl, + }); + await assert.rejects( + () => verifier.verify({ authorization: "Bearer eyJhbGciOiJSUzI1NiJ9.e30.sig" }), + /oidc verification failed/, + ); + } finally { + console.warn = origWarn; + } + assert.ok( + warnings.some((w) => w.includes(`JWKS request failed (${jwksUri}): HTTP 503`)), + `expected JWKS HTTP status in warn log, got: ${JSON.stringify(warnings)}`, + ); + }); + it("wraps invalid JWKS documents with the JWKS URL", async () => { const issuer = "https://idp.test/api/v1/oidc"; const jwksUri = `${issuer}/jwks`; @@ -338,6 +390,18 @@ describe("createOidcVerifier (jose, locally-minted JWT)", () => { }); await assert.rejects(() => verifier.verify({ authorization: "Bearer sk_not_a_jwt" }), /not a JWT/); }); + + it("rejects a JWKS JSON object passed as jwks", () => { + assert.throws( + () => + createOidcVerifier({ + jwtIssuer: "https://idp.test/", + jwtAudience: "clearinghouse", + jwks: { keys: [] }, + }), + /jwks must be a jose key resolver function/, + ); + }); }); describe("createEndUserVerifierFromEnv", () => { From d58b2240e1038118fe61bed5fb6d861487d19a26 Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Mon, 13 Jul 2026 14:28:18 -0400 Subject: [PATCH 08/11] fix(identity-webhook): include malformed JWKS URI in errors Wrap createRemoteJWKSet URL construction so Invalid URL failures retain the offending jwks_uri / OIDC_JWKS_URI in the verify warn log. --- identity-webhook/verifiers.mjs | 10 +++++++++- identity-webhook/verifiers.test.mjs | 23 +++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/identity-webhook/verifiers.mjs b/identity-webhook/verifiers.mjs index 1c4de09..7de4360 100644 --- a/identity-webhook/verifiers.mjs +++ b/identity-webhook/verifiers.mjs @@ -162,7 +162,15 @@ function createOidcKeyResolver({ jwks, jwksUri, jwtIssuer, fetchImpl }) { ); } } - return createRemoteJWKSet(new URL(uri)); + let jwksUrl; + try { + jwksUrl = new URL(uri); + } catch (err) { + throw new Error( + `JWKS URI is not a valid URL (${uri}): ${err instanceof Error ? err.message : err}`, + ); + } + return createRemoteJWKSet(jwksUrl); })(); try { remote = await resolving; diff --git a/identity-webhook/verifiers.test.mjs b/identity-webhook/verifiers.test.mjs index 056e4aa..70c8878 100644 --- a/identity-webhook/verifiers.test.mjs +++ b/identity-webhook/verifiers.test.mjs @@ -402,6 +402,29 @@ describe("createOidcVerifier (jose, locally-minted JWT)", () => { /jwks must be a jose key resolver function/, ); }); + + it("includes a malformed jwksUri in the verification warning", async () => { + const warnings = []; + const origWarn = console.warn; + console.warn = (...args) => warnings.push(args.join(" ")); + try { + const verifier = createOidcVerifier({ + jwtIssuer: "https://idp.test/", + jwtAudience: "clearinghouse", + jwksUri: "/relative/jwks", + }); + await assert.rejects( + () => verifier.verify({ authorization: "Bearer eyJhbGciOiJSUzI1NiJ9.e30.sig" }), + /oidc verification failed/, + ); + } finally { + console.warn = origWarn; + } + assert.ok( + warnings.some((w) => w.includes("JWKS URI is not a valid URL (/relative/jwks)")), + `expected malformed JWKS URI in warn log, got: ${JSON.stringify(warnings)}`, + ); + }); }); describe("createEndUserVerifierFromEnv", () => { From 6db7aea094211a460888721fa12c6395725e15d4 Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Thu, 16 Jul 2026 11:12:33 -0400 Subject: [PATCH 09/11] fix(identity-webhook): resolve JWKS via OIDC discovery (#62) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(identity-webhook): resolve JWKS via OIDC discovery Replace the incorrect default `{issuer}/.well-known/jwks.json` with issuer-relative OIDC discovery (`openid-configuration` → `jwks_uri`), while keeping `OIDC_JWKS_URI` as an explicit override. Validate discovery issuer, trim/reject blank issuers, require `jwks` to be a jose key function, and clear the in-flight discovery promise on failure so later verifies can retry. Wrap JWKS fetch, `createLocalJWKSet`, and malformed JWKS URL errors with the offending URL for clearer verify logs. Also update issuer examples in docs and unit tests; api_key mode remains unchanged. --- .env.example | 2 +- README.md | 2 +- identity-webhook/verifiers.mjs | 149 +++++++++++++- identity-webhook/verifiers.test.mjs | 308 ++++++++++++++++++++++++++++ 4 files changed, 451 insertions(+), 10 deletions(-) diff --git a/.env.example b/.env.example index 8fdb7e0..82e182f 100644 --- a/.env.example +++ b/.env.example @@ -39,7 +39,7 @@ USAGE_SUBJECT_TYPE=api_key_user # Generic OIDC (raw IdP access tokens with azp/sub): # OIDC_ISSUER=https://your-tenant.auth0.com/ # OIDC_AUDIENCE=https://api.your-platform.com -# OIDC_JWKS_URI= # defaults to ${OIDC_ISSUER}/.well-known/jwks.json +# OIDC_JWKS_URI= # optional override; else OIDC Discovery → jwks_uri # OIDC_CLIENT_CLAIM=azp # OIDC_SUBJECT_CLAIM=sub # OIDC_SUBJECT_TYPE=oidc_user diff --git a/README.md b/README.md index 81df48d..6c2a1a8 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,7 @@ Shared keys (`WEBHOOK_SECRET`, `KAFKA_BROKERS`, `KAFKA_GATEWAY_TOPIC`) are liste | `API_KEY_PREFIX` | `api_key`, optional | Default `sk_` | | `OIDC_ISSUER` | `oidc` | JWT issuer / JWKS host | | `OIDC_AUDIENCE` | `oidc` | Expected JWT audience | -| `OIDC_JWKS_URI` | `oidc`, optional | Defaults to `${OIDC_ISSUER}/.well-known/jwks.json` | +| `OIDC_JWKS_URI` | `oidc`, optional | Explicit JWKS override; otherwise resolved via OIDC Discovery (`${OIDC_ISSUER}/.well-known/openid-configuration` → `jwks_uri`) | | `OIDC_CLIENT_CLAIM` | `oidc`, optional | Tenant claim (default `azp`; Auth0 clearinghouse uses `app_client_id`) | | `OIDC_SUBJECT_CLAIM` | `oidc`, optional | End-user claim (default `sub`; Auth0 clearinghouse uses `external_user_id`) | | `OIDC_SUBJECT_TYPE` | `oidc`, optional | Default `oidc_user`; Auth0 clearinghouse uses `external_user_id` | diff --git a/identity-webhook/verifiers.mjs b/identity-webhook/verifiers.mjs index 968c6bc..09d636e 100644 --- a/identity-webhook/verifiers.mjs +++ b/identity-webhook/verifiers.mjs @@ -9,7 +9,7 @@ * - createOidcVerifier: verifies a JWT bearer against an OIDC issuer's JWKS (jose). * - createEndUserVerifierFromEnv: picks exactly one verifier via IDENTITY_AUTH_MODE. */ -import { createRemoteJWKSet, jwtVerify } from "jose"; +import { createLocalJWKSet, createRemoteJWKSet, jwtVerify } from "jose"; import { bearerToken, WebhookError } from "./protocol.mjs"; import { loadApiKeyStore } from "./keys.mjs"; @@ -59,10 +59,141 @@ export function createApiKeyVerifier({ }; } +/** Trim and strip a trailing slash so issuer comparisons are stable. */ +function normalizeIssuer(issuer) { + return String(issuer ?? "").trim().replace(/\/$/, ""); +} + +/** + * Resolve `jwks_uri` via OIDC Discovery (issuer-relative + * `/.well-known/openid-configuration`), matching oauth4webapi / builder-sdk. + * + * Example: issuer `https://idp.example/api/v1/oidc` → + * discovery advertises `jwks_uri` `https://idp.example/api/v1/oidc/jwks`. + * + * @param {string} jwtIssuer + * @param {{ fetchImpl?: typeof fetch }} [options] + * @returns {Promise} + */ +export async function discoverJwksUri(jwtIssuer, options = {}) { + const fetchImpl = options.fetchImpl ?? fetch; + const base = normalizeIssuer(jwtIssuer); + if (!base) { + throw new Error("discoverJwksUri: jwtIssuer is required"); + } + const url = `${base}/.well-known/openid-configuration`; + let response; + try { + response = await fetchImpl(url); + } catch (err) { + throw new Error( + `OIDC discovery request failed (${url}): ${err instanceof Error ? err.message : err}`, + ); + } + if (!response.ok) { + throw new Error(`OIDC discovery failed (${url}): HTTP ${response.status}`); + } + let doc; + try { + doc = await response.json(); + } catch { + throw new Error(`OIDC discovery response is not JSON (${url})`); + } + if (!doc || typeof doc.issuer !== "string" || !doc.issuer.trim()) { + throw new Error(`OIDC discovery document missing issuer (${url})`); + } + if (normalizeIssuer(doc.issuer) !== base) { + throw new Error( + `OIDC discovery issuer mismatch (${url}): expected ${base}, got ${doc.issuer.trim()}`, + ); + } + if (typeof doc.jwks_uri !== "string" || !doc.jwks_uri.trim()) { + throw new Error(`OIDC discovery document missing jwks_uri (${url})`); + } + return doc.jwks_uri.trim(); +} + +/** + * Build a jose key resolver: explicit `jwks`, explicit `jwksUri`, or lazy OIDC + * discovery of `jwks_uri` from `{issuer}/.well-known/openid-configuration`. + * + * When `fetchImpl` is set (tests / custom HTTP), JWKS is loaded through it into + * a local keyset. Otherwise jose's createRemoteJWKSet fetches and caches. + */ +function createOidcKeyResolver({ jwks, jwksUri, jwtIssuer, fetchImpl }) { + if (jwks) { + if (typeof jwks !== "function") { + throw new Error( + "createOidcVerifier: jwks must be a jose key resolver function (e.g. createLocalJWKSet(...))", + ); + } + return jwks; + } + + let remote; + let resolving; + return async (protectedHeader, token) => { + if (!remote) { + resolving ??= (async () => { + const uri = jwksUri ?? (await discoverJwksUri(jwtIssuer, { fetchImpl })); + if (fetchImpl) { + let response; + try { + response = await fetchImpl(uri); + } catch (err) { + throw new Error( + `JWKS request failed (${uri}): ${err instanceof Error ? err.message : err}`, + ); + } + if (!response.ok) { + throw new Error(`JWKS request failed (${uri}): HTTP ${response.status}`); + } + let doc; + try { + doc = await response.json(); + } catch { + throw new Error(`JWKS response is not JSON (${uri})`); + } + try { + return createLocalJWKSet(doc); + } catch (err) { + throw new Error( + `JWKS is invalid (${uri}): ${err instanceof Error ? err.message : err}`, + ); + } + } + let jwksUrl; + try { + jwksUrl = new URL(uri); + } catch (err) { + throw new Error( + `JWKS URI is not a valid URL (${uri}): ${err instanceof Error ? err.message : err}`, + ); + } + return createRemoteJWKSet(jwksUrl); + })(); + try { + remote = await resolving; + } catch (err) { + // Clear so a later verify can retry after transient discovery/JWKS failures. + resolving = undefined; + throw err; + } + } + return remote(protectedHeader, token); + }; +} + /** * OIDC/JWT verifier (bring-your-own OAuth). Validates a `Bearer ` against - * `jwtIssuer`/`jwtAudience` using the issuer's JWKS (auto-cached & refreshed by - * jose's createRemoteJWKSet). `jwks` may be injected for testing. + * `jwtIssuer`/`jwtAudience` using the issuer's JWKS. By default jose's + * `createRemoteJWKSet` fetches, caches, and refreshes keys. When `fetchImpl` is + * provided (tests / custom HTTP), JWKS is fetched once into a local keyset. + * + * JWKS resolution order: + * 1. `jwks` (injected keyset, for tests) + * 2. `jwksUri` (explicit override, e.g. OIDC_JWKS_URI) + * 3. OIDC Discovery: `{jwtIssuer}/.well-known/openid-configuration` → `jwks_uri` */ export function createOidcVerifier({ jwtIssuer, @@ -74,6 +205,7 @@ export function createOidcVerifier({ subjectClaim = "sub", subjectTypeValue = "oidc_user", requiredScopes = [], + fetchImpl, }) { if (!jwtIssuer) { throw new Error("createOidcVerifier: jwtIssuer is required"); @@ -81,11 +213,12 @@ export function createOidcVerifier({ if (!jwtAudience) { throw new Error("createOidcVerifier: jwtAudience is required"); } - const keyset = - jwks ?? - createRemoteJWKSet( - new URL(jwksUri ?? `${jwtIssuer.replace(/\/$/, "")}/.well-known/jwks.json`), - ); + const keyset = createOidcKeyResolver({ + jwks, + jwksUri, + jwtIssuer, + fetchImpl, + }); const identityIssuer = issuer ?? jwtIssuer; return { diff --git a/identity-webhook/verifiers.test.mjs b/identity-webhook/verifiers.test.mjs index 3993ae7..d846d91 100644 --- a/identity-webhook/verifiers.test.mjs +++ b/identity-webhook/verifiers.test.mjs @@ -5,10 +5,283 @@ import { createApiKeyVerifier, createEndUserVerifierFromEnv, createOidcVerifier, + discoverJwksUri, } from "./verifiers.mjs"; const ISSUER = "http://identity-webhook:8090"; +describe("discoverJwksUri", () => { + it("reads jwks_uri from issuer-relative openid-configuration", async () => { + const issuer = "https://idp.test/api/v1/oidc"; + const jwksUri = `${issuer}/jwks`; + const seen = []; + const fetchImpl = async (input) => { + seen.push(String(input)); + return Response.json({ + issuer, + jwks_uri: jwksUri, + }); + }; + const uri = await discoverJwksUri(issuer, { + fetchImpl, + }); + assert.equal(uri, jwksUri); + assert.deepEqual(seen, [`${issuer}/.well-known/openid-configuration`]); + }); + + it("rejects discovery without jwks_uri", async () => { + await assert.rejects( + () => + discoverJwksUri("https://idp.test", { + fetchImpl: async () => Response.json({ issuer: "https://idp.test" }), + }), + /missing jwks_uri/, + ); + }); + + it("rejects discovery when issuer does not match jwtIssuer", async () => { + await assert.rejects( + () => + discoverJwksUri("https://idp.test/api/v1/oidc", { + fetchImpl: async () => + Response.json({ + issuer: "https://other.example/oidc", + jwks_uri: "https://other.example/oidc/jwks", + }), + }), + /issuer mismatch/, + ); + }); + + it("accepts discovery issuer that differs only by trailing slash", async () => { + const uri = await discoverJwksUri("https://idp.test/api/v1/oidc/", { + fetchImpl: async () => + Response.json({ + issuer: "https://idp.test/api/v1/oidc", + jwks_uri: "https://idp.test/api/v1/oidc/jwks", + }), + }); + assert.equal(uri, "https://idp.test/api/v1/oidc/jwks"); + }); + + it("trims surrounding whitespace on jwtIssuer and discovery issuer", async () => { + const uri = await discoverJwksUri(" https://idp.test/api/v1/oidc/ ", { + fetchImpl: async () => + Response.json({ + issuer: " https://idp.test/api/v1/oidc ", + jwks_uri: "https://idp.test/api/v1/oidc/jwks", + }), + }); + assert.equal(uri, "https://idp.test/api/v1/oidc/jwks"); + }); + + it("rejects a blank jwtIssuer", async () => { + await assert.rejects(() => discoverJwksUri(" "), /jwtIssuer is required/); + await assert.rejects(() => discoverJwksUri(""), /jwtIssuer is required/); + }); +}); + +describe("createOidcVerifier discovery", () => { + it("discovers JWKS via openid-configuration then verifies", async () => { + const { publicKey, privateKey } = await generateKeyPair("RS256"); + const jwk = await exportJWK(publicKey); + jwk.kid = "disc-key"; + jwk.alg = "RS256"; + jwk.use = "sig"; + const issuer = "https://idp.test/api/v1/oidc"; + const jwksUri = `${issuer}/jwks`; + const seen = []; + const fetchImpl = async (input) => { + const url = String(input); + seen.push(url); + if (url.endsWith("/.well-known/openid-configuration")) { + return Response.json({ issuer, jwks_uri: jwksUri }); + } + if (url.startsWith(jwksUri)) { + return Response.json({ keys: [jwk] }); + } + return new Response("not found", { status: 404 }); + }; + + const token = await new SignJWT({ sub: "user-b", azp: "app-b" }) + .setProtectedHeader({ alg: "RS256", kid: "disc-key" }) + .setIssuer(issuer) + .setAudience("clearinghouse") + .setIssuedAt() + .setExpirationTime("5m") + .sign(privateKey); + + const verifier = createOidcVerifier({ + jwtIssuer: issuer, + jwtAudience: "clearinghouse", + fetchImpl, + }); + const { identity } = await verifier.verify({ authorization: `Bearer ${token}` }); + assert.equal(identity.usage_subject, "user-b"); + assert.ok(seen.some((u) => u.endsWith("/.well-known/openid-configuration"))); + assert.ok(seen.some((u) => u.startsWith(jwksUri))); + assert.ok(!seen.some((u) => u.includes(".well-known/jwks.json"))); + }); + + it("wraps JWKS fetch failures with the JWKS URL", async () => { + const issuer = "https://idp.test/api/v1/oidc"; + const jwksUri = `${issuer}/jwks`; + const fetchImpl = async (input) => { + const url = String(input); + if (url.endsWith("/.well-known/openid-configuration")) { + return Response.json({ issuer, jwks_uri: jwksUri }); + } + if (url.startsWith(jwksUri)) { + throw new Error("network down"); + } + return new Response("not found", { status: 404 }); + }; + + const warnings = []; + const origWarn = console.warn; + console.warn = (...args) => warnings.push(args.join(" ")); + try { + const verifier = createOidcVerifier({ + jwtIssuer: issuer, + jwtAudience: "clearinghouse", + fetchImpl, + }); + await assert.rejects( + () => verifier.verify({ authorization: "Bearer eyJhbGciOiJSUzI1NiJ9.e30.sig" }), + /oidc verification failed/, + ); + } finally { + console.warn = origWarn; + } + assert.ok( + warnings.some((w) => + w.includes(`JWKS request failed (${jwksUri}): network down`), + ), + `expected JWKS URL in warn log, got: ${JSON.stringify(warnings)}`, + ); + }); + + it("wraps non-ok JWKS HTTP responses with the JWKS URL", async () => { + const issuer = "https://idp.test/api/v1/oidc"; + const jwksUri = `${issuer}/jwks`; + const fetchImpl = async (input) => { + const url = String(input); + if (url.endsWith("/.well-known/openid-configuration")) { + return Response.json({ issuer, jwks_uri: jwksUri }); + } + if (url.startsWith(jwksUri)) { + return new Response("gone", { status: 503 }); + } + return new Response("not found", { status: 404 }); + }; + + const warnings = []; + const origWarn = console.warn; + console.warn = (...args) => warnings.push(args.join(" ")); + try { + const verifier = createOidcVerifier({ + jwtIssuer: issuer, + jwtAudience: "clearinghouse", + fetchImpl, + }); + await assert.rejects( + () => verifier.verify({ authorization: "Bearer eyJhbGciOiJSUzI1NiJ9.e30.sig" }), + /oidc verification failed/, + ); + } finally { + console.warn = origWarn; + } + assert.ok( + warnings.some((w) => w.includes(`JWKS request failed (${jwksUri}): HTTP 503`)), + `expected JWKS HTTP status in warn log, got: ${JSON.stringify(warnings)}`, + ); + }); + + it("wraps invalid JWKS documents with the JWKS URL", async () => { + const issuer = "https://idp.test/api/v1/oidc"; + const jwksUri = `${issuer}/jwks`; + const fetchImpl = async (input) => { + const url = String(input); + if (url.endsWith("/.well-known/openid-configuration")) { + return Response.json({ issuer, jwks_uri: jwksUri }); + } + if (url.startsWith(jwksUri)) { + return Response.json({ keys: "not-an-array" }); + } + return new Response("not found", { status: 404 }); + }; + + const warnings = []; + const origWarn = console.warn; + console.warn = (...args) => warnings.push(args.join(" ")); + try { + const verifier = createOidcVerifier({ + jwtIssuer: issuer, + jwtAudience: "clearinghouse", + fetchImpl, + }); + await assert.rejects( + () => verifier.verify({ authorization: "Bearer eyJhbGciOiJSUzI1NiJ9.e30.sig" }), + /oidc verification failed/, + ); + } finally { + console.warn = origWarn; + } + assert.ok( + warnings.some((w) => w.includes(`JWKS is invalid (${jwksUri})`)), + `expected JWKS URL in warn log, got: ${JSON.stringify(warnings)}`, + ); + }); + + it("retries discovery after a transient failure", async () => { + const { publicKey, privateKey } = await generateKeyPair("RS256"); + const jwk = await exportJWK(publicKey); + jwk.kid = "retry-key"; + jwk.alg = "RS256"; + jwk.use = "sig"; + const issuer = "https://idp.test/api/v1/oidc"; + const jwksUri = `${issuer}/jwks`; + let discoveryAttempts = 0; + const fetchImpl = async (input) => { + const url = String(input); + if (url.endsWith("/.well-known/openid-configuration")) { + discoveryAttempts += 1; + if (discoveryAttempts === 1) { + throw new Error("temporary outage"); + } + return Response.json({ issuer, jwks_uri: jwksUri }); + } + if (url.startsWith(jwksUri)) { + return Response.json({ keys: [jwk] }); + } + return new Response("not found", { status: 404 }); + }; + + const token = await new SignJWT({ sub: "user-retry", azp: "app-retry" }) + .setProtectedHeader({ alg: "RS256", kid: "retry-key" }) + .setIssuer(issuer) + .setAudience("clearinghouse") + .setIssuedAt() + .setExpirationTime("5m") + .sign(privateKey); + + const verifier = createOidcVerifier({ + jwtIssuer: issuer, + jwtAudience: "clearinghouse", + fetchImpl, + }); + + await assert.rejects( + () => verifier.verify({ authorization: `Bearer ${token}` }), + /oidc verification failed/, + ); + + const { identity } = await verifier.verify({ authorization: `Bearer ${token}` }); + assert.equal(identity.usage_subject, "user-retry"); + assert.equal(discoveryAttempts, 2); + }); +}); + describe("createApiKeyVerifier", () => { const store = new Map([ ["sk_demo", { userId: "demo-user", clientId: "demo-client", usageSubjectType: "api_key_user" }], @@ -117,6 +390,41 @@ describe("createOidcVerifier (jose, locally-minted JWT)", () => { }); await assert.rejects(() => verifier.verify({ authorization: "Bearer sk_not_a_jwt" }), /not a JWT/); }); + + it("rejects a JWKS JSON object passed as jwks", () => { + assert.throws( + () => + createOidcVerifier({ + jwtIssuer: "https://idp.test/", + jwtAudience: "clearinghouse", + jwks: { keys: [] }, + }), + /jwks must be a jose key resolver function/, + ); + }); + + it("includes a malformed jwksUri in the verification warning", async () => { + const warnings = []; + const origWarn = console.warn; + console.warn = (...args) => warnings.push(args.join(" ")); + try { + const verifier = createOidcVerifier({ + jwtIssuer: "https://idp.test/", + jwtAudience: "clearinghouse", + jwksUri: "/relative/jwks", + }); + await assert.rejects( + () => verifier.verify({ authorization: "Bearer eyJhbGciOiJSUzI1NiJ9.e30.sig" }), + /oidc verification failed/, + ); + } finally { + console.warn = origWarn; + } + assert.ok( + warnings.some((w) => w.includes("JWKS URI is not a valid URL (/relative/jwks)")), + `expected malformed JWKS URI in warn log, got: ${JSON.stringify(warnings)}`, + ); + }); }); describe("createEndUserVerifierFromEnv", () => { From be3215256227435d52bfa7c6be086512058ecef5 Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Thu, 16 Jul 2026 14:39:31 -0400 Subject: [PATCH 10/11] fix(identity-webhook): address PR #63 review and OIDC hardening Expire stale composite-exchange inflight cache entries, require Bearer scheme, reject non-origin token-exchange URLs, expand JWT algs to RS256/ES256, require exp with 60s clock skew, and normalize OIDC issuer consistently for discovery and jwtVerify. --- README.md | 6 ++- identity-webhook/protocol.mjs | 6 +-- identity-webhook/protocol.test.mjs | 4 +- identity-webhook/verifiers.mjs | 38 ++++++++++---- identity-webhook/verifiers.test.mjs | 80 ++++++++++++++++++++++++++++- 5 files changed, 115 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 579e69a..f852561 100644 --- a/README.md +++ b/README.md @@ -156,16 +156,18 @@ Shared keys (`WEBHOOK_SECRET`, `KAFKA_BROKERS`, `KAFKA_GATEWAY_TOPIC`) are liste | `DEMO_API_KEYS` | `api_key`, optional | JSON map of extra keys, e.g. `{"sk_other":{"clientId":"app-b","userId":"user-b"}}` | | `USAGE_SUBJECT_TYPE` | `api_key`, optional | Default `api_key_user`; stamped on API-key identities | | `API_KEY_PREFIX` | `api_key`, optional | Default `sk_` | -| `OIDC_ISSUER` | `oidc` | JWT issuer / JWKS host | +| `OIDC_ISSUER` | `oidc` | JWT issuer / JWKS host (trailing slash and surrounding whitespace are normalized) | | `OIDC_AUDIENCE` | `oidc` | Expected JWT audience | | `OIDC_JWKS_URI` | `oidc`, optional | Explicit JWKS override; otherwise resolved via OIDC Discovery (`${OIDC_ISSUER}/.well-known/openid-configuration` → `jwks_uri`) | | `OIDC_CLIENT_CLAIM` | `oidc`, optional | Tenant claim (default `azp`; Auth0 clearinghouse uses `app_client_id`) | | `OIDC_SUBJECT_CLAIM` | `oidc`, optional | End-user claim (default `sub`; Auth0 clearinghouse uses `external_user_id`) | | `OIDC_SUBJECT_TYPE` | `oidc`, optional | Default `oidc_user`; Auth0 clearinghouse uses `external_user_id` | | `OIDC_REQUIRED_SCOPES` | `oidc`, optional | Space- or comma-separated required scopes (e.g. `sign:job`) | -| `OIDC_TOKEN_EXCHANGE_BASE_URL` | `oidc`, optional | Enables composite `Bearer app_<24hex>_`: RFC 8693 exchange at `/api/v1/apps/{clientId}/oidc/token`, then JWT verify | +| `OIDC_TOKEN_EXCHANGE_BASE_URL` | `oidc`, optional | Origin only (no path/query/hash). Enables composite `Bearer app_<24hex>_`: RFC 8693 exchange at `/api/v1/apps/{clientId}/oidc/token`, then JWT verify (`RS256`/`ES256`, required `exp`, 60s clock skew) | | `OIDC_EXCHANGE_M2M_CLIENT_ID` / `OIDC_EXCHANGE_M2M_CLIENT_SECRET` | `oidc`, optional | Optional client credentials for the exchange request (both or neither) | +OIDC JWT verification accepts only `RS256` and `ES256` (asymmetric algorithms). Tokens must include `exp` and the configured subject claim. + `PORT` is set by Compose (`8090`), not `.env`. See the `identity-webhook` block in [`.env.example`](.env.example) for Auth0 vs generic OIDC examples. **npm:** `@pymthouse/clearinghouse-identity-webhook` is published from this directory for embedded use (e.g. Pymthouse `POST /webhooks/remote-signer`). Releases use tag `v*.*.*` and [trusted publishing](identity-webhook/docs/RELEASING.md). Upstream: [livepeer/clearinghouse](https://github.com/livepeer/clearinghouse). diff --git a/identity-webhook/protocol.mjs b/identity-webhook/protocol.mjs index c4a69d3..c9e45ee 100644 --- a/identity-webhook/protocol.mjs +++ b/identity-webhook/protocol.mjs @@ -42,10 +42,10 @@ export class WebhookError extends Error { } } -/** Strip a `Bearer ` prefix (case-insensitive); returns the raw token otherwise. */ +/** Extract the token from `Bearer ` (RFC 6750); empty if scheme is missing. */ export function bearerToken(authorization) { - const value = (authorization ?? "").trim(); - return value.replace(/^Bearer\s+/i, "").trim(); + const m = /^Bearer\s+(.+)$/i.exec((authorization ?? "").trim()); + return m ? m[1].trim() : ""; } function timingSafeEqualStrings(a, b) { diff --git a/identity-webhook/protocol.test.mjs b/identity-webhook/protocol.test.mjs index 3980467..f10f2ff 100644 --- a/identity-webhook/protocol.test.mjs +++ b/identity-webhook/protocol.test.mjs @@ -47,10 +47,10 @@ const fakeVerifier = { const config = { webhookSecret: SECRET, endUserAuth: fakeVerifier }; describe("bearerToken", () => { - it("strips a case-insensitive Bearer prefix", () => { + it("requires a case-insensitive Bearer scheme", () => { assert.equal(bearerToken("Bearer sk_abc"), "sk_abc"); assert.equal(bearerToken("bearer sk_abc"), "sk_abc"); - assert.equal(bearerToken("sk_abc"), "sk_abc"); + assert.equal(bearerToken("sk_abc"), ""); assert.equal(bearerToken(undefined), ""); }); }); diff --git a/identity-webhook/verifiers.mjs b/identity-webhook/verifiers.mjs index 6962eeb..f5aa237 100644 --- a/identity-webhook/verifiers.mjs +++ b/identity-webhook/verifiers.mjs @@ -24,7 +24,9 @@ const SUBJECT_ACCESS_TOKEN_TYPE = /** Underscore composite: `app_<24hex>_` (no `.` — better copy/select UX). */ const COMPOSITE_API_KEY_RE = /^(app_[a-f0-9]{24})_(.+)$/; const COMPOSITE_CACHE_MAX_TTL_SECONDS = 60; -const ALLOWED_JWT_ALGS = ["RS256"]; +/** Asymmetric algs only — never HS* (shared-secret confusion). */ +const ALLOWED_JWT_ALGS = ["RS256", "ES256"]; +const JWT_CLOCK_TOLERANCE_SECONDS = 60; function nowSeconds() { return Math.floor(Date.now() / 1000); @@ -56,11 +58,13 @@ function isLoopbackHost(hostname) { /** * Validate OIDC_TOKEN_EXCHANGE_BASE_URL: HTTPS required except loopback. + * Must be an origin only — path/query/hash are rejected so a mis-set + * `https://host/exchange` cannot silently become `https://host`. * @param {string} baseUrl * @returns {string} normalized origin (no trailing slash) */ export function normalizeTokenExchangeBaseUrl(baseUrl) { - const trimmed = (baseUrl ?? "").trim().replace(/\/$/, ""); + const trimmed = (baseUrl ?? "").trim(); if (!trimmed) { throw new Error("tokenExchangeBaseUrl is required for composite API key exchange"); } @@ -70,6 +74,12 @@ export function normalizeTokenExchangeBaseUrl(baseUrl) { } catch { throw new Error(`tokenExchangeBaseUrl is not a valid URL: ${trimmed}`); } + const path = url.pathname.replace(/\/$/, "") || "/"; + if (path !== "/" || url.search || url.hash) { + throw new Error( + `tokenExchangeBaseUrl must be an origin only (no path, query, or hash); got ${trimmed}`, + ); + } if (url.protocol === "https:") { return url.origin; } @@ -307,8 +317,8 @@ function mapVerifiedPayloadToIdentity({ usage_subject: String(usageSubject), usage_subject_type: subjectTypeValue, }; - const expiry = typeof payload.exp === "number" ? payload.exp : nowSeconds() + 60; - return { identity, expiry, raw: payload }; + // `exp` is required by jwtVerify(requiredClaims); no forever-valid fallback. + return { identity, expiry: payload.exp, raw: payload }; } async function verifyJwtBearer({ @@ -328,6 +338,8 @@ async function verifyJwtBearer({ issuer: jwtIssuer, audience: jwtAudience, algorithms: ALLOWED_JWT_ALGS, + requiredClaims: ["exp", subjectClaim], + clockTolerance: JWT_CLOCK_TOLERANCE_SECONDS, })); } catch (err) { console.warn(`oidc verification failed: ${err.message}`); @@ -348,7 +360,8 @@ async function verifyJwtBearer({ }); } -function createCompositeExchangeCache() { +/** @internal Exported for unit tests. */ +export function createCompositeExchangeCache() { /** @type {Map }>} */ const cache = new Map(); @@ -358,7 +371,11 @@ function createCompositeExchangeCache() { if (!entry) { return null; } - if (entry.result !== undefined && entry.expiresAt > nowSeconds()) { + if (entry.expiresAt <= nowSeconds()) { + cache.delete(key); + return null; + } + if (entry.result !== undefined) { return entry.result; } if (entry.inflight !== undefined) { @@ -488,7 +505,8 @@ export function createOidcVerifier({ exchangeM2mClientId, exchangeM2mClientSecret, }) { - if (!jwtIssuer) { + const normalizedJwtIssuer = normalizeIssuer(jwtIssuer); + if (!normalizedJwtIssuer) { throw new Error("createOidcVerifier: jwtIssuer is required"); } if (!jwtAudience) { @@ -497,10 +515,10 @@ export function createOidcVerifier({ const keyset = createOidcKeyResolver({ jwks, jwksUri, - jwtIssuer, + jwtIssuer: normalizedJwtIssuer, fetchImpl, }); - const identityIssuer = issuer ?? jwtIssuer; + const identityIssuer = issuer ?? normalizedJwtIssuer; const http = fetchImpl ?? fetch; const exchangeOrigin = tokenExchangeBaseUrl ? normalizeTokenExchangeBaseUrl(tokenExchangeBaseUrl) @@ -518,7 +536,7 @@ export function createOidcVerifier({ return verifyJwtBearer({ token, keyset, - jwtIssuer, + jwtIssuer: normalizedJwtIssuer, jwtAudience, identityIssuer, clientClaim, diff --git a/identity-webhook/verifiers.test.mjs b/identity-webhook/verifiers.test.mjs index b6eed65..c829519 100644 --- a/identity-webhook/verifiers.test.mjs +++ b/identity-webhook/verifiers.test.mjs @@ -3,6 +3,7 @@ import { describe, it } from "node:test"; import { SignJWT, createLocalJWKSet, exportJWK, generateKeyPair } from "jose"; import { createApiKeyVerifier, + createCompositeExchangeCache, createEndUserVerifierFromEnv, createOidcVerifier, discoverJwksUri, @@ -325,7 +326,7 @@ describe("createOidcVerifier (jose, locally-minted JWT)", () => { return { privateKey, jwks }; } - async function mint(privateKey, claims, { aud = "clearinghouse", iss = "https://idp.test/" } = {}) { + async function mint(privateKey, claims, { aud = "clearinghouse", iss = "https://idp.test" } = {}) { return new SignJWT(claims) .setProtectedHeader({ alg: "RS256", kid: "test-key" }) .setIssuer(iss) @@ -354,6 +355,37 @@ describe("createOidcVerifier (jose, locally-minted JWT)", () => { assert.equal(typeof expiry, "number"); }); + it("normalizes jwtIssuer whitespace and trailing slash for verification", async () => { + const { privateKey, jwks } = await setup(); + const token = await mint(privateKey, { sub: "user-b", azp: "app-b" }); + const verifier = createOidcVerifier({ + jwtIssuer: " https://idp.test/ ", + jwtAudience: "clearinghouse", + jwks, + }); + const { identity } = await verifier.verify({ authorization: `Bearer ${token}` }); + assert.equal(identity.usage_subject, "user-b"); + }); + + it("rejects a token without exp", async () => { + const { privateKey, jwks } = await setup(); + const token = await new SignJWT({ sub: "user-b", azp: "app-b" }) + .setProtectedHeader({ alg: "RS256", kid: "test-key" }) + .setIssuer("https://idp.test") + .setAudience("clearinghouse") + .setIssuedAt() + .sign(privateKey); + const verifier = createOidcVerifier({ + jwtIssuer: "https://idp.test", + jwtAudience: "clearinghouse", + jwks, + }); + await assert.rejects( + () => verifier.verify({ authorization: `Bearer ${token}` }), + /oidc verification failed/, + ); + }); + it("rejects a wrong audience", async () => { const { privateKey, jwks } = await setup(); const token = await mint(privateKey, { sub: "user-b", azp: "app-b" }, { aud: "other-api" }); @@ -464,7 +496,7 @@ describe("createEndUserVerifierFromEnv", () => { const token = await new SignJWT({ azp: "app-b", scope: "other" }) .setProtectedHeader({ alg: "RS256", kid: "k" }) - .setIssuer("https://idp.test/") + .setIssuer("https://idp.test") .setAudience("clearinghouse") .setSubject("user-b") .setIssuedAt() @@ -545,6 +577,50 @@ describe("splitCompositeApiKey / normalizeTokenExchangeBaseUrl", () => { /must be https/, ); }); + + it("rejects path, query, or hash (origin only)", () => { + assert.throws( + () => normalizeTokenExchangeBaseUrl("https://billing.example.com/exchange"), + /origin only/, + ); + assert.throws( + () => normalizeTokenExchangeBaseUrl("https://billing.example.com/?x=1"), + /origin only/, + ); + assert.throws( + () => normalizeTokenExchangeBaseUrl("https://billing.example.com/#frag"), + /origin only/, + ); + }); +}); + +describe("createCompositeExchangeCache", () => { + it("expires inflight entries the same way as results", async () => { + const cache = createCompositeExchangeCache(); + const realNow = Date.now; + let fakeMs = 1_700_000_000_000; + Date.now = () => fakeMs; + try { + let resolveHang; + const hang = new Promise((resolve) => { + resolveHang = resolve; + }); + cache.setInflight("k", hang); + assert.equal(cache.get("k"), hang); + + fakeMs += 61_000; + assert.equal(cache.get("k"), null); + + const result = { ok: true }; + cache.setResult("k", result, 30); + assert.deepEqual(cache.get("k"), result); + fakeMs += 31_000; + assert.equal(cache.get("k"), null); + resolveHang(); + } finally { + Date.now = realNow; + } + }); }); describe("createOidcVerifier composite API key exchange", () => { From d1d816eb3587928509521fca764506388d4ede4b Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Thu, 16 Jul 2026 16:05:05 -0400 Subject: [PATCH 11/11] fix(identity-webhook): accept trailing-slash iss; guard exchange cache races Allow jwtVerify to match iss with or without a trailing slash after issuer normalization, and only clear/set composite-exchange cache entries when they still reference the same inflight promise. --- identity-webhook/verifiers.mjs | 27 +++++++++++++++++++---- identity-webhook/verifiers.test.mjs | 33 +++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/identity-webhook/verifiers.mjs b/identity-webhook/verifiers.mjs index f5aa237..98ce466 100644 --- a/identity-webhook/verifiers.mjs +++ b/identity-webhook/verifiers.mjs @@ -334,8 +334,10 @@ async function verifyJwtBearer({ }) { let payload; try { + // Accept iss with or without a trailing slash (OIDC issuers vary); jwtIssuer is + // already normalized (no trailing slash) for discovery/caching. ({ payload } = await jwtVerify(token, keyset, { - issuer: jwtIssuer, + issuer: [jwtIssuer, `${jwtIssuer}/`], audience: jwtAudience, algorithms: ALLOWED_JWT_ALGS, requiredClaims: ["exp", subjectClaim], @@ -394,6 +396,22 @@ export function createCompositeExchangeCache() { const ttl = Math.max(1, Math.min(ttlSeconds, COMPOSITE_CACHE_MAX_TTL_SECONDS)); cache.set(key, { expiresAt: nowSeconds() + ttl, result }); }, + /** Only clear when this promise is still the cached inflight (avoid clobbering a newer entry). */ + clearInflight(key, promise) { + const entry = cache.get(key); + if (entry?.inflight === promise) { + cache.delete(key); + } + }, + /** Only store a result when this promise is still the cached inflight. */ + setResultForInflight(key, promise, result, ttlSeconds) { + const entry = cache.get(key); + if (entry?.inflight !== promise) { + return; + } + const ttl = Math.max(1, Math.min(ttlSeconds, COMPOSITE_CACHE_MAX_TTL_SECONDS)); + cache.set(key, { expiresAt: nowSeconds() + ttl, result }); + }, clear(key) { cache.delete(key); }, @@ -560,7 +578,8 @@ export function createOidcVerifier({ return cached; } - const inflight = (async () => { + let inflight; + inflight = (async () => { const accessToken = await exchangeCompositeApiKey({ exchangeBaseUrl: exchangeOrigin, publicClientId: parts.publicClientId, @@ -579,10 +598,10 @@ export function createOidcVerifier({ }); } const ttl = Math.max(1, verified.expiry - nowSeconds()); - exchangeCache.setResult(cacheKey, verified, ttl); + exchangeCache.setResultForInflight(cacheKey, inflight, verified, ttl); return verified; })().catch((err) => { - exchangeCache.clear(cacheKey); + exchangeCache.clearInflight(cacheKey, inflight); throw err; }); diff --git a/identity-webhook/verifiers.test.mjs b/identity-webhook/verifiers.test.mjs index c829519..a9ac886 100644 --- a/identity-webhook/verifiers.test.mjs +++ b/identity-webhook/verifiers.test.mjs @@ -367,6 +367,22 @@ describe("createOidcVerifier (jose, locally-minted JWT)", () => { assert.equal(identity.usage_subject, "user-b"); }); + it("accepts tokens whose iss claim has a trailing slash", async () => { + const { privateKey, jwks } = await setup(); + const token = await mint( + privateKey, + { sub: "user-b", azp: "app-b" }, + { iss: "https://idp.test/" }, + ); + const verifier = createOidcVerifier({ + jwtIssuer: "https://idp.test", + jwtAudience: "clearinghouse", + jwks, + }); + const { identity } = await verifier.verify({ authorization: `Bearer ${token}` }); + assert.equal(identity.usage_subject, "user-b"); + }); + it("rejects a token without exp", async () => { const { privateKey, jwks } = await setup(); const token = await new SignJWT({ sub: "user-b", azp: "app-b" }) @@ -621,6 +637,23 @@ describe("createCompositeExchangeCache", () => { Date.now = realNow; } }); + + it("clearInflight / setResultForInflight only touch the matching promise", async () => { + const cache = createCompositeExchangeCache(); + const older = Promise.resolve("old"); + const newer = Promise.resolve("new"); + cache.setInflight("k", older); + cache.setInflight("k", newer); + + cache.clearInflight("k", older); + assert.equal(cache.get("k"), newer); + + cache.setResultForInflight("k", older, { from: "old" }, 30); + assert.equal(cache.get("k"), newer); + + cache.setResultForInflight("k", newer, { from: "new" }, 30); + assert.deepEqual(cache.get("k"), { from: "new" }); + }); }); describe("createOidcVerifier composite API key exchange", () => {