import { createHash } from "node:crypto"; import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, realpathSync, } from "node:fs"; import type { Stats } from "node:fs"; import { dirname, isAbsolute, normalize } from "node:path"; import { parseDocument } from "yaml"; import { z } from "zod"; import type { AuthenticationConfig, AuthenticationConfigProvider, AuthMode, LoadedAuthConfig, Permission, Role, } from "./types.js"; import { parseConfiguredTransportUrl } from "./url-policy.js"; import { createWindowsAuthStorageBridge, type WindowsAuthStorageBridge } from "./windows-auth-storage.js"; export type { AuthenticationConfig, AuthenticationConfigProvider, AuthMode, LoadedAuthConfig, Permission, Role, } from "./types.js"; const MAX_AUTH_CONFIG_BYTES = 1024 * 1024; // Keep live catalog work within the same deterministic bound as the mandatory direct groups claim. const MAX_MAPPED_GROUPS = 128; const ROLES = ["user", "admin"] as const; export const PERMISSION_CATALOG: readonly Permission[] = [ "session.use", "session.read_all", "session.manage_all", "settings.manage", "workspace.manage", "workspace.secrets.manage", "database.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)) .refine((value) => Object.keys(value).length <= MAX_MAPPED_GROUPS); 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 }), }); interface FileIdentity { dev: number; ino: number; uid: number; size: number; mtimeMs: number; ctimeMs: number; mode: number; nlink: number; } interface DirectoryIdentity { dev: number; ino: number; uid: number; mode: number; ctimeMs: number; } interface StorageIdentity { file: FileIdentity; directory: DirectoryIdentity; } export interface AuthenticationConfigLoadOptions { /** Test seam; production creates the existing bounded internal tht auth-storage bridge. */ windowsStorageBridge?: Pick; } function validateCanonicalPath(path: string): void { if (typeof path !== "string" || path.length === 0 || path.trim() !== path || path.includes("\0") || !isAbsolute(path) || normalize(path) !== path || realpathSync(path) !== path || realpathSync(dirname(path)) !== dirname(path)) throw invalid(); } function runtimeOwner(): number { if (process.platform === "win32" || typeof process.geteuid !== "function") throw invalid(); const owner = process.geteuid(); if (!Number.isSafeInteger(owner) || owner < 0) throw invalid(); return owner; } function fileMetadata(info: Stats): FileIdentity { const mode = info.mode & 0o7777; if (!info.isFile() || info.uid !== runtimeOwner() || info.nlink !== 1 || mode !== 0o600 || info.size < 0 || info.size > MAX_AUTH_CONFIG_BYTES) throw invalid(); return { dev: info.dev, ino: info.ino, uid: info.uid, size: info.size, mtimeMs: info.mtimeMs, ctimeMs: info.ctimeMs, mode, nlink: info.nlink, }; } function directoryMetadata(info: Stats): DirectoryIdentity { const mode = info.mode & 0o7777; if (!info.isDirectory() || info.uid !== runtimeOwner() || mode !== 0o700) throw invalid(); return { dev: info.dev, ino: info.ino, uid: info.uid, mode, ctimeMs: info.ctimeMs }; } function sameFileIdentity(left: FileIdentity, right: FileIdentity): boolean { return left.dev === right.dev && left.ino === right.ino && left.uid === right.uid && left.size === right.size && left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs && left.mode === right.mode && left.nlink === right.nlink; } function sameDirectoryIdentity(left: DirectoryIdentity, right: DirectoryIdentity): boolean { return left.dev === right.dev && left.ino === right.ino && left.uid === right.uid && left.mode === right.mode && left.ctimeMs === right.ctimeMs; } function sameIdentity(left: StorageIdentity, right: StorageIdentity): boolean { return sameFileIdentity(left.file, right.file) && sameDirectoryIdentity(left.directory, right.directory); } function storageIdentity(path: string): StorageIdentity { try { validateCanonicalPath(path); return { file: fileMetadata(lstatSync(path) as Stats), directory: directoryMetadata(lstatSync(dirname(path)) as Stats), }; } catch { throw invalid(); } } function openDirectoryDescriptor(path: string): number { return openSync(path, constants.O_RDONLY | (constants.O_DIRECTORY ?? 0) | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0)); } function readBoundedConfig(path: string): { source: string; identity: StorageIdentity } { let directoryDescriptor: number | undefined; let fd: number | undefined; try { const before = storageIdentity(path); directoryDescriptor = openDirectoryDescriptor(dirname(path)); const openedDirectory = directoryMetadata(fstatSync(directoryDescriptor) as Stats); if (!sameDirectoryIdentity(before.directory, openedDirectory)) throw invalid(); fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); const opened = fileMetadata(fstatSync(fd) as Stats); if (!sameFileIdentity(before.file, opened)) throw invalid(); const buffer = Buffer.allocUnsafe(MAX_AUTH_CONFIG_BYTES + 1); let offset = 0; while (offset < buffer.length) { const bytesRead = readSync(fd, buffer, offset, buffer.length - offset, null); if (bytesRead === 0) break; offset += bytesRead; } if (offset > MAX_AUTH_CONFIG_BYTES) throw invalid(); const afterFile = fileMetadata(fstatSync(fd) as Stats); const afterPath = storageIdentity(path); const afterOpenedDirectory = directoryMetadata(fstatSync(directoryDescriptor) as Stats); if (!sameFileIdentity(opened, afterFile) || !sameFileIdentity(afterFile, afterPath.file) || !sameDirectoryIdentity(before.directory, afterPath.directory) || !sameDirectoryIdentity(openedDirectory, afterOpenedDirectory)) throw invalid(); validateCanonicalPath(path); return { source: new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, offset)), identity: afterPath, }; } catch { throw invalid(); } finally { if (fd !== undefined) try { closeSync(fd); } catch { /* sanitized by design */ } if (directoryDescriptor !== undefined) try { closeSync(directoryDescriptor); } catch { /* sanitized by design */ } } } function validOrigin(value: string, httpLoopbackAllowed: boolean): boolean { return parseConfiguredTransportUrl(value, { allowLoopbackHttp: httpLoopbackAllowed, originOnly: true }) !== undefined; } function validIssuer(value: string): boolean { return parseConfiguredTransportUrl(value, { allowLoopbackHttp: false }) !== undefined; } 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 < right ? -1 : left > right ? 1 : 0) .map(([key, nested]) => [key, canonicalize(nested)])); } return value; } function canonicalRevision(value: AuthenticationConfig): string { return createHash("sha256").update(JSON.stringify(canonicalize(value))).digest("hex"); } export function parseAuthenticationConfigSource(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(); } } function loadAuthenticationConfigWithIdentity(path: string): { loaded: LoadedAuthConfig; identity: StorageIdentity } { const read = readBoundedConfig(path); const value = parseAuthenticationConfigSource(read.source); return { loaded: { value, revision: canonicalRevision(value), sourcePath: path }, identity: read.identity }; } function loadWindowsAuthenticationConfig( path: string, bridge: Pick, ): LoadedAuthConfig { try { const contents = bridge.readAuthConfig(path); if (!Buffer.isBuffer(contents) || contents.length === 0 || contents.length > MAX_AUTH_CONFIG_BYTES) throw invalid(); const source = new TextDecoder("utf-8", { fatal: true }).decode(contents); const value = parseAuthenticationConfigSource(source); return { value, revision: canonicalRevision(value), sourcePath: path }; } catch { throw invalid(); } } export function loadAuthenticationConfig(path: string, options: AuthenticationConfigLoadOptions = {}): LoadedAuthConfig { if (process.platform === "win32") { return loadWindowsAuthenticationConfig(path, options.windowsStorageBridge ?? createWindowsAuthStorageBridge()); } return loadAuthenticationConfigWithIdentity(path).loaded; } export function createAuthenticationConfigProvider( path: string, options: AuthenticationConfigLoadOptions = {}, ): AuthenticationConfigProvider { if (process.platform === "win32") { const bridge = options.windowsStorageBridge ?? createWindowsAuthStorageBridge(); return { current: () => loadWindowsAuthenticationConfig(path, bridge) }; } let cached: { identity: StorageIdentity; loaded: LoadedAuthConfig } | undefined; return { current(): LoadedAuthConfig { const before = storageIdentity(path); if (cached && sameIdentity(cached.identity, before)) return cached.loaded; for (let attempt = 0; attempt < 2; attempt += 1) { try { const { loaded, identity } = loadAuthenticationConfigWithIdentity(path); if (sameIdentity(identity, storageIdentity(path))) { cached = { identity, loaded }; return loaded; } } catch { /* retry one concurrent atomic replacement, then fail closed */ } } throw invalid(); } }; } 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 PERMISSION_CATALOG; return requested.has("user") ? ["session.use"] : []; } export function isPermission(value: string): value is Permission { return PERMISSION_CATALOG.includes(value as Permission); }