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" }); });