fix(auth): bind current local registry and CORS

This commit is contained in:
2026-08-17 00:34:58 +02:00
parent c34e01e9b9
commit 94c2cd3709
15 changed files with 514 additions and 87 deletions
+54 -10
View File
@@ -1,12 +1,13 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { randomBytes } from "node:crypto";
import type { AuthenticationConfigProvider } from "./types.js";
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 { requirePermission, isPrincipalContext } from "./authorization.js";
import { deriveCsrfToken } from "./csrf.js";
import { verifyWithDummy } from "./password.js";
const TEN_MINUTES_MS = 10 * 60 * 1000;
const REMEMBER_COOKIE_SECONDS = 2_592_000;
@@ -18,7 +19,9 @@ export interface AuthRouteDependencies {
authMode: "local" | "oidc" | "upstream" | "none" | "mock";
authentication?: AuthenticationConfigProvider;
sessionStore?: AuthSessionStore;
/** Test-only compatibility seam; production resolves from each loaded config snapshot. */
localUserRegistry?: LocalUserRegistry;
resolveLocalUserRegistry?: (loaded: LoadedAuthConfig) => LocalUserRegistry | undefined;
}
interface LoginPayload {
@@ -90,6 +93,23 @@ class VerificationGate {
}
}
async function unavailableAfterDummy(
gate: VerificationGate,
password: string,
reply: FastifyReply,
): Promise<FastifyReply> {
try {
const completed = await gate.run(async () => {
await verifyWithDummy(password);
return true;
});
if (completed === undefined) return loginLimited(reply);
} catch {
// Preserve the sanitized operational outcome below.
}
return unavailable(reply);
}
export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDependencies): void {
const limiter = new LoginFailureLimiter();
const verificationGate = new VerificationGate();
@@ -105,25 +125,29 @@ export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDependen
app.post("/auth/local/login", async (request, reply) => {
const configured = currentLocalConfig(deps);
if (!configured || !deps.localUserRegistry || !deps.sessionStore) return unavailable(reply);
if (configured.kind === "unavailable") {
return unavailableAfterDummy(verificationGate, argon2SafePassword(loginPayload(request).password), reply);
}
if (configured.kind !== "local" || !deps.sessionStore) return unavailable(reply);
const originCheck = requireExactOrigin(request, reply, configured.origin);
if (originCheck !== true) return originCheck;
const payload = loginPayload(request);
const safePassword = argon2SafePassword(payload.password);
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);
if (payload.username.length > 0) user = await configured.registry.findByUsername(payload.username);
} catch {
user = undefined;
return unavailableAfterDummy(verificationGate, safePassword, reply);
}
let verified: boolean | undefined;
try {
verified = await verificationGate.run(async () =>
deps.localUserRegistry!.verify(user, argon2SafePassword(payload.password)));
configured.registry.verify(user, safePassword));
} catch {
return unavailable(reply);
}
@@ -177,7 +201,18 @@ export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDependen
if (!isPrincipalContext(principal)) return principal;
const session = request.authSession;
const token = request.authSessionToken;
if (!session || !token) return unavailable(reply);
if (!session || !token) {
return {
issuer: principal.issuer,
subject: principal.subject,
...(principal.displayName === undefined ? {} : { displayName: principal.displayName }),
roles: principal.roles,
permissions: principal.permissions,
isAdmin: principal.isAdmin,
csrfToken: null,
session: null,
};
}
try {
return {
issuer: principal.issuer,
@@ -200,24 +235,33 @@ export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDependen
});
}
function currentLocalConfig(deps: AuthRouteDependencies): {
function currentLocalConfig(deps: AuthRouteDependencies):
| {
revision: string;
origin: string;
secure: boolean;
session: { regularTtlSeconds: number; regularIdleSeconds: number; rememberTtlSeconds: number; rememberIdleSeconds: number };
} | undefined {
registry: LocalUserRegistry;
kind: "local";
}
| { kind: "not_local" }
| { kind: "unavailable" } {
try {
const loaded = deps.authentication?.current();
if (!loaded || loaded.value.mode !== "local") return undefined;
if (!loaded || 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);
return {
kind: "local",
revision: loaded.revision,
origin: url.origin,
secure: url.protocol === "https:",
session: loaded.value.session,
registry,
};
} catch {
return undefined;
return { kind: "unavailable" };
}
}