From 301770e26e23f5fd9f17fd61eda2ee0e432382ce Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Fri, 10 Jul 2026 20:30:00 -0400 Subject: [PATCH 1/7] fix(identity-webhook): resolve JWKS via OIDC discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the incorrect default {issuer}/.well-known/jwks.json with issuer-relative OIDC discovery (openid-configuration → jwks_uri). Validate discovery issuer, wrap JWKS fetch errors with the URL, and keep OIDC_JWKS_URI as an explicit override. Unit tests cover discovery and leave api_key mode unchanged. --- .env.example | 2 +- README.md | 2 +- identity-webhook/verifiers.mjs | 122 +++++++++++++++++++++++-- identity-webhook/verifiers.test.mjs | 137 ++++++++++++++++++++++++++++ 4 files changed, 253 insertions(+), 10 deletions(-) diff --git a/.env.example b/.env.example index 8fdb7e0..82e182f 100644 --- a/.env.example +++ b/.env.example @@ -39,7 +39,7 @@ USAGE_SUBJECT_TYPE=api_key_user # Generic OIDC (raw IdP access tokens with azp/sub): # OIDC_ISSUER=https://your-tenant.auth0.com/ # OIDC_AUDIENCE=https://api.your-platform.com -# OIDC_JWKS_URI= # defaults to ${OIDC_ISSUER}/.well-known/jwks.json +# OIDC_JWKS_URI= # optional override; else OIDC Discovery → jwks_uri # OIDC_CLIENT_CLAIM=azp # OIDC_SUBJECT_CLAIM=sub # OIDC_SUBJECT_TYPE=oidc_user diff --git a/README.md b/README.md index 81df48d..6c2a1a8 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,7 @@ Shared keys (`WEBHOOK_SECRET`, `KAFKA_BROKERS`, `KAFKA_GATEWAY_TOPIC`) are liste | `API_KEY_PREFIX` | `api_key`, optional | Default `sk_` | | `OIDC_ISSUER` | `oidc` | JWT issuer / JWKS host | | `OIDC_AUDIENCE` | `oidc` | Expected JWT audience | -| `OIDC_JWKS_URI` | `oidc`, optional | Defaults to `${OIDC_ISSUER}/.well-known/jwks.json` | +| `OIDC_JWKS_URI` | `oidc`, optional | Explicit JWKS override; otherwise resolved via OIDC Discovery (`${OIDC_ISSUER}/.well-known/openid-configuration` → `jwks_uri`) | | `OIDC_CLIENT_CLAIM` | `oidc`, optional | Tenant claim (default `azp`; Auth0 clearinghouse uses `app_client_id`) | | `OIDC_SUBJECT_CLAIM` | `oidc`, optional | End-user claim (default `sub`; Auth0 clearinghouse uses `external_user_id`) | | `OIDC_SUBJECT_TYPE` | `oidc`, optional | Default `oidc_user`; Auth0 clearinghouse uses `external_user_id` | diff --git a/identity-webhook/verifiers.mjs b/identity-webhook/verifiers.mjs index 968c6bc..53f2859 100644 --- a/identity-webhook/verifiers.mjs +++ b/identity-webhook/verifiers.mjs @@ -9,7 +9,7 @@ * - createOidcVerifier: verifies a JWT bearer against an OIDC issuer's JWKS (jose). * - createEndUserVerifierFromEnv: picks exactly one verifier via IDENTITY_AUTH_MODE. */ -import { createRemoteJWKSet, jwtVerify } from "jose"; +import { createLocalJWKSet, createRemoteJWKSet, jwtVerify } from "jose"; import { bearerToken, WebhookError } from "./protocol.mjs"; import { loadApiKeyStore } from "./keys.mjs"; @@ -59,10 +59,114 @@ export function createApiKeyVerifier({ }; } +/** + * Resolve `jwks_uri` via OIDC Discovery (issuer-relative + * `/.well-known/openid-configuration`), matching oauth4webapi / builder-sdk. + * + * Example: issuer `https://staging.pymthouse.com/api/v1/oidc` → + * discovery advertises `jwks_uri` `https://staging.pymthouse.com/api/v1/oidc/jwks`. + * + * @param {string} jwtIssuer + * @param {{ fetchImpl?: typeof fetch }} [options] + * @returns {Promise} + */ +function normalizeIssuer(issuer) { + return String(issuer ?? "").replace(/\/$/, ""); +} + +export async function discoverJwksUri(jwtIssuer, options = {}) { + const fetchImpl = options.fetchImpl ?? fetch; + const base = normalizeIssuer(jwtIssuer); + const url = `${base}/.well-known/openid-configuration`; + let response; + try { + response = await fetchImpl(url); + } catch (err) { + throw new Error( + `OIDC discovery request failed (${url}): ${err instanceof Error ? err.message : err}`, + ); + } + if (!response.ok) { + throw new Error(`OIDC discovery failed: expected 200 from ${url}, got ${response.status}`); + } + let doc; + try { + doc = await response.json(); + } catch { + throw new Error(`OIDC discovery response is not JSON (${url})`); + } + if (!doc || typeof doc.issuer !== "string" || !doc.issuer.trim()) { + throw new Error(`OIDC discovery document missing issuer (${url})`); + } + if (normalizeIssuer(doc.issuer) !== base) { + throw new Error( + `OIDC discovery issuer mismatch (${url}): expected ${base}, got ${doc.issuer.trim()}`, + ); + } + if (typeof doc.jwks_uri !== "string" || !doc.jwks_uri.trim()) { + throw new Error(`OIDC discovery document missing jwks_uri (${url})`); + } + return doc.jwks_uri.trim(); +} + +/** + * Build a jose key resolver: explicit `jwks`, explicit `jwksUri`, or lazy OIDC + * discovery of `jwks_uri` from `{issuer}/.well-known/openid-configuration`. + * + * When `fetchImpl` is set (tests / custom HTTP), JWKS is loaded through it into + * a local keyset. Otherwise jose's createRemoteJWKSet fetches and caches. + */ +function createOidcKeyResolver({ jwks, jwksUri, jwtIssuer, fetchImpl }) { + if (jwks) { + return jwks; + } + + let remote; + 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( + `Expected 200 OK from the JSON Web Key Set HTTP response (${uri}) [${response.status}]`, + ); + } + let doc; + try { + doc = await response.json(); + } catch { + throw new Error(`JWKS response is not JSON (${uri})`); + } + return createLocalJWKSet(doc); + } + return createRemoteJWKSet(new URL(uri)); + })(); + remote = await resolving; + } + return remote(protectedHeader, token); + }; +} + /** * OIDC/JWT verifier (bring-your-own OAuth). Validates a `Bearer ` against - * `jwtIssuer`/`jwtAudience` using the issuer's JWKS (auto-cached & refreshed by - * jose's createRemoteJWKSet). `jwks` may be injected for testing. + * `jwtIssuer`/`jwtAudience` using the issuer's JWKS. By default jose's + * `createRemoteJWKSet` fetches, caches, and refreshes keys. When `fetchImpl` is + * provided (tests / custom HTTP), JWKS is fetched once into a local keyset. + * + * JWKS resolution order: + * 1. `jwks` (injected keyset, for tests) + * 2. `jwksUri` (explicit override, e.g. OIDC_JWKS_URI) + * 3. OIDC Discovery: `{jwtIssuer}/.well-known/openid-configuration` → `jwks_uri` */ export function createOidcVerifier({ jwtIssuer, @@ -74,6 +178,7 @@ export function createOidcVerifier({ subjectClaim = "sub", subjectTypeValue = "oidc_user", requiredScopes = [], + fetchImpl, }) { if (!jwtIssuer) { throw new Error("createOidcVerifier: jwtIssuer is required"); @@ -81,11 +186,12 @@ export function createOidcVerifier({ if (!jwtAudience) { throw new Error("createOidcVerifier: jwtAudience is required"); } - const keyset = - jwks ?? - createRemoteJWKSet( - new URL(jwksUri ?? `${jwtIssuer.replace(/\/$/, "")}/.well-known/jwks.json`), - ); + const keyset = createOidcKeyResolver({ + jwks, + jwksUri, + jwtIssuer, + fetchImpl, + }); const identityIssuer = issuer ?? jwtIssuer; return { diff --git a/identity-webhook/verifiers.test.mjs b/identity-webhook/verifiers.test.mjs index 3993ae7..ca19a46 100644 --- a/identity-webhook/verifiers.test.mjs +++ b/identity-webhook/verifiers.test.mjs @@ -5,10 +5,147 @@ import { createApiKeyVerifier, createEndUserVerifierFromEnv, createOidcVerifier, + discoverJwksUri, } from "./verifiers.mjs"; const ISSUER = "http://identity-webhook:8090"; +describe("discoverJwksUri", () => { + it("reads jwks_uri from issuer-relative openid-configuration", async () => { + const seen = []; + const fetchImpl = async (input) => { + seen.push(String(input)); + return Response.json({ + issuer: "https://staging.pymthouse.com/api/v1/oidc", + jwks_uri: "https://staging.pymthouse.com/api/v1/oidc/jwks", + }); + }; + const uri = await discoverJwksUri("https://staging.pymthouse.com/api/v1/oidc", { + fetchImpl, + }); + assert.equal(uri, "https://staging.pymthouse.com/api/v1/oidc/jwks"); + assert.deepEqual(seen, [ + "https://staging.pymthouse.com/api/v1/oidc/.well-known/openid-configuration", + ]); + }); + + it("rejects discovery without jwks_uri", async () => { + await assert.rejects( + () => + discoverJwksUri("https://idp.test", { + fetchImpl: async () => Response.json({ issuer: "https://idp.test" }), + }), + /missing jwks_uri/, + ); + }); + + it("rejects discovery when issuer does not match jwtIssuer", async () => { + await assert.rejects( + () => + discoverJwksUri("https://idp.test/api/v1/oidc", { + fetchImpl: async () => + Response.json({ + issuer: "https://other.example/oidc", + jwks_uri: "https://other.example/oidc/jwks", + }), + }), + /issuer mismatch/, + ); + }); + + it("accepts discovery issuer that differs only by trailing slash", async () => { + const uri = await discoverJwksUri("https://idp.test/api/v1/oidc/", { + fetchImpl: async () => + Response.json({ + issuer: "https://idp.test/api/v1/oidc", + jwks_uri: "https://idp.test/api/v1/oidc/jwks", + }), + }); + assert.equal(uri, "https://idp.test/api/v1/oidc/jwks"); + }); +}); + +describe("createOidcVerifier discovery", () => { + it("discovers JWKS via openid-configuration then verifies", async () => { + const { publicKey, privateKey } = await generateKeyPair("RS256"); + const jwk = await exportJWK(publicKey); + jwk.kid = "disc-key"; + jwk.alg = "RS256"; + jwk.use = "sig"; + const issuer = "https://idp.test/api/v1/oidc"; + const jwksUri = `${issuer}/jwks`; + const seen = []; + const fetchImpl = async (input) => { + const url = String(input); + seen.push(url); + if (url.endsWith("/.well-known/openid-configuration")) { + return Response.json({ issuer, jwks_uri: jwksUri }); + } + if (url.startsWith(jwksUri)) { + return Response.json({ keys: [jwk] }); + } + return new Response("not found", { status: 404 }); + }; + + const token = await new SignJWT({ sub: "user-b", azp: "app-b" }) + .setProtectedHeader({ alg: "RS256", kid: "disc-key" }) + .setIssuer(issuer) + .setAudience("clearinghouse") + .setIssuedAt() + .setExpirationTime("5m") + .sign(privateKey); + + const verifier = createOidcVerifier({ + jwtIssuer: issuer, + jwtAudience: "clearinghouse", + fetchImpl, + }); + const { identity } = await verifier.verify({ authorization: `Bearer ${token}` }); + assert.equal(identity.usage_subject, "user-b"); + assert.ok(seen.some((u) => u.endsWith("/.well-known/openid-configuration"))); + assert.ok(seen.some((u) => u.startsWith(jwksUri))); + assert.ok(!seen.some((u) => u.includes(".well-known/jwks.json"))); + }); + + it("wraps JWKS fetch failures with the JWKS URL", async () => { + const issuer = "https://idp.test/api/v1/oidc"; + const jwksUri = `${issuer}/jwks`; + const fetchImpl = async (input) => { + const url = String(input); + if (url.endsWith("/.well-known/openid-configuration")) { + return Response.json({ issuer, jwks_uri: jwksUri }); + } + if (url.startsWith(jwksUri)) { + throw new Error("network down"); + } + return new Response("not found", { status: 404 }); + }; + + const warnings = []; + const origWarn = console.warn; + console.warn = (...args) => warnings.push(args.join(" ")); + try { + const verifier = createOidcVerifier({ + jwtIssuer: issuer, + jwtAudience: "clearinghouse", + fetchImpl, + }); + await assert.rejects( + () => verifier.verify({ authorization: "Bearer eyJhbGciOiJSUzI1NiJ9.e30.sig" }), + /oidc verification failed/, + ); + } finally { + console.warn = origWarn; + } + assert.ok( + warnings.some((w) => + w.includes(`JWKS request failed (${jwksUri}): network down`), + ), + `expected JWKS URL in warn log, got: ${JSON.stringify(warnings)}`, + ); + }); +}); + describe("createApiKeyVerifier", () => { const store = new Map([ ["sk_demo", { userId: "demo-user", clientId: "demo-client", usageSubjectType: "api_key_user" }], From e3a868b969f24cf43407b269a0d1893662b8594a Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Sat, 11 Jul 2026 17:43:28 -0400 Subject: [PATCH 2/7] feat(identity-webhook): exchange composite app_*_* in OIDC mode Accept Bearer app_<24hex>_ when OIDC_TOKEN_EXCHANGE_BASE_URL is set: RFC 8693 exchange at the app-scoped token endpoint, then jwtVerify the minted access token (same JWKS discovery path as #62). Secret segment is opaque (operator prefixes allowed); reject cs_ / _cs_ client-secret shapes. Harden cache keys (salted HKDF) and log sanitization. Document exchange env vars. --- .env.example | 3 + README.md | 2 + identity-webhook/verifiers.mjs | 409 +++++++++++++++++++++++++--- identity-webhook/verifiers.test.mjs | 147 +++++++++- 4 files changed, 510 insertions(+), 51 deletions(-) diff --git a/.env.example b/.env.example index 82e182f..6519be1 100644 --- a/.env.example +++ b/.env.example @@ -44,6 +44,9 @@ USAGE_SUBJECT_TYPE=api_key_user # OIDC_SUBJECT_CLAIM=sub # OIDC_SUBJECT_TYPE=oidc_user # OIDC_REQUIRED_SCOPES= +# OIDC_TOKEN_EXCHANGE_BASE_URL= # optional; enables app_<24hex>_ composite key exchange +# OIDC_EXCHANGE_M2M_CLIENT_ID= +# OIDC_EXCHANGE_M2M_CLIENT_SECRET= # Reference only — not read by Compose services; for minting test JWTs. # Canonical copy: auth0-provisioner/provision/.env.livepeer diff --git a/README.md b/README.md index 6c2a1a8..a03c924 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,8 @@ Shared keys (`WEBHOOK_SECRET`, `KAFKA_BROKERS`, `KAFKA_GATEWAY_TOPIC`) are liste | `OIDC_SUBJECT_CLAIM` | `oidc`, optional | End-user claim (default `sub`; Auth0 clearinghouse uses `external_user_id`) | | `OIDC_SUBJECT_TYPE` | `oidc`, optional | Default `oidc_user`; Auth0 clearinghouse uses `external_user_id` | | `OIDC_REQUIRED_SCOPES` | `oidc`, optional | Space- or comma-separated required scopes (e.g. `sign:job`) | +| `OIDC_TOKEN_EXCHANGE_BASE_URL` | `oidc`, optional | Enables composite `Bearer app_<24hex>_`: RFC 8693 exchange at `/api/v1/apps/{clientId}/oidc/token`, then JWT verify | +| `OIDC_EXCHANGE_M2M_CLIENT_ID` / `OIDC_EXCHANGE_M2M_CLIENT_SECRET` | `oidc`, optional | Optional client credentials for the exchange request (both or neither) | `PORT` is set by Compose (`8090`), not `.env`. See the `identity-webhook` block in [`.env.example`](.env.example) for Auth0 vs generic OIDC examples. diff --git a/identity-webhook/verifiers.mjs b/identity-webhook/verifiers.mjs index 53f2859..7148c60 100644 --- a/identity-webhook/verifiers.mjs +++ b/identity-webhook/verifiers.mjs @@ -6,19 +6,104 @@ * { issuer, client_id, usage_subject, usage_subject_type } * * - createApiKeyVerifier: resolves `sk_…` keys via a caller-supplied lookup. - * - createOidcVerifier: verifies a JWT bearer against an OIDC issuer's JWKS (jose). + * - createOidcVerifier: verifies a JWT bearer against an OIDC issuer's JWKS (jose), + * or exchanges a composite `app_<24hex>_` API key via RFC 8693 then verifies. * - createEndUserVerifierFromEnv: picks exactly one verifier via IDENTITY_AUTH_MODE. */ +import { hkdfSync, randomBytes } from "node:crypto"; import { createLocalJWKSet, createRemoteJWKSet, jwtVerify } from "jose"; import { bearerToken, WebhookError } from "./protocol.mjs"; import { loadApiKeyStore } from "./keys.mjs"; export const IDENTITY_AUTH_MODES = ["api_key", "oidc"]; +const GRANT_TYPE_TOKEN_EXCHANGE = + "urn:ietf:params:oauth:grant-type:token-exchange"; +const SUBJECT_ACCESS_TOKEN_TYPE = + "urn:ietf:params:oauth:token-type:access_token"; +/** Underscore composite: `app_<24hex>_` (no `.` — better copy/select UX). */ +const COMPOSITE_API_KEY_RE = /^(app_[a-f0-9]{24})_(.+)$/; +const COMPOSITE_CACHE_MAX_TTL_SECONDS = 60; +const ALLOWED_JWT_ALGS = ["RS256"]; + function nowSeconds() { return Math.floor(Date.now() / 1000); } +/** + * Split `app_<24hex>_` into parts. Returns null when not composite. + * The secret segment is opaque issuer key material. Client-secret shaped + * segments (`cs_…`) are rejected. + */ +export function splitCompositeApiKey(token) { + const trimmed = (token ?? "").trim(); + const match = COMPOSITE_API_KEY_RE.exec(trimmed); + if (!match) { + return null; + } + const publicClientId = match[1]; + const apiKey = match[2]; + if (!apiKey || apiKey.startsWith("cs_") || apiKey.includes("_cs_")) { + return null; + } + return { publicClientId, apiKey }; +} + +function isLoopbackHost(hostname) { + const host = (hostname ?? "").toLowerCase(); + return host === "localhost" || host === "127.0.0.1" || host === "::1" || host === "[::1]"; +} + +/** + * Validate OIDC_TOKEN_EXCHANGE_BASE_URL: HTTPS required except loopback. + * @param {string} baseUrl + * @returns {string} normalized origin (no trailing slash) + */ +export function normalizeTokenExchangeBaseUrl(baseUrl) { + const trimmed = (baseUrl ?? "").trim().replace(/\/$/, ""); + if (!trimmed) { + throw new Error("tokenExchangeBaseUrl is required for composite API key exchange"); + } + let url; + try { + url = new URL(trimmed); + } catch { + throw new Error(`tokenExchangeBaseUrl is not a valid URL: ${trimmed}`); + } + if (url.protocol === "https:") { + return url.origin; + } + if (url.protocol === "http:" && isLoopbackHost(url.hostname)) { + return url.origin; + } + throw new Error( + `tokenExchangeBaseUrl must be https (or http on loopback); got ${url.protocol}//${url.hostname}`, + ); +} + +// Cache keys must be one-way so the plaintext credential is never retained in +// the exchange cache. The inputs are high-entropy machine-generated keys (not +// user-chosen passwords), so salted HKDF is the appropriate derivation; the +// random per-process salt makes a leaked digest useless for offline +// precomputation, and the cache is per-process anyway. +const CACHE_KEY_SALT = randomBytes(32); + +function deriveCacheKey(token) { + return Buffer.from( + hkdfSync("sha256", token, CACHE_KEY_SALT, "composite-exchange-cache", 32), + ).toString("hex"); +} + +// Allowlist for values interpolated into log lines (correlation ids, client +// ids). Anything else is dropped so response-controlled data cannot inject +// log records. +const LOG_SAFE_RE = /^[A-Za-z0-9._-]{1,64}$/; + +function logSafe(value) { + const str = String(value ?? ""); + return LOG_SAFE_RE.test(str) ? str : ""; +} + /** * API-key verifier. `resolveApiKey(token)` returns * { userId, clientId?, usageSubjectType? } or null. @@ -63,8 +148,8 @@ export function createApiKeyVerifier({ * Resolve `jwks_uri` via OIDC Discovery (issuer-relative * `/.well-known/openid-configuration`), matching oauth4webapi / builder-sdk. * - * Example: issuer `https://staging.pymthouse.com/api/v1/oidc` → - * discovery advertises `jwks_uri` `https://staging.pymthouse.com/api/v1/oidc/jwks`. + * Example: issuer `https://issuer.example/api/v1/oidc` → + * discovery advertises `jwks_uri` `https://issuer.example/api/v1/oidc/jwks`. * * @param {string} jwtIssuer * @param {{ fetchImpl?: typeof fetch }} [options] @@ -157,12 +242,205 @@ function createOidcKeyResolver({ jwks, jwksUri, jwtIssuer, fetchImpl }) { }; } +function mapVerifiedPayloadToIdentity({ + payload, + identityIssuer, + clientClaim, + subjectClaim, + subjectTypeValue, + jwtAudience, + requiredScopes, +}) { + if (requiredScopes.length) { + const granted = new Set( + String(payload.scope ?? payload.scp ?? "") + .split(/[\s,]+/) + .filter(Boolean), + ); + const missing = requiredScopes.filter((s) => !granted.has(s)); + if (missing.length) { + throw new WebhookError(`missing required scope(s): ${missing.join(", ")}`, { + status: 403, + code: "insufficient_scope", + }); + } + } + + const usageSubject = payload[subjectClaim]; + if (!usageSubject) { + throw new WebhookError(`token missing ${subjectClaim} claim`, { + status: 401, + code: "invalid_token", + }); + } + + const identity = { + issuer: identityIssuer, + client_id: String(payload[clientClaim] ?? jwtAudience), + usage_subject: String(usageSubject), + usage_subject_type: subjectTypeValue, + }; + const expiry = typeof payload.exp === "number" ? payload.exp : nowSeconds() + 60; + return { identity, expiry, raw: payload }; +} + +async function verifyJwtBearer({ + token, + keyset, + jwtIssuer, + jwtAudience, + identityIssuer, + clientClaim, + subjectClaim, + subjectTypeValue, + requiredScopes, +}) { + let payload; + try { + ({ payload } = await jwtVerify(token, keyset, { + issuer: jwtIssuer, + audience: jwtAudience, + algorithms: ALLOWED_JWT_ALGS, + })); + } catch (err) { + console.warn(`oidc verification failed: ${err.message}`); + throw new WebhookError("oidc verification failed", { + status: 401, + code: "invalid_token", + }); + } + + return mapVerifiedPayloadToIdentity({ + payload, + identityIssuer, + clientClaim, + subjectClaim, + subjectTypeValue, + jwtAudience, + requiredScopes, + }); +} + +function createCompositeExchangeCache() { + /** @type {Map }>} */ + const cache = new Map(); + + return { + get(key) { + const entry = cache.get(key); + if (!entry) { + return null; + } + if (entry.result !== undefined && entry.expiresAt > nowSeconds()) { + return entry.result; + } + if (entry.inflight !== undefined) { + return entry.inflight; + } + cache.delete(key); + return null; + }, + setInflight(key, promise) { + cache.set(key, { + expiresAt: nowSeconds() + COMPOSITE_CACHE_MAX_TTL_SECONDS, + inflight: promise, + }); + }, + setResult(key, result, ttlSeconds) { + const ttl = Math.max(1, Math.min(ttlSeconds, COMPOSITE_CACHE_MAX_TTL_SECONDS)); + cache.set(key, { expiresAt: nowSeconds() + ttl, result }); + }, + clear(key) { + cache.delete(key); + }, + }; +} + +async function exchangeCompositeApiKey({ + exchangeBaseUrl, + publicClientId, + apiKey, + keyId, + jwtAudience, + m2mClientId, + m2mClientSecret, + fetchImpl, +}) { + const url = `${exchangeBaseUrl}/api/v1/apps/${encodeURIComponent(publicClientId)}/oidc/token`; + const body = new URLSearchParams({ + grant_type: GRANT_TYPE_TOKEN_EXCHANGE, + subject_token: apiKey, + subject_token_type: SUBJECT_ACCESS_TOKEN_TYPE, + requested_token_type: SUBJECT_ACCESS_TOKEN_TYPE, + audience: jwtAudience, + }); + + const headers = { + "content-type": "application/x-www-form-urlencoded", + accept: "application/json", + }; + if (m2mClientId && m2mClientSecret) { + const basicCredentials = Buffer.from(`${m2mClientId}:${m2mClientSecret}`).toString("base64"); + headers.authorization = `Basic ${basicCredentials}`; + } + + let response; + try { + response = await fetchImpl(url, { + method: "POST", + headers, + body: body.toString(), + }); + } catch (err) { + console.warn( + `composite api key exchange request failed client_id=${logSafe(publicClientId)} key_id=${keyId}: ${err instanceof Error ? err.message : err}`, + ); + throw new WebhookError("token exchange failed", { + status: 401, + code: "invalid_token", + }); + } + + let payload; + try { + payload = await response.json(); + } catch { + payload = null; + } + + if (!response.ok) { + const correlationId = logSafe(payload?.correlation_id); + console.warn( + `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", + }); + } + + const accessToken = + payload && typeof payload.access_token === "string" ? payload.access_token.trim() : ""; + if (!accessToken) { + throw new WebhookError("token exchange returned no access_token", { + status: 401, + code: "invalid_token", + }); + } + return accessToken; +} + /** * OIDC/JWT verifier (bring-your-own OAuth). Validates a `Bearer ` against * `jwtIssuer`/`jwtAudience` using the issuer's JWKS. By default jose's * `createRemoteJWKSet` fetches, caches, and refreshes keys. When `fetchImpl` is * provided (tests / custom HTTP), JWKS is fetched once into a local keyset. * + * Also accepts composite `Bearer app_<24hex>_` when + * `tokenExchangeBaseUrl` is configured: RFC 8693 exchange at + * `/api/v1/apps/{clientId}/oidc/token`, then the same JWT verification path. + * * JWKS resolution order: * 1. `jwks` (injected keyset, for tests) * 2. `jwksUri` (explicit override, e.g. OIDC_JWKS_URI) @@ -179,6 +457,9 @@ export function createOidcVerifier({ subjectTypeValue = "oidc_user", requiredScopes = [], fetchImpl, + tokenExchangeBaseUrl, + exchangeM2mClientId, + exchangeM2mClientSecret, }) { if (!jwtIssuer) { throw new Error("createOidcVerifier: jwtIssuer is required"); @@ -193,60 +474,95 @@ export function createOidcVerifier({ fetchImpl, }); const identityIssuer = issuer ?? jwtIssuer; + const http = fetchImpl ?? fetch; + const exchangeOrigin = tokenExchangeBaseUrl + ? normalizeTokenExchangeBaseUrl(tokenExchangeBaseUrl) + : null; + const m2mId = (exchangeM2mClientId ?? "").trim(); + const m2mSecret = (exchangeM2mClientSecret ?? "").trim(); + if ((m2mId && !m2mSecret) || (!m2mId && m2mSecret)) { + throw new Error( + "createOidcVerifier: exchangeM2mClientId and exchangeM2mClientSecret must both be set or both omitted", + ); + } + const exchangeCache = createCompositeExchangeCache(); + + async function verifyJwt(token) { + return verifyJwtBearer({ + token, + keyset, + jwtIssuer, + jwtAudience, + identityIssuer, + clientClaim, + subjectClaim, + subjectTypeValue, + requiredScopes, + }); + } + + async function verifyComposite(compositeToken, parts) { + if (!exchangeOrigin) { + throw new WebhookError("composite api key exchange is not configured", { + status: 401, + code: "invalid_token", + }); + } + + const cacheKey = deriveCacheKey(compositeToken); + const cached = exchangeCache.get(cacheKey); + if (cached !== null) { + return cached; + } + + const inflight = (async () => { + const accessToken = await exchangeCompositeApiKey({ + exchangeBaseUrl: exchangeOrigin, + publicClientId: parts.publicClientId, + apiKey: parts.apiKey, + keyId: cacheKey.slice(0, 12), + jwtAudience, + m2mClientId: m2mId, + m2mClientSecret: m2mSecret, + fetchImpl: http, + }); + const verified = await verifyJwt(accessToken); + if (verified.identity.client_id !== parts.publicClientId) { + throw new WebhookError("minted token client_id does not match credential prefix", { + status: 401, + code: "invalid_token", + }); + } + const ttl = Math.max(1, verified.expiry - nowSeconds()); + exchangeCache.setResult(cacheKey, verified, ttl); + return verified; + })().catch((err) => { + exchangeCache.clear(cacheKey); + throw err; + }); + + exchangeCache.setInflight(cacheKey, inflight); + return inflight; + } return { kind: "oidc", verify: async ({ authorization }) => { const token = bearerToken(authorization); - if (!token || token.split(".").length !== 3) { + if (!token) { throw new WebhookError("not a JWT", { status: 401, code: "invalid_token" }); } - let payload; - try { - ({ payload } = await jwtVerify(token, keyset, { - issuer: jwtIssuer, - audience: jwtAudience, - })); - } catch (err) { - console.warn(`oidc verification failed: ${err.message}`); - throw new WebhookError("oidc verification failed", { - status: 401, - code: "invalid_token", - }); + const composite = splitCompositeApiKey(token); + if (composite) { + return verifyComposite(token, composite); } - if (requiredScopes.length) { - const granted = new Set( - String(payload.scope ?? payload.scp ?? "") - .split(/[\s,]+/) - .filter(Boolean), - ); - const missing = requiredScopes.filter((s) => !granted.has(s)); - if (missing.length) { - throw new WebhookError(`missing required scope(s): ${missing.join(", ")}`, { - status: 403, - code: "insufficient_scope", - }); - } - } - - const usageSubject = payload[subjectClaim]; - if (!usageSubject) { - throw new WebhookError(`token missing ${subjectClaim} claim`, { - status: 401, - code: "invalid_token", - }); + if (token.split(".").length !== 3) { + throw new WebhookError("not a JWT", { status: 401, code: "invalid_token" }); } - const identity = { - issuer: identityIssuer, - client_id: String(payload[clientClaim] ?? jwtAudience), - usage_subject: String(usageSubject), - usage_subject_type: subjectTypeValue, - }; - const expiry = typeof payload.exp === "number" ? payload.exp : nowSeconds() + 60; - return { identity, expiry, raw: payload }; + return verifyJwt(token); }, }; } @@ -306,5 +622,8 @@ export function createEndUserVerifierFromEnv(env) { requiredScopes: (envTrim(env, "OIDC_REQUIRED_SCOPES") || "") .split(/[\s,]+/) .filter(Boolean), + tokenExchangeBaseUrl: envTrim(env, "OIDC_TOKEN_EXCHANGE_BASE_URL") || undefined, + 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 ca19a46..5ee1d97 100644 --- a/identity-webhook/verifiers.test.mjs +++ b/identity-webhook/verifiers.test.mjs @@ -6,6 +6,8 @@ import { createEndUserVerifierFromEnv, createOidcVerifier, discoverJwksUri, + normalizeTokenExchangeBaseUrl, + splitCompositeApiKey, } from "./verifiers.mjs"; const ISSUER = "http://identity-webhook:8090"; @@ -16,16 +18,16 @@ describe("discoverJwksUri", () => { const fetchImpl = async (input) => { seen.push(String(input)); return Response.json({ - issuer: "https://staging.pymthouse.com/api/v1/oidc", - jwks_uri: "https://staging.pymthouse.com/api/v1/oidc/jwks", + issuer: "https://issuer.example/api/v1/oidc", + jwks_uri: "https://issuer.example/api/v1/oidc/jwks", }); }; - const uri = await discoverJwksUri("https://staging.pymthouse.com/api/v1/oidc", { + const uri = await discoverJwksUri("https://issuer.example/api/v1/oidc", { fetchImpl, }); - assert.equal(uri, "https://staging.pymthouse.com/api/v1/oidc/jwks"); + assert.equal(uri, "https://issuer.example/api/v1/oidc/jwks"); assert.deepEqual(seen, [ - "https://staging.pymthouse.com/api/v1/oidc/.well-known/openid-configuration", + "https://issuer.example/api/v1/oidc/.well-known/openid-configuration", ]); }); @@ -312,12 +314,13 @@ describe("createEndUserVerifierFromEnv", () => { ); }); - it("oidc mode does not accept sk_ API keys", async () => { + it("oidc mode rejects bare secrets and non-composite tokens", async () => { const verifier = createEndUserVerifierFromEnv({ IDENTITY_ISSUER: ISSUER, IDENTITY_AUTH_MODE: "oidc", OIDC_ISSUER: "https://idp.test/", OIDC_AUDIENCE: "clearinghouse", + OIDC_TOKEN_EXCHANGE_BASE_URL: "https://billing.test", }); assert.equal(verifier.kind, "oidc"); @@ -325,5 +328,137 @@ describe("createEndUserVerifierFromEnv", () => { () => verifier.verify({ authorization: "Bearer sk_demo" }), /not a JWT/, ); + await assert.rejects( + () => verifier.verify({ authorization: "Bearer deadbeefsecret" }), + /not a JWT/, + ); + await assert.rejects( + () => + verifier.verify({ + authorization: "Bearer app_3b386c81a1db1169fd2c3986_cs_secret", + }), + /not a JWT/, + ); + }); +}); + +describe("splitCompositeApiKey / normalizeTokenExchangeBaseUrl", () => { + const clientId = "app_3b386c81a1db1169fd2c3986"; + + it("parses composite credentials", () => { + assert.deepEqual(splitCompositeApiKey(`${clientId}_deadbeef`), { + publicClientId: clientId, + apiKey: "deadbeef", + }); + assert.deepEqual(splitCompositeApiKey(`${clientId}_key_deadbeef`), { + publicClientId: clientId, + apiKey: "key_deadbeef", + }); + assert.equal(splitCompositeApiKey("deadbeef"), null); + assert.equal(splitCompositeApiKey(`${clientId}.deadbeef`), null); + assert.equal(splitCompositeApiKey(`${clientId}_cs_secret`), null); + assert.equal(splitCompositeApiKey("app_abc_short"), null); + }); + + it("requires https except loopback", () => { + assert.equal( + normalizeTokenExchangeBaseUrl("https://billing.example.com/"), + "https://billing.example.com", + ); + assert.equal( + normalizeTokenExchangeBaseUrl("http://localhost:3000"), + "http://localhost:3000", + ); + assert.throws( + () => normalizeTokenExchangeBaseUrl("http://billing.example.com"), + /must be https/, + ); + }); +}); + +describe("createOidcVerifier composite API key exchange", () => { + async function setup() { + const { publicKey, privateKey } = await generateKeyPair("RS256"); + const jwk = await exportJWK(publicKey); + jwk.kid = "test-key"; + jwk.alg = "RS256"; + jwk.use = "sig"; + const jwks = createLocalJWKSet({ keys: [jwk] }); + return { privateKey, jwks }; + } + + it("exchanges app_*_* then verifies the minted JWT", async () => { + const { privateKey, jwks } = await setup(); + const issuer = "https://idp.test/api/v1/oidc"; + const audience = issuer; + const clientId = "app_3b386c81a1db1169fd2c3986"; + const composite = `${clientId}_deadbeef`; + const minted = await new SignJWT({ + client_id: clientId, + external_user_id: "user-1", + scope: "sign:job", + }) + .setProtectedHeader({ alg: "RS256", kid: "test-key" }) + .setIssuer(issuer) + .setAudience(audience) + .setIssuedAt() + .setExpirationTime("5m") + .sign(privateKey); + + const seen = []; + const fetchImpl = async (input, init) => { + const url = String(input); + seen.push({ url, method: init?.method, body: init?.body }); + assert.equal(url, `https://billing.test/api/v1/apps/${clientId}/oidc/token`); + assert.equal(init?.method, "POST"); + const form = new URLSearchParams(String(init?.body ?? "")); + assert.equal(form.get("subject_token"), "deadbeef"); + assert.equal( + form.get("grant_type"), + "urn:ietf:params:oauth:grant-type:token-exchange", + ); + return Response.json({ access_token: minted, expires_in: 300 }); + }; + + const verifier = createOidcVerifier({ + jwtIssuer: issuer, + jwtAudience: audience, + jwks, + clientClaim: "client_id", + subjectClaim: "external_user_id", + subjectTypeValue: "external_user_id", + requiredScopes: ["sign:job"], + tokenExchangeBaseUrl: "https://billing.test", + fetchImpl, + }); + + const { identity } = await verifier.verify({ + authorization: `Bearer ${composite}`, + }); + assert.equal(identity.client_id, clientId); + assert.equal(identity.usage_subject, "user-1"); + assert.equal(seen.length, 1); + + // Second call hits cache — no extra exchange. + await verifier.verify({ authorization: `Bearer ${composite}` }); + assert.equal(seen.length, 1); + }); + + it("rejects when exchange returns 401", async () => { + const { jwks } = await setup(); + const clientId = "app_3b386c81a1db1169fd2c3986"; + const fetchImpl = async () => + Response.json({ error: "invalid_grant", correlation_id: "c1" }, { status: 401 }); + const verifier = createOidcVerifier({ + jwtIssuer: "https://idp.test/", + jwtAudience: "clearinghouse", + jwks, + tokenExchangeBaseUrl: "https://billing.test", + fetchImpl, + }); + await assert.rejects( + () => verifier.verify({ authorization: `Bearer ${clientId}_bad` }), + /token exchange failed/, + ); }); }); From 378b79b7b33395afa162ff1d05bbfe326739d36f Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Thu, 16 Jul 2026 14:39:31 -0400 Subject: [PATCH 3/7] fix(identity-webhook): address PR #63 review and OIDC hardening Expire stale composite-exchange inflight cache entries, require Bearer scheme, reject non-origin token-exchange URLs, expand JWT algs to RS256/ES256, require exp with 60s clock skew, and normalize OIDC issuer consistently for discovery and jwtVerify. --- README.md | 6 ++- identity-webhook/protocol.mjs | 6 +-- identity-webhook/protocol.test.mjs | 4 +- identity-webhook/verifiers.mjs | 38 ++++++++++---- identity-webhook/verifiers.test.mjs | 80 ++++++++++++++++++++++++++++- 5 files changed, 115 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index a03c924..ccc1fe6 100644 --- a/README.md +++ b/README.md @@ -152,16 +152,18 @@ Shared keys (`WEBHOOK_SECRET`, `KAFKA_BROKERS`, `KAFKA_GATEWAY_TOPIC`) are liste | `DEMO_API_KEYS` | `api_key`, optional | JSON map of extra keys, e.g. `{"sk_other":{"clientId":"app-b","userId":"user-b"}}` | | `USAGE_SUBJECT_TYPE` | `api_key`, optional | Default `api_key_user`; stamped on API-key identities | | `API_KEY_PREFIX` | `api_key`, optional | Default `sk_` | -| `OIDC_ISSUER` | `oidc` | JWT issuer / JWKS host | +| `OIDC_ISSUER` | `oidc` | JWT issuer / JWKS host (trailing slash and surrounding whitespace are normalized) | | `OIDC_AUDIENCE` | `oidc` | Expected JWT audience | | `OIDC_JWKS_URI` | `oidc`, optional | Explicit JWKS override; otherwise resolved via OIDC Discovery (`${OIDC_ISSUER}/.well-known/openid-configuration` → `jwks_uri`) | | `OIDC_CLIENT_CLAIM` | `oidc`, optional | Tenant claim (default `azp`; Auth0 clearinghouse uses `app_client_id`) | | `OIDC_SUBJECT_CLAIM` | `oidc`, optional | End-user claim (default `sub`; Auth0 clearinghouse uses `external_user_id`) | | `OIDC_SUBJECT_TYPE` | `oidc`, optional | Default `oidc_user`; Auth0 clearinghouse uses `external_user_id` | | `OIDC_REQUIRED_SCOPES` | `oidc`, optional | Space- or comma-separated required scopes (e.g. `sign:job`) | -| `OIDC_TOKEN_EXCHANGE_BASE_URL` | `oidc`, optional | Enables composite `Bearer app_<24hex>_`: RFC 8693 exchange at `/api/v1/apps/{clientId}/oidc/token`, then JWT verify | +| `OIDC_TOKEN_EXCHANGE_BASE_URL` | `oidc`, optional | Origin only (no path/query/hash). Enables composite `Bearer app_<24hex>_`: RFC 8693 exchange at `/api/v1/apps/{clientId}/oidc/token`, then JWT verify (`RS256`/`ES256`, required `exp`, 60s clock skew) | | `OIDC_EXCHANGE_M2M_CLIENT_ID` / `OIDC_EXCHANGE_M2M_CLIENT_SECRET` | `oidc`, optional | Optional client credentials for the exchange request (both or neither) | +OIDC JWT verification accepts only `RS256` and `ES256` (asymmetric algorithms). Tokens must include `exp` and the configured subject claim. + `PORT` is set by Compose (`8090`), not `.env`. See the `identity-webhook` block in [`.env.example`](.env.example) for Auth0 vs generic OIDC examples. Signer state (keystore, `.eth-password`, chain DB) is stored under [`remote-signer/data/`](remote-signer/data/), bind-mounted to `/data` in the container. diff --git a/identity-webhook/protocol.mjs b/identity-webhook/protocol.mjs index c4a69d3..c9e45ee 100644 --- a/identity-webhook/protocol.mjs +++ b/identity-webhook/protocol.mjs @@ -42,10 +42,10 @@ export class WebhookError extends Error { } } -/** Strip a `Bearer ` prefix (case-insensitive); returns the raw token otherwise. */ +/** Extract the token from `Bearer ` (RFC 6750); empty if scheme is missing. */ export function bearerToken(authorization) { - const value = (authorization ?? "").trim(); - return value.replace(/^Bearer\s+/i, "").trim(); + const m = /^Bearer\s+(.+)$/i.exec((authorization ?? "").trim()); + return m ? m[1].trim() : ""; } function timingSafeEqualStrings(a, b) { diff --git a/identity-webhook/protocol.test.mjs b/identity-webhook/protocol.test.mjs index 3980467..f10f2ff 100644 --- a/identity-webhook/protocol.test.mjs +++ b/identity-webhook/protocol.test.mjs @@ -47,10 +47,10 @@ const fakeVerifier = { const config = { webhookSecret: SECRET, endUserAuth: fakeVerifier }; describe("bearerToken", () => { - it("strips a case-insensitive Bearer prefix", () => { + it("requires a case-insensitive Bearer scheme", () => { assert.equal(bearerToken("Bearer sk_abc"), "sk_abc"); assert.equal(bearerToken("bearer sk_abc"), "sk_abc"); - assert.equal(bearerToken("sk_abc"), "sk_abc"); + assert.equal(bearerToken("sk_abc"), ""); assert.equal(bearerToken(undefined), ""); }); }); diff --git a/identity-webhook/verifiers.mjs b/identity-webhook/verifiers.mjs index 6962eeb..f5aa237 100644 --- a/identity-webhook/verifiers.mjs +++ b/identity-webhook/verifiers.mjs @@ -24,7 +24,9 @@ const SUBJECT_ACCESS_TOKEN_TYPE = /** Underscore composite: `app_<24hex>_` (no `.` — better copy/select UX). */ const COMPOSITE_API_KEY_RE = /^(app_[a-f0-9]{24})_(.+)$/; const COMPOSITE_CACHE_MAX_TTL_SECONDS = 60; -const ALLOWED_JWT_ALGS = ["RS256"]; +/** Asymmetric algs only — never HS* (shared-secret confusion). */ +const ALLOWED_JWT_ALGS = ["RS256", "ES256"]; +const JWT_CLOCK_TOLERANCE_SECONDS = 60; function nowSeconds() { return Math.floor(Date.now() / 1000); @@ -56,11 +58,13 @@ function isLoopbackHost(hostname) { /** * Validate OIDC_TOKEN_EXCHANGE_BASE_URL: HTTPS required except loopback. + * Must be an origin only — path/query/hash are rejected so a mis-set + * `https://host/exchange` cannot silently become `https://host`. * @param {string} baseUrl * @returns {string} normalized origin (no trailing slash) */ export function normalizeTokenExchangeBaseUrl(baseUrl) { - const trimmed = (baseUrl ?? "").trim().replace(/\/$/, ""); + const trimmed = (baseUrl ?? "").trim(); if (!trimmed) { throw new Error("tokenExchangeBaseUrl is required for composite API key exchange"); } @@ -70,6 +74,12 @@ export function normalizeTokenExchangeBaseUrl(baseUrl) { } catch { throw new Error(`tokenExchangeBaseUrl is not a valid URL: ${trimmed}`); } + const path = url.pathname.replace(/\/$/, "") || "/"; + if (path !== "/" || url.search || url.hash) { + throw new Error( + `tokenExchangeBaseUrl must be an origin only (no path, query, or hash); got ${trimmed}`, + ); + } if (url.protocol === "https:") { return url.origin; } @@ -307,8 +317,8 @@ function mapVerifiedPayloadToIdentity({ usage_subject: String(usageSubject), usage_subject_type: subjectTypeValue, }; - const expiry = typeof payload.exp === "number" ? payload.exp : nowSeconds() + 60; - return { identity, expiry, raw: payload }; + // `exp` is required by jwtVerify(requiredClaims); no forever-valid fallback. + return { identity, expiry: payload.exp, raw: payload }; } async function verifyJwtBearer({ @@ -328,6 +338,8 @@ async function verifyJwtBearer({ issuer: jwtIssuer, audience: jwtAudience, algorithms: ALLOWED_JWT_ALGS, + requiredClaims: ["exp", subjectClaim], + clockTolerance: JWT_CLOCK_TOLERANCE_SECONDS, })); } catch (err) { console.warn(`oidc verification failed: ${err.message}`); @@ -348,7 +360,8 @@ async function verifyJwtBearer({ }); } -function createCompositeExchangeCache() { +/** @internal Exported for unit tests. */ +export function createCompositeExchangeCache() { /** @type {Map }>} */ const cache = new Map(); @@ -358,7 +371,11 @@ function createCompositeExchangeCache() { if (!entry) { return null; } - if (entry.result !== undefined && entry.expiresAt > nowSeconds()) { + if (entry.expiresAt <= nowSeconds()) { + cache.delete(key); + return null; + } + if (entry.result !== undefined) { return entry.result; } if (entry.inflight !== undefined) { @@ -488,7 +505,8 @@ export function createOidcVerifier({ exchangeM2mClientId, exchangeM2mClientSecret, }) { - if (!jwtIssuer) { + const normalizedJwtIssuer = normalizeIssuer(jwtIssuer); + if (!normalizedJwtIssuer) { throw new Error("createOidcVerifier: jwtIssuer is required"); } if (!jwtAudience) { @@ -497,10 +515,10 @@ export function createOidcVerifier({ const keyset = createOidcKeyResolver({ jwks, jwksUri, - jwtIssuer, + jwtIssuer: normalizedJwtIssuer, fetchImpl, }); - const identityIssuer = issuer ?? jwtIssuer; + const identityIssuer = issuer ?? normalizedJwtIssuer; const http = fetchImpl ?? fetch; const exchangeOrigin = tokenExchangeBaseUrl ? normalizeTokenExchangeBaseUrl(tokenExchangeBaseUrl) @@ -518,7 +536,7 @@ export function createOidcVerifier({ return verifyJwtBearer({ token, keyset, - jwtIssuer, + jwtIssuer: normalizedJwtIssuer, jwtAudience, identityIssuer, clientClaim, diff --git a/identity-webhook/verifiers.test.mjs b/identity-webhook/verifiers.test.mjs index b6eed65..c829519 100644 --- a/identity-webhook/verifiers.test.mjs +++ b/identity-webhook/verifiers.test.mjs @@ -3,6 +3,7 @@ import { describe, it } from "node:test"; import { SignJWT, createLocalJWKSet, exportJWK, generateKeyPair } from "jose"; import { createApiKeyVerifier, + createCompositeExchangeCache, createEndUserVerifierFromEnv, createOidcVerifier, discoverJwksUri, @@ -325,7 +326,7 @@ describe("createOidcVerifier (jose, locally-minted JWT)", () => { return { privateKey, jwks }; } - async function mint(privateKey, claims, { aud = "clearinghouse", iss = "https://idp.test/" } = {}) { + async function mint(privateKey, claims, { aud = "clearinghouse", iss = "https://idp.test" } = {}) { return new SignJWT(claims) .setProtectedHeader({ alg: "RS256", kid: "test-key" }) .setIssuer(iss) @@ -354,6 +355,37 @@ describe("createOidcVerifier (jose, locally-minted JWT)", () => { assert.equal(typeof expiry, "number"); }); + it("normalizes jwtIssuer whitespace and trailing slash for verification", async () => { + const { privateKey, jwks } = await setup(); + const token = await mint(privateKey, { sub: "user-b", azp: "app-b" }); + const verifier = createOidcVerifier({ + jwtIssuer: " https://idp.test/ ", + jwtAudience: "clearinghouse", + jwks, + }); + const { identity } = await verifier.verify({ authorization: `Bearer ${token}` }); + assert.equal(identity.usage_subject, "user-b"); + }); + + it("rejects a token without exp", async () => { + const { privateKey, jwks } = await setup(); + const token = await new SignJWT({ sub: "user-b", azp: "app-b" }) + .setProtectedHeader({ alg: "RS256", kid: "test-key" }) + .setIssuer("https://idp.test") + .setAudience("clearinghouse") + .setIssuedAt() + .sign(privateKey); + const verifier = createOidcVerifier({ + jwtIssuer: "https://idp.test", + jwtAudience: "clearinghouse", + jwks, + }); + await assert.rejects( + () => verifier.verify({ authorization: `Bearer ${token}` }), + /oidc verification failed/, + ); + }); + it("rejects a wrong audience", async () => { const { privateKey, jwks } = await setup(); const token = await mint(privateKey, { sub: "user-b", azp: "app-b" }, { aud: "other-api" }); @@ -464,7 +496,7 @@ describe("createEndUserVerifierFromEnv", () => { const token = await new SignJWT({ azp: "app-b", scope: "other" }) .setProtectedHeader({ alg: "RS256", kid: "k" }) - .setIssuer("https://idp.test/") + .setIssuer("https://idp.test") .setAudience("clearinghouse") .setSubject("user-b") .setIssuedAt() @@ -545,6 +577,50 @@ describe("splitCompositeApiKey / normalizeTokenExchangeBaseUrl", () => { /must be https/, ); }); + + it("rejects path, query, or hash (origin only)", () => { + assert.throws( + () => normalizeTokenExchangeBaseUrl("https://billing.example.com/exchange"), + /origin only/, + ); + assert.throws( + () => normalizeTokenExchangeBaseUrl("https://billing.example.com/?x=1"), + /origin only/, + ); + assert.throws( + () => normalizeTokenExchangeBaseUrl("https://billing.example.com/#frag"), + /origin only/, + ); + }); +}); + +describe("createCompositeExchangeCache", () => { + it("expires inflight entries the same way as results", async () => { + const cache = createCompositeExchangeCache(); + const realNow = Date.now; + let fakeMs = 1_700_000_000_000; + Date.now = () => fakeMs; + try { + let resolveHang; + const hang = new Promise((resolve) => { + resolveHang = resolve; + }); + cache.setInflight("k", hang); + assert.equal(cache.get("k"), hang); + + fakeMs += 61_000; + assert.equal(cache.get("k"), null); + + const result = { ok: true }; + cache.setResult("k", result, 30); + assert.deepEqual(cache.get("k"), result); + fakeMs += 31_000; + assert.equal(cache.get("k"), null); + resolveHang(); + } finally { + Date.now = realNow; + } + }); }); describe("createOidcVerifier composite API key exchange", () => { From 45686cc4d30a4f177986d0ca4d5df49e22ae413e Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Thu, 16 Jul 2026 16:05:05 -0400 Subject: [PATCH 4/7] fix(identity-webhook): accept trailing-slash iss; guard exchange cache races Allow jwtVerify to match iss with or without a trailing slash after issuer normalization, and only clear/set composite-exchange cache entries when they still reference the same inflight promise. --- identity-webhook/verifiers.mjs | 27 +++++++++++++++++++---- identity-webhook/verifiers.test.mjs | 33 +++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/identity-webhook/verifiers.mjs b/identity-webhook/verifiers.mjs index f5aa237..98ce466 100644 --- a/identity-webhook/verifiers.mjs +++ b/identity-webhook/verifiers.mjs @@ -334,8 +334,10 @@ async function verifyJwtBearer({ }) { let payload; try { + // Accept iss with or without a trailing slash (OIDC issuers vary); jwtIssuer is + // already normalized (no trailing slash) for discovery/caching. ({ payload } = await jwtVerify(token, keyset, { - issuer: jwtIssuer, + issuer: [jwtIssuer, `${jwtIssuer}/`], audience: jwtAudience, algorithms: ALLOWED_JWT_ALGS, requiredClaims: ["exp", subjectClaim], @@ -394,6 +396,22 @@ export function createCompositeExchangeCache() { const ttl = Math.max(1, Math.min(ttlSeconds, COMPOSITE_CACHE_MAX_TTL_SECONDS)); cache.set(key, { expiresAt: nowSeconds() + ttl, result }); }, + /** Only clear when this promise is still the cached inflight (avoid clobbering a newer entry). */ + clearInflight(key, promise) { + const entry = cache.get(key); + if (entry?.inflight === promise) { + cache.delete(key); + } + }, + /** Only store a result when this promise is still the cached inflight. */ + setResultForInflight(key, promise, result, ttlSeconds) { + const entry = cache.get(key); + if (entry?.inflight !== promise) { + return; + } + const ttl = Math.max(1, Math.min(ttlSeconds, COMPOSITE_CACHE_MAX_TTL_SECONDS)); + cache.set(key, { expiresAt: nowSeconds() + ttl, result }); + }, clear(key) { cache.delete(key); }, @@ -560,7 +578,8 @@ export function createOidcVerifier({ return cached; } - const inflight = (async () => { + let inflight; + inflight = (async () => { const accessToken = await exchangeCompositeApiKey({ exchangeBaseUrl: exchangeOrigin, publicClientId: parts.publicClientId, @@ -579,10 +598,10 @@ export function createOidcVerifier({ }); } const ttl = Math.max(1, verified.expiry - nowSeconds()); - exchangeCache.setResult(cacheKey, verified, ttl); + exchangeCache.setResultForInflight(cacheKey, inflight, verified, ttl); return verified; })().catch((err) => { - exchangeCache.clear(cacheKey); + exchangeCache.clearInflight(cacheKey, inflight); throw err; }); diff --git a/identity-webhook/verifiers.test.mjs b/identity-webhook/verifiers.test.mjs index c829519..a9ac886 100644 --- a/identity-webhook/verifiers.test.mjs +++ b/identity-webhook/verifiers.test.mjs @@ -367,6 +367,22 @@ describe("createOidcVerifier (jose, locally-minted JWT)", () => { assert.equal(identity.usage_subject, "user-b"); }); + it("accepts tokens whose iss claim has a trailing slash", async () => { + const { privateKey, jwks } = await setup(); + const token = await mint( + privateKey, + { sub: "user-b", azp: "app-b" }, + { iss: "https://idp.test/" }, + ); + const verifier = createOidcVerifier({ + jwtIssuer: "https://idp.test", + jwtAudience: "clearinghouse", + jwks, + }); + const { identity } = await verifier.verify({ authorization: `Bearer ${token}` }); + assert.equal(identity.usage_subject, "user-b"); + }); + it("rejects a token without exp", async () => { const { privateKey, jwks } = await setup(); const token = await new SignJWT({ sub: "user-b", azp: "app-b" }) @@ -621,6 +637,23 @@ describe("createCompositeExchangeCache", () => { Date.now = realNow; } }); + + it("clearInflight / setResultForInflight only touch the matching promise", async () => { + const cache = createCompositeExchangeCache(); + const older = Promise.resolve("old"); + const newer = Promise.resolve("new"); + cache.setInflight("k", older); + cache.setInflight("k", newer); + + cache.clearInflight("k", older); + assert.equal(cache.get("k"), newer); + + cache.setResultForInflight("k", older, { from: "old" }, 30); + assert.equal(cache.get("k"), newer); + + cache.setResultForInflight("k", newer, { from: "new" }, 30); + assert.deepEqual(cache.get("k"), { from: "new" }); + }); }); describe("createOidcVerifier composite API key exchange", () => { From 46373cb7547ef1da42c329958b00d5024aee8d39 Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Thu, 16 Jul 2026 16:09:27 -0400 Subject: [PATCH 5/7] fix(identity-webhook): improve bearerToken function for better token extraction Refactor the bearerToken function to enhance token extraction from the authorization header. The new implementation checks for the "Bearer" scheme and handles leading spaces more robustly. Additionally, add tests to cover various cases, including malformed input and trailing spaces, ensuring the function behaves correctly under different scenarios. --- identity-webhook/protocol.mjs | 17 +++++++++++++++-- identity-webhook/protocol.test.mjs | 7 +++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/identity-webhook/protocol.mjs b/identity-webhook/protocol.mjs index c9e45ee..97d33fd 100644 --- a/identity-webhook/protocol.mjs +++ b/identity-webhook/protocol.mjs @@ -44,8 +44,21 @@ export class WebhookError extends Error { /** Extract the token from `Bearer ` (RFC 6750); empty if scheme is missing. */ export function bearerToken(authorization) { - const m = /^Bearer\s+(.+)$/i.exec((authorization ?? "").trim()); - return m ? m[1].trim() : ""; + const value = (authorization ?? "").trim(); + const scheme = "Bearer"; + if ( + value.length <= scheme.length || + value.slice(0, scheme.length).toLowerCase() !== scheme.toLowerCase() || + value[scheme.length] !== " " + ) { + return ""; + } + + let tokenStart = scheme.length + 1; + while (value[tokenStart] === " ") { + tokenStart += 1; + } + return value.slice(tokenStart); } function timingSafeEqualStrings(a, b) { diff --git a/identity-webhook/protocol.test.mjs b/identity-webhook/protocol.test.mjs index f10f2ff..425c72a 100644 --- a/identity-webhook/protocol.test.mjs +++ b/identity-webhook/protocol.test.mjs @@ -50,9 +50,16 @@ describe("bearerToken", () => { it("requires a case-insensitive Bearer scheme", () => { assert.equal(bearerToken("Bearer sk_abc"), "sk_abc"); assert.equal(bearerToken("bearer sk_abc"), "sk_abc"); + assert.equal(bearerToken("Bearer sk_abc"), "sk_abc"); assert.equal(bearerToken("sk_abc"), ""); + assert.equal(bearerToken("Bearer"), ""); + assert.equal(bearerToken("Bearer\tsk_abc"), ""); assert.equal(bearerToken(undefined), ""); }); + + it("handles long malformed input without regular-expression backtracking", () => { + assert.equal(bearerToken(`NotBearer ${"a".repeat(100_000)}`), ""); + }); }); describe("authenticateWebhookCaller", () => { From 4b8d10c8d8b30495f991f91dd43a7588690456f3 Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Thu, 16 Jul 2026 16:10:55 -0400 Subject: [PATCH 6/7] refactor(identity-webhook): simplify bearerToken function for token extraction Update the bearerToken function to utilize a regular expression for more concise and efficient token extraction from the authorization header. This change improves readability and maintains functionality by ensuring proper handling of the "Bearer" scheme and leading spaces. --- identity-webhook/protocol.mjs | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/identity-webhook/protocol.mjs b/identity-webhook/protocol.mjs index 97d33fd..3fe3303 100644 --- a/identity-webhook/protocol.mjs +++ b/identity-webhook/protocol.mjs @@ -44,21 +44,8 @@ export class WebhookError extends Error { /** Extract the token from `Bearer ` (RFC 6750); empty if scheme is missing. */ export function bearerToken(authorization) { - const value = (authorization ?? "").trim(); - const scheme = "Bearer"; - if ( - value.length <= scheme.length || - value.slice(0, scheme.length).toLowerCase() !== scheme.toLowerCase() || - value[scheme.length] !== " " - ) { - return ""; - } - - let tokenStart = scheme.length + 1; - while (value[tokenStart] === " ") { - tokenStart += 1; - } - return value.slice(tokenStart); + const match = /^Bearer +([A-Za-z0-9._~+/-]+=*)$/i.exec((authorization ?? "").trim()); + return match?.[1] ?? ""; } function timingSafeEqualStrings(a, b) { From e0be4d77aa9a922dd98f7ceb798264c7f761b28c Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Thu, 16 Jul 2026 18:45:58 -0400 Subject: [PATCH 7/7] fix(identity-webhook): reject exchange URL userinfo; document HTTPS Address remaining PR #63 Copilot review: reject username/password in OIDC_TOKEN_EXCHANGE_BASE_URL (origin drops them silently), document HTTPS-except-loopback in README/.env.example, and cover RFC 6750 whitespace rejection in bearerToken tests. --- .env.example | 2 +- README.md | 2 +- identity-webhook/protocol.test.mjs | 5 +++++ identity-webhook/verifiers.mjs | 10 ++++++++-- identity-webhook/verifiers.test.mjs | 11 +++++++++++ 5 files changed, 26 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index 6519be1..b9f9f67 100644 --- a/.env.example +++ b/.env.example @@ -44,7 +44,7 @@ USAGE_SUBJECT_TYPE=api_key_user # OIDC_SUBJECT_CLAIM=sub # OIDC_SUBJECT_TYPE=oidc_user # OIDC_REQUIRED_SCOPES= -# OIDC_TOKEN_EXCHANGE_BASE_URL= # optional; enables app_<24hex>_ composite key exchange +# OIDC_TOKEN_EXCHANGE_BASE_URL= # optional; origin only (no path/query/hash/userinfo); https except loopback; enables app_<24hex>_ exchange # OIDC_EXCHANGE_M2M_CLIENT_ID= # OIDC_EXCHANGE_M2M_CLIENT_SECRET= diff --git a/README.md b/README.md index ccc1fe6..a3dc09f 100644 --- a/README.md +++ b/README.md @@ -159,7 +159,7 @@ Shared keys (`WEBHOOK_SECRET`, `KAFKA_BROKERS`, `KAFKA_GATEWAY_TOPIC`) are liste | `OIDC_SUBJECT_CLAIM` | `oidc`, optional | End-user claim (default `sub`; Auth0 clearinghouse uses `external_user_id`) | | `OIDC_SUBJECT_TYPE` | `oidc`, optional | Default `oidc_user`; Auth0 clearinghouse uses `external_user_id` | | `OIDC_REQUIRED_SCOPES` | `oidc`, optional | Space- or comma-separated required scopes (e.g. `sign:job`) | -| `OIDC_TOKEN_EXCHANGE_BASE_URL` | `oidc`, optional | Origin only (no path/query/hash). Enables composite `Bearer app_<24hex>_`: RFC 8693 exchange at `/api/v1/apps/{clientId}/oidc/token`, then JWT verify (`RS256`/`ES256`, required `exp`, 60s clock skew) | +| `OIDC_TOKEN_EXCHANGE_BASE_URL` | `oidc`, optional | Origin only (no path/query/hash/userinfo). HTTPS required except loopback (`localhost` / `127.0.0.1` / `::1`). Enables composite `Bearer app_<24hex>_`: RFC 8693 exchange at `/api/v1/apps/{clientId}/oidc/token`, then JWT verify (`RS256`/`ES256`, required `exp`, 60s clock skew) | | `OIDC_EXCHANGE_M2M_CLIENT_ID` / `OIDC_EXCHANGE_M2M_CLIENT_SECRET` | `oidc`, optional | Optional client credentials for the exchange request (both or neither) | OIDC JWT verification accepts only `RS256` and `ES256` (asymmetric algorithms). Tokens must include `exp` and the configured subject claim. diff --git a/identity-webhook/protocol.test.mjs b/identity-webhook/protocol.test.mjs index 425c72a..2c3ee53 100644 --- a/identity-webhook/protocol.test.mjs +++ b/identity-webhook/protocol.test.mjs @@ -57,6 +57,11 @@ describe("bearerToken", () => { assert.equal(bearerToken(undefined), ""); }); + it("rejects tokens with internal whitespace (RFC 6750 b64token)", () => { + assert.equal(bearerToken("Bearer a b"), ""); + assert.equal(bearerToken("Bearer sk_abc extra"), ""); + }); + it("handles long malformed input without regular-expression backtracking", () => { assert.equal(bearerToken(`NotBearer ${"a".repeat(100_000)}`), ""); }); diff --git a/identity-webhook/verifiers.mjs b/identity-webhook/verifiers.mjs index 98ce466..571f9c8 100644 --- a/identity-webhook/verifiers.mjs +++ b/identity-webhook/verifiers.mjs @@ -58,8 +58,9 @@ function isLoopbackHost(hostname) { /** * Validate OIDC_TOKEN_EXCHANGE_BASE_URL: HTTPS required except loopback. - * Must be an origin only — path/query/hash are rejected so a mis-set - * `https://host/exchange` cannot silently become `https://host`. + * Must be an origin only — path/query/hash/userinfo are rejected so a mis-set + * `https://host/exchange` or `https://user:pass@host` cannot silently become + * `https://host`. * @param {string} baseUrl * @returns {string} normalized origin (no trailing slash) */ @@ -74,6 +75,11 @@ export function normalizeTokenExchangeBaseUrl(baseUrl) { } catch { throw new Error(`tokenExchangeBaseUrl is not a valid URL: ${trimmed}`); } + if (url.username || url.password) { + throw new Error( + `tokenExchangeBaseUrl must not include username or password; got ${trimmed}`, + ); + } const path = url.pathname.replace(/\/$/, "") || "/"; if (path !== "/" || url.search || url.hash) { throw new Error( diff --git a/identity-webhook/verifiers.test.mjs b/identity-webhook/verifiers.test.mjs index a9ac886..3af3331 100644 --- a/identity-webhook/verifiers.test.mjs +++ b/identity-webhook/verifiers.test.mjs @@ -608,6 +608,17 @@ describe("splitCompositeApiKey / normalizeTokenExchangeBaseUrl", () => { /origin only/, ); }); + + it("rejects embedded username or password", () => { + assert.throws( + () => normalizeTokenExchangeBaseUrl("https://user:pass@billing.example.com"), + /must not include username or password/, + ); + assert.throws( + () => normalizeTokenExchangeBaseUrl("https://user@billing.example.com"), + /must not include username or password/, + ); + }); }); describe("createCompositeExchangeCache", () => {