Files
ThothII/backend/test/auth-routes-local.test.ts
T
Codex 82e2c91f42
Publish documentation / publish (push) Successful in 1m27s
feat: implement memory and evidence administration with guided repairs
Add PostgreSQL-backed memory, editable evidence with source review and activation, and human-approved archive repairs across the harness, API, and UI. Include migrations, deployment support, regression coverage, and validation documentation.

Refresh permissions from validated session roles so existing administrator logins can access newly deployed archive management features.
2026-09-10 10:31:34 +02:00

507 lines
20 KiB
TypeScript

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";
import { LoginFailureLimiter } from "../src/auth/routes.js";
import { createFixtureAuthStorageBridge, prepareAuthStateRoot } from "./auth-test-fixtures.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<void>> = [];
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 {
const users = [
"version: 1",
"users:",
` - id: ${adminId}`,
` username: ${options.username ?? "Admin"}`,
" displayName: Local administrator",
` passwordHash: ${passwordHash}`,
" roles:",
" - admin",
` enabled: ${options.enabled ?? true}`,
" authRevision: 1",
];
if (options.enabled === false) {
users.push(
" - id: 6ba7b811-9dad-4ed1-80b4-00c04fd430c8",
" username: BackupAdmin",
` passwordHash: ${passwordHash}`,
" roles:",
" - admin",
" enabled: true",
" authRevision: 1",
);
}
return [...users, ""].join("\n");
}
function firstSetCookie(response: { headers: Record<string, string | string[] | undefined> }): string {
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<unknown>;
findBySubject(subject: string): Promise<unknown>;
verify(user: unknown, suppliedPassword: string): Promise<boolean>;
};
} = {}) {
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);
prepareAuthStateRoot(authStateRoot);
const app = buildApp(loadConfig({
THT_AUTH_CONFIG_FILE: authConfigFile,
THT_AUTH_STATE_ROOT: authStateRoot,
THT_HARNESS_DIR: "/tmp/h",
}), {
...(options.registry === undefined ? {} : { localUserRegistry: options.registry }),
authStorageBridgeForTest: createFixtureAuthStorageBridge(),
} 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<ReturnType<typeof createLocalApp>>["app"], body: Record<string, unknown> = {}) {
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", "database.manage", "memory.manage", "evidence.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);
prepareAuthStateRoot(authStateRoot);
const config = () => loadConfig({ THT_AUTH_CONFIG_FILE: authConfigFile, THT_AUTH_STATE_ROOT: authStateRoot, THT_HARNESS_DIR: "/tmp/h" });
const first = buildApp(config(), { authStorageBridgeForTest: createFixtureAuthStorageBridge() });
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(), { authStorageBridgeForTest: createFixtureAuthStorageBridge() });
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.each([false, true])(
"an uppercase HTTPS public URL sets and clears the secure cookie for remembered=%s",
async (remember) => {
const configuredPublicUrl = "HTTPS://thothii.example.test";
const origin = new URL(configuredPublicUrl).origin;
const { app } = await createLocalApp({ publicUrl: configuredPublicUrl });
const signedIn = await app.inject({
method: "POST",
url: "/auth/local/login",
headers: { origin, "sec-fetch-site": "same-origin" },
payload: { username: "Admin", password, remember },
});
const setCookie = firstSetCookie(signedIn);
expect(signedIn.statusCode).toBe(200);
expect(setCookie).toContain("Secure");
if (remember) expect(setCookie).toContain("Max-Age=2592000");
else expect(setCookie).not.toMatch(/Max-Age=/i);
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,
"sec-fetch-site": "same-origin",
"x-thothii-csrf": me.json().csrfToken,
},
});
expect(loggedOut.statusCode).toBe(204);
expect(firstSetCookie(loggedOut)).toContain("Secure");
},
);
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("failed-attempt limiter keeps exact bounded windows without check-time mutation or active-key eviction", () => {
const now = 1_000_000;
const limiter = new LoginFailureLimiter({ maximumEntries: 2 });
expect(limiter.isLimited("checked-only", "127.0.0.1", now)).toBe(false);
expect(limiter.recordFailure("victim", "127.0.0.1", now)).toBe(true);
expect(limiter.recordFailure("attacker", "127.0.0.1", now)).toBe(true);
expect(limiter.recordFailure("flood", "127.0.0.1", now)).toBe(false);
for (let attempt = 1; attempt < 10; attempt += 1) {
expect(limiter.recordFailure("victim", "127.0.0.1", now)).toBe(true);
}
expect(limiter.isLimited("victim", "127.0.0.1", now)).toBe(true);
expect(limiter.recordFailure("victim", "127.0.0.1", now)).toBe(false);
expect(limiter.isLimited("victim", "127.0.0.1", now + 10 * 60 * 1000 - 1)).toBe(true);
expect(limiter.isLimited("victim", "127.0.0.1", now + 10 * 60 * 1000)).toBe(false);
expect(limiter.recordFailure("victim", "127.0.0.1", now + 10 * 60 * 1000)).toBe(true);
});
test("failed-attempt limiter allows twenty source-address failures, then rejects the twenty-first", () => {
const limiter = new LoginFailureLimiter();
const now = 1_000_000;
for (let attempt = 0; attempt < 20; attempt += 1) {
expect(limiter.recordFailure(`user-${attempt}`, "127.0.0.1", now)).toBe(true);
}
expect(limiter.isLimited("new-user", "127.0.0.1", now)).toBe(true);
expect(limiter.recordFailure("new-user", "127.0.0.1", now)).toBe(false);
});
test("successful and pre-authentication failures never reset or consume failed-login counters", async () => {
let calls = 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 () => {
calls += 1;
return calls === 10;
},
},
});
const originRejected = await app.inject({
method: "POST", url: "/auth/local/login", headers: { origin: "http://wrong.example.test" },
payload: { username: "Admin", password },
});
expect(originRejected.statusCode).toBe(403);
expect(calls).toBe(0);
for (let attempt = 0; attempt < 9; attempt += 1) expect((await login(app)).statusCode).toBe(401);
expect((await login(app)).statusCode).toBe(200);
expect((await login(app)).statusCode).toBe(401);
expect((await login(app)).statusCode).toBe(429);
expect((await login(app)).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<void>((resolve) => { release = resolve; });
let entered!: () => void;
const twoEntered = new Promise<void>((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("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 unavailable OIDC login fails 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(503);
expect(placeholder.json()).toEqual({ code: "auth_unavailable", error: "Authentication is unavailable" });
});