UNPKG

agentfootprint

Version:

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

142 lines (141 loc) • 7.26 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 type { IdentityVerifier } from '../../hosting/identityVerification.js'; import { type ClaimPath } from './verify/claims.js'; import { type DiscoveredIssuer, type DiscoveryFetch } from './verify/discovery.js'; import { type JoseBackend } from './verify/jwtCore.js'; export type { DiscoveryFetch } from './verify/discovery.js'; export interface OidcIdentityOptions { /** * The issuer, exactly as the discovery document names it (compared * exactly, trailing `/` included). With discovery on it must be `https` — * it is FETCHED — except `http` on a loopback host when * {@link allowLoopbackHttp} is set. With discovery off it is a literal, * compared and never fetched. */ readonly issuer: string; /** THIS API's name at the IdP — never a client's id. */ readonly audience: string | readonly string[]; /** * The claim that names the person — REQUIRED, no default. It decides who * owns every conversation, forever. `oid` on Entra ID; a custom * `objectGUID` claim on AD FS; `sub` is wrong on both. A top-level name (a * URI is fine; dots are never split). The value is taken as bytes: never * trimmed or case-folded. */ readonly userIdClaim: string; /** * The OAuth scope only this API's PERSON tokens carry (Entra * `access_as_user`, an AD FS scope granted to the web client, a Keycloak * client scope). One scope, no spaces, compared exactly. */ readonly requiredScope: string; /** Where the scopes are. Default `'scp'` (Entra, AD FS); `'scope'` for RFC 9068 IdPs (Keycloak, Okta). */ readonly scopeClaim?: string; /** * The clients allowed to obtain a person token for this API, read from * `azp`, else `appid`, else `cid`, else `client_id`. `'any'` turns the check * off — the banner says so. A token naming no client is refused. * * **Every client listed here must have service accounts / the * client-credentials grant turned OFF.** On Keycloak and Okta a service * account's token carries your API's scope, so this list is the check that * refuses it — a listed client that can mint tokens for itself is a daemon * that passes as a person. `identityFromConfig` refuses `'any'` in production. */ readonly allowedClients: readonly string[] | 'any'; /** The roles claim: a top-level name or a path (`['realm_access', 'roles']`). Default `'roles'`. A string is one role. */ readonly rolesClaim?: ClaimPath; /** Overrides the discovery document's `jwks_uri`. REQUIRED when discovery is off. Must be `https`. */ readonly jwksUrl?: string; /** `'on'` (default) reads the issuer's discovery document; `'off'` needs {@link jwksUrl} and treats `issuer` as a literal. */ readonly discovery?: 'on' | 'off'; /** Seconds of clock skew tolerated on `exp`/`nbf`. Default 60; at most 300 — skew, not lifetime. */ readonly clockToleranceSeconds?: number; /** * Signature algorithms. Default: the RSA and ECDSA families. `HS*` is * REFUSED: a key set is public, and an HMAC algorithm over a published key is * the classic algorithm-confusion forgery. */ readonly algorithms?: readonly string[]; /** Accept `http` URLs on a loopback host (development only). Default `false`. */ readonly allowLoopbackHttp?: boolean; /** Timeout for the discovery fetch, in ms. Default 5000; 1 to 60 000. */ readonly discoveryTimeoutMs?: number; /** After an outage, the least time between two discovery attempts, in ms. Default 5000; 0 to 3 600 000. */ readonly discoveryRetryMs?: number; /** The fetch discovery uses. Default: the runtime's `fetch`. */ readonly fetch?: DiscoveryFetch; /** * Where a later CHANGE of discovery state is written (outage → misconfigured * → ready), with its full reason — the server's log, never a caller's reply. * Default `console.warn`. */ readonly log?: (line: string) => void; /** An already-imported `jose` (see `jwksIdentity`'s `backend`). */ readonly backend?: JoseBackend; } /** * Where discovery stands. `ready` is final for the life of the verifier; * `outage` is retried on a later request; `misconfigured` is final. */ export type OidcDiscoveryState = { readonly kind: 'ready'; readonly issuer: DiscoveredIssuer; } | { readonly kind: 'outage'; readonly reason: string; } | { readonly kind: 'misconfigured'; readonly check: string; }; /** The verifier, plus its discovery state for a boot check or a banner. */ export interface OidcIdentity extends IdentityVerifier { /** * Run discovery now (or return the settled answer). Never throws. A second * call after an outage retries, no sooner than `discoveryRetryMs`. */ discover(): Promise<OidcDiscoveryState>; } export declare function oidcIdentity(options: OidcIdentityOptions): OidcIdentity; /** The most clock skew tolerated: skew between clocks, never a second token lifetime. */ export declare const MAX_CLOCK_TOLERANCE_SECONDS = 300;