From a66ef58766f9170b10e2d8784915fca793621701 Mon Sep 17 00:00:00 2001 From: mptyl Date: Sun, 16 Aug 2026 17:26:31 +0200 Subject: [PATCH] feat(auth): define strict installation authentication config --- backend/src/auth/auth.ts | 3 +- backend/src/auth/config.ts | 181 +++++++++++++++++++++++++++++++ backend/src/auth/types.ts | 56 ++++++++++ backend/src/config.ts | 62 +++++++++-- backend/test/auth-config.test.ts | 176 ++++++++++++++++++++++++++++++ backend/test/config.test.ts | 66 +++++++++++ 6 files changed, 533 insertions(+), 11 deletions(-) create mode 100644 backend/src/auth/config.ts create mode 100644 backend/src/auth/types.ts create mode 100644 backend/test/auth-config.test.ts diff --git a/backend/src/auth/auth.ts b/backend/src/auth/auth.ts index 8379e8ba..b8dbcfc3 100644 --- a/backend/src/auth/auth.ts +++ b/backend/src/auth/auth.ts @@ -1,11 +1,12 @@ import type { FastifyRequest, FastifyReply } from "fastify"; import { localPrincipal, type PrincipalContext, upstreamPrincipal } from "./principal.js"; +import type { AuthMode } from "./types.js"; declare module "fastify" { interface FastifyRequest { principal?: PrincipalContext } } -export function authPreHandler(mode: "none" | "mock" | "upstream") { +export function authPreHandler(mode: AuthMode) { return async (req: FastifyRequest, reply: FastifyReply) => { if (mode === "none") { req.principal = localPrincipal(); diff --git a/backend/src/auth/config.ts b/backend/src/auth/config.ts new file mode 100644 index 00000000..82e51796 --- /dev/null +++ b/backend/src/auth/config.ts @@ -0,0 +1,181 @@ +import { createHash } from "node:crypto"; +import { closeSync, constants, fstatSync, openSync, readSync, statSync } from "node:fs"; +import { parseDocument } from "yaml"; +import { z } from "zod"; +import type { + AuthenticationConfig, + AuthenticationConfigProvider, + AuthMode, + LoadedAuthConfig, + Permission, + Role, +} from "./types.js"; + +export type { + AuthenticationConfig, + AuthenticationConfigProvider, + AuthMode, + LoadedAuthConfig, + Permission, + Role, +} from "./types.js"; + +const MAX_AUTH_CONFIG_BYTES = 1024 * 1024; +const ROLES = ["user", "admin"] as const; +const PERMISSIONS: readonly Permission[] = [ + "session.use", "session.read_all", "session.manage_all", "settings.manage", + "workspace.manage", "workspace.secrets.manage", "pi.manage", "auth.diagnostics.read", +]; + +const invalid = (): Error => new Error("authentication configuration is invalid"); +const nonEmptyText = z.string().min(1).max(512).refine( + (value) => value.trim() === value && !/[\u0000-\u001f\u007f]/.test(value), +); +const positiveSeconds = z.number().int().min(1).max(365 * 24 * 60 * 60); +const sessionSchema = z.strictObject({ + regularTtlSeconds: positiveSeconds.default(43_200), + regularIdleSeconds: positiveSeconds.default(7_200), + rememberTtlSeconds: positiveSeconds.default(2_592_000), + rememberIdleSeconds: positiveSeconds.default(604_800), + oidcTtlSeconds: positiveSeconds.default(28_800), +}); +const roleSchema = z.enum(ROLES); +const groupNameSchema = nonEmptyText.max(256); +const groupRolesSchema = z.record(groupNameSchema, z.array(roleSchema).min(1)); + +const localSchema = z.strictObject({ + version: z.literal(1), mode: z.literal("local"), publicUrl: nonEmptyText, session: sessionSchema.optional(), + local: z.strictObject({ usersFile: nonEmptyText.max(255) }), +}); +const oidcSchema = z.strictObject({ + version: z.literal(1), mode: z.literal("oidc"), publicUrl: nonEmptyText, session: sessionSchema.optional(), + oidc: z.strictObject({ + issuer: nonEmptyText, clientId: nonEmptyText, clientSecretRef: z.literal("THT_OIDC_CLIENT_SECRET"), + scopes: z.array(nonEmptyText).min(1).max(16), groupsClaim: z.literal("groups"), + }), + groupCatalog: z.strictObject({ + driver: z.literal("authentik"), baseUrl: nonEmptyText, apiTokenRef: z.literal("THT_AUTHENTIK_API_TOKEN"), + }), + authorization: z.strictObject({ groupRoles: groupRolesSchema }), +}); + +function readBoundedConfig(path: string): string { + let fd: number | undefined; + try { + fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW); + const info = fstatSync(fd); + if (!info.isFile() || info.size < 0 || info.size > MAX_AUTH_CONFIG_BYTES) throw invalid(); + const buffer = Buffer.allocUnsafe(MAX_AUTH_CONFIG_BYTES + 1); + const bytesRead = readSync(fd, buffer, 0, buffer.length, 0); + if (bytesRead > MAX_AUTH_CONFIG_BYTES) throw invalid(); + return new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, bytesRead)); + } catch { + throw invalid(); + } finally { + if (fd !== undefined) try { closeSync(fd); } catch { /* sanitized by design */ } + } +} + +function loopbackHost(host: string): boolean { + return host === "::1" || /^127(?:\.\d{1,3}){3}$/.test(host); +} + +function validOrigin(value: string, httpLoopbackAllowed: boolean): boolean { + try { + const url = new URL(value); + return (url.protocol === "https:" || (httpLoopbackAllowed && url.protocol === "http:" && loopbackHost(url.hostname))) + && url.username.length === 0 && url.password.length === 0 && url.pathname === "/" + && url.search.length === 0 && url.hash.length === 0; + } catch { return false; } +} + +function validIssuer(value: string): boolean { + try { + const url = new URL(value); + return url.protocol === "https:" && url.username.length === 0 && url.password.length === 0 + && url.search.length === 0 && url.hash.length === 0; + } catch { return false; } +} + +function validUsersFile(value: string): boolean { + return /^[A-Za-z0-9][A-Za-z0-9._-]*\.yaml$/.test(value); +} + +function canonicalize(value: unknown): unknown { + if (Array.isArray(value)) return value.map(canonicalize); + if (value && typeof value === "object") { + return Object.fromEntries(Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, nested]) => [key, canonicalize(nested)])); + } + return value; +} + +function canonicalRevision(value: AuthenticationConfig): string { + return createHash("sha256").update(JSON.stringify(canonicalize(value))).digest("hex"); +} + +function parseAuthenticationConfig(source: string): AuthenticationConfig { + try { + const document = parseDocument(source, { uniqueKeys: true }); + if (document.errors.length > 0 || document.warnings.length > 0) throw invalid(); + const parsed = document.toJSON(); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw invalid(); + const config = parsed as Record; + const schema = config.mode === "local" ? localSchema : config.mode === "oidc" ? oidcSchema : undefined; + if (!schema) throw invalid(); + const validated = schema.parse(config); + const session = sessionSchema.parse(validated.session ?? {}); + if (!validOrigin(validated.publicUrl, true)) throw invalid(); + if (validated.mode === "local") { + if (!validUsersFile(validated.local.usersFile)) throw invalid(); + return { ...validated, session }; + } + if (!validIssuer(validated.oidc.issuer) || !validOrigin(validated.groupCatalog.baseUrl, false)) throw invalid(); + if (!validated.oidc.scopes.includes("openid")) throw invalid(); + const mappings = Object.entries(validated.authorization.groupRoles); + if (mappings.length === 0 || mappings.filter(([, roles]) => roles.includes("admin")).length !== 1) throw invalid(); + return { ...validated, session }; + } catch { throw invalid(); } +} + +export function loadAuthenticationConfig(path: string): LoadedAuthConfig { + if (typeof path !== "string" || path.length === 0 || path.trim() !== path || path.includes("\0")) throw invalid(); + const value = parseAuthenticationConfig(readBoundedConfig(path)); + return { value, revision: canonicalRevision(value), sourcePath: path }; +} + +interface FileIdentity { dev: number; ino: number; size: number; mtimeMs: number } + +function fileIdentity(path: string): FileIdentity { + try { + const info = statSync(path); + if (!info.isFile()) throw invalid(); + return { dev: info.dev, ino: info.ino, size: info.size, mtimeMs: info.mtimeMs }; + } catch { throw invalid(); } +} + +function sameIdentity(left: FileIdentity, right: FileIdentity): boolean { + return left.dev === right.dev && left.ino === right.ino && left.size === right.size && left.mtimeMs === right.mtimeMs; +} + +export function createAuthenticationConfigProvider(path: string): AuthenticationConfigProvider { + let cached: { identity: FileIdentity; loaded: LoadedAuthConfig } | undefined; + return { current(): LoadedAuthConfig { + const before = fileIdentity(path); + if (cached && sameIdentity(cached.identity, before)) return cached.loaded; + const loaded = loadAuthenticationConfig(path); + cached = { identity: fileIdentity(path), loaded }; + return loaded; + } }; +} + +export function rolesToPermissions(roles: readonly Role[]): readonly Permission[] { + const requested = new Set(); + for (const role of roles) { + if (!ROLES.includes(role)) throw invalid(); + requested.add(role); + } + if (requested.has("admin")) return PERMISSIONS; + return requested.has("user") ? ["session.use"] : []; +} diff --git a/backend/src/auth/types.ts b/backend/src/auth/types.ts new file mode 100644 index 00000000..a83bde4c --- /dev/null +++ b/backend/src/auth/types.ts @@ -0,0 +1,56 @@ +export type AuthMode = "local" | "oidc" | "upstream" | "none" | "mock"; + +export type Role = "user" | "admin"; + +export type Permission = + | "session.use" | "session.read_all" | "session.manage_all" + | "settings.manage" | "workspace.manage" | "workspace.secrets.manage" + | "pi.manage" | "auth.diagnostics.read"; + +export interface AuthenticationSessionConfig { + regularTtlSeconds: number; + regularIdleSeconds: number; + rememberTtlSeconds: number; + rememberIdleSeconds: number; + oidcTtlSeconds: number; +} + +export interface LocalAuthenticationConfig { + version: 1; + mode: "local"; + publicUrl: string; + session: AuthenticationSessionConfig; + local: { usersFile: string }; +} + +export interface OidcAuthenticationConfig { + version: 1; + mode: "oidc"; + publicUrl: string; + session: AuthenticationSessionConfig; + oidc: { + issuer: string; + clientId: string; + clientSecretRef: "THT_OIDC_CLIENT_SECRET"; + scopes: readonly string[]; + groupsClaim: "groups"; + }; + groupCatalog: { + driver: "authentik"; + baseUrl: string; + apiTokenRef: "THT_AUTHENTIK_API_TOKEN"; + }; + authorization: { groupRoles: Readonly> }; +} + +export type AuthenticationConfig = LocalAuthenticationConfig | OidcAuthenticationConfig; + +export interface LoadedAuthConfig { + value: AuthenticationConfig; + revision: string; + sourcePath: string; +} + +export interface AuthenticationConfigProvider { + current(): LoadedAuthConfig; +} diff --git a/backend/src/config.ts b/backend/src/config.ts index 8eaea18c..88da45ce 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -1,9 +1,18 @@ import path from "node:path"; +import { statSync } from "node:fs"; +import { + createAuthenticationConfigProvider, + type AuthenticationConfigProvider, + type AuthMode, +} from "./auth/config.js"; import type { WorkspaceRegistryConfig } from "./workspaces/types.js"; export interface AppConfig { host: string; port: number; harnessDir: string; thtBin: string; piBin: string; - authMode: "none" | "mock" | "upstream"; + authMode: AuthMode; + authConfigFile: string; + authStateRoot: string; + authentication?: AuthenticationConfigProvider; publicExposure: boolean; sessionStorage: { mode: "local" | "postgres"; @@ -55,6 +64,23 @@ function absoluteRegistryPath(value: string, label: string): string { return pathValue; } +function absoluteAuthPath(value: string, label: string): string { + if (value.length === 0 || value.trim() !== value || value.includes("\0") || !path.isAbsolute(value)) { + throw new Error(`authentication ${label} configuration is invalid`); + } + return value; +} + +function authConfigFileExists(file: string): boolean { + try { + if (!statSync(file).isFile()) throw new Error("authentication configuration is invalid"); + return true; + } catch (error: any) { + if (error?.code === "ENOENT") return false; + throw new Error("authentication configuration is invalid"); + } +} + function refSafeGitBranch(value: string): string { const branch = requiredRegistryValue(value, "branch"); if ( @@ -149,13 +175,26 @@ function positiveDimension(value: string | undefined, fallback: number): number } export function loadConfig(env: Record): AppConfig { - const authMode = env.AUTH_MODE ?? "none"; - if (!(["none", "mock", "upstream"] as const).includes(authMode as AppConfig["authMode"])) { - throw new Error(`unsupported AUTH_MODE=${authMode}; use none, mock, or upstream`); + const authConfigFile = absoluteAuthPath(env.THT_AUTH_CONFIG_FILE ?? "/run/thothii-auth/auth.yaml", "file"); + const authStateRoot = absoluteAuthPath(env.THT_AUTH_STATE_ROOT ?? "/data/auth", "state root"); + const hasAuthenticationConfig = authConfigFileExists(authConfigFile); + if (hasAuthenticationConfig && env.AUTH_MODE !== undefined) { + throw new Error("authentication configuration and AUTH_MODE cannot both be set"); + } + const authentication = hasAuthenticationConfig ? createAuthenticationConfigProvider(authConfigFile) : undefined; + let authMode: AuthMode; + if (authentication) { + authMode = authentication.current().value.mode; + } else { + const requestedMode = env.AUTH_MODE ?? "none"; + if (!(["none", "mock", "upstream"] as const).includes(requestedMode as "none" | "mock" | "upstream")) { + throw new Error(`unsupported AUTH_MODE=${requestedMode}; use none, mock, or upstream`); + } + authMode = requestedMode as "none" | "mock" | "upstream"; } const publicExposure = env.THOTH_PUBLIC_EXPOSURE === "true"; - if (publicExposure && authMode !== "upstream") { - throw new Error("public exposure requires AUTH_MODE=upstream behind a trusted proxy"); + if (publicExposure && authMode !== "oidc" && authMode !== "upstream") { + throw new Error("public exposure requires AUTH_MODE=upstream or configured OIDC behind a trusted proxy"); } const sessionStorageMode = env.THT_SESSION_STORAGE ?? "local"; if (sessionStorageMode !== "local" && sessionStorageMode !== "postgres") { @@ -181,15 +220,15 @@ export function loadConfig(env: Record): AppConfig { const sslrootcert = env.THT_SESSION_DB_SSLROOTCERT; const port = Number(env.THT_SESSION_DB_PORT ?? 5432); if ( - authMode !== "upstream" + (authMode !== "upstream" && authMode !== "oidc") || !host || !database || !runtimeUser || !runtimePasswordFile || !path.isAbsolute(runtimePasswordFile) || (sslmode !== "verify-ca" && sslmode !== "verify-full") || !sslrootcert || !path.isAbsolute(sslrootcert) || !Number.isInteger(port) || port < 1 || port > 65535 ) { - if (authMode !== "upstream") { - throw new Error("server session storage requires AUTH_MODE=upstream"); + if (authMode !== "upstream" && authMode !== "oidc") { + throw new Error("server session storage requires AUTH_MODE=upstream or configured OIDC"); } throw new Error("server session storage configuration is invalid"); } @@ -277,7 +316,10 @@ export function loadConfig(env: Record): AppConfig { harnessDir: env.THT_HARNESS_DIR ?? "../harness", thtBin: env.THT_BIN ?? "tht", piBin: env.PI_BIN ?? "pi", - authMode: authMode as AppConfig["authMode"], + authMode, + authConfigFile, + authStateRoot, + authentication, publicExposure, sessionStorage, defaults: { provider: env.PI_PROVIDER, model: env.PI_MODEL, thinking: env.PI_THINKING }, diff --git a/backend/test/auth-config.test.ts b/backend/test/auth-config.test.ts new file mode 100644 index 00000000..1beb6db6 --- /dev/null +++ b/backend/test/auth-config.test.ts @@ -0,0 +1,176 @@ +import { afterEach, expect, test } from "vitest"; +import { mkdtempSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { stringify } from "yaml"; +import { + createAuthenticationConfigProvider, + loadAuthenticationConfig, + rolesToPermissions, +} from "../src/auth/config.js"; + +const directories: string[] = []; + +afterEach(() => { + for (const directory of directories.splice(0)) rmSync(directory, { recursive: true, force: true }); +}); + +function writeFixture(value: unknown): string { + const directory = mkdtempSync(join(tmpdir(), "thothii-auth-config-")); + directories.push(directory); + const file = join(directory, "auth.yaml"); + writeFileSync(file, stringify(value), "utf8"); + return file; +} + +function localConfig(overrides: Record = {}): Record { + return { + version: 1, + mode: "local", + publicUrl: "http://127.0.0.1:8080", + local: { usersFile: "users.yaml" }, + ...overrides, + }; +} + +function oidcConfig(overrides: Record = {}): Record { + return { + version: 1, + mode: "oidc", + publicUrl: "https://thothii.example.org", + oidc: { + issuer: "https://authentik.example.org/application/o/thothii/", + clientId: "thothii", + clientSecretRef: "THT_OIDC_CLIENT_SECRET", + scopes: ["openid", "profile", "email"], + groupsClaim: "groups", + }, + groupCatalog: { + driver: "authentik", + baseUrl: "https://authentik.example.org", + apiTokenRef: "THT_AUTHENTIK_API_TOKEN", + }, + authorization: { + groupRoles: { + "TOT Users": ["user"], + "TOT Admin": ["admin"], + }, + }, + ...overrides, + }; +} + +test("loads local configuration with the specified default lifetimes", () => { + const loaded = loadAuthenticationConfig(writeFixture(localConfig())); + + expect(loaded.value).toMatchObject({ + mode: "local", + publicUrl: "http://127.0.0.1:8080", + session: { + regularTtlSeconds: 43_200, + regularIdleSeconds: 7_200, + rememberTtlSeconds: 2_592_000, + rememberIdleSeconds: 604_800, + oidcTtlSeconds: 28_800, + }, + local: { usersFile: "users.yaml" }, + }); + expect(loaded.revision).toMatch(/^[a-f0-9]{64}$/); +}); + +test("loads the fixed OIDC secret references and preserves exact group names", () => { + const loaded = loadAuthenticationConfig(writeFixture(oidcConfig())); + + expect(loaded.value).toMatchObject({ + mode: "oidc", + oidc: { clientSecretRef: "THT_OIDC_CLIENT_SECRET", groupsClaim: "groups" }, + groupCatalog: { driver: "authentik", apiTokenRef: "THT_AUTHENTIK_API_TOKEN" }, + authorization: { + groupRoles: { "TOT Users": ["user"], "TOT Admin": ["admin"] }, + }, + }); + expect(loaded.value.authorization.groupRoles["tot users"]).toBeUndefined(); +}); + +test.each([ + ["unknown root key", localConfig({ unexpected: true })], + ["relative users file", localConfig({ local: { usersFile: "../users.yaml" } })], + ["OIDC without groups claim", oidcConfig({ oidc: { + issuer: "https://authentik.example.org/application/o/thothii/", clientId: "thothii", + clientSecretRef: "THT_OIDC_CLIENT_SECRET", scopes: ["openid"], + } })], + ["OIDC without admin mapping", oidcConfig({ authorization: { groupRoles: {} } })], + ["HTTP non-loopback public URL", localConfig({ publicUrl: "http://thoth.example" })], +])("rejects %s", (_label, value) => { + expect(() => loadAuthenticationConfig(writeFixture(value))).toThrow("authentication configuration is invalid"); +}); + +test("accepts the explicit loopback HTTP OIDC exception", () => { + expect(loadAuthenticationConfig(writeFixture(oidcConfig({ publicUrl: "http://127.0.0.1:8787" }))).value.mode) + .toBe("oidc"); +}); + +test("rejects unknown roles and requires exactly one admin group", () => { + expect(() => loadAuthenticationConfig(writeFixture(oidcConfig({ + authorization: { groupRoles: { "TOT Users": ["user", "operator"], "TOT Admin": ["admin"] } }, + })))).toThrow("authentication configuration is invalid"); + expect(() => loadAuthenticationConfig(writeFixture(oidcConfig({ + authorization: { groupRoles: { "TOT Users": ["user"], "TOT Admin": ["admin"], "Other Admin": ["admin"] } }, + })))).toThrow("authentication configuration is invalid"); +}); + +test("roles collapse duplicates and admin contains all administrative permissions", () => { + expect(rolesToPermissions(["admin", "admin", "user"])).toEqual([ + "session.use", + "session.read_all", + "session.manage_all", + "settings.manage", + "workspace.manage", + "workspace.secrets.manage", + "pi.manage", + "auth.diagnostics.read", + ]); + expect(() => rolesToPermissions(["unknown"] as never)).toThrow("authentication configuration is invalid"); +}); + +test("rejects duplicate YAML keys and does not leak invalid reference values", () => { + const duplicate = writeFixture(`version: 1\nmode: local\nmode: oidc\npublicUrl: http://127.0.0.1:8787\nlocal:\n usersFile: users.yaml\n`); + expect(() => loadAuthenticationConfig(duplicate)).toThrow("authentication configuration is invalid"); + + const referenceCanary = "never-load-or-emit-this-secret"; + const invalid = writeFixture(oidcConfig({ oidc: { + issuer: "https://authentik.example.org/application/o/thothii/", clientId: "thothii", + clientSecretRef: referenceCanary, scopes: ["openid"], groupsClaim: "groups", + } })); + try { + loadAuthenticationConfig(invalid); + } catch (error) { + expect(String(error)).not.toContain(referenceCanary); + } +}); + +test("canonical group map order produces one stable revision", () => { + const first = writeFixture(oidcConfig()); + const reordered = writeFixture(oidcConfig({ + authorization: { groupRoles: { "TOT Admin": ["admin"], "TOT Users": ["user"] } }, + })); + expect(loadAuthenticationConfig(first).revision).toBe(loadAuthenticationConfig(reordered).revision); +}); + +test("provider reloads after an atomic configuration replacement", () => { + const file = writeFixture(localConfig()); + const provider = createAuthenticationConfigProvider(file); + const original = provider.current(); + const replacement = `${file}.replacement`; + writeFileSync(replacement, stringify(localConfig({ publicUrl: "http://127.0.0.1:9999" })), "utf8"); + renameSync(replacement, file); + + const reloaded = provider.current(); + expect(reloaded.revision).not.toBe(original.revision); + expect(reloaded.value.publicUrl).toBe("http://127.0.0.1:9999"); +}); + +test("rejects input larger than one MiB", () => { + const file = writeFixture(`${"#".repeat(1024 * 1024)}\n`); + expect(() => loadAuthenticationConfig(file)).toThrow("authentication configuration is invalid"); +}); diff --git a/backend/test/config.test.ts b/backend/test/config.test.ts index 284ca5f7..f15d6469 100644 --- a/backend/test/config.test.ts +++ b/backend/test/config.test.ts @@ -1,6 +1,29 @@ import { expect, test } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { stringify } from "yaml"; import { loadConfig } from "../src/config.js"; +function authFile(value: unknown): { directory: string; file: string } { + const directory = mkdtempSync(join(tmpdir(), "thothii-app-auth-config-")); + const file = join(directory, "auth.yaml"); + writeFileSync(file, stringify(value), "utf8"); + return { directory, file }; +} + +function oidcAuthConfig(): Record { + return { + version: 1, mode: "oidc", publicUrl: "https://thothii.example.org", + oidc: { + issuer: "https://authentik.example.org/application/o/thothii/", clientId: "thothii", + clientSecretRef: "THT_OIDC_CLIENT_SECRET", scopes: ["openid"], groupsClaim: "groups", + }, + groupCatalog: { driver: "authentik", baseUrl: "https://authentik.example.org", apiTokenRef: "THT_AUTHENTIK_API_TOKEN" }, + authorization: { groupRoles: { "TOT Users": ["user"], "TOT Admin": ["admin"] } }, + }; +} + test("loadConfig accepts container listening and runtime paths", () => { expect(loadConfig({ HOST: "0.0.0.0", @@ -41,10 +64,53 @@ test("loadConfig keeps local development defaults", () => { internalEmbeddingUrl: "http://embedding:11434", internalEmbeddingModel: "qwen3-embedding:0.6b", internalEmbeddingDimensions: 1024, + authMode: "none", + authConfigFile: "/run/thothii-auth/auth.yaml", + authStateRoot: "/data/auth", }); expect(loadConfig({}).dataRoot).toBeUndefined(); }); +test("loadConfig makes an existing auth.yaml authoritative and rejects AUTH_MODE split-brain", () => { + const { directory, file } = authFile(oidcAuthConfig()); + try { + const config = loadConfig({ THT_AUTH_CONFIG_FILE: file, THT_AUTH_STATE_ROOT: "/state/auth" }); + expect(config.authMode).toBe("oidc"); + expect(config.authStateRoot).toBe("/state/auth"); + expect(config.authentication?.current().sourcePath).toBe(file); + expect(() => loadConfig({ THT_AUTH_CONFIG_FILE: file, AUTH_MODE: "upstream" })) + .toThrow("authentication configuration and AUTH_MODE cannot both be set"); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + +test("loadConfig rejects an auth config path that exists but is not a regular file", () => { + const directory = mkdtempSync(join(tmpdir(), "thothii-app-auth-config-directory-")); + try { + expect(() => loadConfig({ THT_AUTH_CONFIG_FILE: directory })) + .toThrow("authentication configuration is invalid"); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + +test("public exposure accepts configured OIDC and the upstream migration mode only", () => { + const { directory, file } = authFile(oidcAuthConfig()); + try { + expect(loadConfig({ + THOTH_PUBLIC_EXPOSURE: "true", THT_AUTH_CONFIG_FILE: file, THT_SESSION_STORAGE: "postgres", + THT_SESSION_DB_HOST: "db.internal", THT_SESSION_DB_NAME: "thoth", THT_SESSION_RUNTIME_USER: "thoth_sessions_app", + THT_SESSION_RUNTIME_PASSWORD_FILE: "/run/secrets/session_runtime_password", THT_SESSION_DB_SSLMODE: "verify-full", + THT_SESSION_DB_SSLROOTCERT: "/run/secrets/session_ca.pem", + }).authMode).toBe("oidc"); + expect(() => loadConfig({ THOTH_PUBLIC_EXPOSURE: "true", AUTH_MODE: "mock" })) + .toThrow("public exposure requires AUTH_MODE=upstream or configured OIDC"); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + test("loadConfig accepts only the allowed internal semantic runtime hosts", () => { expect(loadConfig({ THT_INTERNAL_QDRANT_URL: "http://localhost:6333",