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
+50 -20
View File
@@ -2,7 +2,7 @@ import Fastify, { type FastifyInstance } from "fastify";
import cors from "@fastify/cors";
import cookie from "@fastify/cookie";
import rateLimit from "@fastify/rate-limit";
import { dirname, join } from "node:path";
import { join } from "node:path";
import { tmpdir } from "node:os";
import type { AppConfig } from "./config.js";
import { ThtRunner } from "./tht/tht-runner.js";
@@ -10,8 +10,9 @@ import { PiProcessManager } from "./pi/pi-process-manager.js";
import { SseHub } from "./sse/sse-hub.js";
import { authenticateSession } from "./auth/auth.js";
import type { PrincipalContext } from "./auth/principal.js";
import { createLocalUserRegistry, type LocalUserRegistry } from "./auth/local-registry.js";
import { createFileAuthSessionStore, type AuthSessionStore } from "./auth/session-store.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 { registerAuthRoutes } from "./auth/routes.js";
import { sessionRoutes } from "./routes/sessions.js";
import { sqlRoutes } from "./routes/sql.js";
@@ -66,10 +67,20 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
installationId: config.workspaceRegistry.installationId,
});
// Allow any origin in dev/e2e; tighten in production via config if needed.
const cookieAuth = config.authMode === "local" || config.authMode === "oidc";
app.register(cors, {
origin: true,
credentials: true,
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"],
});
// Cookie parsing and the rate-limit plugin must precede every auth/application route.
@@ -150,22 +161,40 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
const piManagement = deps?.piManagement ?? createPiManagement(config, { listModels });
const maintenanceBarrier = deps?.maintenanceBarrier ?? new MaintenanceBarrier(config.maintenanceFile);
const loadedAuthentication = config.authentication?.current();
const configuredLocalRegistry = loadedAuthentication?.value.mode === "local"
? createLocalUserRegistry(join(dirname(loadedAuthentication.sourcePath), loadedAuthentication.value.local.usersFile))
const localRegistryResolver = deps?.localUserRegistry === undefined
? createCurrentLocalUserRegistryResolver()
: undefined;
const localUserRegistry = deps?.localUserRegistry ?? configuredLocalRegistry;
const resolveLocalUserRegistry = (loaded: LoadedAuthConfig) => {
return deps?.localUserRegistry ?? localRegistryResolver?.resolve(loaded);
};
const authSessionStore = deps?.authSessionStore ?? (config.authMode === "local" || config.authMode === "oidc"
? createFileAuthSessionStore(config.authStateRoot, {
currentAuthConfigRevision: () => config.authentication?.current().revision ?? "",
findLocalUser: async (subject) => {
if (config.authentication?.current().value.mode !== "local") return undefined;
const user = await localUserRegistry?.findBySubject(subject);
return user === undefined ? undefined : {
enabled: user.enabled,
authRevision: user.authRevision,
roles: user.roles,
};
currentAuthConfigRevision: () => {
try {
return config.authentication?.current().revision ?? "";
} catch {
throw new AuthSessionOperationalError();
}
},
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,
},
};
} catch (error) {
if (error instanceof AuthSessionOperationalError) throw error;
throw new AuthSessionOperationalError();
}
},
})
: undefined);
@@ -204,7 +233,8 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
authMode: config.authMode,
authentication: config.authentication,
sessionStore: authSessionStore,
localUserRegistry,
localUserRegistry: deps?.localUserRegistry,
resolveLocalUserRegistry,
});
sessionRoutes(app, {
mgr, tht: tht as ThtRunner, hub, getSettings, readiness, listModels, workspaceRegistry,
+10 -3
View File
@@ -2,7 +2,7 @@ import type { FastifyRequest, FastifyReply, preHandlerHookHandler } from "fastif
import { localPrincipal, type PrincipalContext, upstreamPrincipal } from "./principal.js";
import { rolesToPermissions } from "./config.js";
import type { AuthenticationConfigProvider, AuthMode, AuthSessionRecord } from "./types.js";
import type { AuthSessionStore } from "./session-store.js";
import { AuthSessionOperationalError, type AuthSessionStore } from "./session-store.js";
import { deriveCsrfToken, csrfTokensEqual } from "./csrf.js";
import { requireSameOriginOrNonBrowser } from "./authorization.js";
@@ -78,7 +78,10 @@ export function authenticateSession(deps: AuthDependencies): preHandlerHookHandl
try {
session = await deps.sessionStore.resolve(token);
if (session) await deps.sessionStore.touch(token);
} catch {
} 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);
@@ -174,7 +177,11 @@ function singleHeader(value: string | string[] | undefined): string | false | un
function matchesOrigin(request: FastifyRequest, expectedOrigin: string): boolean {
const origin = singleHeader(request.headers.origin);
if (origin !== expectedOrigin) return false;
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";
}
+21 -2
View File
@@ -8,11 +8,11 @@ import {
realpathSync,
} from "node:fs";
import type { Stats } from "node:fs";
import { dirname, isAbsolute, normalize } from "node:path";
import { dirname, isAbsolute, join, normalize } from "node:path";
import { parseDocument } from "yaml";
import { z } from "zod";
import { isValidPasswordHash, verifyPassword, verifyWithDummy } from "./password.js";
import type { Role } from "./types.js";
import type { LoadedAuthConfig, Role } from "./types.js";
const MAX_USERS_YAML_BYTES = 1 << 20;
const USERNAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._@-]{2,63}$/;
@@ -37,6 +37,11 @@ export interface LocalUserRegistry {
verify(user: LocalUserRecord | undefined, password: string): Promise<boolean>;
}
/** Keeps only the registry named by the current coherent authentication-config snapshot. */
export interface CurrentLocalUserRegistryResolver {
resolve(loaded: LoadedAuthConfig): LocalUserRegistry | undefined;
}
interface FileIdentity {
dev: number;
ino: number;
@@ -253,3 +258,17 @@ export function createLocalUserRegistry(usersPath: string): LocalUserRegistry {
},
};
}
export function createCurrentLocalUserRegistryResolver(): CurrentLocalUserRegistryResolver {
let current: { usersPath: string; registry: LocalUserRegistry } | undefined;
return {
resolve(loaded: LoadedAuthConfig): LocalUserRegistry | undefined {
if (loaded.value.mode !== "local") return undefined;
const usersPath = join(dirname(loaded.sourcePath), loaded.value.local.usersFile);
if (current?.usersPath === usersPath) return current.registry;
const registry = createLocalUserRegistry(usersPath);
current = { usersPath, registry };
return registry;
},
};
}
+20 -14
View File
@@ -1,13 +1,11 @@
import { argon2, timingSafeEqual } from "node:crypto";
const MAXIMUM_PHC_BYTES = 256;
const MAXIMUM_MEMORY_KIB = 256 * 1024;
const MAXIMUM_PASSES = 10;
const MAXIMUM_PARALLELISM = 4;
const MINIMUM_SALT_BYTES = 16;
const MAXIMUM_SALT_BYTES = 64;
const MINIMUM_KEY_BYTES = 16;
const MAXIMUM_KEY_BYTES = 64;
const ARGON2_MEMORY_KIB = 65_536;
const ARGON2_PASSES = 3;
const ARGON2_PARALLELISM = 1;
const ARGON2_SALT_BYTES = 16;
const ARGON2_KEY_BYTES = 32;
const MINIMUM_PASSWORD_BYTES = 12;
const MAXIMUM_PASSWORD_BYTES = 1024;
@@ -19,6 +17,13 @@ interface Argon2Parameters {
digest: Buffer;
}
/** Internal-only signal for unavailable native Argon2 work; never expose its cause. */
export class LocalPasswordVerificationError extends Error {
constructor() {
super("local_password_verification_failed");
}
}
function parseDecimal(value: string, maximum: number): number | undefined {
if (!/^\d+$/.test(value) || (value.length > 1 && value[0] === "0") || value.length > 10) return undefined;
const parsed = Number(value);
@@ -46,13 +51,13 @@ function parsePHC(encoded: string): Argon2Parameters | undefined {
const parameterParts = parts[3].split(",");
if (parameterParts.length !== 3 || !parameterParts[0].startsWith("m=") || !parameterParts[1].startsWith("t=") || !parameterParts[2].startsWith("p=")) return undefined;
const memory = parseDecimal(parameterParts[0].slice(2), MAXIMUM_MEMORY_KIB);
const passes = parseDecimal(parameterParts[1].slice(2), MAXIMUM_PASSES);
const parallelism = parseDecimal(parameterParts[2].slice(2), MAXIMUM_PARALLELISM);
if (memory === undefined || memory < 8 || passes === undefined || passes === 0 || parallelism === undefined || parallelism === 0 || memory < 8 * parallelism) return undefined;
const memory = parseDecimal(parameterParts[0].slice(2), ARGON2_MEMORY_KIB);
const passes = parseDecimal(parameterParts[1].slice(2), ARGON2_PASSES);
const parallelism = parseDecimal(parameterParts[2].slice(2), ARGON2_PARALLELISM);
if (memory !== ARGON2_MEMORY_KIB || passes !== ARGON2_PASSES || parallelism !== ARGON2_PARALLELISM) return undefined;
const salt = decodeRawBase64(parts[4], MINIMUM_SALT_BYTES, MAXIMUM_SALT_BYTES);
const digest = decodeRawBase64(parts[5], MINIMUM_KEY_BYTES, MAXIMUM_KEY_BYTES);
const salt = decodeRawBase64(parts[4], ARGON2_SALT_BYTES, ARGON2_SALT_BYTES);
const digest = decodeRawBase64(parts[5], ARGON2_KEY_BYTES, ARGON2_KEY_BYTES);
if (!salt || !digest) {
salt?.fill(0);
digest?.fill(0);
@@ -92,6 +97,7 @@ function deriveArgon2(message: Buffer, parameters: Argon2Parameters): Promise<Bu
}
settled = true;
if (error || !derived) {
derived?.fill(0);
reject(error ?? new Error("argon2_failed"));
return;
}
@@ -135,7 +141,7 @@ export async function verifyPassword(password: string, encoded: string): Promise
derived = await deriveArgon2(message, parameters);
return derived.length === parameters.digest.length && timingSafeEqual(derived, parameters.digest);
} catch {
return false;
throw new LocalPasswordVerificationError();
} finally {
message.fill(0);
derived?.fill(0);
+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" };
}
}
+24 -4
View File
@@ -81,13 +81,26 @@ export interface LocalSessionUser {
roles: readonly Role[];
}
export interface CurrentLocalSessionUser {
revision: string;
user: LocalSessionUser | undefined;
}
/** Operational validity-source failures must not masquerade as revoked credentials. */
export class AuthSessionOperationalError extends Error {
constructor() {
super("auth_session_operational_error");
}
}
/**
* The route layer supplies the current installation revision and local-registry lookup.
* Supplying this hook makes every resolve an authorization-generation check.
*/
export interface AuthSessionValidity {
currentAuthConfigRevision(): string | Promise<string>;
findLocalUser(subject: string): LocalSessionUser | undefined | Promise<LocalSessionUser | undefined>;
findLocalUser?(subject: string): LocalSessionUser | undefined | Promise<LocalSessionUser | undefined>;
currentLocalUser?(subject: string): CurrentLocalSessionUser | Promise<CurrentLocalSessionUser>;
}
export interface AuthSessionStore {
@@ -682,10 +695,15 @@ async function recordIsCurrent(record: AuthSessionRecord, validity: AuthSessionV
// A root-only store remains useful for creation/diagnostics, but is intentionally incapable
// of authenticating a principal. Task 8 must supply config and local-registry dependencies.
if (!validity) return false;
if (record.method === "local" && validity.currentLocalUser) {
const current = await validity.currentLocalUser(record.subject);
return typeof current.revision === "string" && current.revision === record.authConfigRevision
&& validLocalUser(current.user, record);
}
const revision = await validity.currentAuthConfigRevision();
if (typeof revision !== "string" || revision !== record.authConfigRevision) return false;
if (record.method !== "local") return true;
return validLocalUser(await validity.findLocalUser(record.subject), record);
return validity.findLocalUser === undefined ? false : validLocalUser(await validity.findLocalUser(record.subject), record);
}
const locks = new Map<string, Promise<void>>();
@@ -799,7 +817,8 @@ export function createFileAuthSessionStore(
}
try {
if (await recordIsCurrent(record, validity)) return record;
} catch {
} catch (error) {
if (error instanceof AuthSessionOperationalError) throw error;
await bridge.remove(root, "sessions", filename);
throw invalid();
}
@@ -815,7 +834,8 @@ export function createFileAuthSessionStore(
}
try {
if (await recordIsCurrent(trusted.value, validity)) return trusted.value;
} catch {
} catch (error) {
if (error instanceof AuthSessionOperationalError) throw error;
removeTrusted(directories.sessions, filename, trusted.identity);
throw invalid();
}
+18 -5
View File
@@ -698,16 +698,29 @@ export function sessionRoutes(
if (!await authorize(principal, id)) return reply.code(404).send({ error: "session not found" });
} catch (error) { return lifecycleFailure(reply, error); }
const rt = d.mgr.get(id);
// Add CORS headers manually: reply.raw.writeHead bypasses Fastify's onSend hooks
// (where @fastify/cors injects headers), so we must set them explicitly here.
const origin = (req.headers.origin as string | undefined) ?? "*";
// reply.raw.writeHead bypasses Fastify's CORS hook. Only a cookie-authenticated request
// from the exact configured public origin receives credentialed SSE CORS headers.
const origin = typeof req.headers.origin === "string" ? req.headers.origin : undefined;
let isConfiguredOrigin = false;
try {
isConfiguredOrigin = req.authPublicOrigin !== undefined
&& origin !== undefined
&& new URL(origin).origin === req.authPublicOrigin;
} catch {
isConfiguredOrigin = false;
}
const corsHeaders = isConfiguredOrigin
? {
"Access-Control-Allow-Origin": req.authPublicOrigin,
"Access-Control-Allow-Credentials": "true",
}
: {};
reply.raw.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
Connection: "keep-alive",
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Credentials": "true",
...corsHeaders,
});
// Send the handshake immediately. Without this, Node waits for the first event body and
// proxies/clients cannot establish an idle SSE subscription or inspect its headers.
+54
View File
@@ -0,0 +1,54 @@
import { afterAll, beforeAll, expect, test } from "vitest";
import { createLocalAuthFixture, type LocalAuthFixture, localPublicUrl } from "./auth-test-fixtures.js";
let fixture: LocalAuthFixture;
beforeAll(async () => {
fixture = await createLocalAuthFixture();
});
afterAll(async () => {
await fixture.close();
});
test("credentialed CORS permits only the current configured public origin", async () => {
const allowedPreflight = await fixture.app.inject({
method: "OPTIONS", url: "/me",
headers: { origin: localPublicUrl, "access-control-request-method": "GET" },
});
expect(allowedPreflight.statusCode).toBe(204);
expect(allowedPreflight.headers["access-control-allow-origin"]).toBe(localPublicUrl);
expect(allowedPreflight.headers["access-control-allow-credentials"]).toBe("true");
const canonicalPreflight = await fixture.app.inject({
method: "OPTIONS", url: "/me",
headers: { origin: "HTTP://127.0.0.1:8787", "access-control-request-method": "GET" },
});
expect(canonicalPreflight.statusCode).toBe(204);
expect(canonicalPreflight.headers["access-control-allow-origin"]).toBe(localPublicUrl);
const attackerPreflight = await fixture.app.inject({
method: "OPTIONS", url: "/me",
headers: { origin: "https://attacker.example.test", "access-control-request-method": "GET" },
});
expect(attackerPreflight.headers["access-control-allow-origin"]).toBeUndefined();
expect(attackerPreflight.headers["access-control-allow-credentials"]).toBeUndefined();
const allowed = await fixture.app.inject({
method: "GET", url: "/me", headers: { cookie: fixture.cookie, origin: localPublicUrl },
});
expect(allowed.statusCode).toBe(200);
expect(allowed.headers["access-control-allow-origin"]).toBe(localPublicUrl);
expect(allowed.headers["access-control-allow-credentials"]).toBe("true");
const attacker = await fixture.app.inject({
method: "GET", url: "/me", headers: { cookie: fixture.cookie, origin: "https://attacker.example.test" },
});
expect(attacker.statusCode).toBe(200);
expect(attacker.headers["access-control-allow-origin"]).toBeUndefined();
expect(attacker.headers["access-control-allow-credentials"]).toBeUndefined();
const nonBrowser = await fixture.app.inject({ method: "GET", url: "/me", headers: { cookie: fixture.cookie } });
expect(nonBrowser.statusCode).toBe(200);
expect(nonBrowser.headers["access-control-allow-origin"]).toBeUndefined();
});
@@ -0,0 +1,88 @@
import { afterEach, expect, test } from "vitest";
import { chmodSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { stringify } from "yaml";
import { buildApp, type AppWithAuthSessionStore } from "../src/app.js";
import { loadAuthenticationConfig } from "../src/auth/config.js";
import { loadConfig } from "../src/config.js";
const password = "correct horse battery staple";
const passwordHash = "$argon2id$v=19$m=65536,t=3,p=1$AAECAwQFBgcICQoLDA0ODw$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4";
const userA = { id: "6ba7b810-9dad-4ed1-80b4-00c04fd430c8", username: "AdminA" };
const userB = { id: "6ba7b811-9dad-4ed1-80b4-00c04fd430c8", username: "AdminB" };
const publicUrl = "http://127.0.0.1:8787";
const cleanups: Array<() => Promise<void>> = [];
afterEach(async () => {
for (const cleanup of cleanups.splice(0).reverse()) await cleanup();
});
function usersYaml(user: typeof userA): string {
return [
"version: 1", "users:", ` - id: ${user.id}`, ` username: ${user.username}`,
` passwordHash: ${passwordHash}`, " roles:", " - admin", " enabled: true", " authRevision: 1", "",
].join("\n");
}
function configYaml(usersFile: string) {
return stringify({ version: 1, mode: "local", publicUrl, local: { usersFile } });
}
function cookiePair(response: { headers: Record<string, string | string[] | undefined> }): string {
const header = response.headers["set-cookie"];
const first = Array.isArray(header) ? header[0] : header;
return first?.split(";", 1)[0] ?? "";
}
test("each login and session resolve uses the current config snapshot users file", async () => {
const directory = mkdtempSync(join(realpathSync(tmpdir()), "thothii-auth-dynamic-"));
chmodSync(directory, 0o700);
const authFile = join(directory, "auth.yaml");
const usersAFile = join(directory, "users-a.yaml");
const usersBFile = join(directory, "users-bbbbb.yaml");
writeFileSync(usersAFile, usersYaml(userA), { encoding: "utf8", mode: 0o600 });
writeFileSync(usersBFile, usersYaml(userB), { encoding: "utf8", mode: 0o600 });
writeFileSync(authFile, configYaml("users-a.yaml"), { encoding: "utf8", mode: 0o600 });
chmodSync(authFile, 0o600);
chmodSync(usersAFile, 0o600);
chmodSync(usersBFile, 0o600);
const app = buildApp(loadConfig({
THT_AUTH_CONFIG_FILE: authFile,
THT_AUTH_STATE_ROOT: join(directory, "auth-state"),
THT_HARNESS_DIR: "/tmp/h",
}));
cleanups.push(async () => {
await app.close();
rmSync(directory, { recursive: true, force: true });
});
const signIn = (username: string) => app.inject({
method: "POST", url: "/auth/local/login",
headers: { origin: publicUrl, "sec-fetch-site": "same-origin" },
payload: { username, password },
});
const first = await signIn(userA.username);
expect(first.statusCode).toBe(200);
const aCookie = cookiePair(first);
expect((await app.inject({ method: "GET", url: "/me", headers: { cookie: aCookie } })).json())
.toMatchObject({ subject: userA.id });
writeFileSync(authFile, configYaml("users-bbbbb.yaml"), { encoding: "utf8", mode: 0o600 });
chmodSync(authFile, 0o600);
expect(readFileSync(usersAFile, "utf8")).toContain(userA.username);
const removedUser = await signIn(userA.username);
expect(removedUser.statusCode).toBe(401);
expect(removedUser.json()).toEqual({ code: "invalid_credentials", error: "Invalid username or password" });
expect((await app.inject({ method: "GET", url: "/me", headers: { cookie: aCookie } })).statusCode).toBe(401);
const second = await signIn(userB.username);
expect(second.statusCode).toBe(200);
const bCookie = cookiePair(second);
expect(await app.inject({ method: "GET", url: "/me", headers: { cookie: bCookie } }).then((response) => response.json()))
.toMatchObject({ subject: userB.id });
const token = bCookie.split("=", 2)[1] ?? "";
const record = await (app as AppWithAuthSessionStore).thothiiAuthSessionStore?.resolve(token);
expect(record).toMatchObject({ subject: userB.id, authConfigRevision: loadAuthenticationConfig(authFile).revision });
});
+22 -1
View File
@@ -9,7 +9,7 @@ vi.mock("node:crypto", async (importOriginal) => {
return { ...actual, argon2: argon2Spy };
});
import { verifyPassword } from "../src/auth/password.js";
import { isValidPasswordHash, verifyPassword } from "../src/auth/password.js";
interface Argon2Vector {
password: string;
@@ -76,6 +76,7 @@ describe("local Argon2id password verification", () => {
`$argon2id$v=19$m=65536,t=3,p=5$${salt}$${digest}`,
`$argon2id$v=19$m=65536,t=3,p=1$${salt}$${"A".repeat(88)}`,
`$argon2id$v=19$m=65536,t=3,p=1$${salt}=$${digest}`,
`$argon2id$v=19$m=8,t=1,p=1$${salt}$${digest}`,
];
for (const phc of cases) {
@@ -84,4 +85,24 @@ describe("local Argon2id password verification", () => {
expect(argon2Spy).not.toHaveBeenCalled();
}
});
test("accepts only the exact Go Argon2id policy in registry PHCs", () => {
const [empty, algorithm, version, parameters, salt, digest] = vectors[0].phc.split("$");
expect(empty).toBe("");
expect(algorithm).toBe("argon2id");
expect(version).toBe("v=19");
expect(isValidPasswordHash(`$${algorithm}$${version}$m=8,t=1,p=1$${salt}$${digest}`)).toBe(false);
expect(isValidPasswordHash(`$${algorithm}$${version}$m=65536,t=1,p=1$${salt}$${digest}`)).toBe(false);
expect(isValidPasswordHash(`$${algorithm}$${version}$m=65536,t=3,p=2$${salt}$${digest}`)).toBe(false);
expect(isValidPasswordHash(`$${algorithm}$${version}$${parameters}$${salt}$${digest}`)).toBe(true);
});
test("surfaces a sanitized operational error when native Argon2 fails", async () => {
argon2Spy.mockImplementationOnce((_algorithm, _parameters, callback) => {
callback(new Error("native details must not leave the verifier"));
});
await expect(verifyPassword(vectors[0].password, vectors[0].phc))
.rejects.toThrow("local_password_verification_failed");
});
});
+33 -9
View File
@@ -47,19 +47,30 @@ afterAll(async () => {
test.each(stateChangingRoutes)(
"$family $method $url rejects every production CSRF/session-boundary failure before downstream code",
async ({ method, url }) => {
const failures = [
fixture.sessionHeaders({ "x-thothii-csrf": undefined }),
fixture.sessionHeaders({ "x-thothii-csrf": "malformed" }),
fixture.sessionHeaders({ origin: undefined }),
fixture.sessionHeaders({ origin: "http://wrong.example.test" }),
fixture.sessionHeaders({ "sec-fetch-site": "cross-site" }),
const failures: Array<{ expectedStatus: number; headers: Record<string, string> }> = [
{ expectedStatus: 403, headers: fixture.sessionHeaders({ "x-thothii-csrf": undefined }) },
{ expectedStatus: 403, headers: fixture.sessionHeaders({ "x-thothii-csrf": "malformed" }) },
{ expectedStatus: 403, headers: fixture.sessionHeaders({ "x-thothii-csrf": `${fixture.csrfToken}, ${fixture.csrfToken}` }) },
{ expectedStatus: 403, headers: fixture.sessionHeaders({ "x-thothii-csrf": "x".repeat(4097) }) },
{ expectedStatus: 401, headers: fixture.sessionHeaders({ cookie: undefined }) },
{ expectedStatus: 401, headers: fixture.sessionHeaders({ cookie: "thothii_session=not-a-token" }) },
{ expectedStatus: 401, headers: fixture.sessionHeaders({ cookie: `${fixture.cookie}; ${fixture.cookie}` }) },
{ expectedStatus: 401, headers: fixture.sessionHeaders({ cookie: `${fixture.cookie}; padding=${"x".repeat(4097)}` }) },
{ expectedStatus: 403, headers: fixture.sessionHeaders({ origin: undefined }) },
{ expectedStatus: 403, headers: fixture.sessionHeaders({ origin: "http://wrong.example.test" }) },
{ expectedStatus: 403, headers: fixture.sessionHeaders({ origin: `${localPublicUrl}, ${localPublicUrl}` }) },
{ expectedStatus: 403, headers: fixture.sessionHeaders({ origin: `${localPublicUrl}${"x".repeat(4097)}` }) },
{ expectedStatus: 403, headers: fixture.sessionHeaders({ "sec-fetch-site": "cross-site" }) },
{ expectedStatus: 403, headers: fixture.sessionHeaders({ "sec-fetch-site": "same-origin, same-origin" }) },
];
for (const headers of failures) {
for (const { expectedStatus, headers } of failures) {
fixture.resetDownstreamHits();
const response = await fixture.app.inject({ method, url, headers, payload: {} });
expect(response.statusCode).toBe(403);
expect(response.json()).toEqual({ code: "csrf_failed", error: "Request origin validation failed" });
expect(response.statusCode).toBe(expectedStatus);
expect(response.json()).toEqual(expectedStatus === 403
? { code: "csrf_failed", error: "Request origin validation failed" }
: { code: "authentication_required", error: "Authentication is required" });
expect(fixture.downstreamHits()).toBe(0);
}
},
@@ -78,3 +89,16 @@ test("a valid real local session cookie and derived CSRF token cross the same pr
expect(fixture.downstreamHits()).toBe(1);
expect(localPublicUrl).toBe("http://127.0.0.1:8787");
});
test("a valid control may omit optional Fetch Metadata while retaining its exact Origin and CSRF pair", async () => {
fixture.resetDownstreamHits();
const response = await fixture.app.inject({
method: "PUT",
url: "/settings",
headers: fixture.sessionHeaders({ "sec-fetch-site": undefined }),
payload: {},
});
expect(response.statusCode).toBe(200);
expect(fixture.downstreamHits()).toBe(1);
});
+66 -3
View File
@@ -27,7 +27,7 @@ function localConfig(url = publicUrl) {
}
function usersYaml(options: { enabled?: boolean; username?: string } = {}): string {
return [
const users = [
"version: 1",
"users:",
` - id: ${adminId}`,
@@ -38,8 +38,19 @@ function usersYaml(options: { enabled?: boolean; username?: string } = {}): stri
" - admin",
` enabled: ${options.enabled ?? true}`,
" authRevision: 1",
"",
].join("\n");
];
if (options.enabled === false) {
users.push(
" - id: 6ba7b811-9dad-4ed1-80b4-00c04fd430c8",
" username: BackupAdmin",
` passwordHash: ${passwordHash}`,
" roles:",
" - admin",
" enabled: true",
" authRevision: 1",
);
}
return [...users, ""].join("\n");
}
function firstSetCookie(response: { headers: Record<string, string | string[] | undefined> }): string {
@@ -401,6 +412,58 @@ test("a verifier failure is sanitized and releases its concurrency permit", asyn
expect(attempts).toBe(2);
});
test("operational registry failures do dummy work, return 503, and never consume login-failure capacity", async () => {
let available = false;
let verificationCalls = 0;
const user = {
id: adminId, username: "Admin", normalizedUsername: "admin", displayName: "Local administrator",
passwordHash, roles: ["admin"], enabled: true, authRevision: 1,
};
const { app } = await createLocalApp({
registry: {
findByUsername: async () => {
if (!available) throw new Error("registry file is unavailable");
return user;
},
findBySubject: async () => user,
verify: async () => {
verificationCalls += 1;
return false;
},
},
});
for (let attempt = 0; attempt < 11; attempt += 1) {
const response = await login(app);
expect(response.statusCode).toBe(503);
expect(response.json()).toEqual({ code: "auth_unavailable", error: "Authentication is unavailable" });
}
expect(verificationCalls).toBe(0);
available = true;
for (let attempt = 0; attempt < 10; attempt += 1) expect((await login(app)).statusCode).toBe(401);
expect((await login(app)).statusCode).toBe(429);
});
test("operational config failures return 503 and never consume login-failure capacity", async () => {
const { app, authConfigFile } = await createLocalApp();
writeFileSync(authConfigFile, "version: 1\nmode: unsupported\n", { encoding: "utf8", mode: 0o600 });
chmodSync(authConfigFile, 0o600);
for (let attempt = 0; attempt < 11; attempt += 1) {
const response = await login(app);
expect(response.statusCode).toBe(503);
expect(response.json()).toEqual({ code: "auth_unavailable", error: "Authentication is unavailable" });
}
writeFileSync(authConfigFile, stringify(localConfig()), { encoding: "utf8", mode: 0o600 });
chmodSync(authConfigFile, 0o600);
for (let attempt = 0; attempt < 10; attempt += 1) {
expect((await login(app, { password: `${password}!` })).statusCode).toBe(401);
}
expect((await login(app, { password: `${password}!` })).statusCode).toBe(429);
});
test("public auth configuration is safe and OIDC protocol placeholders fail closed", async () => {
const { app } = await createLocalApp();
const configuration = await app.inject({ method: "GET", url: "/auth/config" });
+44
View File
@@ -5,6 +5,8 @@ import { chmodSync, mkdtempSync, readFileSync, rmSync, statSync } from "node:fs"
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expandLocalHome, localPrincipal, upstreamPrincipal } from "../src/auth/principal.js";
import { buildApp } from "../src/app.js";
import { loadConfig } from "../src/config.js";
test("server smoke trusted claims transform through nginx to a non-admin principal", () => {
const smoke = readFileSync("../scripts/unified-deployment-smoke.sh", "utf8");
@@ -107,6 +109,48 @@ test("upstream mode rejects legacy client identity headers without proxy princip
}
});
test("buildApp exposes safe nullable session metadata for authenticated legacy modes", async () => {
const configurations = [
{
name: "none",
config: loadConfig({ NODE_ENV: "test", AUTH_MODE: "none", THT_HARNESS_DIR: "/tmp/h" }),
headers: {},
expected: { issuer: "local", roles: ["admin"] },
},
{
name: "mock",
config: loadConfig({ NODE_ENV: "test", AUTH_MODE: "mock", THT_HARNESS_DIR: "/tmp/h" }),
headers: { "x-mock-user": "legacy-mock" },
expected: { issuer: "mock", subject: "legacy-mock", roles: ["user"] },
},
{
name: "upstream",
config: loadConfig({ NODE_ENV: "production", AUTH_MODE: "upstream", THT_HARNESS_DIR: "/tmp/h" }),
headers: {
"x-thoth-principal-issuer": "portal",
"x-thoth-principal-subject": "legacy-upstream",
"x-thoth-is-admin": "0",
},
expected: { issuer: "portal", subject: "legacy-upstream", roles: ["user"] },
},
];
for (const legacy of configurations) {
const app = buildApp(legacy.config, { thtRunner: {} as any, listModels: async () => [] });
try {
const response = await app.inject({ method: "GET", url: "/me", headers: legacy.headers });
expect(response.statusCode, legacy.name).toBe(200);
expect(response.json()).toMatchObject({
...legacy.expected,
csrfToken: null,
session: null,
});
} finally {
await app.close();
}
}
});
test("the session boundary exposes only exact health and authentication protocol paths", async () => {
const app = Fastify();
app.addHook("preHandler", authenticateSession({
+9 -16
View File
@@ -20,13 +20,6 @@ const (
argon2SaltBytes = 16
argon2KeyBytes uint32 = 32
argon2MaximumMemoryKiB uint32 = 256 * 1024
argon2MaximumPasses uint32 = 10
argon2MaximumParallel uint8 = 4
argon2MinimumSaltBytes = 16
argon2MaximumSaltBytes = 64
argon2MinimumKeyBytes uint32 = 16
argon2MaximumKeyBytes uint32 = 64
maximumPHCBytes = 256
)
@@ -55,7 +48,7 @@ func HashPassword(password []byte, random io.Reader) (string, error) {
return "$argon2id$v=19$m=65536,t=3,p=1$" + base64.RawStdEncoding.EncodeToString(salt) + "$" + base64.RawStdEncoding.EncodeToString(digest), nil
}
// VerifyPassword accepts only bounded, canonical Argon2id v19 PHC strings.
// VerifyPassword accepts only the exact canonical Argon2id v19 policy.
func VerifyPassword(password []byte, encoded string) bool {
if !validPassword(password) {
return false
@@ -84,11 +77,11 @@ func parsePHC(encoded string) (argon2Parameters, bool) {
if !ok {
return argon2Parameters{}, false
}
salt, ok := decodePHCBase64(parts[4], argon2MinimumSaltBytes, argon2MaximumSaltBytes)
salt, ok := decodePHCBase64(parts[4], argon2SaltBytes, argon2SaltBytes)
if !ok {
return argon2Parameters{}, false
}
digest, ok := decodePHCBase64(parts[5], int(argon2MinimumKeyBytes), int(argon2MaximumKeyBytes))
digest, ok := decodePHCBase64(parts[5], int(argon2KeyBytes), int(argon2KeyBytes))
if !ok {
return argon2Parameters{}, false
}
@@ -102,16 +95,16 @@ func parsePHCParameters(value string) (argon2Parameters, bool) {
if len(parts) != 3 || !strings.HasPrefix(parts[0], "m=") || !strings.HasPrefix(parts[1], "t=") || !strings.HasPrefix(parts[2], "p=") {
return argon2Parameters{}, false
}
memory, ok := parseDecimal(parts[0][2:], uint64(argon2MaximumMemoryKiB))
if !ok || memory < 8 {
memory, ok := parseDecimal(parts[0][2:], uint64(argon2MemoryKiB))
if !ok || memory != uint64(argon2MemoryKiB) {
return argon2Parameters{}, false
}
passes, ok := parseDecimal(parts[1][2:], uint64(argon2MaximumPasses))
if !ok || passes == 0 {
passes, ok := parseDecimal(parts[1][2:], uint64(argon2Passes))
if !ok || passes != uint64(argon2Passes) {
return argon2Parameters{}, false
}
parallelism, ok := parseDecimal(parts[2][2:], uint64(argon2MaximumParallel))
if !ok || parallelism == 0 || memory < 8*parallelism {
parallelism, ok := parseDecimal(parts[2][2:], uint64(argon2Parallel))
if !ok || parallelism != uint64(argon2Parallel) {
return argon2Parameters{}, false
}
return argon2Parameters{memory: uint32(memory), passes: uint32(passes), parallelism: uint8(parallelism)}, true
@@ -57,6 +57,7 @@ func TestVerifyPasswordRejectsMalformedAndOversizedPHCBeforeHashing(t *testing.T
"memory too large": "$argon2id$v=19$m=262145,t=1,p=1$AAECAwQFBgcICQoLDA0ODw$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4",
"passes too large": "$argon2id$v=19$m=65536,t=11,p=1$AAECAwQFBgcICQoLDA0ODw$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4",
"parallelism too large": "$argon2id$v=19$m=65536,t=3,p=5$AAECAwQFBgcICQoLDA0ODw$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4",
"weak bounded policy": "$argon2id$v=19$m=8,t=1,p=1$AAECAwQFBgcICQoLDA0ODw$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4",
"short salt": "$argon2id$v=19$m=65536,t=3,p=1$AAECAwQFBgcICQoLDA0O$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4",
"long digest": "$argon2id$v=19$m=65536,t=3,p=1$AAECAwQFBgcICQoLDA0ODw$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
} {