agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
303 lines • 13.9 kB
JavaScript
;
/**
* oidcIdentity — verify a PERSON's access token from the company's OpenID
* Connect identity provider (AD FS 2016+, Entra ID, Keycloak, Okta, Ping).
*
* Pattern: Adapter over `jose` + OpenID discovery, behind the vendor-neutral
* {@link IdentityVerifier} port. The `oidc-token` strategy's verifier.
* Role: everything `jwksIdentity` checks (signature, `iss`, `aud`, `exp`
* required, `nbf`), plus what makes a signed token a PERSON:
*
* 1. **Discovery, then pinning, with failures classified.** The issuer's
* `/.well-known/openid-configuration` is read before any token is
* accepted. An unreachable IdP is an OUTAGE (503, retried on later
* requests); a 404, a body that is not JSON, a document without
* `jwks_uri` or naming another issuer is a CONFIG ERROR (every request
* answers 503 "misconfigured", and `identityFromConfig` refuses to boot).
* 2. **Issuer.** `iss` must equal the configured issuer, or AD FS's
* `access_token_issuer` from a document that passed (1).
* 3. **`exp` is required.** A token with no lifetime is refused.
* 4. **The stable id claim is required config** — no `sub` default. On both
* Microsoft IdPs `sub` is pairwise per application, not the person.
* 5. **The person test** (`verify/personTest.ts`): the required scope, no
* app-only shape, an allowed client. Refusals `not-a-user-token` and
* `wrong-client`.
* 6. **Roles: a string is ONE role**; a path reads nested roles.
* 7. **Unknown is not none**: a roles claim replaced by an overage pointer
* is refused as `roles-unknown`.
* 8. **Clock tolerance** defaults to 60 s.
*
* Emits nothing. The door that called it reports the refusal — by CLASS. The
* token, its claims and the IdP's own text never appear in an error.
*
* @example
* const verifier = oidcIdentity({
* issuer: 'https://login.microsoftonline.com/<tenant>/v2.0',
* audience: '<API client id>',
* userIdClaim: 'oid',
* requiredScope: 'access_as_user',
* allowedClients: ['<web client id>'],
* });
* standingAgent({ …, identity: { verify: verifier.verify } });
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.MAX_CLOCK_TOLERANCE_SECONDS = exports.oidcIdentity = void 0;
const errors_js_1 = require("../../hosting/errors.js");
const claims_js_1 = require("./verify/claims.js");
const discovery_js_1 = require("./verify/discovery.js");
const jwtCore_js_1 = require("./verify/jwtCore.js");
const personTest_js_1 = require("./verify/personTest.js");
const VERIFIER = 'oidcIdentity';
const DEFAULT_CLOCK_TOLERANCE_SECONDS = 60;
const DEFAULT_DISCOVERY_TIMEOUT_MS = 5_000;
const DEFAULT_DISCOVERY_RETRY_MS = 5_000;
function oidcIdentity(options) {
const settings = checkOptions(options);
const discovery = discoveryCell(settings);
let keyring;
const keysFor = (jwksUri) => (keyring ??= (async () => {
const jose = options.backend ?? (await (0, jwtCore_js_1.loadJose)());
return { jose, keys: jose.createRemoteJWKSet(new URL(jwksUri)) };
})().catch((err) => {
keyring = undefined;
throw err;
}));
return {
discover: discovery.read,
async verify(token) {
const state = await discovery.read();
if (state.kind === 'outage') {
// A fixed sentence to the caller: the discovery URL and the IdP's own
// values are reconnaissance. The full reason goes to the server log
// (`log`) and to `discover()`, where only the operator reads it.
throw new errors_js_1.VerifierUnavailableError(VERIFIER, 'discovery failed');
}
if (state.kind === 'misconfigured') {
throw new errors_js_1.VerifierUnavailableError(VERIFIER, 'identity is misconfigured');
}
const { jose, keys } = await keysFor(settings.jwksUrl ?? state.issuer.jwksUri);
const payload = await (0, jwtCore_js_1.verifySignedToken)(jose, keys, token, {
issuer: acceptedIssuers(state.issuer),
audience: settings.audience,
algorithms: settings.algorithms,
clockToleranceSeconds: settings.clockToleranceSeconds,
verifierName: VERIFIER,
});
return personFrom(payload, settings);
},
};
}
exports.oidcIdentity = oidcIdentity;
/** The token's claims → a person, or the refusal that says why not. */
function personFrom(payload, settings) {
const failure = (0, personTest_js_1.personTestFailure)(payload, settings);
if (failure !== undefined)
throw new errors_js_1.IdentityNotVerifiedError(failure, false);
const userId = payload[settings.userIdClaim];
if (typeof userId !== 'string' || userId.length === 0) {
throw new errors_js_1.IdentityNotVerifiedError('unverifiable', false);
}
if ((0, personTest_js_1.rolesAreElsewhere)(payload, settings.rolesClaim)) {
throw new errors_js_1.IdentityNotVerifiedError('roles-unknown', false);
}
const roles = (0, claims_js_1.rolesOf)((0, claims_js_1.claimAt)(payload, settings.rolesClaim), 'list');
return { userId, ...(roles !== undefined && { roles }), claims: payload };
}
function acceptedIssuers(discovered) {
const { issuer, accessTokenIssuer } = discovered;
return accessTokenIssuer === undefined || accessTokenIssuer === issuer
? issuer
: [issuer, accessTokenIssuer];
}
/**
* Discovery as one cell, single-flight.
*
* - `ready` settles for good.
* - `misconfigured` as the FIRST answer settles for good — that is the boot
* `identityFromConfig` refuses.
* - After an `outage` has been seen, a later `misconfigured` answer is NOT
* final: a load balancer's one maintenance page must not brick sign-in until
* a restart. It is retried, like an outage, no sooner than the retry gap.
* - Every change of state is logged once, with the full reason — to the
* server log only; callers get a fixed sentence.
*/
function discoveryCell(settings) {
if (settings.discovery === 'off') {
const ready = {
kind: 'ready',
issuer: { issuer: settings.issuer, jwksUri: settings.jwksUrl },
};
return { read: () => Promise.resolve(ready) };
}
let settled;
let inFlight;
let retryable;
let everOutage = false;
let lastLogged;
const note = (state) => {
const line = state.kind === 'ready'
? `discovery ok for ${settings.issuer}`
: state.kind === 'outage'
? `discovery unreachable: ${state.reason}`
: `discovery misconfigured: ${state.check}`;
if (line === lastLogged)
return;
// The first answer is the boot's to report (banner or refusal); later
// changes are this cell's.
if (lastLogged !== undefined)
settings.log(`[identity] oidcIdentity: ${line}`);
lastLogged = line;
};
const read = () => {
if (settled !== undefined)
return Promise.resolve(settled);
if (inFlight !== undefined)
return inFlight;
if (retryable !== undefined && Date.now() - retryable.at < settings.discoveryRetryMs) {
return Promise.resolve(retryable.state);
}
inFlight = (0, discovery_js_1.readDiscovery)(settings.issuer, {
fetch: settings.fetch ?? defaultFetch(),
timeoutMs: settings.discoveryTimeoutMs,
allowLoopbackHttp: settings.allowLoopbackHttp,
}).then((outcome) => {
inFlight = undefined;
note(outcome);
if (outcome.kind === 'outage')
everOutage = true;
const retry = outcome.kind === 'outage' || (outcome.kind === 'misconfigured' && everOutage);
if (retry)
retryable = { at: Date.now(), state: outcome };
else
settled = outcome;
return outcome;
});
return inFlight;
};
return { read };
}
function defaultFetch() {
if (typeof fetch !== 'function') {
throw new TypeError('[identity] oidcIdentity needs a global fetch (Node 18+) to read the discovery document, ' +
'or a `fetch` option.');
}
return (url, init) => fetch(url, init);
}
// ─── Construction refusals ───────────────────────────────────────────
function checkOptions(options) {
const discovery = options.discovery ?? 'on';
if (discovery !== 'on' && discovery !== 'off') {
refuse(`discovery must be 'on' or 'off' (got ${JSON.stringify(discovery)})`);
}
const allowLoopbackHttp = options.allowLoopbackHttp === true;
const issuer = nonEmpty(options.issuer, 'issuer', 'the issuer URL your IdP names');
if (discovery === 'on') {
const problem = (0, discovery_js_1.fetchableUrlProblem)(issuer, allowLoopbackHttp);
if (problem !== undefined)
refuse(`the issuer '${issuer}' ${problem}`);
}
if (options.jwksUrl !== undefined) {
const problem = (0, discovery_js_1.fetchableUrlProblem)(options.jwksUrl, allowLoopbackHttp);
if (problem !== undefined)
refuse(`the jwksUrl '${options.jwksUrl}' ${problem}`);
}
else if (discovery === 'off') {
refuse('discovery is off, so jwksUrl is required — there is no document to read it from');
}
if (emptyNames(options.audience)) {
refuse(`audience is required — this API's name at the IdP. Without it a token minted for a ` +
`different service verifies here (the confused-deputy shape).`);
}
const userIdClaim = nonEmpty(options.userIdClaim, 'userIdClaim', `the claim that names the person ('oid' on Entra ID, your objectGUID claim on AD FS). ` +
`There is no default: it decides who owns every conversation`);
const requiredScope = nonEmpty(options.requiredScope, 'requiredScope', `the OAuth scope only this API's person tokens carry. Without it an application's own ` +
`token passes as a person`);
if (/\s/.test(requiredScope))
refuse(`requiredScope is ONE scope, with no spaces`);
const allowedClients = checkClients(options.allowedClients);
return {
issuer,
audience: options.audience,
userIdClaim,
requiredScope,
scopeClaim: options.scopeClaim ?? 'scp',
allowedClients,
rolesClaim: checkPath(options.rolesClaim ?? 'roles'),
...(options.jwksUrl !== undefined && { jwksUrl: options.jwksUrl }),
discovery,
clockToleranceSeconds: checkSeconds(options.clockToleranceSeconds),
algorithms: checkAlgorithms(options.algorithms),
allowLoopbackHttp,
discoveryTimeoutMs: checkMs(options.discoveryTimeoutMs ?? DEFAULT_DISCOVERY_TIMEOUT_MS, 'discoveryTimeoutMs', 1, 60_000),
discoveryRetryMs: checkMs(options.discoveryRetryMs ?? DEFAULT_DISCOVERY_RETRY_MS, 'discoveryRetryMs', 0, 3_600_000),
...(options.fetch !== undefined && { fetch: options.fetch }),
log: options.log ?? ((line) => console.warn(line)),
};
}
function checkClients(value) {
if (value === 'any')
return 'any';
if (!Array.isArray(value) ||
value.length === 0 ||
value.some((c) => typeof c !== 'string' || c.length === 0)) {
refuse(`allowedClients is required: the client ids allowed to obtain a person token for this ` +
`API, or 'any' to turn the client check off`);
}
return value;
}
function checkPath(path) {
const ok = typeof path === 'string'
? path.length > 0
: Array.isArray(path) && path.length > 0 && path.every((p) => typeof p === 'string' && p);
if (!ok)
refuse(`rolesClaim is a claim name or a non-empty path of names`);
return path;
}
/** The most clock skew tolerated: skew between clocks, never a second token lifetime. */
exports.MAX_CLOCK_TOLERANCE_SECONDS = 300;
function checkSeconds(value) {
if (value === undefined)
return DEFAULT_CLOCK_TOLERANCE_SECONDS;
if (!Number.isFinite(value) || value < 0 || value > exports.MAX_CLOCK_TOLERANCE_SECONDS) {
refuse(`clockToleranceSeconds is 0 to ${exports.MAX_CLOCK_TOLERANCE_SECONDS} seconds — clock skew, not ` +
`lifetime (got ${String(value)})`);
}
return value;
}
function checkMs(value, name, min, max) {
if (!Number.isFinite(value) || value < min || value > max) {
refuse(`${name} is ${min} to ${max} ms (got ${String(value)})`);
}
return value;
}
function checkAlgorithms(value) {
if (value === undefined)
return jwtCore_js_1.DEFAULT_ALGORITHMS;
if (!Array.isArray(value) ||
value.length === 0 ||
value.some((a) => typeof a !== 'string' || a.length === 0)) {
refuse(`algorithms is a non-empty list of algorithm names`);
}
const symmetric = value.filter((a) => /^HS/i.test(a) || a === 'none');
if (symmetric.length > 0) {
refuse(`algorithms may not include ${symmetric.join(', ')}: a key set is public, and an HMAC ` +
`algorithm (or none) over a published key is the classic algorithm-confusion forgery`);
}
return value;
}
function nonEmpty(value, name, what) {
if (typeof value !== 'string' || value.trim().length === 0)
refuse(`${name} is required — ${what}`);
return value;
}
function emptyNames(value) {
if (typeof value === 'string')
return value.trim().length === 0;
if (Array.isArray(value))
return value.length === 0 || value.some((v) => typeof v !== 'string' || !v);
return true;
}
function refuse(sentence) {
throw new TypeError(`[identity] oidcIdentity: ${sentence}.`);
}
//# sourceMappingURL=oidc.js.map