From ff6f4a6295c456684841e438d425f0cbdfeecdf1 Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Sat, 11 Jul 2026 17:44:17 -0400 Subject: [PATCH 1/8] 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 | 108 +++++++++++++++++++ identity-webhook/protocol.mjs | 28 ++++- identity-webhook/protocol.test.mjs | 65 +++++++++++ 6 files changed, 504 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 create mode 100644 identity-webhook/protocol.d.ts 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 new file mode 100644 index 0000000..4f36eb0 --- /dev/null +++ b/identity-webhook/protocol.d.ts @@ -0,0 +1,108 @@ +export const REMOTE_SIGNER_HTTP_STATUS: { + readonly REFRESH_SESSION: 480; + readonly PRICE_EXCEEDED: 481; + readonly NO_TICKETS: 482; + readonly INSUFFICIENT_BALANCE: 483; + readonly BILLING_UNAVAILABLE: 503; +}; + +export const REMOTE_SIGNER_ERROR_CODE: { + readonly INSUFFICIENT_BALANCE: "insufficient_balance"; + readonly BILLING_UNAVAILABLE: "billing_unavailable"; +}; + +export class WebhookError extends Error { + status: number; + code?: string; + constructor( + message: string, + options?: { status?: number; code?: string }, + ); +} + +export function bearerToken(authorization: string | undefined): string; + +export function authenticateWebhookCaller( + request: Request, + secret: string, +): boolean; + +export function authorizationFromPayload(payload: { + headers?: Record; + authorization?: string; +}): string; + +export type UsageIdentity = { + issuer: string; + client_id: string; + usage_subject: string; + usage_subject_type: string; +}; + +export function authIdFromIdentity(identity: UsageIdentity): string; + +export function isValidUsageIdentity( + identity: unknown, +): identity is UsageIdentity; + +export type PaymentWebhookResponse = { + status: number; + reason?: string; + code?: string; + expiry?: number; + auth_id?: string; + identity?: UsageIdentity; +}; + +export type EndUserAuthVerifier = { + kind: string; + verify: (ctx: { + authorization: string; + payload: unknown; + request: Request; + }) => Promise<{ + identity: UsageIdentity; + expiry: number; + raw?: unknown; + }>; + adminRoutes?: Array<{ + method: string; + pathname: string; + handler: (request: Request) => Promise; + }>; +}; + +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( + request: Request, + config: RemoteSignerWebhookConfig, +): Promise; + +export function routeWebhookRequest( + request: Request, + config: RemoteSignerWebhookConfig, +): Promise; diff --git a/identity-webhook/protocol.mjs b/identity-webhook/protocol.mjs index 3fe3303..d020247 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 2c3ee53..0c2de4b 100644 --- a/identity-webhook/protocol.test.mjs +++ b/identity-webhook/protocol.test.mjs @@ -176,6 +176,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 b323083e73d8ca85495927d1bb24d8a04a83fc05 Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Mon, 13 Jul 2026 15:33:31 -0400 Subject: [PATCH 2/8] test(identity-webhook): e2e authorize with composite and balance gate (#65) Cover handleAuthorize + createOidcVerifier end-to-end: JWT auth, composite app_<24hex>_ exchange, and live balance-gate 483 / expiry capping. (legacy-env was never present on the livepeer upstream stack.) --- identity-webhook/verifiers.test.mjs | 147 ++++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) diff --git a/identity-webhook/verifiers.test.mjs b/identity-webhook/verifiers.test.mjs index 3af3331..ea27948 100644 --- a/identity-webhook/verifiers.test.mjs +++ b/identity-webhook/verifiers.test.mjs @@ -10,9 +10,18 @@ import { normalizeTokenExchangeBaseUrl, splitCompositeApiKey, } from "./verifiers.mjs"; +import { handleAuthorize } from "./protocol.mjs"; const ISSUER = "http://identity-webhook:8090"; +function stripTrailingSlashes(value) { + let end = value.length; + while (end > 0 && value[end - 1] === "/") { + end -= 1; + } + return value.slice(0, end); +} + describe("discoverJwksUri", () => { it("reads jwks_uri from issuer-relative openid-configuration", async () => { const issuer = "https://idp.test/api/v1/oidc"; @@ -753,3 +762,141 @@ describe("createOidcVerifier composite API key exchange", () => { ); }); }); + +describe("embedded OIDC issuer flow (handleAuthorize + createOidcVerifier)", () => { + const ISSUER_URL = "https://issuer.example/api/v1/oidc"; + const SECRET = "dev-webhook-secret"; + + async function setupIssuerJwt() { + const { publicKey, privateKey } = await generateKeyPair("RS256"); + const jwk = await exportJWK(publicKey); + jwk.kid = "issuer-test"; + jwk.alg = "RS256"; + jwk.use = "sig"; + const jwks = createLocalJWKSet({ keys: [jwk] }); + const audience = stripTrailingSlashes(ISSUER_URL); + const token = await new SignJWT({ + client_id: "app_3b386c81a1db1169fd2c3986", + external_user_id: "user-456", + scope: "sign:job", + }) + .setProtectedHeader({ alg: "RS256", kid: "issuer-test" }) + .setIssuer(ISSUER_URL) + .setAudience(audience) + .setIssuedAt() + .setExpirationTime("5m") + .sign(privateKey); + return { token, jwks, audience }; + } + + function verifierFor(jwks, audience, extras = {}) { + return createOidcVerifier({ + jwtIssuer: ISSUER_URL, + jwtAudience: audience, + issuer: ISSUER_URL, + jwks, + clientClaim: "client_id", + subjectClaim: "external_user_id", + subjectTypeValue: "external_user_id", + requiredScopes: ["sign:job"], + ...extras, + }); + } + + function authorizeRequestFor(token) { + return new Request("http://localhost/webhooks/remote-signer", { + method: "POST", + headers: { + authorization: `Bearer ${SECRET}`, + "content-type": "application/json", + }, + body: JSON.stringify({ headers: { Authorization: [`Bearer ${token}`] } }), + }); + } + + it("authorizes an issuer JWT end-to-end", async () => { + const { token, jwks, audience } = await setupIssuerJwt(); + const config = { + webhookSecret: SECRET, + endUserAuth: verifierFor(jwks, audience), + }; + + const response = await handleAuthorize(authorizeRequestFor(token), config); + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.status, 200); + assert.equal(body.auth_id, "app_3b386c81a1db1169fd2c3986:user-456"); + assert.equal(body.identity.client_id, "app_3b386c81a1db1169fd2c3986"); + assert.equal(body.identity.usage_subject, "user-456"); + assert.equal(body.identity.usage_subject_type, "external_user_id"); + }); + + it("authorizes a composite app_*_* via mocked exchange", async () => { + const { token, jwks, audience } = await setupIssuerJwt(); + const clientId = "app_3b386c81a1db1169fd2c3986"; + const fetchImpl = async (input) => { + const url = String(input); + assert.ok(url.includes(`/api/v1/apps/${clientId}/oidc/token`)); + return Response.json({ access_token: token, expires_in: 300 }); + }; + const config = { + webhookSecret: SECRET, + endUserAuth: verifierFor(jwks, audience, { + tokenExchangeBaseUrl: "http://localhost:3000", + fetchImpl, + }), + }; + + const response = await handleAuthorize( + authorizeRequestFor(`${clientId}_deadbeef`), + config, + ); + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.status, 200); + assert.equal(body.auth_id, "app_3b386c81a1db1169fd2c3986:user-456"); + }); + + it("rejects a verified JWT with 483 when the live balance gate sees zero credit", async () => { + const { token, jwks, audience } = await setupIssuerJwt(); + const { createBalanceGate } = await import("./balance-gate.mjs"); + const config = { + webhookSecret: SECRET, + endUserAuth: verifierFor(jwks, audience), + checkBalance: createBalanceGate({ getBalanceUsdMicros: async () => 0n }), + }; + + const response = await handleAuthorize(authorizeRequestFor(token), config); + assert.equal(response.status, 200); // HTTP 200 per go-livepeer contract + const body = await response.json(); + assert.equal(body.status, 483); + assert.equal(body.code, "insufficient_balance"); + }); + + it("authorizes a verified JWT and caps expiry when the live balance gate sees credit", async () => { + const { token, jwks, audience } = await setupIssuerJwt(); + const { createBalanceGate } = await import("./balance-gate.mjs"); + let seenIdentity; + const config = { + webhookSecret: SECRET, + endUserAuth: verifierFor(jwks, audience), + checkBalance: createBalanceGate({ + getBalanceUsdMicros: async (identity) => { + seenIdentity = identity; + return 5_000_000n; + }, + reauthTtlSeconds: 30, + }), + }; + + const before = Math.floor(Date.now() / 1000); + const response = await handleAuthorize(authorizeRequestFor(token), config); + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.status, 200); + assert.equal(body.auth_id, "app_3b386c81a1db1169fd2c3986:user-456"); + assert.equal(seenIdentity.client_id, "app_3b386c81a1db1169fd2c3986"); + assert.equal(seenIdentity.usage_subject, "user-456"); + assert.ok(body.expiry >= before + 30 && body.expiry <= before + 31); + }); +}); From b544587a22d37efeb1eb3aedc1fbf1a7397a2719 Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Thu, 16 Jul 2026 11:05:06 -0400 Subject: [PATCH 3/8] fix(identity-webhook): surface exchange allowance failures (#66) Map token-exchange HTTP 402 responses onto the existing remote-signer 483 insufficient_balance wire status, preserve billing failures as 503, and keep authentication failures at 401 so gateways can distinguish exhausted funds. --- identity-webhook/verifiers.mjs | 56 ++++++++++++++++++++--- identity-webhook/verifiers.test.mjs | 69 ++++++++++++++++++++++++++++- 2 files changed, 119 insertions(+), 6 deletions(-) diff --git a/identity-webhook/verifiers.mjs b/identity-webhook/verifiers.mjs index 571f9c8..991b653 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"]; @@ -424,6 +429,50 @@ export function createCompositeExchangeCache() { }; } +/** + * Map a failed token exchange onto go-livepeer's identity-hook wire statuses. + * The exchange endpoint uses HTTP 402 for exhausted credits, while the + * identity hook consistently exposes that condition as 483. + */ +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, + }); + } + + 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, @@ -482,10 +531,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 ea27948..c3a539e 100644 --- a/identity-webhook/verifiers.test.mjs +++ b/identity-webhook/verifiers.test.mjs @@ -758,7 +758,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("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"); + assert.equal(err.status, 483); + assert.equal(err.code, "insufficient_balance"); + assert.equal(err.message, "Starter allowance exhausted"); + return true; + }, + ); + }); + + it("maps exchange billing_unavailable to webhook 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; + }, ); }); }); From 4cb01267cf707a5a9395d7028264a89c33b24217 Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Fri, 17 Jul 2026 16:40:29 -0400 Subject: [PATCH 4/8] feat(identity-webhook): wire optional demo balance gate in compose server Copy balance-gate.mjs into the image and enable createBalanceGate when DEMO_BALANCE_USD_MICROS is set so local stack / remote-signer smokes can exercise 483 reject and allow paths without a billing backend. --- identity-webhook/Dockerfile | 2 +- identity-webhook/server.mjs | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/identity-webhook/Dockerfile b/identity-webhook/Dockerfile index 177fc24..bfa5821 100644 --- a/identity-webhook/Dockerfile +++ b/identity-webhook/Dockerfile @@ -7,7 +7,7 @@ WORKDIR /app COPY identity-webhook/package.json identity-webhook/package-lock.json ./ RUN npm ci --omit=dev -COPY identity-webhook/keys.mjs identity-webhook/protocol.mjs identity-webhook/verifiers.mjs identity-webhook/server.mjs ./ +COPY identity-webhook/keys.mjs identity-webhook/protocol.mjs identity-webhook/verifiers.mjs identity-webhook/balance-gate.mjs identity-webhook/server.mjs ./ ENV PORT=8090 diff --git a/identity-webhook/server.mjs b/identity-webhook/server.mjs index 3a9b8aa..88a90d7 100644 --- a/identity-webhook/server.mjs +++ b/identity-webhook/server.mjs @@ -1,4 +1,5 @@ import { createServer } from "node:http"; +import { createBalanceGate } from "./balance-gate.mjs"; import { routeWebhookRequest } from "./protocol.mjs"; import { createEndUserVerifierFromEnv } from "./verifiers.mjs"; @@ -20,6 +21,20 @@ const config = { endUserAuth, }; +// Optional compose/dev hook: DEMO_BALANCE_USD_MICROS enables createBalanceGate +// against a fixed balance (e.g. "0" → 483, "5000000" → allow + reauth TTL). +const demoBalance = process.env.DEMO_BALANCE_USD_MICROS?.trim(); +if (demoBalance !== undefined && demoBalance !== "") { + const reauthRaw = process.env.DEMO_BALANCE_REAUTH_TTL_SECONDS?.trim(); + config.checkBalance = createBalanceGate({ + getBalanceUsdMicros: async () => demoBalance, + reauthTtlSeconds: reauthRaw ? Number(reauthRaw) : 30, + }); + console.log( + `identity-webhook: DEMO_BALANCE_USD_MICROS=${demoBalance} (live balance gate enabled)`, + ); +} + function readBody(req) { return new Promise((resolve, reject) => { const chunks = []; From 56a7c0ff10595efafa47243c17ef16274b3fcf9e Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Fri, 17 Jul 2026 16:41:27 -0400 Subject: [PATCH 5/8] fix(identity-webhook): address Copilot review on balance gate Use relative JSDoc example imports (package exports not shipped yet), point JSDoc types at protocol.mjs, accept only safe integer numbers in parseUsdMicros, require integer reauthTtlSeconds, and ignore non-finite checkBalance expiry values before capping. --- identity-webhook/balance-gate.mjs | 29 +++++++++++++------------- identity-webhook/balance-gate.test.mjs | 15 ++++++++++++- identity-webhook/protocol.mjs | 7 ++++++- identity-webhook/protocol.test.mjs | 10 +++++++++ 4 files changed, 45 insertions(+), 16 deletions(-) diff --git a/identity-webhook/balance-gate.mjs b/identity-webhook/balance-gate.mjs index a9f86cc..6485503 100644 --- a/identity-webhook/balance-gate.mjs +++ b/identity-webhook/balance-gate.mjs @@ -10,11 +10,11 @@ * 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. + * safe integer number, or integer string. * - * Example: - * import { handleAuthorize } from "@livepeer/clearinghouse-identity-webhook/protocol"; - * import { createBalanceGate } from "@livepeer/clearinghouse-identity-webhook/balance-gate"; + * Example (in-repo relative imports; published package exports may differ): + * import { handleAuthorize } from "./protocol.mjs"; + * import { createBalanceGate } from "./balance-gate.mjs"; * * const checkBalance = createBalanceGate({ * getBalanceUsdMicros: async (identity) => @@ -34,15 +34,16 @@ function nowSeconds() { } /** - * Coerce a USD-micros balance (bigint | integer number | integer string) to a - * bigint. Returns null for anything non-integer (including "1.5", "", null). + * Coerce a USD-micros balance (bigint | safe integer number | integer string) + * to a bigint. Returns null for anything non-integer (including "1.5", "", null). + * Numbers outside Number.MAX_SAFE_INTEGER must be passed as string or bigint. */ export function parseUsdMicros(value) { if (typeof value === "bigint") { return value; } if (typeof value === "number") { - return Number.isInteger(value) ? BigInt(value) : null; + return Number.isSafeInteger(value) ? BigInt(value) : null; } if (typeof value === "string") { const trimmed = value.trim(); @@ -62,20 +63,20 @@ export function parseUsdMicros(value) { * 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 + * @param {(identity: import("./protocol.mjs").UsageIdentity, ctx: import("./protocol.mjs").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. + * When set, caps the returned expiry to now + this (whole seconds), 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] + * @param {(err: unknown, identity: import("./protocol.mjs").UsageIdentity) => void} [options.onError] * Optional hook to observe lookup errors / unparseable balances. - * @returns {import("./protocol.js").BalanceCheck} + * @returns {import("./protocol.mjs").BalanceCheck} */ export function createBalanceGate({ getBalanceUsdMicros, @@ -94,8 +95,8 @@ export function createBalanceGate({ 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"); + if (!Number.isInteger(ttl) || ttl <= 0) { + throw new TypeError("createBalanceGate: reauthTtlSeconds must be a positive integer"); } } diff --git a/identity-webhook/balance-gate.test.mjs b/identity-webhook/balance-gate.test.mjs index 1b62d16..6e5a5d1 100644 --- a/identity-webhook/balance-gate.test.mjs +++ b/identity-webhook/balance-gate.test.mjs @@ -31,6 +31,15 @@ describe("parseUsdMicros", () => { assert.equal(parseUsdMicros("1.5"), null); assert.equal(parseUsdMicros("abc"), null); assert.equal(parseUsdMicros(1.5), null); + assert.equal(parseUsdMicros(Number.MAX_SAFE_INTEGER + 1), null); + assert.equal(parseUsdMicros(Number.POSITIVE_INFINITY), null); + assert.equal(parseUsdMicros(Number.NaN), null); + }); + + it("accepts large balances via string or bigint", () => { + const big = "9007199254740993"; // MAX_SAFE_INTEGER + 2 + assert.equal(parseUsdMicros(big), BigInt(big)); + assert.equal(parseUsdMicros(BigInt(big)), BigInt(big)); }); }); @@ -46,7 +55,11 @@ describe("createBalanceGate", () => { ); assert.throws( () => createBalanceGate({ getBalanceUsdMicros: async () => 0n, reauthTtlSeconds: 0 }), - /reauthTtlSeconds must be a positive number/, + /reauthTtlSeconds must be a positive integer/, + ); + assert.throws( + () => createBalanceGate({ getBalanceUsdMicros: async () => 0n, reauthTtlSeconds: 1.5 }), + /reauthTtlSeconds must be a positive integer/, ); }); diff --git a/identity-webhook/protocol.mjs b/identity-webhook/protocol.mjs index d020247..fbe161d 100644 --- a/identity-webhook/protocol.mjs +++ b/identity-webhook/protocol.mjs @@ -181,7 +181,12 @@ export async function handleAuthorize(request, config) { payload, request, }); - if (decision && typeof decision.expiry === "number") { + if ( + decision && + typeof decision.expiry === "number" && + Number.isFinite(decision.expiry) && + Number.isInteger(decision.expiry) + ) { expiry = Math.min(expiry, decision.expiry); } } diff --git a/identity-webhook/protocol.test.mjs b/identity-webhook/protocol.test.mjs index 0c2de4b..9dacc2f 100644 --- a/identity-webhook/protocol.test.mjs +++ b/identity-webhook/protocol.test.mjs @@ -232,6 +232,16 @@ describe("handleAuthorize checkBalance hook", () => { assert.equal((await res.json()).expiry, 1234567890); }); + it("ignores non-finite or non-integer checkBalance expiry values", async () => { + for (const bad of [Number.NaN, Number.POSITIVE_INFINITY, 12.5]) { + const res = await handleAuthorize(authorizeRequest({ body: goodBody }), { + ...config, + checkBalance: async () => ({ expiry: bad }), + }); + assert.equal((await res.json()).expiry, 1234567890); + } + }); + it("allows the request when checkBalance returns nothing", async () => { const res = await handleAuthorize(authorizeRequest({ body: goodBody }), { ...config, From 21683e51c8a80ca58432c5ba1cd47231d6da59ee Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Fri, 17 Jul 2026 16:50:51 -0400 Subject: [PATCH 6/8] fix(identity-webhook): reject negative minBalance and validate demo TTL env Refuse negative minBalanceUsdMicros (misconfig would always pass) and validate DEMO_BALANCE_REAUTH_TTL_SECONDS before createBalanceGate so bad compose env fails with a clear variable name. --- identity-webhook/balance-gate.mjs | 5 ++++- identity-webhook/balance-gate.test.mjs | 8 ++++++++ identity-webhook/server.mjs | 11 ++++++++++- 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/identity-webhook/balance-gate.mjs b/identity-webhook/balance-gate.mjs index 6485503..9910d77 100644 --- a/identity-webhook/balance-gate.mjs +++ b/identity-webhook/balance-gate.mjs @@ -67,7 +67,7 @@ export function parseUsdMicros(value) { * 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). + * Minimum balance required to authorize (non-negative). Default: 1 micro. * @param {number} [options.reauthTtlSeconds] * When set, caps the returned expiry to now + this (whole seconds), forcing * go-livepeer to call back and re-check the balance at least this often. @@ -92,6 +92,9 @@ export function createBalanceGate({ if (minBalance === null) { throw new TypeError("createBalanceGate: minBalanceUsdMicros must be an integer"); } + if (minBalance < 0n) { + throw new TypeError("createBalanceGate: minBalanceUsdMicros must not be negative"); + } let ttl = null; if (reauthTtlSeconds != null) { ttl = Number(reauthTtlSeconds); diff --git a/identity-webhook/balance-gate.test.mjs b/identity-webhook/balance-gate.test.mjs index 6e5a5d1..ebbc43b 100644 --- a/identity-webhook/balance-gate.test.mjs +++ b/identity-webhook/balance-gate.test.mjs @@ -53,6 +53,14 @@ describe("createBalanceGate", () => { () => createBalanceGate({ getBalanceUsdMicros: async () => 0n, minBalanceUsdMicros: "x" }), /minBalanceUsdMicros must be an integer/, ); + assert.throws( + () => createBalanceGate({ getBalanceUsdMicros: async () => 0n, minBalanceUsdMicros: -1 }), + /minBalanceUsdMicros must not be negative/, + ); + assert.throws( + () => createBalanceGate({ getBalanceUsdMicros: async () => 0n, minBalanceUsdMicros: "-5" }), + /minBalanceUsdMicros must not be negative/, + ); assert.throws( () => createBalanceGate({ getBalanceUsdMicros: async () => 0n, reauthTtlSeconds: 0 }), /reauthTtlSeconds must be a positive integer/, diff --git a/identity-webhook/server.mjs b/identity-webhook/server.mjs index 88a90d7..e25cbf3 100644 --- a/identity-webhook/server.mjs +++ b/identity-webhook/server.mjs @@ -26,9 +26,18 @@ const config = { const demoBalance = process.env.DEMO_BALANCE_USD_MICROS?.trim(); if (demoBalance !== undefined && demoBalance !== "") { const reauthRaw = process.env.DEMO_BALANCE_REAUTH_TTL_SECONDS?.trim(); + let reauthTtlSeconds = 30; + if (reauthRaw) { + reauthTtlSeconds = Number(reauthRaw); + if (!Number.isInteger(reauthTtlSeconds) || reauthTtlSeconds <= 0) { + throw new Error( + `DEMO_BALANCE_REAUTH_TTL_SECONDS must be a positive integer (got ${JSON.stringify(reauthRaw)})`, + ); + } + } config.checkBalance = createBalanceGate({ getBalanceUsdMicros: async () => demoBalance, - reauthTtlSeconds: reauthRaw ? Number(reauthRaw) : 30, + reauthTtlSeconds, }); console.log( `identity-webhook: DEMO_BALANCE_USD_MICROS=${demoBalance} (live balance gate enabled)`, From dca2714eafa94ebfdc410b5acc1cc4f7eba2fd57 Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Fri, 17 Jul 2026 16:57:02 -0400 Subject: [PATCH 7/8] refactor(identity-webhook): rename reauthTtlSeconds to expiryTtlSeconds Name the option after the webhook response field it sets (`expiry`), so the link to go-livepeer's AuthExpiry cache is obvious. --- identity-webhook/balance-gate.d.ts | 2 +- identity-webhook/balance-gate.mjs | 17 +++++++++-------- identity-webhook/balance-gate.test.mjs | 14 +++++++------- identity-webhook/server.mjs | 16 ++++++++-------- identity-webhook/verifiers.test.mjs | 2 +- 5 files changed, 26 insertions(+), 25 deletions(-) diff --git a/identity-webhook/balance-gate.d.ts b/identity-webhook/balance-gate.d.ts index 4279e90..9be026b 100644 --- a/identity-webhook/balance-gate.d.ts +++ b/identity-webhook/balance-gate.d.ts @@ -20,7 +20,7 @@ export function createBalanceGate(options: { | null | undefined; minBalanceUsdMicros?: bigint | number | string; - reauthTtlSeconds?: number; + expiryTtlSeconds?: number; failClosed?: boolean; onError?: (err: unknown, identity: UsageIdentity) => void; }): BalanceCheck; diff --git a/identity-webhook/balance-gate.mjs b/identity-webhook/balance-gate.mjs index 9910d77..eedceaf 100644 --- a/identity-webhook/balance-gate.mjs +++ b/identity-webhook/balance-gate.mjs @@ -19,7 +19,7 @@ * const checkBalance = createBalanceGate({ * getBalanceUsdMicros: async (identity) => * readLiveCreditBalanceUsdMicros(identity.client_id, identity.usage_subject), - * reauthTtlSeconds: 30, // re-check at least every 30s mid-stream + * expiryTtlSeconds: 30, // sets webhook expiry = now + 30s (go-livepeer AuthExpiry cache) * }); * return handleAuthorize(request, { webhookSecret, endUserAuth, checkBalance }); */ @@ -68,9 +68,10 @@ export function parseUsdMicros(value) { * Return null/undefined to signal "balance unknown" (see failClosed). * @param {bigint | number | string} [options.minBalanceUsdMicros=1] * Minimum balance required to authorize (non-negative). Default: 1 micro. - * @param {number} [options.reauthTtlSeconds] - * When set, caps the returned expiry to now + this (whole seconds), forcing - * go-livepeer to call back and re-check the balance at least this often. + * @param {number} [options.expiryTtlSeconds] + * When set, sets the webhook response `expiry` to now + this many whole + * seconds (also capped against the verifier expiry). go-livepeer stores that + * as AuthExpiry and skips /authorize until it elapses. * @param {boolean} [options.failClosed=true] * On lookup error or unknown balance: true → reject 503 billing_unavailable; * false → allow (fail open). @@ -81,7 +82,7 @@ export function parseUsdMicros(value) { export function createBalanceGate({ getBalanceUsdMicros, minBalanceUsdMicros = 1n, - reauthTtlSeconds, + expiryTtlSeconds, failClosed = true, onError, } = {}) { @@ -96,10 +97,10 @@ export function createBalanceGate({ throw new TypeError("createBalanceGate: minBalanceUsdMicros must not be negative"); } let ttl = null; - if (reauthTtlSeconds != null) { - ttl = Number(reauthTtlSeconds); + if (expiryTtlSeconds != null) { + ttl = Number(expiryTtlSeconds); if (!Number.isInteger(ttl) || ttl <= 0) { - throw new TypeError("createBalanceGate: reauthTtlSeconds must be a positive integer"); + throw new TypeError("createBalanceGate: expiryTtlSeconds must be a positive integer"); } } diff --git a/identity-webhook/balance-gate.test.mjs b/identity-webhook/balance-gate.test.mjs index ebbc43b..7c8c175 100644 --- a/identity-webhook/balance-gate.test.mjs +++ b/identity-webhook/balance-gate.test.mjs @@ -48,7 +48,7 @@ describe("createBalanceGate", () => { assert.throws(() => createBalanceGate({}), /getBalanceUsdMicros is required/); }); - it("validates minBalanceUsdMicros and reauthTtlSeconds", () => { + it("validates minBalanceUsdMicros and expiryTtlSeconds", () => { assert.throws( () => createBalanceGate({ getBalanceUsdMicros: async () => 0n, minBalanceUsdMicros: "x" }), /minBalanceUsdMicros must be an integer/, @@ -62,12 +62,12 @@ describe("createBalanceGate", () => { /minBalanceUsdMicros must not be negative/, ); assert.throws( - () => createBalanceGate({ getBalanceUsdMicros: async () => 0n, reauthTtlSeconds: 0 }), - /reauthTtlSeconds must be a positive integer/, + () => createBalanceGate({ getBalanceUsdMicros: async () => 0n, expiryTtlSeconds: 0 }), + /expiryTtlSeconds must be a positive integer/, ); assert.throws( - () => createBalanceGate({ getBalanceUsdMicros: async () => 0n, reauthTtlSeconds: 1.5 }), - /reauthTtlSeconds must be a positive integer/, + () => createBalanceGate({ getBalanceUsdMicros: async () => 0n, expiryTtlSeconds: 1.5 }), + /expiryTtlSeconds must be a positive integer/, ); }); @@ -112,10 +112,10 @@ describe("createBalanceGate", () => { assert.equal(seen.usage_subject, "user-1"); }); - it("caps expiry via reauthTtlSeconds", async () => { + it("caps expiry via expiryTtlSeconds", async () => { const gate = createBalanceGate({ getBalanceUsdMicros: async () => 10n, - reauthTtlSeconds: 30, + expiryTtlSeconds: 30, }); const before = Math.floor(Date.now() / 1000); const result = await gate(ctx()); diff --git a/identity-webhook/server.mjs b/identity-webhook/server.mjs index e25cbf3..02dfae9 100644 --- a/identity-webhook/server.mjs +++ b/identity-webhook/server.mjs @@ -22,22 +22,22 @@ const config = { }; // Optional compose/dev hook: DEMO_BALANCE_USD_MICROS enables createBalanceGate -// against a fixed balance (e.g. "0" → 483, "5000000" → allow + reauth TTL). +// against a fixed balance (e.g. "0" → 483, "5000000" → allow + expiry TTL). const demoBalance = process.env.DEMO_BALANCE_USD_MICROS?.trim(); if (demoBalance !== undefined && demoBalance !== "") { - const reauthRaw = process.env.DEMO_BALANCE_REAUTH_TTL_SECONDS?.trim(); - let reauthTtlSeconds = 30; - if (reauthRaw) { - reauthTtlSeconds = Number(reauthRaw); - if (!Number.isInteger(reauthTtlSeconds) || reauthTtlSeconds <= 0) { + const expiryTtlRaw = process.env.DEMO_BALANCE_EXPIRY_TTL_SECONDS?.trim(); + let expiryTtlSeconds = 30; + if (expiryTtlRaw) { + expiryTtlSeconds = Number(expiryTtlRaw); + if (!Number.isInteger(expiryTtlSeconds) || expiryTtlSeconds <= 0) { throw new Error( - `DEMO_BALANCE_REAUTH_TTL_SECONDS must be a positive integer (got ${JSON.stringify(reauthRaw)})`, + `DEMO_BALANCE_EXPIRY_TTL_SECONDS must be a positive integer (got ${JSON.stringify(expiryTtlRaw)})`, ); } } config.checkBalance = createBalanceGate({ getBalanceUsdMicros: async () => demoBalance, - reauthTtlSeconds, + expiryTtlSeconds, }); console.log( `identity-webhook: DEMO_BALANCE_USD_MICROS=${demoBalance} (live balance gate enabled)`, diff --git a/identity-webhook/verifiers.test.mjs b/identity-webhook/verifiers.test.mjs index c3a539e..8351642 100644 --- a/identity-webhook/verifiers.test.mjs +++ b/identity-webhook/verifiers.test.mjs @@ -952,7 +952,7 @@ describe("embedded OIDC issuer flow (handleAuthorize + createOidcVerifier)", () seenIdentity = identity; return 5_000_000n; }, - reauthTtlSeconds: 30, + expiryTtlSeconds: 30, }), }; From 31dd346886250d1f158fcbeb708d19f9469e6671 Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Fri, 17 Jul 2026 17:04:03 -0400 Subject: [PATCH 8/8] fix(identity-webhook): validate demo balance and model expiry TTL Fail fast on malformed DEMO_BALANCE_USD_MICROS, ignore negative webhook expiry decisions, and represent expiryTtl as an explicit seconds duration object while preserving the integer Unix expiry wire contract. --- identity-webhook/balance-gate.d.ts | 6 +++++- identity-webhook/balance-gate.mjs | 23 +++++++++++++++++------ identity-webhook/balance-gate.test.mjs | 26 +++++++++++++++++++------- identity-webhook/protocol.mjs | 3 ++- identity-webhook/protocol.test.mjs | 4 ++-- identity-webhook/server.mjs | 16 +++++++++++----- identity-webhook/verifiers.test.mjs | 2 +- 7 files changed, 57 insertions(+), 23 deletions(-) diff --git a/identity-webhook/balance-gate.d.ts b/identity-webhook/balance-gate.d.ts index 9be026b..22f935d 100644 --- a/identity-webhook/balance-gate.d.ts +++ b/identity-webhook/balance-gate.d.ts @@ -4,6 +4,10 @@ import type { UsageIdentity, } from "./protocol.js"; +export type ExpiryTtl = Readonly<{ + seconds: number; +}>; + export function parseUsdMicros( value: bigint | number | string | null | undefined, ): bigint | null; @@ -20,7 +24,7 @@ export function createBalanceGate(options: { | null | undefined; minBalanceUsdMicros?: bigint | number | string; - expiryTtlSeconds?: number; + expiryTtl?: ExpiryTtl; failClosed?: boolean; onError?: (err: unknown, identity: UsageIdentity) => void; }): BalanceCheck; diff --git a/identity-webhook/balance-gate.mjs b/identity-webhook/balance-gate.mjs index eedceaf..0224462 100644 --- a/identity-webhook/balance-gate.mjs +++ b/identity-webhook/balance-gate.mjs @@ -19,7 +19,7 @@ * const checkBalance = createBalanceGate({ * getBalanceUsdMicros: async (identity) => * readLiveCreditBalanceUsdMicros(identity.client_id, identity.usage_subject), - * expiryTtlSeconds: 30, // sets webhook expiry = now + 30s (go-livepeer AuthExpiry cache) + * expiryTtl: { seconds: 30 }, // sets webhook expiry = now + 30s * }); * return handleAuthorize(request, { webhookSecret, endUserAuth, checkBalance }); */ @@ -68,7 +68,7 @@ export function parseUsdMicros(value) { * Return null/undefined to signal "balance unknown" (see failClosed). * @param {bigint | number | string} [options.minBalanceUsdMicros=1] * Minimum balance required to authorize (non-negative). Default: 1 micro. - * @param {number} [options.expiryTtlSeconds] + * @param {{ seconds: number }} [options.expiryTtl] * When set, sets the webhook response `expiry` to now + this many whole * seconds (also capped against the verifier expiry). go-livepeer stores that * as AuthExpiry and skips /authorize until it elapses. @@ -82,7 +82,7 @@ export function parseUsdMicros(value) { export function createBalanceGate({ getBalanceUsdMicros, minBalanceUsdMicros = 1n, - expiryTtlSeconds, + expiryTtl, failClosed = true, onError, } = {}) { @@ -97,10 +97,21 @@ export function createBalanceGate({ throw new TypeError("createBalanceGate: minBalanceUsdMicros must not be negative"); } let ttl = null; - if (expiryTtlSeconds != null) { - ttl = Number(expiryTtlSeconds); + if (expiryTtl != null) { + if ( + typeof expiryTtl !== "object" || + expiryTtl === null || + !("seconds" in expiryTtl) + ) { + throw new TypeError( + "createBalanceGate: expiryTtl must be an object with positive integer seconds", + ); + } + ttl = Number(expiryTtl.seconds); if (!Number.isInteger(ttl) || ttl <= 0) { - throw new TypeError("createBalanceGate: expiryTtlSeconds must be a positive integer"); + throw new TypeError( + "createBalanceGate: expiryTtl.seconds must be a positive integer", + ); } } diff --git a/identity-webhook/balance-gate.test.mjs b/identity-webhook/balance-gate.test.mjs index 7c8c175..c6e85e0 100644 --- a/identity-webhook/balance-gate.test.mjs +++ b/identity-webhook/balance-gate.test.mjs @@ -48,7 +48,7 @@ describe("createBalanceGate", () => { assert.throws(() => createBalanceGate({}), /getBalanceUsdMicros is required/); }); - it("validates minBalanceUsdMicros and expiryTtlSeconds", () => { + it("validates minBalanceUsdMicros and expiryTtl", () => { assert.throws( () => createBalanceGate({ getBalanceUsdMicros: async () => 0n, minBalanceUsdMicros: "x" }), /minBalanceUsdMicros must be an integer/, @@ -62,12 +62,24 @@ describe("createBalanceGate", () => { /minBalanceUsdMicros must not be negative/, ); assert.throws( - () => createBalanceGate({ getBalanceUsdMicros: async () => 0n, expiryTtlSeconds: 0 }), - /expiryTtlSeconds must be a positive integer/, + () => createBalanceGate({ getBalanceUsdMicros: async () => 0n, expiryTtl: 30 }), + /expiryTtl must be an object/, ); assert.throws( - () => createBalanceGate({ getBalanceUsdMicros: async () => 0n, expiryTtlSeconds: 1.5 }), - /expiryTtlSeconds must be a positive integer/, + () => + createBalanceGate({ + getBalanceUsdMicros: async () => 0n, + expiryTtl: { seconds: 0 }, + }), + /expiryTtl.seconds must be a positive integer/, + ); + assert.throws( + () => + createBalanceGate({ + getBalanceUsdMicros: async () => 0n, + expiryTtl: { seconds: 1.5 }, + }), + /expiryTtl.seconds must be a positive integer/, ); }); @@ -112,10 +124,10 @@ describe("createBalanceGate", () => { assert.equal(seen.usage_subject, "user-1"); }); - it("caps expiry via expiryTtlSeconds", async () => { + it("caps expiry via expiryTtl", async () => { const gate = createBalanceGate({ getBalanceUsdMicros: async () => 10n, - expiryTtlSeconds: 30, + expiryTtl: { seconds: 30 }, }); const before = Math.floor(Date.now() / 1000); const result = await gate(ctx()); diff --git a/identity-webhook/protocol.mjs b/identity-webhook/protocol.mjs index fbe161d..a49fa4c 100644 --- a/identity-webhook/protocol.mjs +++ b/identity-webhook/protocol.mjs @@ -185,7 +185,8 @@ export async function handleAuthorize(request, config) { decision && typeof decision.expiry === "number" && Number.isFinite(decision.expiry) && - Number.isInteger(decision.expiry) + Number.isInteger(decision.expiry) && + decision.expiry >= 0 ) { expiry = Math.min(expiry, decision.expiry); } diff --git a/identity-webhook/protocol.test.mjs b/identity-webhook/protocol.test.mjs index 9dacc2f..081ee11 100644 --- a/identity-webhook/protocol.test.mjs +++ b/identity-webhook/protocol.test.mjs @@ -232,8 +232,8 @@ describe("handleAuthorize checkBalance hook", () => { assert.equal((await res.json()).expiry, 1234567890); }); - it("ignores non-finite or non-integer checkBalance expiry values", async () => { - for (const bad of [Number.NaN, Number.POSITIVE_INFINITY, 12.5]) { + it("ignores negative, non-finite, or non-integer checkBalance expiry values", async () => { + for (const bad of [-1, Number.NaN, Number.POSITIVE_INFINITY, 12.5]) { const res = await handleAuthorize(authorizeRequest({ body: goodBody }), { ...config, checkBalance: async () => ({ expiry: bad }), diff --git a/identity-webhook/server.mjs b/identity-webhook/server.mjs index 02dfae9..6f4ecc6 100644 --- a/identity-webhook/server.mjs +++ b/identity-webhook/server.mjs @@ -1,5 +1,5 @@ import { createServer } from "node:http"; -import { createBalanceGate } from "./balance-gate.mjs"; +import { createBalanceGate, parseUsdMicros } from "./balance-gate.mjs"; import { routeWebhookRequest } from "./protocol.mjs"; import { createEndUserVerifierFromEnv } from "./verifiers.mjs"; @@ -25,19 +25,25 @@ const config = { // against a fixed balance (e.g. "0" → 483, "5000000" → allow + expiry TTL). const demoBalance = process.env.DEMO_BALANCE_USD_MICROS?.trim(); if (demoBalance !== undefined && demoBalance !== "") { + if (parseUsdMicros(demoBalance) === null) { + throw new Error( + `DEMO_BALANCE_USD_MICROS must be an integer (got ${JSON.stringify(demoBalance)})`, + ); + } const expiryTtlRaw = process.env.DEMO_BALANCE_EXPIRY_TTL_SECONDS?.trim(); - let expiryTtlSeconds = 30; + let expiryTtl = { seconds: 30 }; if (expiryTtlRaw) { - expiryTtlSeconds = Number(expiryTtlRaw); - if (!Number.isInteger(expiryTtlSeconds) || expiryTtlSeconds <= 0) { + const seconds = Number(expiryTtlRaw); + if (!Number.isInteger(seconds) || seconds <= 0) { throw new Error( `DEMO_BALANCE_EXPIRY_TTL_SECONDS must be a positive integer (got ${JSON.stringify(expiryTtlRaw)})`, ); } + expiryTtl = { seconds }; } config.checkBalance = createBalanceGate({ getBalanceUsdMicros: async () => demoBalance, - expiryTtlSeconds, + expiryTtl, }); console.log( `identity-webhook: DEMO_BALANCE_USD_MICROS=${demoBalance} (live balance gate enabled)`, diff --git a/identity-webhook/verifiers.test.mjs b/identity-webhook/verifiers.test.mjs index 8351642..65633ac 100644 --- a/identity-webhook/verifiers.test.mjs +++ b/identity-webhook/verifiers.test.mjs @@ -952,7 +952,7 @@ describe("embedded OIDC issuer flow (handleAuthorize + createOidcVerifier)", () seenIdentity = identity; return 5_000_000n; }, - expiryTtlSeconds: 30, + expiryTtl: { seconds: 30 }, }), };