321 lines
13 KiB
TypeScript
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);
|
|
}
|