feat(auth): define strict installation authentication config
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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"] : [];
|
||||
}
|
||||
@@ -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
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user