Files
ThothII/backend/src/auth/config.ts
T

321 lines
13 KiB
TypeScript

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<WindowsAuthStorageBridge, "readAuthConfig">;
}
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<string, unknown>)
.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<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(); }
}
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<WindowsAuthStorageBridge, "readAuthConfig">,
): 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<Role>();
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);
}