agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
150 lines • 8.43 kB
TypeScript
/**
* 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 type { IdentityVerifier } from '../../hosting/identityVerification.js';
import { type ClaimPath, type RolesFormat } from './verify/claims.js';
import { type JoseBackend } from './verify/jwtCore.js';
export { MissingJwksSupportError } from './verify/jwtCore.js';
export type { JoseBackend } from './verify/jwtCore.js';
export type { ClaimPath, RolesFormat } from './verify/claims.js';
export interface JwksIdentityOptions {
/** Where the signing keys are published — an IdP's
* `.../.well-known/jwks.json`. */
readonly jwksUrl: string;
/** The `iss` every accepted token must carry. Required: a verifier that
* accepted any issuer would accept a token minted by anybody who publishes
* a key set. */
readonly issuer: string | readonly string[];
/** The `aud` every accepted token must carry — THIS API's name at the IdP.
* Required for the same reason: a token minted for a different service is
* a valid token, and honouring it is confused-deputy by construction. */
readonly audience: string | readonly string[];
/**
* Which claim names the user. Default `'sub'` — the subject, which is what
* `sub` means. Point it at `'email'`, `'oid'` or a namespaced claim when your
* IdP's stable id lives elsewhere; a token whose chosen claim is missing or
* blank is `unverifiable`, never silently anonymous.
*/
readonly userIdClaim?: string;
/**
* Which claim carries roles. Default `'roles'`. A top-level name — a full
* URI is fine, and a dotted name is never split — or a PATH into nested
* objects: `['realm_access', 'roles']` for Keycloak's realm roles. ABSENT
* on the result when the claim is absent. Never invented.
*/
readonly rolesClaim?: ClaimPath;
/**
* How a roles claim holding a single STRING is read.
*
* - `'list'` (default): the string is ONE role. `"Not bot-users"` is the
* role `Not bot-users`, so it can never satisfy a check for `bot-users`.
* An array contributes each string entry as one role.
* - `'space-delimited'`: the string is split on whitespace — the reading
* every release before this one used. Choose it only for a claim that
* really is a space-delimited list (an OAuth `scope` read as roles).
*/
readonly rolesFormat?: RolesFormat;
/**
* Signature algorithms to accept. Default: the RSA and ECDSA families
* (`RS256/384/512`, `PS256/384/512`, `ES256/384/512`).
*
* Symmetric algorithms (`HS*`) are deliberately NOT in the default: with a
* JWKS the key is public, and a verifier that accepts an HMAC alg over a
* published key is the classic algorithm-confusion forgery. Name them
* explicitly if a deployment genuinely needs one and you know why.
*/
readonly algorithms?: readonly string[];
/** Seconds of clock skew tolerated on `exp`/`nbf`. Default 0 — the library
* does not decide how much drift your fleet has. */
readonly clockToleranceSeconds?: number;
/** How long a fetched key set is reused before re-fetching, in ms. Default
* is `jose`'s own (10 minutes). */
readonly cacheMaxAgeMs?: number;
/** Timeout for the key-set fetch, in ms. Default is `jose`'s own (5s). */
readonly fetchTimeoutMs?: number;
/**
* An ALREADY-IMPORTED `jose`. Supply it and the lazy load never happens —
* which is what makes this work in a BUNDLED app, where a bare specifier
* reaches the runtime unresolved:
*
* ```ts
* import * as jose from 'jose';
* jwksIdentity({ jwksUrl, issuer, audience, backend: jose });
* ```
*/
readonly backend?: JoseBackend;
}
export declare function jwksIdentity(options: JwksIdentityOptions): IdentityVerifier;
//# sourceMappingURL=jwks.d.ts.map