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 { 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();
+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 { 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<string, string | undefined>): 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<string, string | undefined>): 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<string, string | undefined>): 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 },