fix(auth): bind request auth snapshots

This commit is contained in:
2026-08-17 01:11:25 +02:00
parent 94c2cd3709
commit 09e546c1ef
8 changed files with 308 additions and 58 deletions
+32 -7
View File
@@ -1,8 +1,8 @@
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 { AuthSessionOperationalError, type AuthSessionStore } from "./session-store.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";
@@ -13,6 +13,10 @@ declare module "fastify" {
/** 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;
}
}
@@ -25,6 +29,22 @@ export interface AuthDependencies {
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) {
@@ -59,6 +79,7 @@ export function authenticateSession(deps: AuthDependencies): preHandlerHookHandl
: undefined;
const handle = async (request: FastifyRequest, reply: FastifyReply): Promise<void> => {
const snapshot = captureAuthConfigSnapshot(request, deps.authentication);
if (isPublicRoute(request)) return;
if (legacy) {
@@ -67,8 +88,8 @@ export function authenticateSession(deps: AuthDependencies): preHandlerHookHandl
return requireSameOriginOrNonBrowser(request, reply);
}
const origin = configuredOrigin(deps.authentication);
if (!origin || !deps.sessionStore) {
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);
@@ -76,7 +97,11 @@ export function authenticateSession(deps: AuthDependencies): preHandlerHookHandl
let session: AuthSessionRecord | undefined;
try {
session = await deps.sessionStore.resolve(token);
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) {
@@ -150,9 +175,9 @@ 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 {
export function configuredOrigin(snapshot: LoadedAuthConfig | undefined): string | undefined {
try {
const publicUrl = authentication?.current().value.publicUrl;
const publicUrl = snapshot?.value.publicUrl;
return publicUrl ? new URL(publicUrl).origin : undefined;
} catch {
return undefined;
+13 -15
View File
@@ -4,7 +4,7 @@ import type { AuthenticationConfigProvider, LoadedAuthConfig } 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 { captureAuthConfigSnapshot, getPrincipal, requireExactOrigin, sessionCookieName } from "./auth.js";
import { requirePermission, isPrincipalContext } from "./authorization.js";
import { deriveCsrfToken } from "./csrf.js";
import { verifyWithDummy } from "./password.js";
@@ -114,17 +114,15 @@ export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDependen
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.get("/auth/config", async (request, reply) => {
const snapshot = captureAuthConfigSnapshot(request, deps.authentication);
if (!snapshot) return unavailable(reply);
const mode = snapshot.value.mode;
return reply.send({ mode, localLogin: mode === "local", oidcLogin: false });
});
app.post("/auth/local/login", async (request, reply) => {
const configured = currentLocalConfig(deps);
const configured = currentLocalConfig(captureAuthConfigSnapshot(request, deps.authentication), deps);
if (configured.kind === "unavailable") {
return unavailableAfterDummy(verificationGate, argon2SafePassword(loginPayload(request).password), reply);
}
@@ -189,7 +187,7 @@ export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDependen
if (!token || !deps.sessionStore) return unavailable(reply);
try {
await deps.sessionStore.revoke(token);
reply.clearCookie(sessionCookieName(), cookieOptions(currentSecure(deps), false));
reply.clearCookie(sessionCookieName(), cookieOptions(currentSecure(request), false));
return reply.code(204).send();
} catch {
return unavailable(reply);
@@ -235,7 +233,7 @@ export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDependen
});
}
function currentLocalConfig(deps: AuthRouteDependencies):
function currentLocalConfig(loaded: LoadedAuthConfig | undefined, deps: AuthRouteDependencies):
| {
revision: string;
origin: string;
@@ -247,8 +245,8 @@ function currentLocalConfig(deps: AuthRouteDependencies):
| { kind: "not_local" }
| { kind: "unavailable" } {
try {
const loaded = deps.authentication?.current();
if (!loaded || loaded.value.mode !== "local") return { kind: "not_local" };
if (!loaded) return { kind: "unavailable" };
if (loaded.value.mode !== "local") return { kind: "not_local" };
const registry = deps.resolveLocalUserRegistry?.(loaded) ?? deps.localUserRegistry;
if (!registry) return { kind: "unavailable" };
const url = new URL(loaded.value.publicUrl);
@@ -265,8 +263,8 @@ function currentLocalConfig(deps: AuthRouteDependencies):
}
}
function currentSecure(deps: AuthRouteDependencies): boolean {
try { return new URL(deps.authentication?.current().value.publicUrl ?? "").protocol === "https:"; } catch { return false; }
function currentSecure(request: FastifyRequest): boolean {
return request.authConfigSnapshot?.value.publicUrl.startsWith("https:") ?? false;
}
function cookieOptions(secure: boolean, remembered: boolean) {
+8 -4
View File
@@ -105,7 +105,7 @@ export interface AuthSessionValidity {
export interface AuthSessionStore {
create(input: SessionCreateInput, now?: Date): Promise<CreatedAuthSession>;
resolve(token: string, now?: Date): Promise<AuthSessionRecord | undefined>;
resolve(token: string, now?: Date, validity?: AuthSessionValidity): Promise<AuthSessionRecord | undefined>;
touch(token: string, now?: Date): Promise<void>;
revoke(token: string): Promise<void>;
prune(now?: Date): Promise<number>;
@@ -801,7 +801,11 @@ export function createFileAuthSessionStore(
throw invalid();
}
async function resolveSession(token: string, now = new Date()): Promise<AuthSessionRecord | undefined> {
async function resolveSession(
token: string,
now = new Date(),
requestValidity = validity,
): Promise<AuthSessionRecord | undefined> {
if (!canonicalRawValue(token)) return undefined;
const nowMs = dateMilliseconds(now);
const filename = digestFilename(token);
@@ -816,7 +820,7 @@ export function createFileAuthSessionStore(
return undefined;
}
try {
if (await recordIsCurrent(record, validity)) return record;
if (await recordIsCurrent(record, requestValidity)) return record;
} catch (error) {
if (error instanceof AuthSessionOperationalError) throw error;
await bridge.remove(root, "sessions", filename);
@@ -833,7 +837,7 @@ export function createFileAuthSessionStore(
return undefined;
}
try {
if (await recordIsCurrent(trusted.value, validity)) return trusted.value;
if (await recordIsCurrent(trusted.value, requestValidity)) return trusted.value;
} catch (error) {
if (error instanceof AuthSessionOperationalError) throw error;
removeTrusted(directories.sessions, filename, trusted.identity);