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..a7d6ac1 --- /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 "@pymthouse/clearinghouse-identity-webhook/protocol"; + * import { createBalanceGate } from "@pymthouse/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/package-lock.json b/identity-webhook/package-lock.json index 7a9f179..5c17374 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.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pymthouse/clearinghouse-identity-webhook", - "version": "0.3.3", + "version": "0.4.2", + "license": "MIT", "dependencies": { "jose": "^5.9.6" }, diff --git a/identity-webhook/package.json b/identity-webhook/package.json index 9f959a1..50b0cca 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.2", "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" diff --git a/identity-webhook/protocol.d.ts b/identity-webhook/protocol.d.ts index b61a486..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"; }; @@ -72,9 +74,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..5b9eaf0 100644 --- a/identity-webhook/protocol.mjs +++ b/identity-webhook/protocol.mjs @@ -12,13 +12,21 @@ * * 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 + * 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"; /** 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, @@ -28,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", }; @@ -160,9 +169,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( diff --git a/identity-webhook/verifiers.mjs b/identity-webhook/verifiers.mjs index c70c779..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 = @@ -605,11 +651,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"); @@ -631,16 +682,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"), @@ -649,7 +699,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 f0ea3ea..24ddf7e 100644 --- a/identity-webhook/verifiers.test.mjs +++ b/identity-webhook/verifiers.test.mjs @@ -445,10 +445,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/, ); }); @@ -629,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; + }, ); }); });