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, type AppWithAuthSessionStore } from "../src/app.js"; import { loadAuthenticationConfig } from "../src/auth/config.js"; import type { AuthenticationConfigProvider, LoadedAuthConfig } from "../src/auth/types.js"; import { loadConfig } from "../src/config.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 originA = "http://127.0.0.1:8787"; const originB = "http://127.0.0.1:8788"; const userA = { id: "6ba7b810-9dad-4ed1-80b4-00c04fd430c8", username: "AdminA" }; const userB = { id: "6ba7b811-9dad-4ed1-80b4-00c04fd430c8", username: "AdminB" }; const cleanups: Array<() => Promise> = []; afterEach(async () => { for (const cleanup of cleanups.splice(0).reverse()) await cleanup(); }); function localYaml(publicUrl: string, usersFile: string): string { return stringify({ version: 1, mode: "local", publicUrl, local: { usersFile } }); } function oidcYaml(publicUrl: string): string { return stringify({ version: 1, mode: "oidc", publicUrl, oidc: { issuer: "https://issuer.example.test/application/o/thothii/", clientId: "thothii", clientSecretRef: "THT_OIDC_CLIENT_SECRET", scopes: ["openid"], groupsClaim: "groups", }, groupCatalog: { driver: "authentik", baseUrl: "https://issuer.example.test", apiTokenRef: "THT_AUTHENTIK_API_TOKEN", }, authorization: { groupRoles: { Users: ["user"], Admins: ["admin"] } }, }); } 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 firstCookie(response: { headers: Record }): string { const header = response.headers["set-cookie"]; return (Array.isArray(header) ? header[0] : header)?.split(";", 1)[0] ?? ""; } async function createFixture(first: "A" | "B", later: "A" | "B" | "oidc") { const directory = mkdtempSync(join(realpathSync(tmpdir()), "thothii-auth-request-snapshot-")); chmodSync(directory, 0o700); const authA = join(directory, "auth-a.yaml"); const authB = join(directory, "auth-b.yaml"); const usersA = join(directory, "users-a.yaml"); const usersB = join(directory, "users-b.yaml"); writeFileSync(usersA, usersYaml(userA), { encoding: "utf8", mode: 0o600 }); writeFileSync(usersB, usersYaml(userB), { encoding: "utf8", mode: 0o600 }); writeFileSync(authA, localYaml(originA, "users-a.yaml"), { encoding: "utf8", mode: 0o600 }); writeFileSync(authB, later === "oidc" ? oidcYaml(originB) : localYaml(originB, "users-b.yaml"), { encoding: "utf8", mode: 0o600 }); for (const path of [authA, authB, usersA, usersB]) chmodSync(path, 0o600); const snapshots = { A: loadAuthenticationConfig(authA), B: loadAuthenticationConfig(authB) }; let calls = 0; const provider: AuthenticationConfigProvider = { current: () => { calls += 1; return calls === 1 ? snapshots[first] : snapshots[later === "oidc" ? "B" : later]; }, }; const authStateRoot = join(directory, "auth-state"); prepareAuthStateRoot(authStateRoot); const config = loadConfig({ THT_AUTH_CONFIG_FILE: authA, THT_AUTH_STATE_ROOT: authStateRoot, THT_HARNESS_DIR: "/tmp/h", }); config.authentication = provider; const app = buildApp(config, { authStorageBridgeForTest: createFixtureAuthStorageBridge() }) as AppWithAuthSessionStore; cleanups.push(async () => { await app.close(); rmSync(directory, { recursive: true, force: true }); }); return { app, snapshots, calls: () => calls, resetCalls: () => { calls = 0; }, userFor(snapshot: LoadedAuthConfig) { return snapshot.sourcePath === authA ? userA : userB; }, }; } test.each([ { first: "A" as const, later: "B" as const }, { first: "B" as const, later: "A" as const }, ])("a $later session cannot yield data under the replacement $first CORS snapshot", async ({ first, later }) => { const fixture = await createFixture(first, later); const requestSnapshot = fixture.snapshots[first]; const replacementSnapshot = fixture.snapshots[later]; const user = fixture.userFor(replacementSnapshot); const created = await fixture.app.thothiiAuthSessionStore?.create({ principal: { issuer: "local", subject: user.id, displayName: user.username, roles: ["admin"], permissions: ["session.use", "session.read_all", "session.manage_all", "settings.manage", "workspace.manage", "workspace.secrets.manage", "database.manage", "pi.manage", "auth.diagnostics.read"], isAdmin: true, }, method: "local", remembered: false, userAuthRevision: 1, authConfigRevision: replacementSnapshot.revision, idleTtlMs: 60_000, absoluteTtlMs: 60_000, }); expect(created).toBeDefined(); fixture.resetCalls(); const response = await fixture.app.inject({ method: "GET", url: "/me", headers: { cookie: `thothii_session=${created?.token}`, origin: requestSnapshot.value.publicUrl }, }); expect(fixture.calls()).toBe(1); expect(response.headers["access-control-allow-origin"]).toBe(new URL(requestSnapshot.value.publicUrl).origin); expect(response.statusCode).toBe(401); expect(response.json()).toEqual({ code: "authentication_required", error: "Authentication is required" }); expect(response.body).not.toContain(user.id); }); test.each([ { first: "A" as const, later: "B" as const }, { first: "B" as const, later: "A" as const }, ])("local login uses and stamps its one $first request snapshot despite $later replacement", async ({ first, later }) => { const fixture = await createFixture(first, later); const snapshot = fixture.snapshots[first]; const user = fixture.userFor(snapshot); fixture.resetCalls(); const response = await fixture.app.inject({ method: "POST", url: "/auth/local/login", headers: { origin: snapshot.value.publicUrl, "sec-fetch-site": "same-origin" }, payload: { username: user.username, password }, }); expect(response.statusCode).toBe(200); expect(fixture.calls()).toBe(1); const token = firstCookie(response).split("=", 2)[1] ?? ""; fixture.resetCalls(); const record = await fixture.app.thothiiAuthSessionStore?.resolve(token); expect(record).toMatchObject({ subject: user.id, authConfigRevision: snapshot.revision }); }); test("auth config and valid preflight use the same first snapshot when the provider is replaced", async () => { const fixture = await createFixture("A", "oidc"); fixture.resetCalls(); const preflight = await fixture.app.inject({ method: "OPTIONS", url: "/me", headers: { origin: originA, "access-control-request-method": "GET" }, }); expect(preflight.statusCode).toBe(204); expect(preflight.headers["access-control-allow-origin"]).toBe(originA); expect(fixture.calls()).toBe(1); fixture.resetCalls(); const response = await fixture.app.inject({ method: "GET", url: "/auth/config", headers: { origin: originA } }); expect(response.statusCode).toBe(200); expect(response.headers["access-control-allow-origin"]).toBe(originA); expect(response.json()).toEqual({ mode: "local", localLogin: true, oidcLogin: false }); expect(fixture.calls()).toBe(1); });