diff --git a/.env.example b/.env.example index 82e182f..b9f9f67 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; origin only (no path/query/hash/userinfo); https except loopback; enables app_<24hex>_ 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..a3dc09f 100644 --- a/README.md +++ b/README.md @@ -152,13 +152,17 @@ 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 | 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. `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/protocol.mjs b/identity-webhook/protocol.mjs index c4a69d3..3fe3303 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 match = /^Bearer +([A-Za-z0-9._~+/-]+=*)$/i.exec((authorization ?? "").trim()); + return match?.[1] ?? ""; } function timingSafeEqualStrings(a, b) { diff --git a/identity-webhook/protocol.test.mjs b/identity-webhook/protocol.test.mjs index 3980467..2c3ee53 100644 --- a/identity-webhook/protocol.test.mjs +++ b/identity-webhook/protocol.test.mjs @@ -47,12 +47,24 @@ 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("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("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)}`), ""); + }); }); describe("authenticateWebhookCaller", () => { diff --git a/identity-webhook/verifiers.mjs b/identity-webhook/verifiers.mjs index 09d636e..571f9c8 100644 --- a/identity-webhook/verifiers.mjs +++ b/identity-webhook/verifiers.mjs @@ -6,19 +6,120 @@ * { 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; +/** 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); } +/** + * 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. + * 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) + */ +export function normalizeTokenExchangeBaseUrl(baseUrl) { + const trimmed = (baseUrl ?? "").trim(); + 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.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( + `tokenExchangeBaseUrl must be an origin only (no path, query, or hash); got ${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. @@ -184,12 +285,230 @@ 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, + }; + // `exp` is required by jwtVerify(requiredClaims); no forever-valid fallback. + return { identity, expiry: payload.exp, raw: payload }; +} + +async function verifyJwtBearer({ + token, + keyset, + jwtIssuer, + jwtAudience, + identityIssuer, + clientClaim, + subjectClaim, + subjectTypeValue, + requiredScopes, +}) { + 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, `${jwtIssuer}/`], + audience: jwtAudience, + algorithms: ALLOWED_JWT_ALGS, + requiredClaims: ["exp", subjectClaim], + clockTolerance: JWT_CLOCK_TOLERANCE_SECONDS, + })); + } 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, + }); +} + +/** @internal Exported for unit tests. */ +export function createCompositeExchangeCache() { + /** @type {Map }>} */ + const cache = new Map(); + + return { + get(key) { + const entry = cache.get(key); + if (!entry) { + return null; + } + if (entry.expiresAt <= nowSeconds()) { + cache.delete(key); + return null; + } + if (entry.result !== undefined) { + 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 }); + }, + /** 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); + }, + }; +} + +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) @@ -206,8 +525,12 @@ export function createOidcVerifier({ subjectTypeValue = "oidc_user", requiredScopes = [], fetchImpl, + tokenExchangeBaseUrl, + exchangeM2mClientId, + exchangeM2mClientSecret, }) { - if (!jwtIssuer) { + const normalizedJwtIssuer = normalizeIssuer(jwtIssuer); + if (!normalizedJwtIssuer) { throw new Error("createOidcVerifier: jwtIssuer is required"); } if (!jwtAudience) { @@ -216,64 +539,100 @@ 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) + : 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: normalizedJwtIssuer, + 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; + } + + let inflight; + 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.setResultForInflight(cacheKey, inflight, verified, ttl); + return verified; + })().catch((err) => { + exchangeCache.clearInflight(cacheKey, inflight); + 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", - }); - } - - 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 composite = splitCompositeApiKey(token); + if (composite) { + return verifyComposite(token, composite); } - 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); }, }; } @@ -333,5 +692,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 d846d91..3af3331 100644 --- a/identity-webhook/verifiers.test.mjs +++ b/identity-webhook/verifiers.test.mjs @@ -3,9 +3,12 @@ import { describe, it } from "node:test"; import { SignJWT, createLocalJWKSet, exportJWK, generateKeyPair } from "jose"; import { createApiKeyVerifier, + createCompositeExchangeCache, createEndUserVerifierFromEnv, createOidcVerifier, discoverJwksUri, + normalizeTokenExchangeBaseUrl, + splitCompositeApiKey, } from "./verifiers.mjs"; const ISSUER = "http://identity-webhook:8090"; @@ -323,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) @@ -352,6 +355,53 @@ 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("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" }) + .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" }); @@ -462,7 +512,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() @@ -483,12 +533,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"); @@ -496,5 +547,209 @@ 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/, + ); + }); + + 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/, + ); + }); + + 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", () => { + 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; + } + }); + + 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", () => { + 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/, + ); }); });