diff --git a/backend/src/app.ts b/backend/src/app.ts index 2b405e62..4b187dfd 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -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, diff --git a/backend/src/auth/auth.ts b/backend/src/auth/auth.ts index 2a36414c..48035e96 100644 --- a/backend/src/auth/auth.ts +++ b/backend/src/auth/auth.ts @@ -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"; } diff --git a/backend/src/auth/local-registry.ts b/backend/src/auth/local-registry.ts index af157c8d..5ae615d4 100644 --- a/backend/src/auth/local-registry.ts +++ b/backend/src/auth/local-registry.ts @@ -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; } +/** 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; + }, + }; +} diff --git a/backend/src/auth/password.ts b/backend/src/auth/password.ts index ac283991..2074982c 100644 --- a/backend/src/auth/password.ts +++ b/backend/src/auth/password.ts @@ -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 LocalUserRegistry | undefined; } interface LoginPayload { @@ -90,6 +93,23 @@ class VerificationGate { } } +async function unavailableAfterDummy( + gate: VerificationGate, + password: string, + reply: FastifyReply, +): Promise { + 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" }; } } diff --git a/backend/src/auth/session-store.ts b/backend/src/auth/session-store.ts index 1b0d68d2..f8e5c6c0 100644 --- a/backend/src/auth/session-store.ts +++ b/backend/src/auth/session-store.ts @@ -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; - findLocalUser(subject: string): LocalSessionUser | undefined | Promise; + findLocalUser?(subject: string): LocalSessionUser | undefined | Promise; + currentLocalUser?(subject: string): CurrentLocalSessionUser | Promise; } 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>(); @@ -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(); } diff --git a/backend/src/routes/sessions.ts b/backend/src/routes/sessions.ts index 955bde81..c7d5ed84 100644 --- a/backend/src/routes/sessions.ts +++ b/backend/src/routes/sessions.ts @@ -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. diff --git a/backend/test/auth-cors.test.ts b/backend/test/auth-cors.test.ts new file mode 100644 index 00000000..79c8bb7a --- /dev/null +++ b/backend/test/auth-cors.test.ts @@ -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(); +}); diff --git a/backend/test/auth-dynamic-registry.test.ts b/backend/test/auth-dynamic-registry.test.ts new file mode 100644 index 00000000..6abcd0ef --- /dev/null +++ b/backend/test/auth-dynamic-registry.test.ts @@ -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> = []; + +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 { + 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 }); +}); diff --git a/backend/test/auth-password.test.ts b/backend/test/auth-password.test.ts index 9307fa66..da249457 100644 --- a/backend/test/auth-password.test.ts +++ b/backend/test/auth-password.test.ts @@ -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"); + }); }); diff --git a/backend/test/auth-production-csrf.test.ts b/backend/test/auth-production-csrf.test.ts index fe8ac193..dec9ae6d 100644 --- a/backend/test/auth-production-csrf.test.ts +++ b/backend/test/auth-production-csrf.test.ts @@ -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 }> = [ + { 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); +}); diff --git a/backend/test/auth-routes-local.test.ts b/backend/test/auth-routes-local.test.ts index bd8205ba..8edf2d74 100644 --- a/backend/test/auth-routes-local.test.ts +++ b/backend/test/auth-routes-local.test.ts @@ -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 { @@ -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" }); diff --git a/backend/test/auth.test.ts b/backend/test/auth.test.ts index 76737772..20044f7d 100644 --- a/backend/test/auth.test.ts +++ b/backend/test/auth.test.ts @@ -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({ diff --git a/tools/tht/internal/authconfig/password.go b/tools/tht/internal/authconfig/password.go index ad6eb59f..bc516334 100644 --- a/tools/tht/internal/authconfig/password.go +++ b/tools/tht/internal/authconfig/password.go @@ -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 diff --git a/tools/tht/internal/authconfig/password_test.go b/tools/tht/internal/authconfig/password_test.go index bd02cad2..119d9542 100644 --- a/tools/tht/internal/authconfig/password_test.go +++ b/tools/tht/internal/authconfig/password_test.go @@ -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", } {