/** * `/api/v1/session` — the only place this box issues a credential rather than * checking one. * * It exists because neither committed mode lets a human sign in: `none` is open * and `jwt` verifies a token somebody else already issued. `TERA_AUTH_MODE=password` * fills that gap for a self-hoster with no identity provider, and it fills it by * signing the same HS256 token `jwt` mode verifies and putting it in a cookie. * `offices.ts` is unchanged and unaware; there is still exactly one code path * that decides whether a private office exists for you. * * Three deliberate refusals to be helpful, all of them about not answering * questions an anonymous caller should not get answers to: * * - A wrong username and a wrong password produce the same status, the same body * and the same amount of work. `credentialsMatch` in `auth/password.ts` is * where that is enforced. * - When this box cannot sign anyone in, `POST` answers **404 with the body an * unrouted path gets**, not "password login is disabled". Whether an operator * configured a local account is not public information. * - A private office still answers 404 rather than 403, before and after signing * in. That rule lives in `offices.ts` and nothing here weakens it. */ import type { FastifyInstance } from "fastify"; import { clearedSessionCookie, grantsAdmin, issueSessionToken, sessionCookie, type Viewer, } from "../auth/index.ts"; import { MAX_PASSWORD_LENGTH, credentialsMatch } from "../auth/password.ts"; import type { ErrorBody } from "../../../src/server/wire.ts"; import type { Services } from "../services.ts"; /** * What the client gets to know. `passwordLogin` is the field the UI gates on: * it says a login form would do something here, which is not a secret — the * login page is served to anyone — while `authenticated` says whether this * particular caller needs one. */ export interface SessionBody { authenticated: boolean; /** The signed-in subject, or `null`. Never a token. */ subject: string | null; /** * The god tier, decided by `TERA_ADMIN_SUBJECTS` on the server. The client * reads it to decide what to draw — the time scrubber, the debug panel — and * that is all it is for. It is not a capability: everything gated on it is * gated again where it is enforced, because a boolean that arrived over the * wire is a rendering hint and nothing more. */ admin: boolean; /** Whether `POST` to this endpoint can sign somebody in on this deployment. */ passwordLogin: boolean; } /** Byte-identical to what the not-found handler serves for a path with no route. */ const NOT_FOUND: ErrorBody = { error: "not_found", message: "No such route." }; /** * One message for every rejected credential. It says nothing about which half * was wrong, and the same object is sent for a username that does not exist. */ const REJECTED: ErrorBody = { error: "unauthorized", message: "Those credentials were not accepted.", }; /** * `unauthorized` rather than a token of its own because the error union in * `wire.ts` is closed and deliberately short. The 429 and `Retry-After` carry * the distinction, and a client that only reads `error` treats a rate-limited * attempt as a failed one, which is the right thing for it to do. */ const RATE_LIMITED: ErrorBody = { error: "unauthorized", message: "Too many sign-in attempts. Try again shortly.", }; const MAX_USERNAME_LENGTH = 256; /** * What `DELETE` reports. Signing out drops the tier with the session, and it * has to be said explicitly rather than left to the client: a page that cached * `admin: true` and only ever hears "authenticated: false" would keep drawing * the god-only controls until the next reload. */ const SIGNED_OUT: Viewer = { authenticated: false, subject: null, admin: false }; export function registerSession(app: FastifyInstance, services: Services): void { const { auth } = services.config; // Per app instance rather than per module, so two servers in one process — // which is what the tests are — cannot share a limiter. const limiter = createLimiter( auth.passwordLogin?.rateAttempts ?? 8, auth.passwordLogin?.rateWindowSeconds ?? 300, ); app.get("/api/v1/session", async (req) => { const viewer = await services.auth.resolve(req); return body(viewer, auth.passwordLogin !== null); }); app.post("/api/v1/session", async (req, reply) => { const login = auth.passwordLogin; if (login === null) return reply.code(404).send(NOT_FOUND); const retryAfter = limiter.check(req.ip); if (retryAfter !== null) { // Before reading the body and before hashing anything: the point of the // limit is that a flood of attempts costs the box nothing. return reply.code(429).header("retry-after", String(retryAfter)).send(RATE_LIMITED); } const supplied = credentials(req.body); if (supplied === null) { // A malformed body is a failed attempt, not a 400. Answering differently // would let an attacker probe the endpoint without spending attempts. limiter.fail(req.ip); return reply.code(401).send(REJECTED); } const ok = await credentialsMatch( login.username, login.hash, supplied.username, supplied.password, ); if (!ok) { limiter.fail(req.ip); return reply.code(401).send(REJECTED); } limiter.succeed(req.ip); const token = issueSessionToken(auth, login.username, login.sessionTtlSeconds); reply.header("set-cookie", sessionCookie(auth, token, login.sessionTtlSeconds)); // The real grant for the account that just signed in, not `false` and not a // guess. `issueSessionToken` put `login.username` in the `sub` claim, so // `grantsAdmin` is being asked the same question about the same string that // `resolve()` will ask on the very next request with this cookie — a login // that answered differently from the GET a moment later would be a flicker // nobody could reproduce. return body( { authenticated: true, subject: login.username, admin: grantsAdmin(auth, login.username) }, true, ); }); // Signing out is available in every mode, including the ones where this box // never issued the cookie. Refusing to clear a cookie tells the caller // something about how the deployment is configured and helps nobody. app.delete("/api/v1/session", async (_req, reply) => { reply.header("set-cookie", clearedSessionCookie(auth)); return body(SIGNED_OUT, auth.passwordLogin !== null); }); } /** * The one place the session shape is written. All three handlers go through it, * so `admin` cannot be present on one response and missing from another — which * is the bug a client's `s.admin === true` would read as "demoted" and act on. */ function body(viewer: Viewer, passwordLogin: boolean): SessionBody { return { authenticated: viewer.authenticated, subject: viewer.subject, admin: viewer.admin, passwordLogin, }; } /** * Read the two fields, or decide there was nothing to read. * * Length caps are here rather than at the hash: a request body is up to a * megabyte and there is no reason to hand any of it to a key-derivation * function on an unauthenticated route. */ function credentials(raw: unknown): { username: string; password: string } | null { if (raw === null || typeof raw !== "object") return null; const { username, password } = raw as { username?: unknown; password?: unknown }; if (typeof username !== "string" || typeof password !== "string") return null; if (username === "" || password === "") return null; if (username.length > MAX_USERNAME_LENGTH || password.length > MAX_PASSWORD_LENGTH) return null; return { username, password }; } // ---- Rate limiting -------------------------------------------------------- interface Bucket { failures: number; windowEndsAt: number; } /** * A fixed window of failures per client address, held in memory. * * In memory because there is one process and one box (CONTRACT.md §5) and a * Redis to lose is worse than a limiter that resets on deploy. Keyed on * `req.ip`, which is the real client because Caddy is the only thing that can * reach the socket and `trustProxy` is on — nothing else can forge the header. * * Only failures count. A working session should not be able to lock its own * owner out by reloading, and an attacker who is already succeeding is not * someone a rate limit is going to help with. */ function createLimiter(attempts: number, windowSeconds: number) { const buckets = new Map(); const limit = Math.max(1, Math.floor(attempts)); const windowMs = Math.max(1, Math.floor(windowSeconds)) * 1000; /** Bounded so a flood from many addresses cannot grow the map without limit. */ const MAX_TRACKED = 4096; function sweep(now: number): void { for (const [key, bucket] of buckets) { if (bucket.windowEndsAt <= now) buckets.delete(key); } // Still full after dropping the expired ones: that is a flood rather than // accumulation, and clearing is the right trade. The worst case is that some // attackers get their attempts back; the alternative is unbounded memory. if (buckets.size >= MAX_TRACKED) buckets.clear(); } return { /** Seconds the caller should wait, or `null` when it may try now. */ check(ip: string): number | null { const now = Date.now(); const bucket = buckets.get(ip); if (bucket === undefined || bucket.windowEndsAt <= now) return null; if (bucket.failures < limit) return null; return Math.max(1, Math.ceil((bucket.windowEndsAt - now) / 1000)); }, fail(ip: string): void { const now = Date.now(); if (buckets.size >= MAX_TRACKED) sweep(now); const bucket = buckets.get(ip); if (bucket === undefined || bucket.windowEndsAt <= now) { buckets.set(ip, { failures: 1, windowEndsAt: now + windowMs }); return; } bucket.failures += 1; }, succeed(ip: string): void { buckets.delete(ip); }, }; }