diff --git a/.github/workflows/bootstrap-publish.yml b/.github/workflows/bootstrap-publish.yml index 379c095..114e3fe 100644 --- a/.github/workflows/bootstrap-publish.yml +++ b/.github/workflows/bootstrap-publish.yml @@ -5,7 +5,7 @@ # # Token: npmjs.com → Access Tokens → Granular → Read and Write on @pymthouse # → enable "Bypass two-factor authentication for automation" -# Secret: gh secret set NPM_TOKEN -R pymthouse/clearinghouse-runtime +# Secret: gh secret set NPM_TOKEN -R pymthouse/clearinghouse name: bootstrap-publish on: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e7ee330..b2ca89f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,7 +2,7 @@ # repo, or npm returns E422 when verifying the sigstore bundle. # # npm auth: Trusted Publishing (OIDC). Configure on npmjs.com for -# pymthouse/clearinghouse-runtime and workflow filename `release.yml`. Do not set +# pymthouse/clearinghouse and workflow filename `release.yml`. Do not set # secrets.NPM_TOKEN on publish — it overrides OIDC and can cause EOTP. # See identity-webhook/docs/RELEASING.md. name: release diff --git a/identity-webhook/docs/RELEASING.md b/identity-webhook/docs/RELEASING.md index c16b64d..8f17b1d 100644 --- a/identity-webhook/docs/RELEASING.md +++ b/identity-webhook/docs/RELEASING.md @@ -1,7 +1,7 @@ # Releasing `@pymthouse/clearinghouse-identity-webhook` Releases are triggered by pushing a semver tag (`v*.*.*`) on -`pymthouse/clearinghouse-runtime` (fork of [livepeer/clearinghouse](https://github.com/livepeer/clearinghouse)). +`pymthouse/clearinghouse` (fork of [livepeer/clearinghouse](https://github.com/livepeer/clearinghouse)). The [release workflow](../../.github/workflows/release.yml) runs tests, publishes to npm via **trusted publishing** (OIDC), and creates a GitHub Release. @@ -18,7 +18,7 @@ This package publishes with [npm trusted publishing](https://docs.npmjs.com/trus `@pymthouse` org). 2. Open **Settings** → **Trusted publishing**. 3. Add a **GitHub Actions** publisher: - - **Repository:** `pymthouse/clearinghouse-runtime` + - **Repository:** `pymthouse/clearinghouse` - **Workflow filename:** `release.yml` (exact name, including `.yml`) - **Environment:** leave empty unless you use a GitHub Environment 4. **Remove** the `NPM_TOKEN` repository secret if it still exists. A leftover token diff --git a/identity-webhook/legacy-env.d.ts b/identity-webhook/legacy-env.d.ts deleted file mode 100644 index acfbf9c..0000000 --- a/identity-webhook/legacy-env.d.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { EndUserAuthVerifier, RemoteSignerWebhookConfig } from "./protocol.js"; - -export function defaultSignerWebhookJwtAudience(jwtIssuer: string): string; - -export function resolveLegacyJwksUri( - env: NodeJS.ProcessEnv | Record, -): string | undefined; - -export function resolveLegacyTokenExchangeBaseUrl( - env: NodeJS.ProcessEnv | Record, -): string | undefined; - -export function createLegacyOidcVerifierFromEnv( - env: NodeJS.ProcessEnv | Record, - options?: { jwtIssuer?: string }, -): EndUserAuthVerifier; - -export function createLegacyWebhookConfigFromEnv( - env: NodeJS.ProcessEnv | Record, - options?: { jwtIssuer?: string }, -): RemoteSignerWebhookConfig; diff --git a/identity-webhook/legacy-env.mjs b/identity-webhook/legacy-env.mjs deleted file mode 100644 index 2df8631..0000000 --- a/identity-webhook/legacy-env.mjs +++ /dev/null @@ -1,99 +0,0 @@ -/** - * Map pymthouse / builder-sdk legacy env vars to identity-webhook OIDC verifier config. - * - * Preserves pymthouse defaults from src/app/webhooks/remote-signer/route.ts: - * CLAIM_CLIENT_ID=client_id, CLAIM_USAGE_SUBJECT=external_user_id, - * USAGE_SUBJECT_TYPE=external_user_id, JWT_AUDIENCE defaults to JWT_ISSUER. - */ -import { createOidcVerifier } from "./verifiers.mjs"; - -function envTrim(env, name) { - return env[name]?.trim() || ""; -} - -function envOptional(env, name, fallback) { - return envTrim(env, name) || fallback; -} - -/** Strip trailing slashes from an issuer URL (builder-sdk audience default). */ -export function defaultSignerWebhookJwtAudience(jwtIssuer) { - let end = jwtIssuer.length; - while (end > 0 && jwtIssuer[end - 1] === "/") { - end -= 1; - } - return jwtIssuer.slice(0, end); -} - -/** - * Optional explicit JWKS override. When unset, createOidcVerifier resolves - * `jwks_uri` via OIDC Discovery (`{issuer}/.well-known/openid-configuration`). - * - * @param {NodeJS.ProcessEnv | Record} env - */ -export function resolveLegacyJwksUri(env) { - return envTrim(env, "OIDC_JWKS_URI") || envTrim(env, "JWKS_URI") || undefined; -} - -/** - * Base URL for RFC 8693 app-scoped token exchange (composite API keys). - * Prefer OIDC_TOKEN_EXCHANGE_BASE_URL; else NEXTAUTH_URL / public origin. - * - * @param {NodeJS.ProcessEnv | Record} env - */ -export function resolveLegacyTokenExchangeBaseUrl(env) { - return ( - envTrim(env, "OIDC_TOKEN_EXCHANGE_BASE_URL") || - envTrim(env, "NEXTAUTH_URL") || - undefined - ); -} - -/** - * Build an OIDC end-user verifier from legacy JWT_* / CLAIM_* env vars. - * - * @param {NodeJS.ProcessEnv | Record} env - * @param {{ jwtIssuer?: string }} [options] - optional issuer override (e.g. pymthouse getIssuer()) - */ -export function createLegacyOidcVerifierFromEnv(env, options = {}) { - const jwtIssuer = options.jwtIssuer?.trim() || envTrim(env, "JWT_ISSUER"); - if (!jwtIssuer) { - throw new Error("JWT_ISSUER is required"); - } - - const jwtAudience = - envTrim(env, "JWT_AUDIENCE") || defaultSignerWebhookJwtAudience(jwtIssuer); - - return createOidcVerifier({ - jwtIssuer, - jwtAudience, - issuer: jwtIssuer, - clientClaim: envOptional(env, "CLAIM_CLIENT_ID", "client_id"), - subjectClaim: envOptional(env, "CLAIM_USAGE_SUBJECT", "external_user_id"), - subjectTypeValue: envOptional(env, "USAGE_SUBJECT_TYPE", "external_user_id"), - requiredScopes: (envTrim(env, "OIDC_REQUIRED_SCOPES") || "sign:job") - .split(/[\s,]+/) - .filter(Boolean), - jwksUri: resolveLegacyJwksUri(env), - tokenExchangeBaseUrl: resolveLegacyTokenExchangeBaseUrl(env), - exchangeM2mClientId: envTrim(env, "OIDC_EXCHANGE_M2M_CLIENT_ID") || undefined, - exchangeM2mClientSecret: envTrim(env, "OIDC_EXCHANGE_M2M_CLIENT_SECRET") || undefined, - }); -} - -/** - * Full webhook config for handleAuthorize from legacy env. - * - * @param {NodeJS.ProcessEnv | Record} env - * @param {{ jwtIssuer?: string }} [options] - */ -export function createLegacyWebhookConfigFromEnv(env, options = {}) { - const webhookSecret = envTrim(env, "WEBHOOK_SECRET"); - if (!webhookSecret) { - throw new Error("WEBHOOK_SECRET is required"); - } - - return { - webhookSecret, - endUserAuth: createLegacyOidcVerifierFromEnv(env, options), - }; -} diff --git a/identity-webhook/legacy-env.test.mjs b/identity-webhook/legacy-env.test.mjs deleted file mode 100644 index 13b30c3..0000000 --- a/identity-webhook/legacy-env.test.mjs +++ /dev/null @@ -1,237 +0,0 @@ -import assert from "node:assert/strict"; -import { describe, it } from "node:test"; -import { SignJWT, createLocalJWKSet, exportJWK, generateKeyPair } from "jose"; -import { - createLegacyOidcVerifierFromEnv, - createLegacyWebhookConfigFromEnv, - defaultSignerWebhookJwtAudience, - resolveLegacyJwksUri, - resolveLegacyTokenExchangeBaseUrl, -} from "./legacy-env.mjs"; -import { handleAuthorize } from "./protocol.mjs"; - -describe("defaultSignerWebhookJwtAudience", () => { - it("strips trailing slashes", () => { - assert.equal( - defaultSignerWebhookJwtAudience("https://pymthouse.com/api/v1/oidc/"), - "https://pymthouse.com/api/v1/oidc", - ); - }); -}); - -describe("resolveLegacyJwksUri", () => { - it("returns undefined so createOidcVerifier uses OIDC discovery", () => { - assert.equal(resolveLegacyJwksUri({}), undefined); - }); - - it("prefers OIDC_JWKS_URI over JWKS_URI", () => { - assert.equal( - resolveLegacyJwksUri({ - OIDC_JWKS_URI: "https://a.example/jwks", - JWKS_URI: "https://b.example/jwks", - }), - "https://a.example/jwks", - ); - assert.equal( - resolveLegacyJwksUri({ JWKS_URI: "https://b.example/jwks" }), - "https://b.example/jwks", - ); - }); -}); - -describe("resolveLegacyTokenExchangeBaseUrl", () => { - it("prefers OIDC_TOKEN_EXCHANGE_BASE_URL over NEXTAUTH_URL", () => { - assert.equal( - resolveLegacyTokenExchangeBaseUrl({ - OIDC_TOKEN_EXCHANGE_BASE_URL: "https://staging.pymthouse.com", - NEXTAUTH_URL: "http://localhost:3000", - }), - "https://staging.pymthouse.com", - ); - assert.equal( - resolveLegacyTokenExchangeBaseUrl({ NEXTAUTH_URL: "http://localhost:3000" }), - "http://localhost:3000", - ); - }); -}); - -describe("createLegacyOidcVerifierFromEnv", () => { - it("uses pymthouse claim defaults", () => { - const verifier = createLegacyOidcVerifierFromEnv({ - JWT_ISSUER: "https://pymthouse.com/api/v1/oidc", - }); - assert.equal(verifier.kind, "oidc"); - }); - - it("defaults JWT_AUDIENCE to issuer without trailing slash", () => { - const verifier = createLegacyOidcVerifierFromEnv({ - JWT_ISSUER: "https://pymthouse.com/api/v1/oidc/", - }); - assert.equal(verifier.kind, "oidc"); - }); - - it("accepts jwtIssuer override", () => { - const verifier = createLegacyOidcVerifierFromEnv( - {}, - { jwtIssuer: "https://override.example/oidc" }, - ); - assert.equal(verifier.kind, "oidc"); - }); - - it("throws when JWT_ISSUER is missing", () => { - assert.throws( - () => createLegacyOidcVerifierFromEnv({}), - /JWT_ISSUER is required/, - ); - }); -}); - -describe("createLegacyWebhookConfigFromEnv", () => { - it("requires WEBHOOK_SECRET", () => { - assert.throws( - () => - createLegacyWebhookConfigFromEnv({ - JWT_ISSUER: "https://pymthouse.com/api/v1/oidc", - }), - /WEBHOOK_SECRET is required/, - ); - }); - - it("returns webhookSecret and endUserAuth", () => { - const config = createLegacyWebhookConfigFromEnv({ - WEBHOOK_SECRET: "secret", - JWT_ISSUER: "https://pymthouse.com/api/v1/oidc", - CLAIM_CLIENT_ID: "client_id", - CLAIM_USAGE_SUBJECT: "external_user_id", - USAGE_SUBJECT_TYPE: "external_user_id", - }); - assert.equal(config.webhookSecret, "secret"); - assert.equal(config.endUserAuth.kind, "oidc"); - }); -}); - -describe("pymthouse embedded flow (handleAuthorize + legacy config)", () => { - const ISSUER = "https://pymthouse.com/api/v1/oidc"; - const SECRET = "dev-webhook-secret"; - - async function setupPymthouseJwt() { - const { publicKey, privateKey } = await generateKeyPair("RS256"); - const jwk = await exportJWK(publicKey); - jwk.kid = "pymthouse-test"; - jwk.alg = "RS256"; - jwk.use = "sig"; - const jwks = createLocalJWKSet({ keys: [jwk] }); - const token = await new SignJWT({ - client_id: "app_abc123", - external_user_id: "user-456", - scope: "sign:job", - }) - .setProtectedHeader({ alg: "RS256", kid: "pymthouse-test" }) - .setIssuer(ISSUER) - .setAudience(defaultSignerWebhookJwtAudience(ISSUER)) - .setIssuedAt() - .setExpirationTime("5m") - .sign(privateKey); - return { token, jwks }; - } - - it("authorizes a pymthouse-style JWT end-to-end", async () => { - const { token, jwks } = await setupPymthouseJwt(); - const { createOidcVerifier } = await import("./verifiers.mjs"); - const config = { - webhookSecret: SECRET, - endUserAuth: createOidcVerifier({ - jwtIssuer: ISSUER, - jwtAudience: defaultSignerWebhookJwtAudience(ISSUER), - issuer: ISSUER, - jwks, - clientClaim: "client_id", - subjectClaim: "external_user_id", - subjectTypeValue: "external_user_id", - requiredScopes: ["sign:job"], - }), - }; - - const request = new Request("http://localhost/webhooks/remote-signer", { - method: "POST", - headers: { - authorization: `Bearer ${SECRET}`, - "content-type": "application/json", - }, - body: JSON.stringify({ - headers: { Authorization: [`Bearer ${token}`] }, - }), - }); - - const response = await handleAuthorize(request, config); - assert.equal(response.status, 200); - const body = await response.json(); - assert.equal(body.status, 200); - assert.equal(body.auth_id, "app_abc123:user-456"); - assert.equal(body.identity.client_id, "app_abc123"); - 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 { publicKey, privateKey } = await generateKeyPair("RS256"); - const jwk = await exportJWK(publicKey); - jwk.kid = "pymthouse-test"; - jwk.alg = "RS256"; - jwk.use = "sig"; - const jwks = createLocalJWKSet({ keys: [jwk] }); - const clientId = "app_abc123abc123abc123abcdef"; - const token = await new SignJWT({ - client_id: clientId, - external_user_id: "user-456", - scope: "sign:job", - }) - .setProtectedHeader({ alg: "RS256", kid: "pymthouse-test" }) - .setIssuer(ISSUER) - .setAudience(defaultSignerWebhookJwtAudience(ISSUER)) - .setIssuedAt() - .setExpirationTime("5m") - .sign(privateKey); - - const { createOidcVerifier } = await import("./verifiers.mjs"); - 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: createOidcVerifier({ - jwtIssuer: ISSUER, - jwtAudience: defaultSignerWebhookJwtAudience(ISSUER), - issuer: ISSUER, - jwks, - clientClaim: "client_id", - subjectClaim: "external_user_id", - subjectTypeValue: "external_user_id", - requiredScopes: ["sign:job"], - tokenExchangeBaseUrl: "http://localhost:3000", - fetchImpl, - }), - }; - - const request = new Request("http://localhost/webhooks/remote-signer", { - method: "POST", - headers: { - authorization: `Bearer ${SECRET}`, - "content-type": "application/json", - }, - body: JSON.stringify({ - headers: { - Authorization: [`Bearer ${clientId}_pmth_deadbeef`], - }, - }), - }); - - const response = await handleAuthorize(request, config); - assert.equal(response.status, 200); - const body = await response.json(); - assert.equal(body.status, 200); - assert.equal(body.auth_id, `${clientId}:user-456`); - }); -}); diff --git a/identity-webhook/package-lock.json b/identity-webhook/package-lock.json index 22c3d64..3f5c84d 100644 --- a/identity-webhook/package-lock.json +++ b/identity-webhook/package-lock.json @@ -1,12 +1,12 @@ { "name": "@pymthouse/clearinghouse-identity-webhook", - "version": "0.4.0", + "version": "0.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pymthouse/clearinghouse-identity-webhook", - "version": "0.4.0", + "version": "0.4.1", "license": "MIT", "dependencies": { "jose": "^5.9.6" diff --git a/identity-webhook/package.json b/identity-webhook/package.json index 511a3a6..ed6f53e 100644 --- a/identity-webhook/package.json +++ b/identity-webhook/package.json @@ -1,6 +1,6 @@ { "name": "@pymthouse/clearinghouse-identity-webhook", - "version": "0.4.0", + "version": "0.4.1", "description": "API-key + OIDC identity webhook for go-livepeer remote signer (jose)", "type": "module", "license": "MIT", @@ -26,11 +26,6 @@ "types": "./balance-gate.d.ts", "import": "./balance-gate.mjs", "default": "./balance-gate.mjs" - }, - "./legacy-env": { - "types": "./legacy-env.d.ts", - "import": "./legacy-env.mjs", - "default": "./legacy-env.mjs" } }, "files": [ @@ -40,8 +35,6 @@ "verifiers.d.ts", "balance-gate.mjs", "balance-gate.d.ts", - "legacy-env.mjs", - "legacy-env.d.ts", "keys.mjs" ], "dependencies": { @@ -62,11 +55,11 @@ ], "repository": { "type": "git", - "url": "https://github.com/pymthouse/clearinghouse-runtime.git", + "url": "https://github.com/pymthouse/clearinghouse.git", "directory": "identity-webhook" }, "bugs": { - "url": "https://github.com/pymthouse/clearinghouse-runtime/issues" + "url": "https://github.com/pymthouse/clearinghouse/issues" }, - "homepage": "https://github.com/pymthouse/clearinghouse-runtime/tree/main/identity-webhook#readme" + "homepage": "https://github.com/pymthouse/clearinghouse/tree/main/identity-webhook#readme" } diff --git a/identity-webhook/verifiers.test.mjs b/identity-webhook/verifiers.test.mjs index cdc1492..7552e83 100644 --- a/identity-webhook/verifiers.test.mjs +++ b/identity-webhook/verifiers.test.mjs @@ -9,9 +9,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 seen = []; @@ -652,3 +661,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); + }); +});