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 5c17f4eece6411a56a842d21646e9d997f84a5f5 Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Sat, 11 Jul 2026 17:44:17 -0400 Subject: [PATCH 05/11] feat(identity-webhook): add live balance gate after identity verify Add createBalanceGate / parseUsdMicros and an optional checkBalance hook on handleAuthorize so consumers can enforce live credit at sign time (483 insufficient_balance, optional expiry cap for mid-stream reauth). --- identity-webhook/balance-gate.d.ts | 26 +++++ identity-webhook/balance-gate.mjs | 144 +++++++++++++++++++++++++ identity-webhook/balance-gate.test.mjs | 134 +++++++++++++++++++++++ identity-webhook/protocol.d.ts | 20 ++++ identity-webhook/protocol.mjs | 28 ++++- identity-webhook/protocol.test.mjs | 65 +++++++++++ 6 files changed, 416 insertions(+), 1 deletion(-) create mode 100644 identity-webhook/balance-gate.d.ts create mode 100644 identity-webhook/balance-gate.mjs create mode 100644 identity-webhook/balance-gate.test.mjs diff --git a/identity-webhook/balance-gate.d.ts b/identity-webhook/balance-gate.d.ts new file mode 100644 index 0000000..4279e90 --- /dev/null +++ b/identity-webhook/balance-gate.d.ts @@ -0,0 +1,26 @@ +import type { + BalanceCheck, + BalanceCheckContext, + UsageIdentity, +} from "./protocol.js"; + +export function parseUsdMicros( + value: bigint | number | string | null | undefined, +): bigint | null; + +export function createBalanceGate(options: { + getBalanceUsdMicros: ( + identity: UsageIdentity, + ctx: BalanceCheckContext, + ) => + | Promise + | bigint + | number + | string + | null + | undefined; + minBalanceUsdMicros?: bigint | number | string; + reauthTtlSeconds?: number; + failClosed?: boolean; + onError?: (err: unknown, identity: UsageIdentity) => void; +}): BalanceCheck; diff --git a/identity-webhook/balance-gate.mjs b/identity-webhook/balance-gate.mjs new file mode 100644 index 0000000..a9f86cc --- /dev/null +++ b/identity-webhook/balance-gate.mjs @@ -0,0 +1,144 @@ +/** + * Live balance/credit gate for the identity webhook. + * + * `createBalanceGate` turns a simple per-identity balance lookup into a + * `checkBalance` hook for `handleAuthorize` (protocol.mjs). It is verifier + * agnostic: whatever proved the identity (OIDC JWT, composite API key, or a + * plain API key), the gate reads the caller-supplied balance and rejects with + * the go-livepeer wire status 483 (`insufficient_balance`) when the customer is + * out of credit — closing the "still streaming after credit hits zero" gap that + * a mint-time-only gate leaves open. + * + * Balances are USD micros (1 USD = 1_000_000 micros), accepted as bigint, + * integer number, or integer string. + * + * Example: + * import { handleAuthorize } from "@livepeer/clearinghouse-identity-webhook/protocol"; + * import { createBalanceGate } from "@livepeer/clearinghouse-identity-webhook/balance-gate"; + * + * const checkBalance = createBalanceGate({ + * getBalanceUsdMicros: async (identity) => + * readLiveCreditBalanceUsdMicros(identity.client_id, identity.usage_subject), + * reauthTtlSeconds: 30, // re-check at least every 30s mid-stream + * }); + * return handleAuthorize(request, { webhookSecret, endUserAuth, checkBalance }); + */ +import { + REMOTE_SIGNER_ERROR_CODE, + REMOTE_SIGNER_HTTP_STATUS, + WebhookError, +} from "./protocol.mjs"; + +function nowSeconds() { + return Math.floor(Date.now() / 1000); +} + +/** + * Coerce a USD-micros balance (bigint | integer number | integer string) to a + * bigint. Returns null for anything non-integer (including "1.5", "", null). + */ +export function parseUsdMicros(value) { + if (typeof value === "bigint") { + return value; + } + if (typeof value === "number") { + return Number.isInteger(value) ? BigInt(value) : null; + } + if (typeof value === "string") { + const trimmed = value.trim(); + if (!/^-?\d+$/.test(trimmed)) { + return null; + } + try { + return BigInt(trimmed); + } catch { + return null; + } + } + return null; +} + +/** + * Build a `checkBalance` hook from a balance lookup. + * + * @param {object} options + * @param {(identity: import("./protocol.js").UsageIdentity, ctx: import("./protocol.js").BalanceCheckContext) => any} options.getBalanceUsdMicros + * Resolve remaining balance (USD micros) for the identity. May be async. + * Return null/undefined to signal "balance unknown" (see failClosed). + * @param {bigint | number | string} [options.minBalanceUsdMicros=1] + * Minimum balance required to authorize. Default: 1 micro (any positive credit). + * @param {number} [options.reauthTtlSeconds] + * When set, caps the returned expiry to now + this, forcing go-livepeer to + * call back and re-check the balance at least this often. + * @param {boolean} [options.failClosed=true] + * On lookup error or unknown balance: true → reject 503 billing_unavailable; + * false → allow (fail open). + * @param {(err: unknown, identity: import("./protocol.js").UsageIdentity) => void} [options.onError] + * Optional hook to observe lookup errors / unparseable balances. + * @returns {import("./protocol.js").BalanceCheck} + */ +export function createBalanceGate({ + getBalanceUsdMicros, + minBalanceUsdMicros = 1n, + reauthTtlSeconds, + failClosed = true, + onError, +} = {}) { + if (typeof getBalanceUsdMicros !== "function") { + throw new TypeError("createBalanceGate: getBalanceUsdMicros is required"); + } + const minBalance = parseUsdMicros(minBalanceUsdMicros); + if (minBalance === null) { + throw new TypeError("createBalanceGate: minBalanceUsdMicros must be an integer"); + } + let ttl = null; + if (reauthTtlSeconds != null) { + ttl = Number(reauthTtlSeconds); + if (!Number.isFinite(ttl) || ttl <= 0) { + throw new TypeError("createBalanceGate: reauthTtlSeconds must be a positive number"); + } + } + + const billingUnavailable = (message) => + new WebhookError(message, { + status: REMOTE_SIGNER_HTTP_STATUS.BILLING_UNAVAILABLE, + code: REMOTE_SIGNER_ERROR_CODE.BILLING_UNAVAILABLE, + }); + + return async function checkBalance(ctx) { + let rawBalance; + try { + rawBalance = await getBalanceUsdMicros(ctx.identity, ctx); + } catch (err) { + onError?.(err, ctx.identity); + if (failClosed) { + throw billingUnavailable("billing balance lookup failed"); + } + return undefined; + } + + const balance = parseUsdMicros(rawBalance); + if (balance === null) { + onError?.( + new Error(`balance is not an integer micros value: ${String(rawBalance)}`), + ctx.identity, + ); + if (failClosed) { + throw billingUnavailable("billing balance unavailable"); + } + return undefined; + } + + if (balance < minBalance) { + throw new WebhookError("insufficient balance", { + status: REMOTE_SIGNER_HTTP_STATUS.INSUFFICIENT_BALANCE, + code: REMOTE_SIGNER_ERROR_CODE.INSUFFICIENT_BALANCE, + }); + } + + if (ttl !== null) { + return { expiry: nowSeconds() + ttl }; + } + return undefined; + }; +} diff --git a/identity-webhook/balance-gate.test.mjs b/identity-webhook/balance-gate.test.mjs new file mode 100644 index 0000000..1b62d16 --- /dev/null +++ b/identity-webhook/balance-gate.test.mjs @@ -0,0 +1,134 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { createBalanceGate, parseUsdMicros } from "./balance-gate.mjs"; +import { WebhookError } from "./protocol.mjs"; + +const identity = { + issuer: "http://webhook.test", + client_id: "tenant-a", + usage_subject: "user-1", + usage_subject_type: "external_user_id", +}; + +function ctx(overrides = {}) { + return { identity, expiry: 2000000000, payload: {}, request: new Request("http://x/authorize"), ...overrides }; +} + +describe("parseUsdMicros", () => { + it("accepts bigint, integer number, and integer string", () => { + assert.equal(parseUsdMicros(5n), 5n); + assert.equal(parseUsdMicros(5), 5n); + assert.equal(parseUsdMicros("5"), 5n); + assert.equal(parseUsdMicros(" 42 "), 42n); + assert.equal(parseUsdMicros("0"), 0n); + assert.equal(parseUsdMicros("-3"), -3n); + }); + + it("rejects non-integer / junk values as null", () => { + assert.equal(parseUsdMicros(null), null); + assert.equal(parseUsdMicros(undefined), null); + assert.equal(parseUsdMicros(""), null); + assert.equal(parseUsdMicros("1.5"), null); + assert.equal(parseUsdMicros("abc"), null); + assert.equal(parseUsdMicros(1.5), null); + }); +}); + +describe("createBalanceGate", () => { + it("throws on missing getBalanceUsdMicros", () => { + assert.throws(() => createBalanceGate({}), /getBalanceUsdMicros is required/); + }); + + it("validates minBalanceUsdMicros and reauthTtlSeconds", () => { + assert.throws( + () => createBalanceGate({ getBalanceUsdMicros: async () => 0n, minBalanceUsdMicros: "x" }), + /minBalanceUsdMicros must be an integer/, + ); + assert.throws( + () => createBalanceGate({ getBalanceUsdMicros: async () => 0n, reauthTtlSeconds: 0 }), + /reauthTtlSeconds must be a positive number/, + ); + }); + + it("allows a positive balance", async () => { + const gate = createBalanceGate({ getBalanceUsdMicros: async () => "1" }); + assert.equal(await gate(ctx()), undefined); + }); + + it("rejects zero balance with 483 insufficient_balance", async () => { + const gate = createBalanceGate({ getBalanceUsdMicros: async () => 0n }); + await assert.rejects(gate(ctx()), (err) => { + assert.ok(err instanceof WebhookError); + assert.equal(err.status, 483); + assert.equal(err.code, "insufficient_balance"); + return true; + }); + }); + + it("rejects a negative balance", async () => { + const gate = createBalanceGate({ getBalanceUsdMicros: async () => "-100" }); + await assert.rejects(gate(ctx()), /insufficient balance/); + }); + + it("honors a custom minBalanceUsdMicros threshold", async () => { + const gate = createBalanceGate({ + getBalanceUsdMicros: async () => 500n, + minBalanceUsdMicros: 1000n, + }); + await assert.rejects(gate(ctx()), (err) => err.status === 483); + }); + + it("passes identity to the lookup", async () => { + let seen; + const gate = createBalanceGate({ + getBalanceUsdMicros: async (id) => { + seen = id; + return 10n; + }, + }); + await gate(ctx()); + assert.equal(seen.client_id, "tenant-a"); + assert.equal(seen.usage_subject, "user-1"); + }); + + it("caps expiry via reauthTtlSeconds", async () => { + const gate = createBalanceGate({ + getBalanceUsdMicros: async () => 10n, + reauthTtlSeconds: 30, + }); + const before = Math.floor(Date.now() / 1000); + const result = await gate(ctx()); + assert.ok(result.expiry >= before + 30 && result.expiry <= before + 31); + }); + + it("fails closed with 503 on lookup error by default", async () => { + const errors = []; + const gate = createBalanceGate({ + getBalanceUsdMicros: async () => { + throw new Error("openmeter down"); + }, + onError: (err) => errors.push(err), + }); + await assert.rejects(gate(ctx()), (err) => { + assert.equal(err.status, 503); + assert.equal(err.code, "billing_unavailable"); + return true; + }); + assert.equal(errors.length, 1); + }); + + it("fails open when failClosed is false", async () => { + const gate = createBalanceGate({ + getBalanceUsdMicros: async () => { + throw new Error("openmeter down"); + }, + failClosed: false, + }); + assert.equal(await gate(ctx()), undefined); + }); + + it("treats an unparseable balance as billing_unavailable when failing closed", async () => { + const gate = createBalanceGate({ getBalanceUsdMicros: async () => "not-a-number" }); + await assert.rejects(gate(ctx()), (err) => err.status === 503); + }); +}); diff --git a/identity-webhook/protocol.d.ts b/identity-webhook/protocol.d.ts index b61a486..4f36eb0 100644 --- a/identity-webhook/protocol.d.ts +++ b/identity-webhook/protocol.d.ts @@ -72,9 +72,29 @@ export type EndUserAuthVerifier = { }>; }; +export type BalanceCheckContext = { + identity: UsageIdentity; + expiry: number; + raw?: unknown; + payload: unknown; + request: Request; +}; + +export type BalanceCheckResult = { expiry?: number } | void; + +/** + * Live balance/credit gate invoked after identity verification. Throw a + * `WebhookError` (e.g. status 483 `insufficient_balance`) to reject; optionally + * return `{ expiry }` to cap how long go-livepeer caches this authorization. + */ +export type BalanceCheck = ( + ctx: BalanceCheckContext, +) => Promise | BalanceCheckResult; + export type RemoteSignerWebhookConfig = { webhookSecret: string; endUserAuth: EndUserAuthVerifier; + checkBalance?: BalanceCheck; }; export function handleAuthorize( diff --git a/identity-webhook/protocol.mjs b/identity-webhook/protocol.mjs index c4a69d3..2131fcc 100644 --- a/identity-webhook/protocol.mjs +++ b/identity-webhook/protocol.mjs @@ -14,6 +14,12 @@ * success → 200 { status:200, auth_id:"client_id:usage_subject", identity, expiry } * reject → 200 { status:<480-483|503|4xx>, reason, code? } (HTTP 200, status in body) * bad caller secret → HTTP 401, bad JSON → HTTP 400. + * + * Consumers may attach an optional `config.checkBalance` hook to enforce a live + * credit/allowance gate at sign time (see ./balance-gate.mjs). It runs after the + * identity is verified and can reject with status 483 (insufficient_balance) / + * 503 (billing_unavailable), or shorten the returned `expiry` so go-livepeer + * re-authorizes (and re-checks the balance) sooner. */ import { timingSafeEqual } from "node:crypto"; @@ -160,9 +166,29 @@ export async function handleAuthorize(request, config) { if (!isValidUsageIdentity(verified.identity)) { throw new WebhookError("verifier returned incomplete identity", { status: 500 }); } + + // Optional live balance/credit gate, applied after identity is proven and + // regardless of verifier kind (OIDC, composite, API key). Throwing a + // WebhookError (e.g. status 483 insufficient_balance) rejects the request; + // returning `{ expiry }` caps how long go-livepeer may cache this auth + // before it must call back and re-check the balance. + let expiry = verified.expiry; + if (typeof config.checkBalance === "function") { + const decision = await config.checkBalance({ + identity: verified.identity, + expiry: verified.expiry, + raw: verified.raw, + payload, + request, + }); + if (decision && typeof decision.expiry === "number") { + expiry = Math.min(expiry, decision.expiry); + } + } + return paymentWebhookJson(200, { status: 200, - expiry: verified.expiry, + expiry, auth_id: authIdFromIdentity(verified.identity), identity: verified.identity, }); diff --git a/identity-webhook/protocol.test.mjs b/identity-webhook/protocol.test.mjs index 3980467..a729290 100644 --- a/identity-webhook/protocol.test.mjs +++ b/identity-webhook/protocol.test.mjs @@ -164,6 +164,71 @@ describe("handleAuthorize", () => { }); }); +describe("handleAuthorize checkBalance hook", () => { + const goodBody = { headers: { Authorization: ["Bearer good-token"] } }; + + it("rejects with 483 when checkBalance throws insufficient_balance", async () => { + const checkBalance = async () => { + throw new WebhookError("insufficient balance", { + status: 483, + code: "insufficient_balance", + }); + }; + const res = await handleAuthorize(authorizeRequest({ body: goodBody }), { + ...config, + checkBalance, + }); + assert.equal(res.status, 200); + assert.deepEqual(await res.json(), { + status: 483, + reason: "insufficient balance", + code: "insufficient_balance", + }); + }); + + it("passes identity and original expiry into checkBalance", async () => { + let seen; + const checkBalance = async (ctx) => { + seen = ctx; + }; + const res = await handleAuthorize(authorizeRequest({ body: goodBody }), { + ...config, + checkBalance, + }); + assert.equal(res.status, 200); + assert.equal(seen.identity.auth_id, undefined); // ctx carries identity, not auth_id + assert.equal(seen.identity.client_id, "tenant-a"); + assert.equal(seen.identity.usage_subject, "user-1"); + assert.equal(seen.expiry, 1234567890); + }); + + it("caps the returned expiry when checkBalance shortens it", async () => { + const checkBalance = async () => ({ expiry: 100 }); + const res = await handleAuthorize(authorizeRequest({ body: goodBody }), { + ...config, + checkBalance, + }); + assert.equal((await res.json()).expiry, 100); + }); + + it("never extends the verifier expiry", async () => { + const checkBalance = async () => ({ expiry: 9999999999 }); + const res = await handleAuthorize(authorizeRequest({ body: goodBody }), { + ...config, + checkBalance, + }); + assert.equal((await res.json()).expiry, 1234567890); + }); + + it("allows the request when checkBalance returns nothing", async () => { + const res = await handleAuthorize(authorizeRequest({ body: goodBody }), { + ...config, + checkBalance: async () => undefined, + }); + assert.equal((await res.json()).status, 200); + }); +}); + describe("routeWebhookRequest", () => { it("routes POST /authorize", async () => { const res = await routeWebhookRequest( From 370e3d67e57511592a5b597a17622a97c053b855 Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Sat, 11 Jul 2026 17:51:07 -0400 Subject: [PATCH 06/11] chore(identity-webhook): export ./balance-gate and bump to 0.4.0 Publish BalanceCheck / createBalanceGate so pymthouse can import @pymthouse/clearinghouse-identity-webhook/balance-gate. --- identity-webhook/balance-gate.mjs | 4 ++-- identity-webhook/package-lock.json | 5 +++-- identity-webhook/package.json | 18 ++++++++++++++---- 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/identity-webhook/balance-gate.mjs b/identity-webhook/balance-gate.mjs index a9f86cc..a7d6ac1 100644 --- a/identity-webhook/balance-gate.mjs +++ b/identity-webhook/balance-gate.mjs @@ -13,8 +13,8 @@ * integer number, or integer string. * * Example: - * import { handleAuthorize } from "@livepeer/clearinghouse-identity-webhook/protocol"; - * import { createBalanceGate } from "@livepeer/clearinghouse-identity-webhook/balance-gate"; + * import { handleAuthorize } from "@pymthouse/clearinghouse-identity-webhook/protocol"; + * import { createBalanceGate } from "@pymthouse/clearinghouse-identity-webhook/balance-gate"; * * const checkBalance = createBalanceGate({ * getBalanceUsdMicros: async (identity) => diff --git a/identity-webhook/package-lock.json b/identity-webhook/package-lock.json index 7a9f179..22c3d64 100644 --- a/identity-webhook/package-lock.json +++ b/identity-webhook/package-lock.json @@ -1,12 +1,13 @@ { "name": "@pymthouse/clearinghouse-identity-webhook", - "version": "0.3.3", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pymthouse/clearinghouse-identity-webhook", - "version": "0.3.3", + "version": "0.4.0", + "license": "MIT", "dependencies": { "jose": "^5.9.6" }, diff --git a/identity-webhook/package.json b/identity-webhook/package.json index 9f959a1..511a3a6 100644 --- a/identity-webhook/package.json +++ b/identity-webhook/package.json @@ -1,6 +1,6 @@ { "name": "@pymthouse/clearinghouse-identity-webhook", - "version": "0.3.3", + "version": "0.4.0", "description": "API-key + OIDC identity webhook for go-livepeer remote signer (jose)", "type": "module", "license": "MIT", @@ -14,15 +14,23 @@ "exports": { "./protocol": { "types": "./protocol.d.ts", - "import": "./protocol.mjs" + "import": "./protocol.mjs", + "default": "./protocol.mjs" }, "./verifiers": { "types": "./verifiers.d.ts", - "import": "./verifiers.mjs" + "import": "./verifiers.mjs", + "default": "./verifiers.mjs" + }, + "./balance-gate": { + "types": "./balance-gate.d.ts", + "import": "./balance-gate.mjs", + "default": "./balance-gate.mjs" }, "./legacy-env": { "types": "./legacy-env.d.ts", - "import": "./legacy-env.mjs" + "import": "./legacy-env.mjs", + "default": "./legacy-env.mjs" } }, "files": [ @@ -30,6 +38,8 @@ "protocol.d.ts", "verifiers.mjs", "verifiers.d.ts", + "balance-gate.mjs", + "balance-gate.d.ts", "legacy-env.mjs", "legacy-env.d.ts", "keys.mjs" From 93e754de85fc7336fb5c835e7fd09c95ae9c7c57 Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Sat, 11 Jul 2026 18:21:24 -0400 Subject: [PATCH 07/11] refactor(identity-webhook): default OIDC issuer/audience from IDENTITY_ISSUER Allow either IDENTITY_ISSUER or OIDC_ISSUER, default OIDC_AUDIENCE to the JWT issuer, and derive OIDC_TOKEN_EXCHANGE_BASE_URL from NEXTAUTH_URL. --- identity-webhook/verifiers.mjs | 26 +++++++++++++++----------- identity-webhook/verifiers.test.mjs | 25 ++++++++++++++++++++++--- 2 files changed, 37 insertions(+), 14 deletions(-) diff --git a/identity-webhook/verifiers.mjs b/identity-webhook/verifiers.mjs index 7148c60..031ca3a 100644 --- a/identity-webhook/verifiers.mjs +++ b/identity-webhook/verifiers.mjs @@ -578,11 +578,16 @@ function envOptional(env, name, fallback) { /** * Build the end-user verifier from env. IDENTITY_AUTH_MODE selects exactly one * verifier — no fallback between OIDC and API-key paths. + * + * Issuer consolidation: IDENTITY_ISSUER is canonical; OIDC_ISSUER is an optional + * legacy alias (either may be set). OIDC_AUDIENCE defaults to the JWT issuer. + * OIDC_TOKEN_EXCHANGE_BASE_URL defaults from NEXTAUTH_URL when unset. */ export function createEndUserVerifierFromEnv(env) { - const issuer = envTrim(env, "IDENTITY_ISSUER"); + const jwtIssuer = envTrim(env, "OIDC_ISSUER") || envTrim(env, "IDENTITY_ISSUER"); + const issuer = envTrim(env, "IDENTITY_ISSUER") || jwtIssuer; if (!issuer) { - throw new Error("IDENTITY_ISSUER is required"); + throw new Error("IDENTITY_ISSUER is required (OIDC_ISSUER accepted as alias)"); } const mode = envTrim(env, "IDENTITY_AUTH_MODE"); @@ -604,16 +609,15 @@ export function createEndUserVerifierFromEnv(env) { }); } - if (!envTrim(env, "OIDC_ISSUER")) { - throw new Error("oidc mode requires OIDC_ISSUER"); - } - if (!envTrim(env, "OIDC_AUDIENCE")) { - throw new Error("oidc mode requires OIDC_AUDIENCE"); - } + const jwtAudience = envTrim(env, "OIDC_AUDIENCE") || jwtIssuer; + const tokenExchangeBaseUrl = + envTrim(env, "OIDC_TOKEN_EXCHANGE_BASE_URL") || + envTrim(env, "NEXTAUTH_URL") || + undefined; return createOidcVerifier({ - jwtIssuer: envTrim(env, "OIDC_ISSUER"), - jwtAudience: envTrim(env, "OIDC_AUDIENCE"), + jwtIssuer, + jwtAudience, jwksUri: envTrim(env, "OIDC_JWKS_URI") || undefined, issuer, clientClaim: envOptional(env, "OIDC_CLIENT_CLAIM", "azp"), @@ -622,7 +626,7 @@ export function createEndUserVerifierFromEnv(env) { requiredScopes: (envTrim(env, "OIDC_REQUIRED_SCOPES") || "") .split(/[\s,]+/) .filter(Boolean), - tokenExchangeBaseUrl: envTrim(env, "OIDC_TOKEN_EXCHANGE_BASE_URL") || undefined, + tokenExchangeBaseUrl, exchangeM2mClientId: envTrim(env, "OIDC_EXCHANGE_M2M_CLIENT_ID") || undefined, exchangeM2mClientSecret: envTrim(env, "OIDC_EXCHANGE_M2M_CLIENT_SECRET") || undefined, }); diff --git a/identity-webhook/verifiers.test.mjs b/identity-webhook/verifiers.test.mjs index 5ee1d97..1db9aa6 100644 --- a/identity-webhook/verifiers.test.mjs +++ b/identity-webhook/verifiers.test.mjs @@ -274,10 +274,29 @@ describe("createEndUserVerifierFromEnv", () => { assert.equal(identity.usage_subject, "demo-user"); }); - it("rejects oidc mode without OIDC_ISSUER", () => { + it("oidc mode accepts IDENTITY_ISSUER alone (OIDC_ISSUER optional alias)", () => { + const verifier = createEndUserVerifierFromEnv({ + IDENTITY_ISSUER: ISSUER, + IDENTITY_AUTH_MODE: "oidc", + }); + assert.equal(verifier.kind, "oidc"); + }); + + it("oidc mode accepts OIDC_ISSUER alone as IDENTITY_ISSUER alias", () => { + const verifier = createEndUserVerifierFromEnv({ + OIDC_ISSUER: "https://idp.test/", + IDENTITY_AUTH_MODE: "oidc", + }); + assert.equal(verifier.kind, "oidc"); + }); + + it("rejects oidc mode without IDENTITY_ISSUER or OIDC_ISSUER", () => { assert.throws( - () => createEndUserVerifierFromEnv({ ...apiKeyEnv, IDENTITY_AUTH_MODE: "oidc" }), - /oidc mode requires OIDC_ISSUER/, + () => + createEndUserVerifierFromEnv({ + IDENTITY_AUTH_MODE: "oidc", + }), + /IDENTITY_ISSUER is required/, ); }); From 0326885983dd543bb9c5f1297cfc01fdfbd99ed8 Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Mon, 13 Jul 2026 14:12:15 -0400 Subject: [PATCH 08/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 09/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 10/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 11/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", () => {