227 lines
9.1 KiB
TypeScript
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;
|
|
}
|