UNPKG

agentfootprint

Version:

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

299 lines • 13.6 kB
/** * 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 } }); */ import { IdentityNotVerifiedError, VerifierUnavailableError } from '../../hosting/errors.js'; import { claimAt, rolesOf } from './verify/claims.js'; import { fetchableUrlProblem, readDiscovery, } from './verify/discovery.js'; import { DEFAULT_ALGORITHMS, loadJose, verifySignedToken, } from './verify/jwtCore.js'; import { personTestFailure, rolesAreElsewhere } from './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; export function oidcIdentity(options) { const settings = checkOptions(options); const discovery = discoveryCell(settings); let keyring; const keysFor = (jwksUri) => (keyring ??= (async () => { const jose = options.backend ?? (await 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 VerifierUnavailableError(VERIFIER, 'discovery failed'); } if (state.kind === 'misconfigured') { throw new VerifierUnavailableError(VERIFIER, 'identity is misconfigured'); } const { jose, keys } = await keysFor(settings.jwksUrl ?? state.issuer.jwksUri); const payload = await verifySignedToken(jose, keys, token, { issuer: acceptedIssuers(state.issuer), audience: settings.audience, algorithms: settings.algorithms, clockToleranceSeconds: settings.clockToleranceSeconds, verifierName: VERIFIER, }); return personFrom(payload, settings); }, }; } /** The token's claims → a person, or the refusal that says why not. */ function personFrom(payload, settings) { const failure = personTestFailure(payload, settings); if (failure !== undefined) throw new IdentityNotVerifiedError(failure, false); const userId = payload[settings.userIdClaim]; if (typeof userId !== 'string' || userId.length === 0) { throw new IdentityNotVerifiedError('unverifiable', false); } if (rolesAreElsewhere(payload, settings.rolesClaim)) { throw new IdentityNotVerifiedError('roles-unknown', false); } const roles = rolesOf(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 = 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 = fetchableUrlProblem(issuer, allowLoopbackHttp); if (problem !== undefined) refuse(`the issuer '${issuer}' ${problem}`); } if (options.jwksUrl !== undefined) { const problem = 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. */ export const MAX_CLOCK_TOLERANCE_SECONDS = 300; function checkSeconds(value) { if (value === undefined) return DEFAULT_CLOCK_TOLERANCE_SECONDS; if (!Number.isFinite(value) || value < 0 || value > MAX_CLOCK_TOLERANCE_SECONDS) { refuse(`clockToleranceSeconds is 0 to ${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 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