Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions identity-webhook/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion identity-webhook/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
10 changes: 5 additions & 5 deletions identity-webhook/protocol.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -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,
);
}

Expand Down
153 changes: 107 additions & 46 deletions identity-webhook/verifiers.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"];
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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(...))",
);
}
Expand All @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 =
Expand Down
70 changes: 69 additions & 1 deletion identity-webhook/verifiers.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
},
);
});
});
Expand Down