Files
ThothII/backend/src/auth/auth.ts
T

227 lines
9.1 KiB
TypeScript

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, LoadedAuthConfig } from "./types.js";
import { AuthSessionOperationalError, type AuthSessionStore, type AuthSessionValidity } from "./session-store.js";
import { deriveCsrfToken, csrfTokensEqual } from "./csrf.js";
import { requireSameOriginOrNonBrowser } from "./authorization.js";
declare module "fastify" {
interface FastifyRequest {
principal?: PrincipalContext;
authSession?: AuthSessionRecord;
/** Internal only: never serialize or write this opaque cookie token to logs. */
authSessionToken?: string;
authPublicOrigin?: string;
/** One immutable configuration load for the whole request, including CORS. */
authConfigSnapshot?: LoadedAuthConfig;
authConfigSnapshotCaptured?: boolean;
authConfigSnapshotUnavailable?: boolean;
}
}
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;
sessionValidityForSnapshot?: (snapshot: LoadedAuthConfig) => AuthSessionValidity;
}
/** Capture the authentication configuration once; CORS calls this before every other hook. */
export function captureAuthConfigSnapshot(
request: FastifyRequest,
authentication: AuthenticationConfigProvider | undefined,
): LoadedAuthConfig | undefined {
if (request.authConfigSnapshotCaptured) return request.authConfigSnapshot;
request.authConfigSnapshotCaptured = true;
try {
request.authConfigSnapshot = authentication?.current();
} catch {
request.authConfigSnapshotUnavailable = true;
}
return request.authConfigSnapshot;
}
export function authPreHandler(mode: "none" | "mock" | "upstream", publicExposure = false) {
return async (req: FastifyRequest, reply: FastifyReply) => {
if (mode === "none") {
req.principal = localPrincipal(publicExposure);
} else if (mode === "mock") {
const subject = typeof req.headers["x-mock-user"] === "string" ? req.headers["x-mock-user"].trim() : "mock";
const elevated = req.headers["x-thoth-is-admin"] === "1" || req.headers["x-thoth-is-admin"] === "true";
const roles = elevated ? ["admin"] as const : ["user"] as const;
req.principal = {
issuer: "mock", subject: subject || "mock", displayName: subject || "mock", roles,
permissions: rolesToPermissions(roles), isAdmin: elevated,
};
} else {
const principal = upstreamPrincipal(req.headers);
if (!principal) {
return reply.code(401).send({ error: "authenticated upstream identity required" });
}
req.principal = principal;
}
};
}
/**
* 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> => {
const snapshot = captureAuthConfigSnapshot(request, deps.authentication);
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(snapshot);
if (!snapshot || !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, undefined, deps.sessionValidityForSnapshot?.(snapshot));
if (session && session.authConfigRevision !== snapshot.revision) {
try { await deps.sessionStore.revoke(token); } catch { /* the mismatch remains denied */ }
return authenticationRequired(reply);
}
if (session) await deps.sessionStore.touch(token);
} catch (error) {
if (error instanceof AuthSessionOperationalError) {
return reply.code(503).send({ code: "auth_unavailable", error: "Authentication is unavailable" });
}
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" });
}
export function configuredOrigin(snapshot: LoadedAuthConfig | undefined): string | undefined {
try {
const publicUrl = snapshot?.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);
try {
if (origin === undefined || origin === false || new URL(origin).origin !== expectedOrigin) return false;
} catch {
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;
}