feat(auth): add local login and CSRF-protected sessions

This commit is contained in:
2026-08-16 23:23:55 +02:00
parent 8477a69a29
commit 29bfb41363
10 changed files with 1062 additions and 35 deletions
+162 -2
View File
@@ -1,9 +1,30 @@
import type { FastifyRequest, FastifyReply } from "fastify";
import type { FastifyRequest, FastifyReply, preHandlerHookHandler } from "fastify";
import { localPrincipal, type PrincipalContext, upstreamPrincipal } from "./principal.js";
import { rolesToPermissions } from "./config.js";
import type { AuthenticationConfigProvider, AuthMode, AuthSessionRecord } from "./types.js";
import type { AuthSessionStore } from "./session-store.js";
import { deriveCsrfToken, csrfTokensEqual } from "./csrf.js";
import { requireSameOriginOrNonBrowser } from "./authorization.js";
declare module "fastify" {
interface FastifyRequest { principal?: PrincipalContext }
interface FastifyRequest {
principal?: PrincipalContext;
authSession?: AuthSessionRecord;
/** Internal only: never serialize or write this opaque cookie token to logs. */
authSessionToken?: string;
authPublicOrigin?: string;
}
}
const SESSION_COOKIE = "thothii_session";
const SESSION_TOKEN = /^[A-Za-z0-9_-]{43}$/;
const STATE_CHANGING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
export interface AuthDependencies {
mode: AuthMode;
publicExposure?: boolean;
authentication?: AuthenticationConfigProvider;
sessionStore?: AuthSessionStore;
}
export function authPreHandler(mode: "none" | "mock" | "upstream", publicExposure = false) {
@@ -28,6 +49,145 @@ export function authPreHandler(mode: "none" | "mock" | "upstream", publicExposur
};
}
/**
* The one application boundary for principal resolution. Auth protocol endpoints are the only
* public exceptions; all other routes get either a resolved principal or a sanitized denial.
*/
export function authenticateSession(deps: AuthDependencies): preHandlerHookHandler {
const legacy = deps.mode === "none" || deps.mode === "mock" || deps.mode === "upstream"
? authPreHandler(deps.mode, deps.publicExposure)
: undefined;
const handle = async (request: FastifyRequest, reply: FastifyReply): Promise<void> => {
if (isPublicRoute(request)) return;
if (legacy) {
await legacy(request, reply);
if (reply.sent || !STATE_CHANGING_METHODS.has(request.method)) return;
return requireSameOriginOrNonBrowser(request, reply);
}
const origin = configuredOrigin(deps.authentication);
if (!origin || !deps.sessionStore) {
return reply.code(503).send({ code: "auth_unavailable", error: "Authentication is unavailable" });
}
const token = readSessionCookie(request);
if (token === undefined || token === false) return authenticationRequired(reply);
let session: AuthSessionRecord | undefined;
try {
session = await deps.sessionStore.resolve(token);
if (session) await deps.sessionStore.touch(token);
} catch {
return authenticationRequired(reply);
}
if (!session) return authenticationRequired(reply);
request.authSession = session;
request.authSessionToken = token;
request.authPublicOrigin = origin;
request.principal = {
issuer: session.issuer,
subject: session.subject,
...(session.displayName === undefined ? {} : { displayName: session.displayName }),
roles: session.roles,
permissions: session.permissions,
isAdmin: session.roles.includes("admin"),
};
if (STATE_CHANGING_METHODS.has(request.method)) {
requireCsrf(request, reply);
return;
}
};
return (request, reply, done) => {
void handle(request, reply).then(
() => done(),
() => {
if (!reply.sent) reply.code(503).send({ code: "auth_unavailable", error: "Authentication is unavailable" });
done();
},
);
};
}
export function requireCsrf(request: FastifyRequest, reply: FastifyReply): true | FastifyReply {
const expectedOrigin = request.authPublicOrigin;
const token = request.authSessionToken;
if (!expectedOrigin || !token) return authenticationRequired(reply);
if (!matchesOrigin(request, expectedOrigin)) return csrfFailed(reply);
const header = singleHeader(request.headers["x-thothii-csrf"]);
const supplied = header === false || header === undefined || !SESSION_TOKEN.test(header) ? undefined : header;
let expected = "";
try {
expected = deriveCsrfToken(token);
} catch {
return authenticationRequired(reply);
}
if (!csrfTokensEqual(expected, supplied)) return csrfFailed(reply);
return true;
}
/** Require an exact configured public origin and browser Fetch Metadata when supplied. */
export function requireExactOrigin(
request: FastifyRequest,
reply: FastifyReply,
expectedOrigin: string,
): true | FastifyReply {
return matchesOrigin(request, expectedOrigin) ? true : csrfFailed(reply);
}
export function sessionCookieName(): string { return SESSION_COOKIE; }
function authenticationRequired(reply: FastifyReply): FastifyReply {
return reply.code(401).send({ code: "authentication_required", error: "Authentication is required" });
}
function csrfFailed(reply: FastifyReply): FastifyReply {
return reply.code(403).send({ code: "csrf_failed", error: "Request origin validation failed" });
}
function configuredOrigin(authentication: AuthenticationConfigProvider | undefined): string | undefined {
try {
const publicUrl = authentication?.current().value.publicUrl;
return publicUrl ? new URL(publicUrl).origin : undefined;
} catch {
return undefined;
}
}
function readSessionCookie(request: FastifyRequest): string | false | undefined {
const raw = request.headers.cookie;
if (raw === undefined) return undefined;
if (Array.isArray(raw) || typeof raw !== "string" || raw.length > 4096) return false;
const values = raw.split(";").filter((part) => /^\s*thothii_session(?:=|\s*$)/.test(part));
if (values.length !== 1) return values.length === 0 ? undefined : false;
const match = /^\s*thothii_session=([A-Za-z0-9_-]{43})\s*$/.exec(values[0]);
return match?.[1] ?? false;
}
function singleHeader(value: string | string[] | undefined): string | false | undefined {
if (value === undefined) return undefined;
if (Array.isArray(value) || typeof value !== "string" || value.includes(",")) return false;
return value;
}
function matchesOrigin(request: FastifyRequest, expectedOrigin: string): boolean {
const origin = singleHeader(request.headers.origin);
if (origin !== expectedOrigin) return false;
const fetchSite = singleHeader(request.headers["sec-fetch-site"]);
return fetchSite === undefined || fetchSite === "same-origin";
}
function isPublicRoute(request: FastifyRequest): boolean {
const rawUrl = request.raw.url ?? request.url;
const query = rawUrl.indexOf("?");
const pathname = query === -1 ? rawUrl : rawUrl.slice(0, query);
return (request.method === "GET" && (pathname === "/health" || pathname === "/auth/config"
|| pathname === "/auth/oidc/login" || pathname === "/auth/oidc/callback"))
|| (request.method === "POST" && pathname === "/auth/local/login");
}
export function getPrincipal(req: FastifyRequest): PrincipalContext {
if (!req.principal) throw new Error("principal missing after authentication");
return req.principal;
+13
View File
@@ -0,0 +1,13 @@
import { timingSafeEqual } from "node:crypto";
import { deriveCsrfToken as deriveStoredCsrfToken } from "./session-store.js";
export { deriveStoredCsrfToken as deriveCsrfToken };
/** Compare a client-supplied CSRF value without exposing a useful length timing oracle. */
export function csrfTokensEqual(expectedToken: string, suppliedToken: string | undefined): boolean {
const expected = Buffer.from(expectedToken, "utf8");
const supplied = Buffer.from(suppliedToken ?? "", "utf8");
const padded = Buffer.alloc(expected.length);
supplied.copy(padded, 0, 0, expected.length);
return timingSafeEqual(expected, padded) && supplied.length === expected.length;
}
+257
View File
@@ -0,0 +1,257 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { randomBytes } from "node:crypto";
import type { AuthenticationConfigProvider } from "./types.js";
import type { LocalUserRecord, LocalUserRegistry } from "./local-registry.js";
import type { AuthSessionStore } from "./session-store.js";
import { rolesToPermissions } from "./config.js";
import { getPrincipal, requireExactOrigin, sessionCookieName } from "./auth.js";
import { requirePermission, isPrincipalContext } from "./authorization.js";
import { deriveCsrfToken } from "./csrf.js";
const TEN_MINUTES_MS = 10 * 60 * 1000;
const REMEMBER_COOKIE_SECONDS = 2_592_000;
const MAX_USERNAME_LENGTH = 64;
const MAX_PASSWORD_LENGTH = 1024;
const MAX_LIMIT_ENTRIES = 10_000;
export interface AuthRouteDependencies {
authMode: "local" | "oidc" | "upstream" | "none" | "mock";
authentication?: AuthenticationConfigProvider;
sessionStore?: AuthSessionStore;
localUserRegistry?: LocalUserRegistry;
}
interface LoginPayload {
username: string;
password: string;
remember: boolean;
}
class LoginFailureLimiter {
private readonly usernames = new Map<string, number[]>();
private readonly addresses = new Map<string, number[]>();
isLimited(username: string, address: string, now = Date.now()): boolean {
return this.active(this.usernames, username, now).length >= 10
|| this.active(this.addresses, address, now).length >= 20;
}
recordFailure(username: string, address: string, now = Date.now()): void {
this.active(this.usernames, username, now).push(now);
this.active(this.addresses, address, now).push(now);
}
private active(bucket: Map<string, number[]>, key: string, now: number): number[] {
const prior = bucket.get(key) ?? [];
const current = prior.filter((timestamp) => timestamp > now - TEN_MINUTES_MS);
if (current.length === 0) bucket.delete(key); else bucket.set(key, current);
if (!bucket.has(key) && bucket.size >= MAX_LIMIT_ENTRIES) {
const oldest = bucket.keys().next().value;
if (typeof oldest === "string") bucket.delete(oldest);
}
if (!bucket.has(key)) bucket.set(key, current);
return current;
}
}
class VerificationGate {
private active = 0;
async run(operation: () => Promise<boolean>): Promise<boolean | undefined> {
if (this.active >= 2) return undefined;
this.active += 1;
try {
return await operation();
} finally {
this.active -= 1;
}
}
}
export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDependencies): void {
const limiter = new LoginFailureLimiter();
const verificationGate = new VerificationGate();
app.get("/auth/config", async (_request, reply) => {
try {
const mode = deps.authentication?.current().value.mode ?? deps.authMode;
return reply.send({ mode, localLogin: mode === "local", oidcLogin: false });
} catch {
return unavailable(reply);
}
});
app.post("/auth/local/login", async (request, reply) => {
const configured = currentLocalConfig(deps);
if (!configured || !deps.localUserRegistry || !deps.sessionStore) return unavailable(reply);
const originCheck = requireExactOrigin(request, reply, configured.origin);
if (originCheck !== true) return originCheck;
const payload = loginPayload(request);
const normalizedUsername = payload.username.replace(/[A-Z]/g, (character) => character.toLowerCase());
const sourceAddress = boundedAddress(request.ip);
if (limiter.isLimited(normalizedUsername, sourceAddress)) return loginLimited(reply);
let user: LocalUserRecord | undefined;
try {
if (payload.username.length > 0) user = await deps.localUserRegistry.findByUsername(payload.username);
} catch {
user = undefined;
}
let verified: boolean | undefined;
try {
verified = await verificationGate.run(async () =>
deps.localUserRegistry!.verify(user, argon2SafePassword(payload.password)));
} catch {
return unavailable(reply);
}
if (verified === undefined) return loginLimited(reply);
if (!verified || !user || !user.enabled) {
limiter.recordFailure(normalizedUsername, sourceAddress);
return invalidCredentials(reply);
}
try {
const created = await deps.sessionStore.create({
principal: {
issuer: "local",
subject: user.id,
displayName: user.displayName ?? user.username,
roles: user.roles,
permissions: rolesToPermissions(user.roles),
isAdmin: user.roles.includes("admin"),
},
method: "local",
remembered: payload.remember,
userAuthRevision: user.authRevision,
authConfigRevision: configured.revision,
idleTtlMs: (payload.remember ? configured.session.rememberIdleSeconds : configured.session.regularIdleSeconds) * 1000,
absoluteTtlMs: (payload.remember ? configured.session.rememberTtlSeconds : configured.session.regularTtlSeconds) * 1000,
});
reply.setCookie(sessionCookieName(), created.token, cookieOptions(configured.secure, payload.remember));
return reply.send({});
} catch {
return unavailable(reply);
}
});
app.get("/auth/oidc/login", async (_request, reply) => notImplemented(reply));
app.get("/auth/oidc/callback", async (_request, reply) => notImplemented(reply));
app.post("/auth/logout", async (request, reply) => {
const token = request.authSessionToken;
if (!token || !deps.sessionStore) return unavailable(reply);
try {
await deps.sessionStore.revoke(token);
reply.clearCookie(sessionCookieName(), cookieOptions(currentSecure(deps), false));
return reply.code(204).send();
} catch {
return unavailable(reply);
}
});
app.get("/me", async (request, reply) => {
const principal = requirePermission(request, reply, "session.use");
if (!isPrincipalContext(principal)) return principal;
const session = request.authSession;
const token = request.authSessionToken;
if (!session || !token) return unavailable(reply);
try {
return {
issuer: principal.issuer,
subject: principal.subject,
...(principal.displayName === undefined ? {} : { displayName: principal.displayName }),
roles: principal.roles,
permissions: principal.permissions,
isAdmin: principal.isAdmin,
csrfToken: deriveCsrfToken(token),
session: {
method: session.method,
remembered: session.remembered,
idleExpiresAt: session.idleExpiresAt,
absoluteExpiresAt: session.absoluteExpiresAt,
},
};
} catch {
return unavailable(reply);
}
});
}
function currentLocalConfig(deps: AuthRouteDependencies): {
revision: string;
origin: string;
secure: boolean;
session: { regularTtlSeconds: number; regularIdleSeconds: number; rememberTtlSeconds: number; rememberIdleSeconds: number };
} | undefined {
try {
const loaded = deps.authentication?.current();
if (!loaded || loaded.value.mode !== "local") return undefined;
const url = new URL(loaded.value.publicUrl);
return {
revision: loaded.revision,
origin: url.origin,
secure: url.protocol === "https:",
session: loaded.value.session,
};
} catch {
return undefined;
}
}
function currentSecure(deps: AuthRouteDependencies): boolean {
try { return new URL(deps.authentication?.current().value.publicUrl ?? "").protocol === "https:"; } catch { return false; }
}
function cookieOptions(secure: boolean, remembered: boolean) {
return {
httpOnly: true,
sameSite: "lax" as const,
path: "/",
secure,
...(remembered ? { maxAge: REMEMBER_COOKIE_SECONDS } : {}),
};
}
function loginPayload(request: FastifyRequest): LoginPayload {
const body = request.body;
if (!body || typeof body !== "object" || Array.isArray(body)) return { username: "", password: "", remember: false };
const input = body as Record<string, unknown>;
return {
username: typeof input.username === "string" && input.username.length <= MAX_USERNAME_LENGTH ? input.username : "",
password: typeof input.password === "string" && input.password.length <= MAX_PASSWORD_LENGTH ? input.password : "",
remember: input.remember === true,
};
}
function boundedAddress(address: string): string {
return typeof address === "string" && address.length > 0 && address.length <= 128 ? address : "unknown";
}
function argon2SafePassword(value: string): string {
const typed = value as string & { isWellFormed?: () => boolean };
const wellFormed = typeof typed.isWellFormed === "function"
? typed.isWellFormed()
: !/[\uD800-\uDFFF]/.test(value);
const bytes = Buffer.byteLength(value, "utf8");
if (wellFormed && bytes >= 12 && bytes <= MAX_PASSWORD_LENGTH) return value;
// A per-attempt random value preserves the Argon2 work without turning an invalid input into
// a reusable password that could happen to match a user's configured secret.
return randomBytes(32).toString("base64url");
}
function invalidCredentials(reply: FastifyReply): FastifyReply {
return reply.code(401).send({ code: "invalid_credentials", error: "Invalid username or password" });
}
function loginLimited(reply: FastifyReply): FastifyReply {
return reply.code(429).send({ code: "login_rate_limited", error: "Too many login attempts" });
}
function unavailable(reply: FastifyReply): FastifyReply {
return reply.code(503).send({ code: "auth_unavailable", error: "Authentication is unavailable" });
}
function notImplemented(reply: FastifyReply): FastifyReply {
return reply.code(501).send({ code: "auth_not_implemented", error: "OIDC login is not implemented" });
}