Files
ThothII/backend/src/auth/diagnostic-command.ts
T

339 lines
13 KiB
TypeScript

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<string>([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<string, unknown>)) {
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<string>();
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<void>;
}
/** 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<string, string> => {
const values = new Map<string, string>();
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<AuthDiagnostics> {
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<string>();
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<number> {
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<void> {
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<string, string | undefined>,
stdout: (line: string) => void,
stderr: (line: string) => void,
): Promise<number> {
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();
}