UNPKG

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 (175 loc) • 9.16 kB
/** * 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; }