diff --git a/identity-webhook/package-lock.json b/identity-webhook/package-lock.json index 3f5c84d..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.1", + "version": "0.4.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pymthouse/clearinghouse-identity-webhook", - "version": "0.4.1", + "version": "0.4.2", "license": "MIT", "dependencies": { "jose": "^5.9.6" diff --git a/identity-webhook/package.json b/identity-webhook/package.json index ed6f53e..913e73d 100644 --- a/identity-webhook/package.json +++ b/identity-webhook/package.json @@ -1,6 +1,6 @@ { "name": "@pymthouse/clearinghouse-identity-webhook", - "version": "0.4.1", + "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.mjs b/identity-webhook/protocol.mjs index 2131fcc..f13628b 100644 --- a/identity-webhook/protocol.mjs +++ b/identity-webhook/protocol.mjs @@ -28,6 +28,7 @@ export const REMOTE_SIGNER_HTTP_STATUS = { 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, }; @@ -114,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 b0f9001..3864fe0 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"]; @@ -117,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", @@ -205,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(...))", ); } @@ -219,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) { @@ -383,6 +396,57 @@ function createCompositeExchangeCache() { }; } +/** + * Map a non-OK composite token-exchange response to a webhook reject. + * + * 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 upstreamCode = + payload && typeof payload.error === "string" && payload.error.trim() + ? payload.error.trim() + : ""; + const reason = + payload && + typeof payload.error_description === "string" && + payload.error_description.trim() + ? payload.error_description.trim() + : ""; + + if ( + httpStatus === REMOTE_SIGNER_HTTP_STATUS.BILLING_UNAVAILABLE || + upstreamCode === REMOTE_SIGNER_ERROR_CODE.BILLING_UNAVAILABLE + ) { + 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 === 402 || + upstreamCode === "trial_credits_exhausted" || + upstreamCode === REMOTE_SIGNER_ERROR_CODE.INSUFFICIENT_BALANCE + ) { + return new WebhookError(reason || "insufficient balance", { + status: REMOTE_SIGNER_HTTP_STATUS.INSUFFICIENT_BALANCE, + code: REMOTE_SIGNER_ERROR_CODE.INSUFFICIENT_BALANCE, + }); + } + + return new WebhookError(reason || "token exchange failed", { + status: 401, + code: "invalid_token", + }); +} + async function exchangeCompositeApiKey({ exchangeBaseUrl, publicClientId, @@ -441,10 +505,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 7552e83..c62f95e 100644 --- a/identity-webhook/verifiers.test.mjs +++ b/identity-webhook/verifiers.test.mjs @@ -657,7 +657,75 @@ 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("maps exchange 402 trial_credits_exhausted to webhook 483", 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"); + // 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; + }, + ); + }); + + 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"); + assert.equal(err.status, 503); + assert.equal(err.code, "billing_unavailable"); + assert.equal(err.message, "Billing allowance could not be confirmed"); + return true; + }, ); }); });