import { fileURLToPath } from "node:url"; import { resolve } from "node:path"; import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, } from "node:fs"; import { loadConfig, type AppConfig } from "../config.js"; import { loadSecretBundle, secretValue } from "../config/secret-bundle.js"; import { createAuthentikGroupCatalog } from "./authentik-group-catalog.js"; import { createCurrentLocalUserRegistryResolver, type LocalUserRegistry } from "./local-registry.js"; import { createOidcProtocol, OidcDeviceFlowUnavailableError, type OidcProtocol } from "./oidc-client.js"; import { createAuthDiagnoser, type AuthDiagnoser, type AuthDiagnostic, type AuthDiagnostics } from "./diagnostics.js"; import { decodeAuthDiagnostics, type GroupCatalog } from "./group-catalog.js"; import type { LoadedAuthConfig } from "./types.js"; const AUTH_SECRET_REFERENCES = ["THT_OIDC_CLIENT_SECRET", "THT_AUTHENTIK_API_TOKEN"] as const; const MAX_DIAGNOSTIC_SECRET_SOURCE_BYTES = 64 * 1024; const MAX_DIAGNOSTIC_SECRET_VALUES = 4096; const MAX_DIAGNOSTIC_SECRET_DEPTH = 32; function unavailableSecretCorpus(): Error { return new Error("diagnostic secret corpus is unavailable"); } function readMountedSecretSource(file: string): string { let fd: number | undefined; try { if (!file || file.trim() !== file || file.includes("\0")) throw unavailableSecretCorpus(); const before = lstatSync(file); if (!before.isFile() || before.isSymbolicLink() || before.size > MAX_DIAGNOSTIC_SECRET_SOURCE_BYTES) { throw unavailableSecretCorpus(); } fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW); const opened = fstatSync(fd); if (!opened.isFile() || opened.size > MAX_DIAGNOSTIC_SECRET_SOURCE_BYTES || before.dev !== opened.dev || before.ino !== opened.ino) { throw unavailableSecretCorpus(); } const value = readFileSync(fd, "utf8"); if (Buffer.byteLength(value, "utf8") > MAX_DIAGNOSTIC_SECRET_SOURCE_BYTES) { throw unavailableSecretCorpus(); } return value; } catch { throw unavailableSecretCorpus(); } finally { if (fd !== undefined) try { closeSync(fd); } catch { /* fixed failure surface above */ } } } function parsedSecretValues(raw: string, requireJson: boolean): readonly string[] { const trimmed = raw.trim(); if (!trimmed) return []; const values = new Set([raw.replace(/[\r\n]+$/u, "")]); const looksJson = trimmed.startsWith("{") || trimmed.startsWith("["); if (!looksJson) { if (requireJson) throw unavailableSecretCorpus(); return [...values]; } let document: unknown; try { document = JSON.parse(trimmed); } catch { throw unavailableSecretCorpus(); } if (requireJson && (!document || typeof document !== "object" || Array.isArray(document))) { throw unavailableSecretCorpus(); } const pending: Array<{ value: unknown; depth: number }> = [{ value: document, depth: 0 }]; let scalarCount = 0; while (pending.length > 0) { const current = pending.pop()!; if (current.depth > MAX_DIAGNOSTIC_SECRET_DEPTH) throw unavailableSecretCorpus(); if (Array.isArray(current.value)) { for (const item of current.value) pending.push({ value: item, depth: current.depth + 1 }); } else if (current.value && typeof current.value === "object") { for (const item of Object.values(current.value as Record)) { pending.push({ value: item, depth: current.depth + 1 }); } } else { scalarCount += 1; if (scalarCount > 1024) throw unavailableSecretCorpus(); if (typeof current.value === "string" && current.value.length > 0) values.add(current.value); } } return [...values]; } export function configuredSecretValues(config: AppConfig): readonly string[] { try { const values = new Set(); if (config.secretsFile) { for (const value of loadSecretBundle(config.secretsFile).values()) values.add(value); } const legacyFiles = new Set(Object.values(config.secretFiles).filter( (file): file is string => file !== undefined, )); for (const file of legacyFiles) { for (const value of parsedSecretValues(readMountedSecretSource(file), false)) values.add(value); } if (config.piAuthFile) { for (const value of parsedSecretValues(readMountedSecretSource(config.piAuthFile), true)) values.add(value); } if (values.size > MAX_DIAGNOSTIC_SECRET_VALUES) throw unavailableSecretCorpus(); return [...values]; } catch { throw unavailableSecretCorpus(); } } export interface ConfiguredAuthDiagnoserOptions { localUserRegistry?: (loaded: LoadedAuthConfig) => LocalUserRegistry | undefined; oidcProtocol?: (loaded: LoadedAuthConfig) => OidcProtocol | undefined; groupCatalog?: (loaded: LoadedAuthConfig) => GroupCatalog | undefined; sessionRootValidator?: (root: string) => void | Promise; } /** Builds the one shared auth diagnostic implementation used by app routes and the one-shot CLI. */ export function createConfiguredAuthDiagnoser( config: AppConfig, options: ConfiguredAuthDiagnoserOptions = {}, ): AuthDiagnoser { const localResolver = options.localUserRegistry === undefined ? createCurrentLocalUserRegistryResolver() : undefined; const secretValues = (): ReadonlyMap => { const values = new Map(); for (const reference of AUTH_SECRET_REFERENCES) { try { const value = secretValue(config, reference); if (value !== undefined) values.set(reference, value); } catch { // The shared diagnoser emits the fixed missing-secret diagnostic below. } } return values; }; const loaded = (): LoadedAuthConfig | undefined => { try { return config.authentication?.current(); } catch { return undefined; } }; return { async inspect(request): Promise { const current = loaded(); const protocol = current?.value.mode === "oidc" ? options.oidcProtocol?.(current) ?? (() => { try { const clientSecret = secretValues().get("THT_OIDC_CLIENT_SECRET"); if (!clientSecret) return undefined; return createOidcProtocol({ issuer: current.value.oidc.issuer, clientId: current.value.oidc.clientId, clientSecret, callbackUrl: new URL("/api/auth/oidc/callback", current.value.publicUrl).href, scopes: current.value.oidc.scopes, groupsClaim: current.value.oidc.groupsClaim, }); } catch { return undefined; } })() : undefined; const groupCatalog = current?.value.mode === "oidc" ? options.groupCatalog?.(current) ?? (() => { try { const token = secretValues().get("THT_AUTHENTIK_API_TOKEN"); return token === undefined ? undefined : createAuthentikGroupCatalog({ baseUrl: current.value.groupCatalog.baseUrl, apiToken: token, }); } catch { return undefined; } })() : undefined; const report = await createAuthDiagnoser({ authMode: config.authMode, authStateRoot: config.authStateRoot, ...(options.sessionRootValidator === undefined ? {} : { sessionRootValidator: options.sessionRootValidator }), authentication: config.authentication, secrets: secretValues(), localUserRegistry: current?.value.mode === "local" ? options.localUserRegistry?.(current) ?? localResolver?.resolve(current) : undefined, oidcProtocol: protocol, groupCatalog, }).inspect(request); if (!request.interactive || !report.ready) return report; if (current?.value.mode !== "oidc" || !protocol?.verifyDeviceFlow || !request.presentDeviceCode) { return { ready: false, mode: report.mode, checks: [{ level: "error", code: "oidc_device_flow_unavailable", message: "Interactive authentication diagnostics require OIDC device authorization.", }], }; } try { const identity = await protocol.verifyDeviceFlow( request.signal ?? AbortSignal.timeout(10 * 60_000), request.presentDeviceCode, ); // Exact names only: unrelated provider groups are neither emitted nor retained. const mappedRoles = new Set(); for (const [configuredGroup, roles] of Object.entries(current.value.authorization.groupRoles)) { if (!identity.groups.includes(configuredGroup)) continue; for (const role of roles) mappedRoles.add(role); } if (mappedRoles.size === 0) { return { ready: false, mode: "oidc", checks: [{ level: "error", code: "oidc_groups_claim_invalid", message: "The OIDC device-flow identity could not be validated.", }], }; } return report; } catch (error) { return { ready: false, mode: "oidc", checks: [{ level: "error", code: error instanceof OidcDeviceFlowUnavailableError ? "oidc_device_flow_unavailable" : "oidc_groups_claim_invalid", message: error instanceof OidcDeviceFlowUnavailableError ? "OIDC device authorization is unavailable." : "The OIDC device-flow identity could not be validated.", }], }; } }, }; } export interface DiagnosticCommandDependencies { diagnoser: AuthDiagnoser; secretValues?: readonly string[]; stdout: (line: string) => void; stderr: (line: string) => void; } function genericFailure(): AuthDiagnostics { return { ready: false, mode: "none", checks: [{ level: "error", code: "auth_config_invalid", message: "Authentication configuration is unavailable." }], }; } function redact(value: string, secrets: readonly string[]): string { let result = value; for (const secret of [...secrets].filter(Boolean).sort((left, right) => right.length - left.length)) { result = result.replaceAll(secret, "[REDACTED]"); } return result; } function redactedReport(report: AuthDiagnostics, secrets: readonly string[]): AuthDiagnostics { return { ...report, checks: report.checks.map((check): AuthDiagnostic => ({ ...check, message: redact(check.message, secrets), ...(check.field === undefined ? {} : { field: redact(check.field, secrets) }), })), }; } function parseArguments(args: readonly string[]): { json: true; interactive: boolean } | undefined { let json = false; let interactive = false; for (const arg of args) { if (arg === "--json" && !json) json = true; else if (arg === "--interactive" && !interactive) interactive = true; else return undefined; } return json ? { json: true, interactive } : undefined; } /** A bounded machine command: stdout receives exactly one final report and no progress text. */ export async function runDiagnosticCommand( args: readonly string[], dependencies: DiagnosticCommandDependencies, ): Promise { const options = parseArguments(args); if (!options) { dependencies.stderr("usage: diagnostic-command.js --json [--interactive]"); return 2; } let report: AuthDiagnostics; const secrets = dependencies.secretValues ?? []; try { report = await dependencies.diagnoser.inspect({ live: true, ...(options.interactive ? { interactive: true, presentDeviceCode: (uri: string, code: string) => dependencies.stderr( redact(`Open ${uri} and enter code ${code}`, secrets), ), } : {}), }); } catch { report = genericFailure(); } const decoded = decodeAuthDiagnostics(report) ?? genericFailure(); const safe = decodeAuthDiagnostics(redactedReport(decoded, secrets)) ?? genericFailure(); dependencies.stdout(`${JSON.stringify(safe)}\n`); return safe.ready ? 0 : 1; } async function main(): Promise { const exitCode = await runConfiguredDiagnosticCommand( process.argv.slice(2), process.env, (line) => process.stdout.write(line), (line) => process.stderr.write(`${line}\n`), ); process.exitCode = exitCode; } export async function runConfiguredDiagnosticCommand( args: readonly string[], env: Record, stdout: (line: string) => void, stderr: (line: string) => void, ): Promise { let diagnoser: AuthDiagnoser = { inspect: async () => genericFailure() }; let secretValues: readonly string[] | undefined; try { const config = loadConfig(env); // Complete this preflight before constructing a diagnoser that may forward a device prompt. secretValues = configuredSecretValues(config); diagnoser = createConfiguredAuthDiagnoser(config); } catch { /* turn startup or corpus faults into the closed report below */ } return runDiagnosticCommand(args, { diagnoser, ...(secretValues === undefined ? {} : { secretValues }), stdout, stderr, }); } if (process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { void main(); }