agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
176 lines • 9.2 kB
TypeScript
/**
* hosting/signin/door — the sign-in door: the routes under `/auth` that start
* and end a sign-in. Nothing it handles is a turn or enters a run's record.
*
* Pattern: a plain `(req, res) → handled?` handler an app mounts in front of
* its own routes (`nodeHost({ onUnhandled })`), like the app's own
* `/auth` routes before it.
*
* GET /auth/config → `{ mode: 'password', passwordKind: 'directory' | 'local' }`
* (`passwordKind` when the checker declares its `kind`)
* GET /auth/me → `{ displayName, accountKey, expiresAt }` or 401
* POST /auth/login → the password sign-in (below)
* POST /auth/logout → end the sign-in, expire the cookie, close its sockets
*
* ── `POST /auth/login`, in order ────────────────────────────────────────────
* 1. The door guard, UNCONDITIONALLY — a login carries no credential, so a
* gate keyed on "a credential came in" would never run on it, and a forged
* cross-site login signs a victim into the attacker's account (login
* forgery). Host, Origin, and `content-type: application/json` (so a form
* cannot post it).
* 2. The body is read by THIS door, capped, and a bad one gets a fixed
* sentence: a JSON parser's own message quotes the input, and the input is
* a password.
* 3. Attempt limits (per typed name and per client address), with a growing
* delay before any refusal.
* 4. A sign-in cookie already present is ENDED first.
* 5. The password is checked. Every wrong credential gets ONE answer.
* 6. A new sign-in: 256 random bits in the cookie, only their SHA-256 in the
* store.
* Every answer waits until a minimum time has passed, so "no such user" and
* "wrong password" cannot be told apart by how fast they come back. The
* minimum is a DURATION, so it is measured on the monotonic clock
* (`performance.now()`) and waited in full (`lib/sleep`) — never on the epoch
* clock `now`, which counts whole milliseconds (an answer up to 1 ms early)
* and jumps when the system time is set (an hour ahead: an answer at once).
*
* ── The cookie (§5.4) ───────────────────────────────────────────────────────
* `__Host-Http-af-signin=…; Path=/; HttpOnly; Secure; SameSite=Strict;
* Max-Age=<lifetime>` — no `Domain`. On a plain-`http` localhost public URL
* (development only; refused in production) the browser would drop a
* `__Host-` cookie, so the name is `af-signin` and `Secure` is left off — the
* banner says so.
*
* Every `/auth` response carries `Cache-Control: no-store` and forbids framing,
* and none carries a CORS header.
*/
/// <reference types="node" />
import type { IncomingMessage, ServerResponse } from 'node:http';
import { type CrossSiteOptions } from '../doorGuard.js';
import type { DoorIdentity, IdentityVerifier } from '../identityVerification.js';
import { type CheckGateOptions } from './checkGate.js';
import { type AttemptLimits } from './limits.js';
import { type SignInAuditSink } from './audit.js';
import { type SealKey } from './seal.js';
import { type SignIns } from './source.js';
import { type HostSignInOptions, type PasswordChecker, type RedirectSignIn, type SignInStore } from './types.js';
export interface SignInDoorOptions {
/** A password strategy (`localPasswords(…)`) — `POST /auth/login`. Exactly one of this and `redirect`. */
readonly passwords?: PasswordChecker;
/**
* A redirect strategy (`oidcSignIn(…)`) — `GET /auth/login` and
* `GET /auth/callback`. Exactly one of this and `passwords`.
*/
readonly redirect?: RedirectSignIn;
/**
* Bearer tokens accepted beside the sign-in cookie (scripts, the bench): the
* strategy's own `verify`. Put into {@link SignInDoor.identity}.
*/
readonly verify?: IdentityVerifier['verify'];
/**
* The key that seals redirect transaction cookies — 32 bytes, shared by
* replicas (`IDENTITY_COOKIE_KEY_FILE`). Unset: random per process, and the
* banner says a restart invalidates sign-ins in progress.
*/
readonly cookieKey?: SealKey;
/** Where sign-ins are kept (`memorySignIns()`). */
readonly store: SignInStore;
/**
* The URL people open the app at. Decides the cookie: `https` → the
* `__Host-Http-` cookie with `Secure`; `http` only on a loopback host, and
* only outside production.
*/
readonly publicUrl: string;
/** Is this production? Supplied by the app, never guessed. */
readonly production: boolean;
/** The door guard's lists — the SAME ones the host uses. `publicUrl` must pass them. */
readonly guard?: CrossSiteOptions;
/** The absolute lifetime of a sign-in, hours. Default 8. */
readonly hours?: number;
/** Minutes of inactivity after which a sign-in ends. Default 60 under a password, 30 under a redirect (signing in again is silent there). */
readonly idleMinutes?: number;
/** Attempt limits on `POST /auth/login`. */
readonly limits?: AttemptLimits;
/**
* Every login answer waits at least this long, ms. Default 400. Measured on
* the monotonic clock from the moment the login starts — never on `now`.
*/
readonly minimumResponseMs?: number;
/**
* Peers whose `X-Forwarded-For` is believed: the client address is then the
* rightmost hop that is not one of them. Unset, `X-Forwarded-For` is ignored.
*/
readonly trustedProxies?: readonly string[];
/** The route prefix. Default `/auth`. */
readonly prefix?: string;
/**
* The clock, epoch ms: sign-in lifetimes and attempt windows. Default
* `Date.now`. Not the minimum answer time (`minimumResponseMs`).
*/
readonly now?: () => number;
/** The door-wide cap on concurrent password checks (default 4 running, 32 waiting). */
readonly checks?: CheckGateOptions;
/** Where a one-time operator warning goes. Default `console.warn`. */
readonly warn?: (message: string) => void;
/**
* The audit trail, typed: called once per sign-in outcome — signed in,
* refused, limited, unavailable, signed out, expired — with the account,
* the client address (after `trustedProxies`), the time, the strategy and,
* on `directory-password`, AD's sub-code and its name. Never a password, a
* cookie, a token or an error's message. See {@link SignInAuditRecord}.
* A sink that throws is contained and reported once through `warn`.
*
* @example
* onAudit: (record) => {
* if (record.outcome !== 'signed-in') securityLog.write(record);
* }
*/
readonly onAudit?: SignInAuditSink;
/**
* Where the door writes ONE structured line per sign-in outcome — the same
* record as `onAudit`, as `[identity] sign-in {"time":"…","outcome":…}`.
* Default `console.info`, so an install has its audit trail with no code;
* pass `() => {}` to keep it out of the process log.
*/
readonly log?: (line: string) => void;
/** Test seam — inject the door's waits: the attempt delay and the minimum answer time. */
readonly _sleep?: (ms: number) => Promise<void>;
}
export interface SignInDoor {
/** Answer one request if it is under the prefix; resolves whether it was. Never rejects. */
handle(req: IncomingMessage, res: ServerResponse): Promise<boolean>;
/** The sign-ins this door starts and ends — what `identity: { signIn }` takes. */
readonly signIns: SignIns;
/** The cookie's name. */
readonly cookieName: string;
/** What the host takes: `nodeHost({ signIn: door.hostSignIn })`. */
readonly hostSignIn: HostSignInOptions;
/** What `standingAgent({ identity })` takes. */
readonly identity: DoorIdentity;
/** Lines for the boot banner. No secrets. */
readonly banner: readonly string[];
}
export { SignInDoorConfigError } from './errors.js';
/** A sign-in's idle limit by door mode, when `idleMinutes` is not given. */
export declare const DEFAULT_IDLE_MINUTES: {
readonly password: 60;
readonly redirect: 30;
};
/** The one answer every wrong credential gets. */
export declare const WRONG_CREDENTIAL_SENTENCE = "That username and password did not sign you in.";
export declare function signInDoor(options: SignInDoorOptions): SignInDoor;
/** A 128-bit key for the page's caches: one per person, not the id itself. */
export declare function accountKeyOf(userId: string): string;
export interface CookieShape {
readonly name: string;
readonly secure: boolean;
readonly url: URL;
issue(value: string, maxAgeSeconds: number): string;
expired(): string;
/** The per-attempt transaction cookie's name. */
txName(attempt: string): string;
/** A transaction cookie: `SameSite=Lax` (it must come back on the IdP's redirect), 10 minutes. */
issueTx(attempt: string, sealed: string): string;
expireTx(attempt: string): string;
}
//# sourceMappingURL=door.d.ts.map