From 29bfb41363a01c291cc8c46b242a83578781c0a7 Mon Sep 17 00:00:00 2001 From: mptyl Date: Sun, 16 Aug 2026 23:23:55 +0200 Subject: [PATCH] feat(auth): add local login and CSRF-protected sessions --- backend/src/app.ts | 68 +++-- backend/src/auth/auth.ts | 164 +++++++++++- backend/src/auth/csrf.ts | 13 + backend/src/auth/routes.ts | 257 +++++++++++++++++++ backend/src/server.ts | 25 +- backend/test/app-auth-mode.test.ts | 14 +- backend/test/auth-csrf.test.ts | 112 ++++++++ backend/test/auth-routes-local.test.ts | 340 +++++++++++++++++++++++++ backend/test/auth.test.ts | 84 +++++- backend/test/routes-sessions.test.ts | 20 +- 10 files changed, 1062 insertions(+), 35 deletions(-) create mode 100644 backend/src/auth/csrf.ts create mode 100644 backend/src/auth/routes.ts create mode 100644 backend/test/auth-csrf.test.ts create mode 100644 backend/test/auth-routes-local.test.ts diff --git a/backend/src/app.ts b/backend/src/app.ts index 56e74009..2b405e62 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -1,14 +1,18 @@ import Fastify, { type FastifyInstance } from "fastify"; import cors from "@fastify/cors"; -import { join } from "node:path"; +import cookie from "@fastify/cookie"; +import rateLimit from "@fastify/rate-limit"; +import { dirname, join } from "node:path"; import { tmpdir } from "node:os"; import type { AppConfig } from "./config.js"; import { ThtRunner } from "./tht/tht-runner.js"; import { PiProcessManager } from "./pi/pi-process-manager.js"; import { SseHub } from "./sse/sse-hub.js"; -import { authPreHandler } from "./auth/auth.js"; -import { requirePermission } from "./auth/authorization.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 { registerAuthRoutes } from "./auth/routes.js"; import { sessionRoutes } from "./routes/sessions.js"; import { sqlRoutes } from "./routes/sql.js"; import { metaRoutes, type ListModelsFn } from "./routes/meta.js"; @@ -41,6 +45,12 @@ export interface BuildAppDeps { workspaceRuntimeSupport?: (workspace: WorkspaceDescriptor) => boolean; maintenanceBarrier?: MaintenanceBarrier; piManagement?: PiManagementService; + localUserRegistry?: LocalUserRegistry; + authSessionStore?: AuthSessionStore; +} + +export interface AppWithAuthSessionStore extends FastifyInstance { + thothiiAuthSessionStore?: AuthSessionStore; } export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstance { @@ -60,8 +70,11 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc app.register(cors, { origin: true, credentials: true, - methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], + methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], }); + // Cookie parsing and the rate-limit plugin must precede every auth/application route. + app.register(cookie); + app.register(rateLimit, { global: false }); const tht = deps?.thtRunner ?? new ThtRunner({ thtBin: config.thtBin, @@ -137,21 +150,41 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc const piManagement = deps?.piManagement ?? createPiManagement(config, { listModels }); const maintenanceBarrier = deps?.maintenanceBarrier ?? new MaintenanceBarrier(config.maintenanceFile); - if (config.authMode === "local" || config.authMode === "oidc") { - throw new Error("configured authentication mode is not implemented"); - } - const authenticate = authPreHandler(config.authMode, config.publicExposure); - app.addHook("preHandler", async (req, reply) => { - // Process readiness is intentionally unauthenticated for local container/proxy probes. - if (req.url === "/health" || req.url === "/health/dwh") return; + const loadedAuthentication = config.authentication?.current(); + const configuredLocalRegistry = loadedAuthentication?.value.mode === "local" + ? createLocalUserRegistry(join(dirname(loadedAuthentication.sourcePath), loadedAuthentication.value.local.usersFile)) + : undefined; + const localUserRegistry = deps?.localUserRegistry ?? configuredLocalRegistry; + 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, + }; + }, + }) + : undefined); + (app as AppWithAuthSessionStore).thothiiAuthSessionStore = authSessionStore; + const authenticate = authenticateSession({ + mode: config.authMode, + publicExposure: config.publicExposure, + authentication: config.authentication, + sessionStore: authSessionStore, + }); + app.addHook("preHandler", (req, reply, done) => { if (isMaintenanceControl(req.url)) { if (!isLoopback(req.ip)) { - return reply.code(403).send({ error: "loopback maintenance control required" }); + reply.code(403).send({ error: "loopback maintenance control required" }); } - return; } - return authenticate(req, reply); + done(); }); + app.addHook("preHandler", authenticate); app.get("/health", async () => ({ status: "ok" })); app.get("/health/dwh", async () => { // In the registry system there is no single legacy DWH config: ping the first active @@ -167,7 +200,12 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc } return { ok: true, detail: "workspace diagnostics own DWH reachability" }; }); - app.get("/me", async (req, reply) => requirePermission(req, reply, "session.use")); + registerAuthRoutes(app, { + authMode: config.authMode, + authentication: config.authentication, + sessionStore: authSessionStore, + localUserRegistry, + }); sessionRoutes(app, { mgr, tht: tht as ThtRunner, hub, getSettings, readiness, listModels, workspaceRegistry, dwhPrecheck: config.dwhPrecheck, diff --git a/backend/src/auth/auth.ts b/backend/src/auth/auth.ts index 8b4ad718..2a36414c 100644 --- a/backend/src/auth/auth.ts +++ b/backend/src/auth/auth.ts @@ -1,9 +1,30 @@ -import type { FastifyRequest, FastifyReply } from "fastify"; +import type { FastifyRequest, FastifyReply, preHandlerHookHandler } from "fastify"; import { localPrincipal, type PrincipalContext, upstreamPrincipal } from "./principal.js"; import { rolesToPermissions } from "./config.js"; +import type { AuthenticationConfigProvider, AuthMode, AuthSessionRecord } from "./types.js"; +import type { AuthSessionStore } from "./session-store.js"; +import { deriveCsrfToken, csrfTokensEqual } from "./csrf.js"; +import { requireSameOriginOrNonBrowser } from "./authorization.js"; declare module "fastify" { - interface FastifyRequest { principal?: PrincipalContext } + interface FastifyRequest { + principal?: PrincipalContext; + authSession?: AuthSessionRecord; + /** Internal only: never serialize or write this opaque cookie token to logs. */ + authSessionToken?: string; + authPublicOrigin?: string; + } +} + +const SESSION_COOKIE = "thothii_session"; +const SESSION_TOKEN = /^[A-Za-z0-9_-]{43}$/; +const STATE_CHANGING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]); + +export interface AuthDependencies { + mode: AuthMode; + publicExposure?: boolean; + authentication?: AuthenticationConfigProvider; + sessionStore?: AuthSessionStore; } export function authPreHandler(mode: "none" | "mock" | "upstream", publicExposure = false) { @@ -28,6 +49,145 @@ export function authPreHandler(mode: "none" | "mock" | "upstream", publicExposur }; } +/** + * The one application boundary for principal resolution. Auth protocol endpoints are the only + * public exceptions; all other routes get either a resolved principal or a sanitized denial. + */ +export function authenticateSession(deps: AuthDependencies): preHandlerHookHandler { + const legacy = deps.mode === "none" || deps.mode === "mock" || deps.mode === "upstream" + ? authPreHandler(deps.mode, deps.publicExposure) + : undefined; + + const handle = async (request: FastifyRequest, reply: FastifyReply): Promise => { + if (isPublicRoute(request)) return; + + if (legacy) { + await legacy(request, reply); + if (reply.sent || !STATE_CHANGING_METHODS.has(request.method)) return; + return requireSameOriginOrNonBrowser(request, reply); + } + + const origin = configuredOrigin(deps.authentication); + if (!origin || !deps.sessionStore) { + return reply.code(503).send({ code: "auth_unavailable", error: "Authentication is unavailable" }); + } + const token = readSessionCookie(request); + if (token === undefined || token === false) return authenticationRequired(reply); + + let session: AuthSessionRecord | undefined; + try { + session = await deps.sessionStore.resolve(token); + if (session) await deps.sessionStore.touch(token); + } catch { + return authenticationRequired(reply); + } + if (!session) return authenticationRequired(reply); + + request.authSession = session; + request.authSessionToken = token; + request.authPublicOrigin = origin; + request.principal = { + issuer: session.issuer, + subject: session.subject, + ...(session.displayName === undefined ? {} : { displayName: session.displayName }), + roles: session.roles, + permissions: session.permissions, + isAdmin: session.roles.includes("admin"), + }; + if (STATE_CHANGING_METHODS.has(request.method)) { + requireCsrf(request, reply); + return; + } + }; + return (request, reply, done) => { + void handle(request, reply).then( + () => done(), + () => { + if (!reply.sent) reply.code(503).send({ code: "auth_unavailable", error: "Authentication is unavailable" }); + done(); + }, + ); + }; +} + +export function requireCsrf(request: FastifyRequest, reply: FastifyReply): true | FastifyReply { + const expectedOrigin = request.authPublicOrigin; + const token = request.authSessionToken; + if (!expectedOrigin || !token) return authenticationRequired(reply); + if (!matchesOrigin(request, expectedOrigin)) return csrfFailed(reply); + + const header = singleHeader(request.headers["x-thothii-csrf"]); + const supplied = header === false || header === undefined || !SESSION_TOKEN.test(header) ? undefined : header; + let expected = ""; + try { + expected = deriveCsrfToken(token); + } catch { + return authenticationRequired(reply); + } + if (!csrfTokensEqual(expected, supplied)) return csrfFailed(reply); + return true; +} + +/** Require an exact configured public origin and browser Fetch Metadata when supplied. */ +export function requireExactOrigin( + request: FastifyRequest, + reply: FastifyReply, + expectedOrigin: string, +): true | FastifyReply { + return matchesOrigin(request, expectedOrigin) ? true : csrfFailed(reply); +} + +export function sessionCookieName(): string { return SESSION_COOKIE; } + +function authenticationRequired(reply: FastifyReply): FastifyReply { + return reply.code(401).send({ code: "authentication_required", error: "Authentication is required" }); +} + +function csrfFailed(reply: FastifyReply): FastifyReply { + return reply.code(403).send({ code: "csrf_failed", error: "Request origin validation failed" }); +} + +function configuredOrigin(authentication: AuthenticationConfigProvider | undefined): string | undefined { + try { + const publicUrl = authentication?.current().value.publicUrl; + return publicUrl ? new URL(publicUrl).origin : undefined; + } catch { + return undefined; + } +} + +function readSessionCookie(request: FastifyRequest): string | false | undefined { + const raw = request.headers.cookie; + if (raw === undefined) return undefined; + if (Array.isArray(raw) || typeof raw !== "string" || raw.length > 4096) return false; + const values = raw.split(";").filter((part) => /^\s*thothii_session(?:=|\s*$)/.test(part)); + if (values.length !== 1) return values.length === 0 ? undefined : false; + const match = /^\s*thothii_session=([A-Za-z0-9_-]{43})\s*$/.exec(values[0]); + return match?.[1] ?? false; +} + +function singleHeader(value: string | string[] | undefined): string | false | undefined { + if (value === undefined) return undefined; + if (Array.isArray(value) || typeof value !== "string" || value.includes(",")) return false; + return value; +} + +function matchesOrigin(request: FastifyRequest, expectedOrigin: string): boolean { + const origin = singleHeader(request.headers.origin); + if (origin !== expectedOrigin) return false; + const fetchSite = singleHeader(request.headers["sec-fetch-site"]); + return fetchSite === undefined || fetchSite === "same-origin"; +} + +function isPublicRoute(request: FastifyRequest): boolean { + const rawUrl = request.raw.url ?? request.url; + const query = rawUrl.indexOf("?"); + const pathname = query === -1 ? rawUrl : rawUrl.slice(0, query); + return (request.method === "GET" && (pathname === "/health" || pathname === "/auth/config" + || pathname === "/auth/oidc/login" || pathname === "/auth/oidc/callback")) + || (request.method === "POST" && pathname === "/auth/local/login"); +} + export function getPrincipal(req: FastifyRequest): PrincipalContext { if (!req.principal) throw new Error("principal missing after authentication"); return req.principal; diff --git a/backend/src/auth/csrf.ts b/backend/src/auth/csrf.ts new file mode 100644 index 00000000..bcb4c062 --- /dev/null +++ b/backend/src/auth/csrf.ts @@ -0,0 +1,13 @@ +import { timingSafeEqual } from "node:crypto"; +import { deriveCsrfToken as deriveStoredCsrfToken } from "./session-store.js"; + +export { deriveStoredCsrfToken as deriveCsrfToken }; + +/** Compare a client-supplied CSRF value without exposing a useful length timing oracle. */ +export function csrfTokensEqual(expectedToken: string, suppliedToken: string | undefined): boolean { + const expected = Buffer.from(expectedToken, "utf8"); + const supplied = Buffer.from(suppliedToken ?? "", "utf8"); + const padded = Buffer.alloc(expected.length); + supplied.copy(padded, 0, 0, expected.length); + return timingSafeEqual(expected, padded) && supplied.length === expected.length; +} diff --git a/backend/src/auth/routes.ts b/backend/src/auth/routes.ts new file mode 100644 index 00000000..46feb36e --- /dev/null +++ b/backend/src/auth/routes.ts @@ -0,0 +1,257 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { randomBytes } from "node:crypto"; +import type { AuthenticationConfigProvider } 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"; + +const TEN_MINUTES_MS = 10 * 60 * 1000; +const REMEMBER_COOKIE_SECONDS = 2_592_000; +const MAX_USERNAME_LENGTH = 64; +const MAX_PASSWORD_LENGTH = 1024; +const MAX_LIMIT_ENTRIES = 10_000; + +export interface AuthRouteDependencies { + authMode: "local" | "oidc" | "upstream" | "none" | "mock"; + authentication?: AuthenticationConfigProvider; + sessionStore?: AuthSessionStore; + localUserRegistry?: LocalUserRegistry; +} + +interface LoginPayload { + username: string; + password: string; + remember: boolean; +} + +class LoginFailureLimiter { + private readonly usernames = new Map(); + private readonly addresses = new Map(); + + isLimited(username: string, address: string, now = Date.now()): boolean { + return this.active(this.usernames, username, now).length >= 10 + || this.active(this.addresses, address, now).length >= 20; + } + + recordFailure(username: string, address: string, now = Date.now()): void { + this.active(this.usernames, username, now).push(now); + this.active(this.addresses, address, now).push(now); + } + + private active(bucket: Map, key: string, now: number): number[] { + const prior = bucket.get(key) ?? []; + const current = prior.filter((timestamp) => timestamp > now - TEN_MINUTES_MS); + if (current.length === 0) bucket.delete(key); else bucket.set(key, current); + if (!bucket.has(key) && bucket.size >= MAX_LIMIT_ENTRIES) { + const oldest = bucket.keys().next().value; + if (typeof oldest === "string") bucket.delete(oldest); + } + if (!bucket.has(key)) bucket.set(key, current); + return current; + } +} + +class VerificationGate { + private active = 0; + + async run(operation: () => Promise): Promise { + if (this.active >= 2) return undefined; + this.active += 1; + try { + return await operation(); + } finally { + this.active -= 1; + } + } +} + +export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDependencies): void { + const limiter = new LoginFailureLimiter(); + const verificationGate = new VerificationGate(); + + app.get("/auth/config", async (_request, reply) => { + try { + const mode = deps.authentication?.current().value.mode ?? deps.authMode; + return reply.send({ mode, localLogin: mode === "local", oidcLogin: false }); + } catch { + return unavailable(reply); + } + }); + + app.post("/auth/local/login", async (request, reply) => { + const configured = currentLocalConfig(deps); + if (!configured || !deps.localUserRegistry || !deps.sessionStore) return unavailable(reply); + const originCheck = requireExactOrigin(request, reply, configured.origin); + if (originCheck !== true) return originCheck; + + const payload = loginPayload(request); + 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); + } catch { + user = undefined; + } + let verified: boolean | undefined; + try { + verified = await verificationGate.run(async () => + deps.localUserRegistry!.verify(user, argon2SafePassword(payload.password))); + } catch { + return unavailable(reply); + } + if (verified === undefined) return loginLimited(reply); + if (!verified || !user || !user.enabled) { + limiter.recordFailure(normalizedUsername, sourceAddress); + return invalidCredentials(reply); + } + + try { + const created = await deps.sessionStore.create({ + principal: { + issuer: "local", + subject: user.id, + displayName: user.displayName ?? user.username, + roles: user.roles, + permissions: rolesToPermissions(user.roles), + isAdmin: user.roles.includes("admin"), + }, + method: "local", + remembered: payload.remember, + userAuthRevision: user.authRevision, + authConfigRevision: configured.revision, + idleTtlMs: (payload.remember ? configured.session.rememberIdleSeconds : configured.session.regularIdleSeconds) * 1000, + absoluteTtlMs: (payload.remember ? configured.session.rememberTtlSeconds : configured.session.regularTtlSeconds) * 1000, + }); + reply.setCookie(sessionCookieName(), created.token, cookieOptions(configured.secure, payload.remember)); + return reply.send({}); + } catch { + return unavailable(reply); + } + }); + + app.get("/auth/oidc/login", async (_request, reply) => notImplemented(reply)); + app.get("/auth/oidc/callback", async (_request, reply) => notImplemented(reply)); + + app.post("/auth/logout", async (request, reply) => { + const token = request.authSessionToken; + if (!token || !deps.sessionStore) return unavailable(reply); + try { + await deps.sessionStore.revoke(token); + reply.clearCookie(sessionCookieName(), cookieOptions(currentSecure(deps), false)); + return reply.code(204).send(); + } catch { + return unavailable(reply); + } + }); + + app.get("/me", async (request, reply) => { + const principal = requirePermission(request, reply, "session.use"); + if (!isPrincipalContext(principal)) return principal; + const session = request.authSession; + const token = request.authSessionToken; + if (!session || !token) return unavailable(reply); + try { + return { + issuer: principal.issuer, + subject: principal.subject, + ...(principal.displayName === undefined ? {} : { displayName: principal.displayName }), + roles: principal.roles, + permissions: principal.permissions, + isAdmin: principal.isAdmin, + csrfToken: deriveCsrfToken(token), + session: { + method: session.method, + remembered: session.remembered, + idleExpiresAt: session.idleExpiresAt, + absoluteExpiresAt: session.absoluteExpiresAt, + }, + }; + } catch { + return unavailable(reply); + } + }); +} + +function currentLocalConfig(deps: AuthRouteDependencies): { + revision: string; + origin: string; + secure: boolean; + session: { regularTtlSeconds: number; regularIdleSeconds: number; rememberTtlSeconds: number; rememberIdleSeconds: number }; +} | undefined { + try { + const loaded = deps.authentication?.current(); + if (!loaded || loaded.value.mode !== "local") return undefined; + const url = new URL(loaded.value.publicUrl); + return { + revision: loaded.revision, + origin: url.origin, + secure: url.protocol === "https:", + session: loaded.value.session, + }; + } catch { + return undefined; + } +} + +function currentSecure(deps: AuthRouteDependencies): boolean { + try { return new URL(deps.authentication?.current().value.publicUrl ?? "").protocol === "https:"; } catch { return false; } +} + +function cookieOptions(secure: boolean, remembered: boolean) { + return { + httpOnly: true, + sameSite: "lax" as const, + path: "/", + secure, + ...(remembered ? { maxAge: REMEMBER_COOKIE_SECONDS } : {}), + }; +} + +function loginPayload(request: FastifyRequest): LoginPayload { + const body = request.body; + if (!body || typeof body !== "object" || Array.isArray(body)) return { username: "", password: "", remember: false }; + const input = body as Record; + return { + username: typeof input.username === "string" && input.username.length <= MAX_USERNAME_LENGTH ? input.username : "", + password: typeof input.password === "string" && input.password.length <= MAX_PASSWORD_LENGTH ? input.password : "", + remember: input.remember === true, + }; +} + +function boundedAddress(address: string): string { + return typeof address === "string" && address.length > 0 && address.length <= 128 ? address : "unknown"; +} + +function argon2SafePassword(value: string): string { + const typed = value as string & { isWellFormed?: () => boolean }; + const wellFormed = typeof typed.isWellFormed === "function" + ? typed.isWellFormed() + : !/[\uD800-\uDFFF]/.test(value); + const bytes = Buffer.byteLength(value, "utf8"); + if (wellFormed && bytes >= 12 && bytes <= MAX_PASSWORD_LENGTH) return value; + // A per-attempt random value preserves the Argon2 work without turning an invalid input into + // a reusable password that could happen to match a user's configured secret. + return randomBytes(32).toString("base64url"); +} + +function invalidCredentials(reply: FastifyReply): FastifyReply { + return reply.code(401).send({ code: "invalid_credentials", error: "Invalid username or password" }); +} + +function loginLimited(reply: FastifyReply): FastifyReply { + return reply.code(429).send({ code: "login_rate_limited", error: "Too many login attempts" }); +} + +function unavailable(reply: FastifyReply): FastifyReply { + return reply.code(503).send({ code: "auth_unavailable", error: "Authentication is unavailable" }); +} + +function notImplemented(reply: FastifyReply): FastifyReply { + return reply.code(501).send({ code: "auth_not_implemented", error: "OIDC login is not implemented" }); +} diff --git a/backend/src/server.ts b/backend/src/server.ts index 6cef6b0d..df9f3536 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -1,6 +1,23 @@ -import { buildApp } from "./app.js"; +import { buildApp, type AppWithAuthSessionStore } from "./app.js"; import { loadConfig } from "./config.js"; const config = loadConfig(process.env); -const app = buildApp(config); -app.listen({ port: config.port, host: config.host }) - .then((addr) => console.log(`backend listening on ${addr}`)); +const app = buildApp(config) as AppWithAuthSessionStore; + +async function start(): Promise { + const sessions = app.thothiiAuthSessionStore; + if (sessions) { + await sessions.prune(); + const interval = setInterval(() => { + void sessions.prune().catch(() => app.log.warn({ component: "auth-session-prune" }, "auth session pruning failed")); + }, 15 * 60 * 1000); + interval.unref(); + app.addHook("onClose", async () => clearInterval(interval)); + } + const address = await app.listen({ port: config.port, host: config.host }); + console.log(`backend listening on ${address}`); +} + +void start().catch(() => { + console.error("backend startup failed"); + process.exitCode = 1; +}); diff --git a/backend/test/app-auth-mode.test.ts b/backend/test/app-auth-mode.test.ts index 8a51d872..8378e3b2 100644 --- a/backend/test/app-auth-mode.test.ts +++ b/backend/test/app-auth-mode.test.ts @@ -6,7 +6,7 @@ import { stringify } from "yaml"; import { buildApp } from "../src/app.js"; import { loadConfig } from "../src/config.js"; -test("configured OIDC fails app startup until an OIDC handler is installed", () => { +test("configured OIDC starts with provider-neutral protocol placeholders that fail closed", async () => { const directory = mkdtempSync(join(tmpdir(), "thothii-app-oidc-mode-")); const file = join(directory, "auth.yaml"); writeFileSync(file, stringify({ @@ -19,8 +19,16 @@ test("configured OIDC fails app startup until an OIDC handler is installed", () authorization: { groupRoles: { "TOT Users": ["user"], "TOT Admin": ["admin"] } }, }), "utf8"); try { - expect(() => buildApp(loadConfig({ THT_AUTH_CONFIG_FILE: file }))) - .toThrow("configured authentication mode is not implemented"); + const app = buildApp(loadConfig({ THT_AUTH_CONFIG_FILE: file, THT_AUTH_STATE_ROOT: join(directory, "auth-state") })); + try { + expect((await app.inject({ method: "GET", url: "/auth/config" })).json()) + .toEqual({ mode: "oidc", localLogin: false, oidcLogin: false }); + const placeholder = await app.inject({ method: "GET", url: "/auth/oidc/login" }); + expect(placeholder.statusCode).toBe(501); + expect(placeholder.json()).toEqual({ code: "auth_not_implemented", error: "OIDC login is not implemented" }); + } finally { + await app.close(); + } } finally { rmSync(directory, { recursive: true, force: true }); } diff --git a/backend/test/auth-csrf.test.ts b/backend/test/auth-csrf.test.ts new file mode 100644 index 00000000..0a5a047b --- /dev/null +++ b/backend/test/auth-csrf.test.ts @@ -0,0 +1,112 @@ +import { afterEach, expect, test } from "vitest"; +import { chmodSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { stringify } from "yaml"; +import { buildApp } from "../src/app.js"; +import { loadConfig } from "../src/config.js"; +import { deriveCsrfToken } from "../src/auth/csrf.js"; + +const password = "correct horse battery staple"; +const passwordHash = "$argon2id$v=19$m=65536,t=3,p=1$AAECAwQFBgcICQoLDA0ODw$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4"; +const adminId = "6ba7b810-9dad-4ed1-80b4-00c04fd430c8"; +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 sessionCookie(response: { headers: Record }): string { + const setCookie = response.headers["set-cookie"]; + const first = Array.isArray(setCookie) ? setCookie[0] : setCookie; + return first?.split(";", 1)[0] ?? ""; +} + +async function createApp() { + const directory = mkdtempSync(join(realpathSync(tmpdir()), "thothii-auth-csrf-")); + chmodSync(directory, 0o700); + const authConfigFile = join(directory, "auth.yaml"); + const usersFile = join(directory, "users.yaml"); + writeFileSync(authConfigFile, stringify({ + version: 1, mode: "local", publicUrl, local: { usersFile: "users.yaml" }, + }), { encoding: "utf8", mode: 0o600 }); + writeFileSync(usersFile, [ + "version: 1", "users:", ` - id: ${adminId}`, " username: Admin", + " displayName: Local administrator", ` passwordHash: ${passwordHash}`, + " roles:", " - admin", " enabled: true", " authRevision: 1", "", + ].join("\n"), { encoding: "utf8", mode: 0o600 }); + chmodSync(authConfigFile, 0o600); + chmodSync(usersFile, 0o600); + const app = buildApp(loadConfig({ + THT_AUTH_CONFIG_FILE: authConfigFile, + 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 signedIn = await app.inject({ + method: "POST", url: "/auth/local/login", + headers: { origin: publicUrl, "sec-fetch-site": "same-origin" }, + payload: { username: "Admin", password }, + }); + const cookie = sessionCookie(signedIn); + const me = await app.inject({ method: "GET", url: "/me", headers: { cookie } }); + return { app, cookie, csrfToken: me.json().csrfToken as string }; +} + +test("derived CSRF tokens are deterministic per opaque cookie and are never the cookie token", async () => { + const { cookie, csrfToken } = await createApp(); + const token = cookie.split("=", 2)[1] ?? ""; + expect(deriveCsrfToken(token)).toBe(csrfToken); + expect(csrfToken).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(csrfToken).not.toBe(token); +}); + +test("cookie-authenticated state changes require an exact Origin, Fetch Metadata when present, and CSRF token", async () => { + const { app, cookie, csrfToken } = await createApp(); + const cases = [ + { headers: { cookie, origin: publicUrl, "sec-fetch-site": "same-origin" } }, + { headers: { cookie, origin: "http://127.0.0.1:8788", "sec-fetch-site": "same-origin", "x-thothii-csrf": csrfToken } }, + { headers: { cookie, origin: publicUrl, "sec-fetch-site": "cross-site", "x-thothii-csrf": csrfToken } }, + { headers: { cookie, origin: publicUrl, "x-thothii-csrf": csrfToken.slice(0, -1) } }, + ]; + + for (const request of cases) { + const response = await app.inject({ method: "POST", url: "/auth/logout", ...request }); + expect(response.statusCode).toBe(403); + expect(response.json()).toEqual({ code: "csrf_failed", error: "Request origin validation failed" }); + } +}); + +test("duplicate or malformed CSRF and session-cookie headers fail closed", async () => { + const { app, cookie, csrfToken } = await createApp(); + const duplicateCsrf = await app.inject({ + method: "POST", url: "/auth/logout", + headers: { cookie, origin: publicUrl, "x-thothii-csrf": `${csrfToken}, ${csrfToken}` }, + }); + const duplicateCookie = await app.inject({ + method: "POST", url: "/auth/logout", + headers: { cookie: `${cookie}; ${cookie}`, origin: publicUrl, "x-thothii-csrf": csrfToken }, + }); + const malformedCookie = await app.inject({ + method: "POST", url: "/auth/logout", + headers: { cookie: "thothii_session=not-a-token", origin: publicUrl, "x-thothii-csrf": csrfToken }, + }); + + expect(duplicateCsrf.statusCode).toBe(403); + expect(duplicateCookie.statusCode).toBe(401); + expect(malformedCookie.statusCode).toBe(401); +}); + +test("an unauthenticated state-changing application route cannot bypass the central boundary", async () => { + const { app } = await createApp(); + const response = await app.inject({ + method: "POST", url: "/sessions", headers: { origin: publicUrl, "x-thothii-csrf": "x".repeat(43) }, + payload: { question: "must not reach a session handler" }, + }); + expect(response.statusCode).toBe(401); + expect(response.json()).toEqual({ code: "authentication_required", error: "Authentication is required" }); +}); diff --git a/backend/test/auth-routes-local.test.ts b/backend/test/auth-routes-local.test.ts new file mode 100644 index 00000000..0c6ff8ee --- /dev/null +++ b/backend/test/auth-routes-local.test.ts @@ -0,0 +1,340 @@ +import { afterEach, expect, test, vi } from "vitest"; +import { chmodSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { stringify } from "yaml"; +import { buildApp } from "../src/app.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 adminId = "6ba7b810-9dad-4ed1-80b4-00c04fd430c8"; +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 localConfig(url = publicUrl) { + return { + version: 1, + mode: "local", + publicUrl: url, + local: { usersFile: "users.yaml" }, + }; +} + +function usersYaml(options: { enabled?: boolean; username?: string } = {}): string { + return [ + "version: 1", + "users:", + ` - id: ${adminId}`, + ` username: ${options.username ?? "Admin"}`, + " displayName: Local administrator", + ` passwordHash: ${passwordHash}`, + " roles:", + " - admin", + ` enabled: ${options.enabled ?? true}`, + " authRevision: 1", + "", + ].join("\n"); +} + +function firstSetCookie(response: { headers: Record }): string { + const header = response.headers["set-cookie"]; + if (Array.isArray(header)) return header[0] ?? ""; + return header ?? ""; +} + +function cookiePair(setCookie: string): string { + return setCookie.split(";", 1)[0] ?? ""; +} + +async function createLocalApp(options: { + publicUrl?: string; + enabled?: boolean; + stateRoot?: string; + registry?: { + findByUsername(username: string): Promise; + findBySubject(subject: string): Promise; + verify(user: unknown, suppliedPassword: string): Promise; + }; +} = {}) { + const directory = mkdtempSync(join(realpathSync(tmpdir()), "thothii-auth-routes-")); + chmodSync(directory, 0o700); + const authConfigFile = join(directory, "auth.yaml"); + const usersFile = join(directory, "users.yaml"); + const authStateRoot = options.stateRoot ?? join(directory, "auth-state"); + writeFileSync(authConfigFile, stringify(localConfig(options.publicUrl)), { encoding: "utf8", mode: 0o600 }); + writeFileSync(usersFile, usersYaml({ enabled: options.enabled }), { encoding: "utf8", mode: 0o600 }); + chmodSync(authConfigFile, 0o600); + chmodSync(usersFile, 0o600); + const app = buildApp(loadConfig({ + THT_AUTH_CONFIG_FILE: authConfigFile, + THT_AUTH_STATE_ROOT: authStateRoot, + THT_HARNESS_DIR: "/tmp/h", + }), options.registry === undefined ? undefined : { localUserRegistry: options.registry } as any); + cleanups.push(async () => { + await app.close(); + rmSync(directory, { recursive: true, force: true }); + }); + return { app, authConfigFile, usersFile, authStateRoot, directory, publicUrl: options.publicUrl ?? publicUrl }; +} + +async function login(app: Awaited>["app"], body: Record = {}) { + return app.inject({ + method: "POST", + url: "/auth/local/login", + headers: { origin: publicUrl, "sec-fetch-site": "same-origin" }, + payload: { username: "Admin", password, ...body }, + }); +} + +test("local login sets a non-persistent opaque session cookie and exposes only a safe /me DTO", async () => { + const { app } = await createLocalApp(); + + const signedIn = await login(app); + expect(signedIn.statusCode).toBe(200); + const setCookie = firstSetCookie(signedIn); + expect(setCookie).toMatch(/^thothii_session=[A-Za-z0-9_-]{43}; /); + expect(setCookie).toContain("HttpOnly"); + expect(setCookie).toContain("SameSite=Lax"); + expect(setCookie).toContain("Path=/"); + expect(setCookie).not.toMatch(/Max-Age=/i); + expect(setCookie).not.toContain("Secure"); + + const me = await app.inject({ method: "GET", url: "/me", headers: { cookie: cookiePair(setCookie) } }); + expect(me.statusCode).toBe(200); + expect(me.json()).toEqual({ + issuer: "local", + subject: adminId, + displayName: "Local administrator", + roles: ["admin"], + permissions: [ + "session.use", "session.read_all", "session.manage_all", "settings.manage", + "workspace.manage", "workspace.secrets.manage", "pi.manage", "auth.diagnostics.read", + ], + isAdmin: true, + csrfToken: expect.stringMatching(/^[A-Za-z0-9_-]{43}$/), + session: { + method: "local", + remembered: false, + idleExpiresAt: expect.any(String), + absoluteExpiresAt: expect.any(String), + }, + }); + expect(JSON.stringify(me.json())).not.toContain("authConfigRevision"); + expect(JSON.stringify(me.json())).not.toContain("authRevision"); + expect(JSON.stringify(me.json())).not.toContain(cookiePair(setCookie).split("=", 2)[1] ?? ""); +}); + +test("remembered login uses a persistent secure cookie under an HTTPS public URL and survives app recreation", async () => { + const directory = mkdtempSync(join(realpathSync(tmpdir()), "thothii-auth-remembered-")); + chmodSync(directory, 0o700); + const authConfigFile = join(directory, "auth.yaml"); + const usersFile = join(directory, "users.yaml"); + const authStateRoot = join(directory, "auth-state"); + writeFileSync(authConfigFile, stringify(localConfig("https://thothii.example.test")), { encoding: "utf8", mode: 0o600 }); + writeFileSync(usersFile, usersYaml(), { encoding: "utf8", mode: 0o600 }); + chmodSync(authConfigFile, 0o600); + chmodSync(usersFile, 0o600); + const config = () => loadConfig({ THT_AUTH_CONFIG_FILE: authConfigFile, THT_AUTH_STATE_ROOT: authStateRoot, THT_HARNESS_DIR: "/tmp/h" }); + const first = buildApp(config()); + try { + const signedIn = await first.inject({ + method: "POST", + url: "/auth/local/login", + headers: { origin: "https://thothii.example.test", "sec-fetch-site": "same-origin" }, + payload: { username: "Admin", password, remember: true }, + }); + const setCookie = firstSetCookie(signedIn); + expect(signedIn.statusCode).toBe(200); + expect(setCookie).toContain("Max-Age=2592000"); + expect(setCookie).toContain("Secure"); + await first.close(); + + const restarted = buildApp(config()); + cleanups.push(async () => { + await restarted.close(); + rmSync(directory, { recursive: true, force: true }); + }); + const me = await restarted.inject({ method: "GET", url: "/me", headers: { cookie: cookiePair(setCookie) } }); + expect(me.statusCode).toBe(200); + expect(me.json()).toMatchObject({ subject: adminId, session: { remembered: true, method: "local" } }); + } catch (error) { + await first.close(); + rmSync(directory, { recursive: true, force: true }); + throw error; + } +}); + +test("unknown, disabled, and wrong-password logins share one generic failure contract", async () => { + const enabled = await createLocalApp(); + const disabled = await createLocalApp({ enabled: false }); + const attempts = await Promise.all([ + login(enabled.app, { username: "Unknown" }), + login(disabled.app), + login(enabled.app, { password: `${password}!` }), + ]); + + for (const response of attempts) { + expect(response.statusCode).toBe(401); + expect(response.json()).toEqual({ code: "invalid_credentials", error: "Invalid username or password" }); + expect(response.headers["set-cookie"]).toBeUndefined(); + } +}); + +test("invalid password input still reaches the local verifier with a bounded Argon2-safe surrogate", async () => { + const user = { + id: adminId, username: "Admin", normalizedUsername: "admin", displayName: "Local administrator", + passwordHash, roles: ["admin"], enabled: true, authRevision: 1, + }; + const verify = vi.fn(async () => false); + const { app } = await createLocalApp({ + registry: { findByUsername: async () => user, findBySubject: async () => user, verify }, + }); + + const response = await login(app, { password: "short" }); + expect(response.statusCode).toBe(401); + const verifierPassword = verify.mock.calls[0]?.[1]; + expect(verifierPassword).not.toBe("short"); + expect(Buffer.byteLength(verifierPassword ?? "", "utf8")).toBeGreaterThanOrEqual(12); +}); + +test("local login requires the exact configured Origin and same-origin Fetch Metadata", async () => { + const { app } = await createLocalApp(); + const missingOrigin = await app.inject({ method: "POST", url: "/auth/local/login", payload: { username: "Admin", password } }); + const wrongOrigin = await app.inject({ + method: "POST", url: "/auth/local/login", headers: { origin: "http://127.0.0.1:8788" }, payload: { username: "Admin", password }, + }); + const crossSite = await app.inject({ + method: "POST", url: "/auth/local/login", headers: { origin: publicUrl, "sec-fetch-site": "cross-site" }, payload: { username: "Admin", password }, + }); + + for (const response of [missingOrigin, wrongOrigin, crossSite]) { + expect(response.statusCode).toBe(403); + expect(response.json()).toEqual({ code: "csrf_failed", error: "Request origin validation failed" }); + } +}); + +test("logout revokes the session and clears the cookie with the production attributes", async () => { + const { app } = await createLocalApp(); + const signedIn = await login(app); + const setCookie = firstSetCookie(signedIn); + const me = await app.inject({ method: "GET", url: "/me", headers: { cookie: cookiePair(setCookie) } }); + + const loggedOut = await app.inject({ + method: "POST", + url: "/auth/logout", + headers: { + cookie: cookiePair(setCookie), origin: publicUrl, "sec-fetch-site": "same-origin", + "x-thothii-csrf": me.json().csrfToken, + }, + }); + expect(loggedOut.statusCode).toBe(204); + const cleared = firstSetCookie(loggedOut); + expect(cleared).toMatch(/^thothii_session=;/); + expect(cleared).toContain("Max-Age=0"); + expect(cleared).toContain("HttpOnly"); + expect(cleared).toContain("SameSite=Lax"); + expect(cleared).toContain("Path=/"); + expect(cleared).toContain("Expires="); + expect((await app.inject({ method: "GET", url: "/me", headers: { cookie: cookiePair(setCookie) } })).statusCode).toBe(401); +}); + +test("failed logins are limited by normalized username and source address", async () => { + const user = { + id: adminId, username: "Admin", normalizedUsername: "admin", displayName: "Local administrator", + passwordHash, roles: ["admin"], enabled: true, authRevision: 1, + }; + const registry = { + findByUsername: async () => user, + findBySubject: async () => user, + verify: async () => false, + }; + const { app } = await createLocalApp({ registry }); + for (let attempt = 0; attempt < 10; attempt += 1) { + const response = await login(app, { username: "aDmIn" }); + expect(response.statusCode).toBe(401); + } + const limited = await login(app, { username: "ADMIN" }); + expect(limited.statusCode).toBe(429); + expect(limited.json()).toEqual({ code: "login_rate_limited", error: "Too many login attempts" }); + + const addressLimited = await createLocalApp({ registry }); + for (let attempt = 0; attempt < 20; attempt += 1) { + const response = await login(addressLimited.app, { username: `User${attempt}` }); + expect(response.statusCode).toBe(401); + } + expect((await login(addressLimited.app, { username: "A-new-username" })).statusCode).toBe(429); +}); + +test("only two Argon2 verifications run concurrently and excess login attempts fail immediately", async () => { + let calls = 0; + let release!: () => void; + const blocked = new Promise((resolve) => { release = resolve; }); + let entered!: () => void; + const twoEntered = new Promise((resolve) => { entered = resolve; }); + const user = { + id: adminId, username: "Admin", normalizedUsername: "admin", displayName: "Local administrator", + passwordHash, roles: ["admin"], enabled: true, authRevision: 1, + }; + const registry = { + findByUsername: async () => user, + findBySubject: async () => user, + verify: async () => { + calls += 1; + if (calls === 2) entered(); + await blocked; + return false; + }, + }; + const { app } = await createLocalApp({ registry }); + const first = login(app); + const second = login(app); + await twoEntered; + const excess = await login(app); + expect(excess.statusCode).toBe(429); + expect(calls).toBe(2); + release(); + expect((await first).statusCode).toBe(401); + expect((await second).statusCode).toBe(401); +}); + +test("a verifier failure is sanitized and releases its concurrency permit", async () => { + let attempts = 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 () => user, + findBySubject: async () => user, + verify: async () => { + attempts += 1; + if (attempts === 1) throw new Error("fixture verifier failure"); + return false; + }, + }, + }); + + const failed = await login(app); + expect(failed.statusCode).toBe(503); + expect(failed.json()).toEqual({ code: "auth_unavailable", error: "Authentication is unavailable" }); + expect((await login(app)).statusCode).toBe(401); + expect(attempts).toBe(2); +}); + +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" }); + expect(configuration.statusCode).toBe(200); + expect(configuration.json()).toEqual({ mode: "local", localLogin: true, oidcLogin: false }); + expect(JSON.stringify(configuration.json())).not.toContain("users.yaml"); + + const placeholder = await app.inject({ method: "GET", url: "/auth/oidc/login" }); + expect(placeholder.statusCode).toBe(501); + expect(placeholder.json()).toEqual({ code: "auth_not_implemented", error: "OIDC login is not implemented" }); +}); diff --git a/backend/test/auth.test.ts b/backend/test/auth.test.ts index 0bec5744..76737772 100644 --- a/backend/test/auth.test.ts +++ b/backend/test/auth.test.ts @@ -1,6 +1,6 @@ -import { test, expect } from "vitest"; +import { test, expect, vi } from "vitest"; import Fastify from "fastify"; -import { authPreHandler, getPrincipal } from "../src/auth/auth.js"; +import { authenticateSession, authPreHandler, getPrincipal } from "../src/auth/auth.js"; import { chmodSync, mkdtempSync, readFileSync, rmSync, statSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -107,6 +107,86 @@ test("upstream mode rejects legacy client identity headers without proxy princip } }); +test("the session boundary exposes only exact health and authentication protocol paths", async () => { + const app = Fastify(); + app.addHook("preHandler", authenticateSession({ + mode: "local", + authentication: { + current: () => ({ + sourcePath: "/private/auth.yaml", + revision: "a".repeat(64), + value: { + version: 1, + mode: "local", + publicUrl: "http://127.0.0.1:8787", + session: { + regularTtlSeconds: 43_200, regularIdleSeconds: 7_200, + rememberTtlSeconds: 2_592_000, rememberIdleSeconds: 604_800, oidcTtlSeconds: 28_800, + }, + local: { usersFile: "users.yaml" }, + }, + }), + }, + sessionStore: { resolve: async () => undefined } as any, + })); + app.get("/health", async () => ({ ok: true })); + app.get("/auth/config", async () => ({ mode: "local" })); + app.get("/healthz", async () => ({ ok: true })); + app.get("/auth/configured", async () => ({ mode: "local" })); + + expect((await app.inject({ method: "GET", url: "/health?probe=1" })).statusCode).toBe(200); + expect((await app.inject({ method: "GET", url: "/auth/config?ui=1" })).statusCode).toBe(200); + expect((await app.inject({ method: "GET", url: "/healthz" })).statusCode).toBe(401); + expect((await app.inject({ method: "GET", url: "/auth/configured" })).statusCode).toBe(401); +}); + +test("the session boundary touches a valid cookie session through the bounded Task 7 store operation", async () => { + const sessions = { + resolve: vi.fn(async () => ({ + version: 1, + issuer: "local", + subject: "user-1", + method: "local", + roles: ["user"], + permissions: ["session.use"], + userAuthRevision: 1, + authConfigRevision: "b".repeat(64), + remembered: false, + createdAt: "2026-08-16T00:00:00.000Z", + lastSeenAt: "2026-08-16T00:00:00.000Z", + idleExpiresAt: "2026-08-16T02:00:00.000Z", + absoluteExpiresAt: "2026-08-16T12:00:00.000Z", + })), + touch: vi.fn(async () => {}), + }; + const app = Fastify(); + app.addHook("preHandler", authenticateSession({ + mode: "local", + authentication: { + current: () => ({ + sourcePath: "/private/auth.yaml", + revision: "b".repeat(64), + value: { + version: 1, + mode: "local", + publicUrl: "http://127.0.0.1:8787", + session: { + regularTtlSeconds: 43_200, regularIdleSeconds: 7_200, + rememberTtlSeconds: 2_592_000, rememberIdleSeconds: 604_800, oidcTtlSeconds: 28_800, + }, + local: { usersFile: "users.yaml" }, + }, + }), + }, + sessionStore: sessions as any, + })); + app.get("/private", async (request) => getPrincipal(request)); + + const token = "z".repeat(43); + expect((await app.inject({ method: "GET", url: "/private", headers: { cookie: `thothii_session=${token}` } })).statusCode).toBe(200); + expect(sessions.touch).toHaveBeenCalledWith(token); +}); + test("local identity expands tilde homes and restores private POSIX permissions", () => { expect(expandLocalHome("~/thoth-test", "/home/tester")).toBe("/home/tester/thoth-test"); expect(expandLocalHome("~", "/home/tester")).toBe("/home/tester"); diff --git a/backend/test/routes-sessions.test.ts b/backend/test/routes-sessions.test.ts index 167bda4f..60e23cdb 100644 --- a/backend/test/routes-sessions.test.ts +++ b/backend/test/routes-sessions.test.ts @@ -163,19 +163,20 @@ test("a durable maintenance marker initializes admission closed after backend re } }); -test.each(["none", "upstream"] as const)( - "maintenance control is loopback-only and independent of %s authentication", - async (authMode) => { +test("maintenance control requires an upstream identity and remains loopback-only", async () => { const dir = mkdtempSync(path.join(tmpdir(), "tht-maintenance-control-")); const marker = path.join(dir, "maintenance.json"); try { const app = buildApp(loadConfig({ - AUTH_MODE: authMode, + AUTH_MODE: "upstream", THT_HARNESS_DIR: "../harness", THT_MAINTENANCE_FILE: marker, }), { thtRunner: {} as any }); - const activated = await app.inject({ method: "POST", url: "/internal/maintenance/activate" }); + expect((await app.inject({ method: "POST", url: "/internal/maintenance/activate" })).statusCode).toBe(401); + const activated = await app.inject({ + method: "POST", url: "/internal/maintenance/activate", headers: aliceHeaders, + }); expect(activated.statusCode).toBe(200); expect(activated.json()).toEqual({ active: true, admissions: 0 }); @@ -190,15 +191,16 @@ test.each(["none", "upstream"] as const)( }); expect(spoofedProxy.statusCode).toBe(403); - const status = await app.inject({ method: "GET", url: "/internal/maintenance/status" }); + const status = await app.inject({ method: "GET", url: "/internal/maintenance/status", headers: aliceHeaders }); expect(status.json()).toEqual({ active: true, admissions: 0 }); - const deactivated = await app.inject({ method: "POST", url: "/internal/maintenance/deactivate" }); + const deactivated = await app.inject({ + method: "POST", url: "/internal/maintenance/deactivate", headers: aliceHeaders, + }); expect(deactivated.json()).toEqual({ active: false, admissions: 0 }); } finally { rmSync(dir, { recursive: true, force: true }); } - }, -); +}); test("maintenance endpoints report marker-derived state after post-rename and post-remove fsync failures", async () => { const dir = mkdtempSync(path.join(tmpdir(), "tht-maintenance-endpoint-fsync-"));