feat(auth): define strict installation authentication config

This commit is contained in:
2026-08-16 17:26:31 +02:00
parent d464f3a982
commit a66ef58766
6 changed files with 533 additions and 11 deletions
+2 -1
View File
@@ -1,11 +1,12 @@
import type { FastifyRequest, FastifyReply } from "fastify"; import type { FastifyRequest, FastifyReply } from "fastify";
import { localPrincipal, type PrincipalContext, upstreamPrincipal } from "./principal.js"; import { localPrincipal, type PrincipalContext, upstreamPrincipal } from "./principal.js";
import type { AuthMode } from "./types.js";
declare module "fastify" { declare module "fastify" {
interface FastifyRequest { principal?: PrincipalContext } interface FastifyRequest { principal?: PrincipalContext }
} }
export function authPreHandler(mode: "none" | "mock" | "upstream") { export function authPreHandler(mode: AuthMode) {
return async (req: FastifyRequest, reply: FastifyReply) => { return async (req: FastifyRequest, reply: FastifyReply) => {
if (mode === "none") { if (mode === "none") {
req.principal = localPrincipal(); req.principal = localPrincipal();
+181
View File
@@ -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<string, unknown>)
.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<string, unknown>;
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<Role>();
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"] : [];
}
+56
View File
@@ -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<Record<string, readonly Role[]>> };
}
export type AuthenticationConfig = LocalAuthenticationConfig | OidcAuthenticationConfig;
export interface LoadedAuthConfig {
value: AuthenticationConfig;
revision: string;
sourcePath: string;
}
export interface AuthenticationConfigProvider {
current(): LoadedAuthConfig;
}
+52 -10
View File
@@ -1,9 +1,18 @@
import path from "node:path"; 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"; import type { WorkspaceRegistryConfig } from "./workspaces/types.js";
export interface AppConfig { export interface AppConfig {
host: string; port: number; harnessDir: string; thtBin: string; piBin: string; host: string; port: number; harnessDir: string; thtBin: string; piBin: string;
authMode: "none" | "mock" | "upstream"; authMode: AuthMode;
authConfigFile: string;
authStateRoot: string;
authentication?: AuthenticationConfigProvider;
publicExposure: boolean; publicExposure: boolean;
sessionStorage: { sessionStorage: {
mode: "local" | "postgres"; mode: "local" | "postgres";
@@ -55,6 +64,23 @@ function absoluteRegistryPath(value: string, label: string): string {
return pathValue; 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 { function refSafeGitBranch(value: string): string {
const branch = requiredRegistryValue(value, "branch"); const branch = requiredRegistryValue(value, "branch");
if ( if (
@@ -149,13 +175,26 @@ function positiveDimension(value: string | undefined, fallback: number): number
} }
export function loadConfig(env: Record<string, string | undefined>): AppConfig { export function loadConfig(env: Record<string, string | undefined>): AppConfig {
const authMode = env.AUTH_MODE ?? "none"; const authConfigFile = absoluteAuthPath(env.THT_AUTH_CONFIG_FILE ?? "/run/thothii-auth/auth.yaml", "file");
if (!(["none", "mock", "upstream"] as const).includes(authMode as AppConfig["authMode"])) { const authStateRoot = absoluteAuthPath(env.THT_AUTH_STATE_ROOT ?? "/data/auth", "state root");
throw new Error(`unsupported AUTH_MODE=${authMode}; use none, mock, or upstream`); 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"; const publicExposure = env.THOTH_PUBLIC_EXPOSURE === "true";
if (publicExposure && authMode !== "upstream") { if (publicExposure && authMode !== "oidc" && authMode !== "upstream") {
throw new Error("public exposure requires AUTH_MODE=upstream behind a trusted proxy"); throw new Error("public exposure requires AUTH_MODE=upstream or configured OIDC behind a trusted proxy");
} }
const sessionStorageMode = env.THT_SESSION_STORAGE ?? "local"; const sessionStorageMode = env.THT_SESSION_STORAGE ?? "local";
if (sessionStorageMode !== "local" && sessionStorageMode !== "postgres") { if (sessionStorageMode !== "local" && sessionStorageMode !== "postgres") {
@@ -181,15 +220,15 @@ export function loadConfig(env: Record<string, string | undefined>): AppConfig {
const sslrootcert = env.THT_SESSION_DB_SSLROOTCERT; const sslrootcert = env.THT_SESSION_DB_SSLROOTCERT;
const port = Number(env.THT_SESSION_DB_PORT ?? 5432); const port = Number(env.THT_SESSION_DB_PORT ?? 5432);
if ( if (
authMode !== "upstream" (authMode !== "upstream" && authMode !== "oidc")
|| !host || !database || !runtimeUser || !host || !database || !runtimeUser
|| !runtimePasswordFile || !path.isAbsolute(runtimePasswordFile) || !runtimePasswordFile || !path.isAbsolute(runtimePasswordFile)
|| (sslmode !== "verify-ca" && sslmode !== "verify-full") || (sslmode !== "verify-ca" && sslmode !== "verify-full")
|| !sslrootcert || !path.isAbsolute(sslrootcert) || !sslrootcert || !path.isAbsolute(sslrootcert)
|| !Number.isInteger(port) || port < 1 || port > 65535 || !Number.isInteger(port) || port < 1 || port > 65535
) { ) {
if (authMode !== "upstream") { if (authMode !== "upstream" && authMode !== "oidc") {
throw new Error("server session storage requires AUTH_MODE=upstream"); throw new Error("server session storage requires AUTH_MODE=upstream or configured OIDC");
} }
throw new Error("server session storage configuration is invalid"); throw new Error("server session storage configuration is invalid");
} }
@@ -277,7 +316,10 @@ export function loadConfig(env: Record<string, string | undefined>): AppConfig {
harnessDir: env.THT_HARNESS_DIR ?? "../harness", harnessDir: env.THT_HARNESS_DIR ?? "../harness",
thtBin: env.THT_BIN ?? "tht", thtBin: env.THT_BIN ?? "tht",
piBin: env.PI_BIN ?? "pi", piBin: env.PI_BIN ?? "pi",
authMode: authMode as AppConfig["authMode"], authMode,
authConfigFile,
authStateRoot,
authentication,
publicExposure, publicExposure,
sessionStorage, sessionStorage,
defaults: { provider: env.PI_PROVIDER, model: env.PI_MODEL, thinking: env.PI_THINKING }, defaults: { provider: env.PI_PROVIDER, model: env.PI_MODEL, thinking: env.PI_THINKING },
+176
View File
@@ -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<string, unknown> = {}): Record<string, unknown> {
return {
version: 1,
mode: "local",
publicUrl: "http://127.0.0.1:8080",
local: { usersFile: "users.yaml" },
...overrides,
};
}
function oidcConfig(overrides: Record<string, unknown> = {}): Record<string, unknown> {
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");
});
+66
View File
@@ -1,6 +1,29 @@
import { expect, test } from "vitest"; 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"; 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<string, unknown> {
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", () => { test("loadConfig accepts container listening and runtime paths", () => {
expect(loadConfig({ expect(loadConfig({
HOST: "0.0.0.0", HOST: "0.0.0.0",
@@ -41,10 +64,53 @@ test("loadConfig keeps local development defaults", () => {
internalEmbeddingUrl: "http://embedding:11434", internalEmbeddingUrl: "http://embedding:11434",
internalEmbeddingModel: "qwen3-embedding:0.6b", internalEmbeddingModel: "qwen3-embedding:0.6b",
internalEmbeddingDimensions: 1024, internalEmbeddingDimensions: 1024,
authMode: "none",
authConfigFile: "/run/thothii-auth/auth.yaml",
authStateRoot: "/data/auth",
}); });
expect(loadConfig({}).dataRoot).toBeUndefined(); 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", () => { test("loadConfig accepts only the allowed internal semantic runtime hosts", () => {
expect(loadConfig({ expect(loadConfig({
THT_INTERNAL_QDRANT_URL: "http://localhost:6333", THT_INTERNAL_QDRANT_URL: "http://localhost:6333",