UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

164 lines • 9.02 kB
/** * jwksIdentity — verify the caller's bearer token against a published key set. * * Pattern: Adapter (GoF) over `jose`, behind the vendor-neutral * {@link IdentityVerifier} port. * Role: the ONE identity verifier this release ships. It covers cloud IdPs * and on-prem ones with the same code, because JWKS is the same * protocol in both: a URL that publishes signing keys, and tokens * signed by one of them. * Emits: nothing. The door that called it reports the refusal — by CLASS. * * **Status: field-validated — an independent field trial, 2026-08-13.** The * trial pointed this adapter at a remote JWKS endpoint during a live, * cloud-backed host run (a real `nodeHost` in a live Google Cloud project, * sessions in a real Firestore) and verified an RS256 token end to end: issuer, * audience, expiry, subject and roles. A valid token paired with a CLAIMED * other identity was rejected; missing, expired and wrong-audience tokens each * answered 401; four different bearer tokens appeared in no captured response * body or error; and the remote key set was fetched once over the network, then * served from the cache below. * * Two bounds on that rung, so nobody reads more into it than happened. The * finding does not name the identity provider, so what is field-proven is the * PROTOCOL path — a real fetch, a real `jose` verification, a real cache — and * not interop with any particular commercial IdP. And the provider behind that * door was a deterministic mock: "401 before any model call" was measured as * *rejected requests made zero provider calls*, which is the cheapest-refusal * claim and not a statement about a real completion's cost. * * ── What it checks, and what it refuses to pretend to check ───────────────── * Signature (against the key the token's `kid` names, fetched and cached from * `jwksUrl`), `iss`, `aud`, `exp` and `nbf`. `exp` is REQUIRED: a signed token * with no lifetime is refused as `unverifiable` rather than honoured forever. * That is the whole list, and it is the whole list on purpose: * * • **It is not an authorization decision.** A verified token proves WHO, not * WHAT-THEY-MAY-DO. Roles and claims come back on the result so your own * policy can decide; this adapter never reads them. * • **It does not ask whether the token stands for a PERSON.** An app's own * client-credentials token verifies here like a person's. When that * matters — any IdP where every registered app can obtain a token for your * API, which includes Entra ID — use `oidcIdentity`, whose person test * refuses app-only tokens by name. * • **It does not check revocation.** A JWT is valid until it expires, and no * amount of key fetching changes that. Short lifetimes are the answer; an * adapter that implied otherwise would be selling a guarantee the protocol * does not make. * • **`alg: none` cannot get through**, because a JWKS resolver has no key for * it — verified against the real library rather than assumed (see the pin * test). Neither can a token signed with an algorithm outside `algorithms`. * * ── The token never comes back out ────────────────────────────────────────── * Every failure is re-raised as {@link IdentityNotVerifiedError} carrying only * the CLASS — `expired`, `wrong-audience`, `wrong-issuer`, `not-yet-valid`, * `unverifiable`. The library's own message is dropped, and the original is * deliberately NOT attached as `cause`: a cause travels into every serializer * that walks own properties, and one `JSON.stringify` would undo all of this. * (The `sdkFailure` law, applied to a credential rather than to a cloud SDK.) * * A key set this adapter could not FETCH is a different fact and gets a * different class: {@link VerifierUnavailableError}, which the host answers 503 * rather than 401. Your IdP being down is not the caller presenting a bad * token, and telling every client to re-authenticate against an unreachable * provider is the wrong instruction at the worst moment. * * @example * const host = await standingAgent({ * agent, sessions, host: nodeHost({ port: 8080 }), * identity: { verify: jwksIdentity({ * jwksUrl: 'https://idp.example.com/.well-known/jwks.json', * issuer: 'https://idp.example.com/', * audience: 'my-api', * }).verify }, * }); */ import { IdentityNotVerifiedError } from '../../hosting/errors.js'; import { claimAt, rolesOf } from './verify/claims.js'; import { DEFAULT_ALGORITHMS, loadJose, verifySignedToken, } from './verify/jwtCore.js'; export { MissingJwksSupportError } from './verify/jwtCore.js'; export function jwksIdentity(options) { const { jwksUrl } = options; if (typeof jwksUrl !== 'string' || jwksUrl.trim().length === 0) { throw new TypeError(`[identity] jwksIdentity({ jwksUrl: ${JSON.stringify(jwksUrl)} }) needs the URL your ` + `identity provider publishes its signing keys at — usually ` + `'<issuer>/.well-known/jwks.json'.`); } let url; try { url = new URL(jwksUrl); } catch { throw new TypeError(`[identity] jwksIdentity({ jwksUrl: '${jwksUrl}' }) is not an absolute URL. Key sets are ` + `fetched over the network, so a relative path has nothing to resolve against.`); } if (emptyName(options.issuer)) { throw new TypeError(`[identity] jwksIdentity needs an \`issuer\`. A verifier that accepted any issuer would ` + `accept a token minted by anybody who publishes a key set — which is not verification, ` + `it is a signature check with no opinion about who signed.`); } if (emptyName(options.audience)) { throw new TypeError(`[identity] jwksIdentity needs an \`audience\` — this API's name at your identity ` + `provider. Without it a token minted for a DIFFERENT service verifies here, which is ` + `the confused-deputy shape: a valid credential, honoured by the wrong door.`); } const userIdClaim = options.userIdClaim ?? 'sub'; const rolesClaim = options.rolesClaim ?? 'roles'; const rolesFormat = options.rolesFormat ?? 'list'; if (rolesFormat !== 'list' && rolesFormat !== 'space-delimited') { throw new TypeError(`[identity] jwksIdentity({ rolesFormat: ${JSON.stringify(rolesFormat)} }) — the reading ` + `of a roles string is 'list' (one string is one role; the default) or ` + `'space-delimited' (split on whitespace).`); } const algorithms = options.algorithms ?? DEFAULT_ALGORITHMS; /** The key-set resolver, built once and reused — `jose` caches inside it. */ let resolver; const getResolver = () => (resolver ??= (async () => { const jose = options.backend ?? (await loadJose()); const keys = jose.createRemoteJWKSet(url, { ...(options.cacheMaxAgeMs !== undefined && { cacheMaxAge: options.cacheMaxAgeMs }), ...(options.fetchTimeoutMs !== undefined && { timeoutDuration: options.fetchTimeoutMs }), }); return { jose, keys }; })().catch((err) => { // A failed BUILD is not cached — a bundler hiccup or a transient import // failure must not permanently poison the verifier. resolver = undefined; throw err; })); return { async verify(token) { const { jose, keys } = await getResolver(); const payload = await verifySignedToken(jose, keys, token, { issuer: options.issuer, audience: options.audience, algorithms, ...(options.clockToleranceSeconds !== undefined && { clockToleranceSeconds: options.clockToleranceSeconds, }), verifierName: 'jwksIdentity', }); const userId = payload[userIdClaim]; if (typeof userId !== 'string' || userId.length === 0) { // A perfectly valid token that does not say who it is about. Refused // rather than served anonymously: "verified, subject unknown" is a // state nothing downstream could act on honestly. throw new IdentityNotVerifiedError('unverifiable', false); } const roles = rolesOf(claimAt(payload, rolesClaim), rolesFormat); return { userId, ...(roles !== undefined && { roles }), claims: payload, }; }, }; } function emptyName(value) { if (typeof value === 'string') return value.trim().length === 0; if (Array.isArray(value)) return value.length === 0 || value.some((v) => typeof v !== 'string'); return true; } //# sourceMappingURL=jwks.js.map