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 • 7.3 kB
TypeScript
/**
* 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;
//# sourceMappingURL=oidc.d.ts.map