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