agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
169 lines • 9.37 kB
JavaScript
;
/**
* 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 },
* });
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.jwksIdentity = exports.MissingJwksSupportError = void 0;
const errors_js_1 = require("../../hosting/errors.js");
const claims_js_1 = require("./verify/claims.js");
const jwtCore_js_1 = require("./verify/jwtCore.js");
var jwtCore_js_2 = require("./verify/jwtCore.js");
Object.defineProperty(exports, "MissingJwksSupportError", { enumerable: true, get: function () { return jwtCore_js_2.MissingJwksSupportError; } });
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 ?? jwtCore_js_1.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 (0, jwtCore_js_1.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 (0, jwtCore_js_1.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 errors_js_1.IdentityNotVerifiedError('unverifiable', false);
}
const roles = (0, claims_js_1.rolesOf)((0, claims_js_1.claimAt)(payload, rolesClaim), rolesFormat);
return {
userId,
...(roles !== undefined && { roles }),
claims: payload,
};
},
};
}
exports.jwksIdentity = jwksIdentity;
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