fix(auth): bind request auth snapshots
This commit is contained in:
+54
-28
@@ -1,4 +1,4 @@
|
||||
import Fastify, { type FastifyInstance } from "fastify";
|
||||
import Fastify, { type FastifyInstance, type FastifyRequest } from "fastify";
|
||||
import cors from "@fastify/cors";
|
||||
import cookie from "@fastify/cookie";
|
||||
import rateLimit from "@fastify/rate-limit";
|
||||
@@ -8,11 +8,11 @@ import type { AppConfig } from "./config.js";
|
||||
import { ThtRunner } from "./tht/tht-runner.js";
|
||||
import { PiProcessManager } from "./pi/pi-process-manager.js";
|
||||
import { SseHub } from "./sse/sse-hub.js";
|
||||
import { authenticateSession } from "./auth/auth.js";
|
||||
import { authenticateSession, captureAuthConfigSnapshot, configuredOrigin } from "./auth/auth.js";
|
||||
import type { PrincipalContext } from "./auth/principal.js";
|
||||
import type { LoadedAuthConfig } from "./auth/types.js";
|
||||
import { createCurrentLocalUserRegistryResolver, type LocalUserRegistry } from "./auth/local-registry.js";
|
||||
import { AuthSessionOperationalError, createFileAuthSessionStore, type AuthSessionStore } from "./auth/session-store.js";
|
||||
import { AuthSessionOperationalError, createFileAuthSessionStore, type AuthSessionStore, type AuthSessionValidity } from "./auth/session-store.js";
|
||||
import { registerAuthRoutes } from "./auth/routes.js";
|
||||
import { sessionRoutes } from "./routes/sessions.js";
|
||||
import { sqlRoutes } from "./routes/sql.js";
|
||||
@@ -56,6 +56,9 @@ export interface AppWithAuthSessionStore extends FastifyInstance {
|
||||
|
||||
export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstance {
|
||||
const app = Fastify({ logger: { level: "warn" }, disableRequestLogging: true });
|
||||
app.decorateRequest("authConfigSnapshot", undefined);
|
||||
app.decorateRequest("authConfigSnapshotCaptured", false);
|
||||
app.decorateRequest("authConfigSnapshotUnavailable", false);
|
||||
const isolatedTestRoot = process.env.VITEST === "true"
|
||||
? join(tmpdir(), `thothii-workspace-secrets-vitest-${process.pid}`)
|
||||
: undefined;
|
||||
@@ -69,19 +72,18 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
|
||||
|
||||
const cookieAuth = config.authMode === "local" || config.authMode === "oidc";
|
||||
app.register(cors, {
|
||||
origin: cookieAuth
|
||||
? (origin, callback) => {
|
||||
try {
|
||||
const allowed = new URL(config.authentication?.current().value.publicUrl ?? "").origin;
|
||||
const requested = origin === undefined ? undefined : new URL(origin).origin;
|
||||
callback(null, requested === allowed ? allowed : false);
|
||||
} catch {
|
||||
callback(null, false);
|
||||
}
|
||||
}
|
||||
: true,
|
||||
credentials: cookieAuth,
|
||||
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
// The delegator runs at CORS's onRequest hook. It owns the one request-scoped config load
|
||||
// which subsequent auth hooks and routes consume, including preflights that end here.
|
||||
delegator: (request, callback) => {
|
||||
const snapshot = captureAuthConfigSnapshot(request, config.authentication);
|
||||
const origin = configuredOrigin(snapshot);
|
||||
const snapshotUsesCookies = snapshot?.value.mode === "local" || snapshot?.value.mode === "oidc";
|
||||
callback(null, {
|
||||
origin: snapshotUsesCookies && origin ? corsOrigin(request, origin) : cookieAuth ? false : true,
|
||||
credentials: snapshotUsesCookies,
|
||||
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
});
|
||||
},
|
||||
});
|
||||
// Cookie parsing and the rate-limit plugin must precede every auth/application route.
|
||||
app.register(cookie);
|
||||
@@ -167,6 +169,29 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
|
||||
const resolveLocalUserRegistry = (loaded: LoadedAuthConfig) => {
|
||||
return deps?.localUserRegistry ?? localRegistryResolver?.resolve(loaded);
|
||||
};
|
||||
const localUserForSnapshot = async (loaded: LoadedAuthConfig, subject: string) => {
|
||||
try {
|
||||
if (loaded.value.mode !== "local") return { revision: loaded.revision, user: undefined };
|
||||
const registry = resolveLocalUserRegistry(loaded);
|
||||
if (!registry) throw new AuthSessionOperationalError();
|
||||
const user = await registry.findBySubject(subject);
|
||||
return {
|
||||
revision: loaded.revision,
|
||||
user: user === undefined ? undefined : {
|
||||
enabled: user.enabled,
|
||||
authRevision: user.authRevision,
|
||||
roles: user.roles,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AuthSessionOperationalError) throw error;
|
||||
throw new AuthSessionOperationalError();
|
||||
}
|
||||
};
|
||||
const sessionValidityForSnapshot = (loaded: LoadedAuthConfig): AuthSessionValidity => ({
|
||||
currentAuthConfigRevision: () => loaded.revision,
|
||||
currentLocalUser: (subject) => localUserForSnapshot(loaded, subject),
|
||||
});
|
||||
const authSessionStore = deps?.authSessionStore ?? (config.authMode === "local" || config.authMode === "oidc"
|
||||
? createFileAuthSessionStore(config.authStateRoot, {
|
||||
currentAuthConfigRevision: () => {
|
||||
@@ -179,18 +204,8 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
|
||||
currentLocalUser: async (subject) => {
|
||||
try {
|
||||
const loaded = config.authentication?.current();
|
||||
if (!loaded || loaded.value.mode !== "local") return { revision: "", user: undefined };
|
||||
const registry = resolveLocalUserRegistry(loaded);
|
||||
if (!registry) throw new AuthSessionOperationalError();
|
||||
const user = await registry.findBySubject(subject);
|
||||
return {
|
||||
revision: loaded.revision,
|
||||
user: user === undefined ? undefined : {
|
||||
enabled: user.enabled,
|
||||
authRevision: user.authRevision,
|
||||
roles: user.roles,
|
||||
},
|
||||
};
|
||||
if (!loaded) return { revision: "", user: undefined };
|
||||
return await localUserForSnapshot(loaded, subject);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthSessionOperationalError) throw error;
|
||||
throw new AuthSessionOperationalError();
|
||||
@@ -204,6 +219,7 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
|
||||
publicExposure: config.publicExposure,
|
||||
authentication: config.authentication,
|
||||
sessionStore: authSessionStore,
|
||||
sessionValidityForSnapshot,
|
||||
});
|
||||
app.addHook("preHandler", (req, reply, done) => {
|
||||
if (isMaintenanceControl(req.url)) {
|
||||
@@ -284,6 +300,16 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
|
||||
return app;
|
||||
}
|
||||
|
||||
function corsOrigin(request: FastifyRequest, expectedOrigin: string): string | false {
|
||||
const supplied = request.headers.origin;
|
||||
if (typeof supplied !== "string") return false;
|
||||
try {
|
||||
return new URL(supplied).origin === expectedOrigin ? expectedOrigin : false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isLoopback(ip: string): boolean { return ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1"; }
|
||||
function isMaintenanceControl(url: string): boolean {
|
||||
return /^\/internal\/maintenance\/(?:activate|deactivate|status)(?:\?|$)/.test(url);
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user