fix(auth): bind current local registry and CORS
This commit is contained in:
+54
-10
@@ -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" };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user