agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
199 lines (198 loc) • 11.1 kB
TypeScript
/**
* hosting/identityVerification — the door checks the badge, instead of reading
* the name off it.
*
* Until 9.26 the hosting port had exactly one honest sentence about WHO is
* calling: `HostRequest.userId` is "what the transport said", and how much that
* is worth is the transport's answer. A managed runtime that authenticates in
* front of the container can fill it in; a container you expose yourself cannot,
* because a header there is a string anybody can send. That sentence is still
* true. This module is the other half of it: a way to make the door itself the
* thing that knows, so a deployment does not have to choose between "trust a
* header" and "no identity at all".
*
* ── The shape: one strategy, one method ──────────────────────────────────────
* {@link IdentityVerifier} takes the bearer token off the wire and hands back
* {@link VerifiedIdentity} — `{ userId, roles?, claims? }` — or throws. It is
* vendor-neutral by construction: a JWT from any IdP, an opaque token
* introspected against a server, a mutual-TLS thumbprint an edge proxy already
* checked. `jwksIdentity({ jwksUrl, issuer, audience })` is the one adapter
* this release ships, and it covers cloud IdPs and on-prem ones alike because
* JWKS is the same protocol in both.
*
* ── Where it sits, exactly ───────────────────────────────────────────────────
* BEFORE `identityForRequest` composes the run's scope. That ordering is the
* whole feature: the composed scope is what memory namespaces on, what
* artifacts isolate on, and what a credential provider scopes a token vault on.
* Verifying after any of that would be checking the badge on the way out.
*
* ── The refusal law, and why it is not optional ──────────────────────────────
* With a verifier configured, a request that names a `userId` and cannot prove
* it is REFUSED — never quietly downgraded to anonymous, and never served under
* the name it claimed. Both alternatives are the accepted-and-silently-wrong
* failure wearing different clothes: one produces an audit trail that names the
* wrong party, the other produces a door that anybody opens by omitting a
* field.
*
* ── Secrets never travel ─────────────────────────────────────────────────────
* A bearer token is a credential. It never appears in an error message, an
* event payload, a log line or a reply body — not truncated, not fingerprinted,
* not "just the first 8 characters". What travels is the CLASS of the failure
* ({@link IdentityFailureClass}) and nothing else, which is exactly what an
* operator needs to act (`expired` → the client should refresh;
* `wrong-audience` → the client is pointed at the wrong API;
* `keys-unavailable` → your IdP is unreachable and this is not the caller's
* fault). That is the `sdkFailure` law, applied to the front door.
*/
import type { IdentityFailureClass } from './errors.js';
import type { SignInSource } from './signin/types.js';
/**
* What a verifier proved. The badge, read after it was checked.
*
* Deliberately NOT a `MemoryIdentity`: this is evidence about a PERSON, and the
* run's identity is a tenant/principal/conversation TUPLE the composer builds
* from this plus the session. Keeping them separate is what stops a verifier
* from silently re-namespacing somebody's conversation by returning a field.
*/
export interface VerifiedIdentity {
/** The end user's id — what becomes {@link HostRequest.userId} and, through
* `identityForRequest`, the run's `principal`. Non-empty by contract; a
* verifier that cannot name the subject must throw rather than invent one. */
readonly userId: string;
/**
* Roles the token asserted, when it asserted any.
*
* These reach the ADMISSION policy (`admission.decide({ identity, … })`) and
* the session-history ops, and nothing else — they do not enter the run's
* identity, which stays the three-field tuple every store already scopes on.
* Stated rather than implied, because "the agent can see my roles" is exactly
* the kind of belief that gets built on.
*/
readonly roles?: readonly string[];
/**
* The verified claim set, as the verifier read it. Whatever an authorization
* decision in THIS deployment needs (a tenant claim, a plan, a scope list)
* and this library has no business interpreting.
*
* Never logged and never emitted by the framework — a claim set routinely
* carries an email, and one echo puts it in every sink.
*/
readonly claims?: Readonly<Record<string, unknown>>;
}
/**
* The port — one method, vendor-neutral.
*
* `verify` is handed the RAW bearer token exactly as the wire delivered it
* (without the `Bearer ` prefix) and must either return a
* {@link VerifiedIdentity} or throw. Throwing an
* {@link IdentityNotVerifiedError} names the failure class; throwing anything
* else is treated as `'unverifiable'`, because a verifier that failed in a way
* it did not describe has not verified anything.
*
* Implementations must never put the token into what they throw.
*/
export interface IdentityVerifier {
verify(token: string): Promise<VerifiedIdentity>;
}
/**
* How a host door is told to check badges — {@link StandingAgentBaseOptions.identity}.
*
* The 9.26 shape, unchanged for its callers (`verify` required), plus two
* optional additions: `signIn` (a sign-in the server keeps, named by the key
* the transport passes after stripping the sign-in cookie) and `tokenHeader`.
* A door that takes sign-ins ONLY is a {@link SignInOnlyIdentity}; every door
* accepts either, as a {@link DoorIdentity}.
*/
export interface IdentityVerificationOptions {
/** The strategy. `jwksIdentity({ … })`, `oidcIdentity({ … })`, or any {@link IdentityVerifier}. */
readonly verify: IdentityVerifier['verify'];
/**
* Let a request that presents NO credential through as anonymous.
* Default **`false`** — configuring a verifier closes the door.
*
* The default is the load-bearing half. A door that verifies a token when one
* is offered and waves the request through when it is not is a door anybody
* opens by sending less, and every per-user bound built on top of it (spend
* ceilings, session listing) evaporates for exactly the callers those bounds
* exist for.
*
* Say `true` when a deployment genuinely serves both — a public demo lane and
* a signed-in lane on one host. An anonymous request can then never carry a
* `userId`: it is refused rather than served under a name nobody proved.
*/
readonly allowAnonymous?: boolean;
/** The sign-ins this door ALSO accepts (`signInSource({ store, idleMinutes })`). */
readonly signIn?: SignInSource;
/**
* Which header carries the token, lower-case. Default `'authorization'`,
* read as `Bearer <token>`. Any other name is read as the raw token (a
* leading `Bearer ` is tolerated) — the header an authenticating proxy
* forwards a signed token in.
*/
readonly tokenHeader?: string;
}
/**
* A door that accepts sign-ins only — no bearer tokens. A presented token is
* `unverifiable` there.
*/
export interface SignInOnlyIdentity {
readonly verify?: undefined;
/** The sign-ins this door accepts. */
readonly signIn: SignInSource;
/** As {@link IdentityVerificationOptions.allowAnonymous}. */
readonly allowAnonymous?: boolean;
/** As {@link IdentityVerificationOptions.tokenHeader}. */
readonly tokenHeader?: string;
}
/** What every door takes: a verifier (optionally with sign-ins), or sign-ins only. */
export type DoorIdentity = IdentityVerificationOptions | SignInOnlyIdentity;
/**
* Pull the bearer token out of the delivered transport headers.
*
* ONE extraction, so every dialect's tokens are read the same way. The header
* is `Authorization: Bearer <token>` — the vocabulary every transport this
* package speaks already normalizes onto:
*
* - `httpHost` (and therefore `nodeHost` / `jsonWire`) hands the handler the
* request's lower-cased headers verbatim, so the token is simply there;
* - the managed-runtime dialect maps its own bearer SUBPROTOCOL onto
* `headers.authorization` before the port ever sees it, which is why that
* mapping was written the way it was;
* - a custom `HttpWire` that carries the credential somewhere else supplies
* the header itself, or wraps its own verifier.
*
* The scheme match is case-insensitive (RFC 7235 says it is), and a header with
* a different scheme returns `undefined` rather than being half-read — handing
* a Basic credential to a JWT verifier would produce a confusing refusal about
* the wrong thing.
*/
export declare function bearerToken(headers: Readonly<Record<string, string>> | undefined): string | undefined;
/**
* The token a request presents under `options.tokenHeader` — `bearerToken` for
* the default `authorization`, the raw (trimmed) value for any other header.
*/
export declare function presentedToken(headers: Readonly<Record<string, string>> | undefined, tokenHeader: string | undefined): string | undefined;
/**
* Run one request's verification and hand back the proven identity — or refuse.
*
* Shared by every door on the composer (a turn, an artifact redemption, a
* session-history op) so all three inherit one answer to "who is this", and a
* later door cannot accidentally be the lenient one.
*
* @param options the configured verifier, or `undefined` — with `undefined`
* this returns `undefined` immediately and NOTHING about the
* request changes (the zero-delta path).
* @param headers the delivered transport headers.
* @param claimedUserId what the transport put on `HostRequest.userId`.
* @param signInKey what the transport put on `HostRequest.signInKey` — the
* sign-in cookie's key, the cookie itself already stripped.
*
* One credential per request (rule 13): a token AND a sign-in together are
* refused as `'two-credentials'` — which one to believe is not a question a
* door should answer. A sign-in that ended, expired or never existed is
* `'expired'`, one answer for all three. A sign-in store that cannot answer is
* {@link VerifierUnavailableError} (503), never a signed-out caller.
*/
export declare function verifyRequestIdentity(options: DoorIdentity | undefined, headers: Readonly<Record<string, string>> | undefined, claimedUserId: string | undefined, signInKey?: string): Promise<VerifiedIdentity | undefined>;
/** Re-exported so a consumer typing a verifier reads one vocabulary. */
export type { IdentityFailureClass };