From 53896c5b2b46650e492aba4d8252b7cf9f129d92 Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Tue, 14 Jul 2026 19:41:24 -0400 Subject: [PATCH 1/3] fix(identity-webhook): propagate exchange 402 allowance rejects Composite app_*_* token exchange was collapsing mint-gate HTTP 402 (trial_credits_exhausted) to webhook status 401, so gateways could not distinguish auth failure from exhausted funds. Forward 402 / 503 and bump to 0.4.2 for publish (0.4.1 already shipped). --- identity-webhook/package-lock.json | 4 +- identity-webhook/package.json | 2 +- identity-webhook/protocol.d.ts | 2 + identity-webhook/protocol.mjs | 5 ++- identity-webhook/verifiers.mjs | 56 ++++++++++++++++++++--- identity-webhook/verifiers.test.mjs | 69 ++++++++++++++++++++++++++++- 6 files changed, 128 insertions(+), 10 deletions(-) diff --git a/identity-webhook/package-lock.json b/identity-webhook/package-lock.json index 22c3d64..5c17374 100644 --- a/identity-webhook/package-lock.json +++ b/identity-webhook/package-lock.json @@ -1,12 +1,12 @@ { "name": "@pymthouse/clearinghouse-identity-webhook", - "version": "0.4.0", + "version": "0.4.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pymthouse/clearinghouse-identity-webhook", - "version": "0.4.0", + "version": "0.4.2", "license": "MIT", "dependencies": { "jose": "^5.9.6" diff --git a/identity-webhook/package.json b/identity-webhook/package.json index 511a3a6..50b0cca 100644 --- a/identity-webhook/package.json +++ b/identity-webhook/package.json @@ -1,6 +1,6 @@ { "name": "@pymthouse/clearinghouse-identity-webhook", - "version": "0.4.0", + "version": "0.4.2", "description": "API-key + OIDC identity webhook for go-livepeer remote signer (jose)", "type": "module", "license": "MIT", diff --git a/identity-webhook/protocol.d.ts b/identity-webhook/protocol.d.ts index 4f36eb0..af3fdc6 100644 --- a/identity-webhook/protocol.d.ts +++ b/identity-webhook/protocol.d.ts @@ -1,4 +1,5 @@ export const REMOTE_SIGNER_HTTP_STATUS: { + readonly PAYMENT_REQUIRED: 402; readonly REFRESH_SESSION: 480; readonly PRICE_EXCEEDED: 481; readonly NO_TICKETS: 482; @@ -7,6 +8,7 @@ export const REMOTE_SIGNER_HTTP_STATUS: { }; export const REMOTE_SIGNER_ERROR_CODE: { + readonly TRIAL_CREDITS_EXHAUSTED: "trial_credits_exhausted"; readonly INSUFFICIENT_BALANCE: "insufficient_balance"; readonly BILLING_UNAVAILABLE: "billing_unavailable"; }; diff --git a/identity-webhook/protocol.mjs b/identity-webhook/protocol.mjs index 2131fcc..5b9eaf0 100644 --- a/identity-webhook/protocol.mjs +++ b/identity-webhook/protocol.mjs @@ -12,7 +12,7 @@ * * Response the signer expects: * 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) + * reject → 200 { status:<402|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 @@ -25,6 +25,8 @@ import { timingSafeEqual } from "node:crypto"; /** HTTP statuses go-livepeer's signer returns to gateway clients. */ export const REMOTE_SIGNER_HTTP_STATUS = { + /** Allowance / credits exhausted (e.g. mint or token-exchange gate). */ + PAYMENT_REQUIRED: 402, REFRESH_SESSION: 480, PRICE_EXCEEDED: 481, NO_TICKETS: 482, @@ -34,6 +36,7 @@ export const REMOTE_SIGNER_HTTP_STATUS = { /** Machine-readable reject codes forwarded through the webhook wire protocol. */ export const REMOTE_SIGNER_ERROR_CODE = { + TRIAL_CREDITS_EXHAUSTED: "trial_credits_exhausted", INSUFFICIENT_BALANCE: "insufficient_balance", BILLING_UNAVAILABLE: "billing_unavailable", }; diff --git a/identity-webhook/verifiers.mjs b/identity-webhook/verifiers.mjs index b0f9001..ce8c73d 100644 --- a/identity-webhook/verifiers.mjs +++ b/identity-webhook/verifiers.mjs @@ -12,7 +12,12 @@ */ import { hkdfSync, randomBytes } from "node:crypto"; import { createLocalJWKSet, createRemoteJWKSet, jwtVerify } from "jose"; -import { bearerToken, WebhookError } from "./protocol.mjs"; +import { + bearerToken, + REMOTE_SIGNER_ERROR_CODE, + REMOTE_SIGNER_HTTP_STATUS, + WebhookError, +} from "./protocol.mjs"; import { loadApiKeyStore } from "./keys.mjs"; export const IDENTITY_AUTH_MODES = ["api_key", "oidc"]; @@ -383,6 +388,50 @@ function createCompositeExchangeCache() { }; } +/** + * Map a non-OK composite token-exchange response to a webhook reject. + * Payment / allowance failures (HTTP 402, trial_credits_exhausted) must reach + * the gateway as 402 — not be collapsed to 401 like auth failures. + */ +function webhookErrorFromExchangeReject(httpStatus, payload) { + const code = + payload && typeof payload.error === "string" && payload.error.trim() + ? payload.error.trim() + : "invalid_token"; + const reason = + payload && + typeof payload.error_description === "string" && + payload.error_description.trim() + ? payload.error_description.trim() + : "token exchange failed"; + + if ( + httpStatus === REMOTE_SIGNER_HTTP_STATUS.BILLING_UNAVAILABLE || + code === REMOTE_SIGNER_ERROR_CODE.BILLING_UNAVAILABLE + ) { + return new WebhookError(reason, { + status: REMOTE_SIGNER_HTTP_STATUS.BILLING_UNAVAILABLE, + code: REMOTE_SIGNER_ERROR_CODE.BILLING_UNAVAILABLE, + }); + } + if ( + httpStatus === REMOTE_SIGNER_HTTP_STATUS.PAYMENT_REQUIRED || + code === REMOTE_SIGNER_ERROR_CODE.TRIAL_CREDITS_EXHAUSTED + ) { + return new WebhookError(reason, { + status: REMOTE_SIGNER_HTTP_STATUS.PAYMENT_REQUIRED, + code: + code === "invalid_token" + ? REMOTE_SIGNER_ERROR_CODE.TRIAL_CREDITS_EXHAUSTED + : code, + }); + } + return new WebhookError(reason, { + status: 401, + code: "invalid_token", + }); +} + async function exchangeCompositeApiKey({ exchangeBaseUrl, publicClientId, @@ -441,10 +490,7 @@ async function exchangeCompositeApiKey({ `composite api key exchange rejected status=${response.status} client_id=${logSafe(publicClientId)} key_id=${keyId}` + (correlationId ? ` correlation_id=${correlationId}` : ""), ); - throw new WebhookError("token exchange failed", { - status: 401, - code: "invalid_token", - }); + throw webhookErrorFromExchangeReject(response.status, payload); } const accessToken = diff --git a/identity-webhook/verifiers.test.mjs b/identity-webhook/verifiers.test.mjs index cdc1492..24ddf7e 100644 --- a/identity-webhook/verifiers.test.mjs +++ b/identity-webhook/verifiers.test.mjs @@ -648,7 +648,74 @@ describe("createOidcVerifier composite API key exchange", () => { }); await assert.rejects( () => verifier.verify({ authorization: `Bearer ${clientId}_bad` }), - /token exchange failed/, + (err) => { + assert.equal(err.name, "WebhookError"); + assert.equal(err.status, 401); + assert.equal(err.code, "invalid_token"); + assert.match(err.message, /token exchange failed|invalid/); + return true; + }, + ); + }); + + it("propagates exchange 402 trial_credits_exhausted to the webhook", async () => { + const { jwks } = await setup(); + const clientId = "app_3b386c81a1db1169fd2c3986"; + const fetchImpl = async () => + Response.json( + { + error: "trial_credits_exhausted", + error_description: "Starter allowance exhausted", + correlation_id: "c-402", + }, + { status: 402 }, + ); + const verifier = createOidcVerifier({ + jwtIssuer: "https://idp.test/", + jwtAudience: "clearinghouse", + jwks, + tokenExchangeBaseUrl: "https://billing.test", + fetchImpl, + }); + await assert.rejects( + () => verifier.verify({ authorization: `Bearer ${clientId}_deadbeef` }), + (err) => { + assert.equal(err.name, "WebhookError"); + assert.equal(err.status, 402); + assert.equal(err.code, "trial_credits_exhausted"); + assert.equal(err.message, "Starter allowance exhausted"); + return true; + }, + ); + }); + + it("propagates exchange billing_unavailable as 503", async () => { + const { jwks } = await setup(); + const clientId = "app_3b386c81a1db1169fd2c3986"; + const fetchImpl = async () => + Response.json( + { + error: "billing_unavailable", + error_description: "Billing allowance could not be confirmed", + }, + { status: 402 }, + ); + const verifier = createOidcVerifier({ + jwtIssuer: "https://idp.test/", + jwtAudience: "clearinghouse", + jwks, + tokenExchangeBaseUrl: "https://billing.test", + fetchImpl, + }); + await assert.rejects( + () => verifier.verify({ authorization: `Bearer ${clientId}_deadbeef` }), + (err) => { + assert.equal(err.name, "WebhookError"); + // Prefer billing_unavailable code over bare HTTP 402 from mint gate. + assert.equal(err.status, 503); + assert.equal(err.code, "billing_unavailable"); + return true; + }, ); }); }); From bd52332a7ac0178090e4d660f67e2715667f2b2d Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Tue, 14 Jul 2026 19:43:07 -0400 Subject: [PATCH 2/3] fix(identity-webhook): map mint 402 onto wire status 483 go-livepeer authLivePayment only passthroughs webhook body status+reason. Align composite exchange allowance rejects with createBalanceGate: 483 insufficient_balance (not 402) and 503 billing_unavailable, preserving upstream error_description as reason for the gateway. --- identity-webhook/protocol.d.ts | 2 -- identity-webhook/protocol.mjs | 6 ++--- identity-webhook/verifiers.mjs | 39 +++++++++++++++++------------ identity-webhook/verifiers.test.mjs | 9 ++++--- 4 files changed, 30 insertions(+), 26 deletions(-) diff --git a/identity-webhook/protocol.d.ts b/identity-webhook/protocol.d.ts index af3fdc6..4f36eb0 100644 --- a/identity-webhook/protocol.d.ts +++ b/identity-webhook/protocol.d.ts @@ -1,5 +1,4 @@ export const REMOTE_SIGNER_HTTP_STATUS: { - readonly PAYMENT_REQUIRED: 402; readonly REFRESH_SESSION: 480; readonly PRICE_EXCEEDED: 481; readonly NO_TICKETS: 482; @@ -8,7 +7,6 @@ export const REMOTE_SIGNER_HTTP_STATUS: { }; export const REMOTE_SIGNER_ERROR_CODE: { - readonly TRIAL_CREDITS_EXHAUSTED: "trial_credits_exhausted"; readonly INSUFFICIENT_BALANCE: "insufficient_balance"; readonly BILLING_UNAVAILABLE: "billing_unavailable"; }; diff --git a/identity-webhook/protocol.mjs b/identity-webhook/protocol.mjs index 5b9eaf0..22d94ca 100644 --- a/identity-webhook/protocol.mjs +++ b/identity-webhook/protocol.mjs @@ -12,7 +12,7 @@ * * Response the signer expects: * success → 200 { status:200, auth_id:"client_id:usage_subject", identity, expiry } - * reject → 200 { status:<402|480-483|503|4xx>, reason, code? } (HTTP 200, status in body) + * 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 @@ -25,18 +25,16 @@ import { timingSafeEqual } from "node:crypto"; /** HTTP statuses go-livepeer's signer returns to gateway clients. */ export const REMOTE_SIGNER_HTTP_STATUS = { - /** Allowance / credits exhausted (e.g. mint or token-exchange gate). */ - PAYMENT_REQUIRED: 402, REFRESH_SESSION: 480, PRICE_EXCEEDED: 481, NO_TICKETS: 482, + /** Identity-hook: end-user allowance / credits exhausted (mint or live gate). */ INSUFFICIENT_BALANCE: 483, BILLING_UNAVAILABLE: 503, }; /** Machine-readable reject codes forwarded through the webhook wire protocol. */ export const REMOTE_SIGNER_ERROR_CODE = { - TRIAL_CREDITS_EXHAUSTED: "trial_credits_exhausted", INSUFFICIENT_BALANCE: "insufficient_balance", BILLING_UNAVAILABLE: "billing_unavailable", }; diff --git a/identity-webhook/verifiers.mjs b/identity-webhook/verifiers.mjs index ce8c73d..4cb272e 100644 --- a/identity-webhook/verifiers.mjs +++ b/identity-webhook/verifiers.mjs @@ -390,43 +390,50 @@ function createCompositeExchangeCache() { /** * Map a non-OK composite token-exchange response to a webhook reject. - * Payment / allowance failures (HTTP 402, trial_credits_exhausted) must reach - * the gateway as 402 — not be collapsed to 401 like auth failures. + * + * go-livepeer `authLivePayment` forwards webhook body `status` + `reason` to the + * gateway unchanged (see remote_signer.go). Align allowance rejects with the + * live balance gate: 483 `insufficient_balance` / 503 `billing_unavailable`. + * Do not collapse payment failures to 401 (auth-only). + * + * Upstream mint gates often use HTTP 402 + `trial_credits_exhausted`; that is + * remapped onto the identity-hook wire statuses above. */ function webhookErrorFromExchangeReject(httpStatus, payload) { - const code = + const upstreamCode = payload && typeof payload.error === "string" && payload.error.trim() ? payload.error.trim() - : "invalid_token"; + : ""; const reason = payload && typeof payload.error_description === "string" && payload.error_description.trim() ? payload.error_description.trim() - : "token exchange failed"; + : ""; if ( httpStatus === REMOTE_SIGNER_HTTP_STATUS.BILLING_UNAVAILABLE || - code === REMOTE_SIGNER_ERROR_CODE.BILLING_UNAVAILABLE + upstreamCode === REMOTE_SIGNER_ERROR_CODE.BILLING_UNAVAILABLE ) { - return new WebhookError(reason, { + return new WebhookError(reason || "billing balance unavailable", { status: REMOTE_SIGNER_HTTP_STATUS.BILLING_UNAVAILABLE, code: REMOTE_SIGNER_ERROR_CODE.BILLING_UNAVAILABLE, }); } + + // Mint/OIDC exchange: HTTP 402 or trial_credits_exhausted → identity-hook 483. if ( - httpStatus === REMOTE_SIGNER_HTTP_STATUS.PAYMENT_REQUIRED || - code === REMOTE_SIGNER_ERROR_CODE.TRIAL_CREDITS_EXHAUSTED + httpStatus === 402 || + upstreamCode === "trial_credits_exhausted" || + upstreamCode === REMOTE_SIGNER_ERROR_CODE.INSUFFICIENT_BALANCE ) { - return new WebhookError(reason, { - status: REMOTE_SIGNER_HTTP_STATUS.PAYMENT_REQUIRED, - code: - code === "invalid_token" - ? REMOTE_SIGNER_ERROR_CODE.TRIAL_CREDITS_EXHAUSTED - : code, + return new WebhookError(reason || "insufficient balance", { + status: REMOTE_SIGNER_HTTP_STATUS.INSUFFICIENT_BALANCE, + code: REMOTE_SIGNER_ERROR_CODE.INSUFFICIENT_BALANCE, }); } - return new WebhookError(reason, { + + return new WebhookError(reason || "token exchange failed", { status: 401, code: "invalid_token", }); diff --git a/identity-webhook/verifiers.test.mjs b/identity-webhook/verifiers.test.mjs index 24ddf7e..93b5a8a 100644 --- a/identity-webhook/verifiers.test.mjs +++ b/identity-webhook/verifiers.test.mjs @@ -658,7 +658,7 @@ describe("createOidcVerifier composite API key exchange", () => { ); }); - it("propagates exchange 402 trial_credits_exhausted to the webhook", async () => { + it("maps exchange 402 trial_credits_exhausted to webhook 483", async () => { const { jwks } = await setup(); const clientId = "app_3b386c81a1db1169fd2c3986"; const fetchImpl = async () => @@ -681,8 +681,9 @@ describe("createOidcVerifier composite API key exchange", () => { () => verifier.verify({ authorization: `Bearer ${clientId}_deadbeef` }), (err) => { assert.equal(err.name, "WebhookError"); - assert.equal(err.status, 402); - assert.equal(err.code, "trial_credits_exhausted"); + // Match createBalanceGate / go-livepeer webhook passthrough wire. + assert.equal(err.status, 483); + assert.equal(err.code, "insufficient_balance"); assert.equal(err.message, "Starter allowance exhausted"); return true; }, @@ -711,9 +712,9 @@ describe("createOidcVerifier composite API key exchange", () => { () => verifier.verify({ authorization: `Bearer ${clientId}_deadbeef` }), (err) => { assert.equal(err.name, "WebhookError"); - // Prefer billing_unavailable code over bare HTTP 402 from mint gate. assert.equal(err.status, 503); assert.equal(err.code, "billing_unavailable"); + assert.equal(err.message, "Billing allowance could not be confirmed"); return true; }, ); From 3bb360cc6ec81bf946a0b09922d0351a6861bb0c Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Tue, 14 Jul 2026 19:58:01 -0400 Subject: [PATCH 3/3] refactor(identity-webhook): resolve Sonar maintainability findings Extract JWKS resolver construction to reduce cognitive complexity, use typed argument errors, and simplify identity validation without changing behavior. --- identity-webhook/protocol.mjs | 9 ++-- identity-webhook/verifiers.mjs | 90 ++++++++++++++++++---------------- 2 files changed, 53 insertions(+), 46 deletions(-) diff --git a/identity-webhook/protocol.mjs b/identity-webhook/protocol.mjs index 22d94ca..f13628b 100644 --- a/identity-webhook/protocol.mjs +++ b/identity-webhook/protocol.mjs @@ -115,11 +115,10 @@ export function authIdFromIdentity(identity) { export function isValidUsageIdentity(identity) { return Boolean( - identity && - identity.issuer && - identity.client_id && - identity.usage_subject && - identity.usage_subject_type, + identity?.issuer && + identity?.client_id && + identity?.usage_subject && + identity?.usage_subject_type, ); } diff --git a/identity-webhook/verifiers.mjs b/identity-webhook/verifiers.mjs index 4cb272e..3864fe0 100644 --- a/identity-webhook/verifiers.mjs +++ b/identity-webhook/verifiers.mjs @@ -122,10 +122,10 @@ export function createApiKeyVerifier({ expiryTtlSeconds = 60, }) { if (!issuer) { - throw new Error("createApiKeyVerifier: issuer is required"); + throw new TypeError("createApiKeyVerifier: issuer is required"); } if (typeof resolveApiKey !== "function") { - throw new Error("createApiKeyVerifier: resolveApiKey is required"); + throw new TypeError("createApiKeyVerifier: resolveApiKey is required"); } return { kind: "api_key", @@ -210,10 +210,55 @@ export async function discoverJwksUri(jwtIssuer, options = {}) { * When `fetchImpl` is set (tests / custom HTTP), JWKS is loaded through it into * a local keyset. Otherwise jose's createRemoteJWKSet fetches and caches. */ +async function createLocalJwksResolver(uri, 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}`, + ); + } +} + +function createRemoteJwksResolver(uri) { + try { + return createRemoteJWKSet(new URL(uri)); + } catch (err) { + throw new Error( + `JWKS URI is not a valid URL (${uri}): ${err instanceof Error ? err.message : err}`, + ); + } +} + +async function resolveOidcKeyResolver({ jwksUri, jwtIssuer, fetchImpl }) { + const uri = jwksUri ?? (await discoverJwksUri(jwtIssuer, { fetchImpl })); + return fetchImpl + ? createLocalJwksResolver(uri, fetchImpl) + : createRemoteJwksResolver(uri); +} + function createOidcKeyResolver({ jwks, jwksUri, jwtIssuer, fetchImpl }) { if (jwks) { if (typeof jwks !== "function") { - throw new Error( + throw new TypeError( "createOidcVerifier: jwks must be a jose key resolver function (e.g. createLocalJWKSet(...))", ); } @@ -224,44 +269,7 @@ function createOidcKeyResolver({ jwks, jwksUri, jwtIssuer, fetchImpl }) { 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); - })(); + resolving ??= resolveOidcKeyResolver({ jwksUri, jwtIssuer, fetchImpl }); try { remote = await resolving; } catch (err) {