feat(auth): include authentication in workspace and tht diagnostics

This commit is contained in:
2026-08-17 15:51:16 +02:00
parent cb0e873ed7
commit 3ed00ff086
19 changed files with 1050 additions and 34 deletions
+8
View File
@@ -16,6 +16,8 @@ import { AuthSessionOperationalError, createFileAuthSessionStore, type AuthSessi
import type { WindowsAuthStorageBridge } from "./auth/windows-auth-storage.js";
import { registerAuthRoutes } from "./auth/routes.js";
import { createOidcProtocol, type OidcProtocol, type OidcProtocolOptions } from "./auth/oidc-client.js";
import { createConfiguredAuthDiagnoser } from "./auth/diagnostic-command.js";
import type { AuthDiagnoser } from "./auth/diagnostics.js";
import { isUsableAuthenticationSecret } from "./auth/secret-policy.js";
import { secretValue } from "./config/secret-bundle.js";
import { sessionRoutes } from "./routes/sessions.js";
@@ -55,6 +57,7 @@ export interface BuildAppDeps {
/** Explicit test-only transport seam; production always invokes the hidden tht bridge. */
authStorageBridgeForTest?: WindowsAuthStorageBridge;
oidcProtocol?: OidcProtocol;
authDiagnoser?: AuthDiagnoser;
/** Explicit test seam; production uses the provider-neutral OIDC constructor. */
oidcProtocolFactory?: (options: OidcProtocolOptions) => OidcProtocol;
}
@@ -219,6 +222,10 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
return undefined;
}
};
const authDiagnoser = deps?.authDiagnoser ?? createConfiguredAuthDiagnoser(config, {
localUserRegistry: resolveLocalUserRegistry,
oidcProtocol: resolveOidcProtocol,
});
const authSessionStore = deps?.authSessionStore ?? (config.authMode === "local" || config.authMode === "oidc"
? createFileAuthSessionStore(config.authStateRoot, {
currentAuthConfigRevision: () => {
@@ -324,6 +331,7 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
registry: workspaceRegistry,
config: config.workspaceRegistry,
diagnose: workspaceDiagnoser,
authDiagnoser,
secretStore: workspaceSecretStore,
});
settingsRoutes(app, { cfg: config, listModels, getSettings });
+221
View File
@@ -0,0 +1,221 @@
import { fileURLToPath } from "node:url";
import { resolve } from "node:path";
import { loadConfig, type AppConfig } from "../config.js";
import { 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 type { LoadedAuthConfig } from "./types.js";
const AUTH_SECRET_REFERENCES = ["THT_OIDC_CLIENT_SECRET", "THT_AUTHENTIK_API_TOKEN"] as const;
function configuredSecretValues(config: AppConfig): readonly string[] {
const values: string[] = [];
for (const reference of AUTH_SECRET_REFERENCES) {
try {
const value = secretValue(config, reference);
if (value !== undefined) values.push(value);
} catch {
// The fixed report below is the only externally-visible failure surface.
}
}
return values;
}
export interface ConfiguredAuthDiagnoserOptions {
localUserRegistry?: (loaded: LoadedAuthConfig) => LocalUserRegistry | undefined;
oidcProtocol?: (loaded: LoadedAuthConfig) => OidcProtocol | undefined;
}
/** 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" ? (() => {
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,
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 group of identity.groups) {
for (const role of current.value.authorization.groupRoles[group] ?? []) mappedRoles.add(role);
}
void mappedRoles;
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;
try {
report = await dependencies.diagnoser.inspect({
live: true,
...(options.interactive ? {
interactive: true,
presentDeviceCode: (uri: string, code: string) => dependencies.stderr(`Open ${uri} and enter code ${code}`),
} : {}),
});
} catch {
report = genericFailure();
}
const safe = redactedReport(report, dependencies.secretValues ?? []);
dependencies.stdout(`${JSON.stringify(safe)}\n`);
return safe.ready ? 0 : 1;
}
async function main(): Promise<void> {
let config: AppConfig | undefined;
try { config = loadConfig(process.env); } catch { /* turn startup faults into the closed report below */ }
const exitCode = await runDiagnosticCommand(process.argv.slice(2), {
diagnoser: config ? createConfiguredAuthDiagnoser(config) : { inspect: async () => genericFailure() },
...(config ? { secretValues: configuredSecretValues(config) } : {}),
stdout: (line) => process.stdout.write(line),
stderr: (line) => process.stderr.write(`${line}\n`),
});
process.exitCode = exitCode;
}
if (process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
void main();
}
+7 -1
View File
@@ -14,7 +14,13 @@ export type { AuthDiagnostic, AuthDiagnosticCode, AuthDiagnostics } from "./grou
const LIVE_DIAGNOSTIC_TIMEOUT_MS = 30_000;
export interface AuthDiagnoser {
inspect(options: { live: boolean; interactive?: boolean; signal?: AbortSignal }): Promise<AuthDiagnostics>;
inspect(options: {
live: boolean;
interactive?: boolean;
signal?: AbortSignal;
/** Device-code presentation is transient operator output, never persisted diagnostic state. */
presentDeviceCode?: (uri: string, code: string) => void;
}): Promise<AuthDiagnostics>;
}
export interface AuthDiagnoserDependencies {
+41
View File
@@ -4,6 +4,8 @@ import {
calculatePKCECodeChallenge,
customFetch,
discovery,
initiateDeviceAuthorization,
pollDeviceAuthorizationGrant,
type Configuration,
type CustomFetch,
} from "openid-client";
@@ -57,6 +59,14 @@ export class OidcIssuerMismatchError extends OidcProtocolError {
}
}
/** Device authorization is optional OIDC metadata and must never fall back to a browser flow. */
export class OidcDeviceFlowUnavailableError extends OidcProtocolError {
constructor() {
super("oidc_device_flow_unavailable");
this.name = "OidcDeviceFlowUnavailableError";
}
}
export interface OidcProtocolOptions {
issuer: string;
clientId: string;
@@ -77,6 +87,7 @@ const DEFAULT_HTTP_TIMEOUT_MS = 5_000;
const MAX_HTTP_TIMEOUT_MS = 30_000;
const DEFAULT_JWKS_TIMEOUT_MS = 5_000;
const MAX_JWKS_TIMEOUT_MS = 30_000;
const MAX_DEVICE_FLOW_TIMEOUT_MS = 10 * 60_000;
const text = (value: unknown, maximum = 2048): value is string =>
typeof value === "string" && value.length > 0 && value.length <= maximum && !/\p{Cc}/u.test(value);
@@ -599,5 +610,35 @@ export function createOidcProtocol(options: OidcProtocolOptions): OidcProtocol {
}
signal.throwIfAborted();
},
async verifyDeviceFlow(signal, present) {
let config: Configuration;
try {
config = await configuration();
const endpoint = config.serverMetadata().device_authorization_endpoint;
httpsEndpoint(endpoint);
} catch (error) {
if (error instanceof OidcIssuerMismatchError || error instanceof OidcProviderUnavailableError) throw error;
throw new OidcDeviceFlowUnavailableError();
}
const deadline = AbortSignal.timeout(MAX_DEVICE_FLOW_TIMEOUT_MS);
const deviceSignal = AbortSignal.any([signal, deadline]);
try {
deviceSignal.throwIfAborted();
const device = await initiateDeviceAuthorization(config, { scope: options.scopes.join(" ") });
if (!text(device.verification_uri, 2048) || !text(device.user_code, 256)) {
throw new OidcDeviceFlowUnavailableError();
}
const verificationUri = httpsEndpoint(device.verification_uri);
present(verificationUri.href, device.user_code);
const tokens = await pollDeviceAuthorizationGrant(config, device, undefined, { signal: deviceSignal });
await verifyIdTokenSignature(tokens.id_token, config, transport, jwksTimeoutMs);
const claims = tokens.claims();
if (!claims || Array.isArray(claims)) throw new OidcProtocolError();
return identityFromClaims(claims as Record<string, unknown>, options);
} catch (error) {
if (error instanceof OidcDeviceFlowUnavailableError) throw error;
throw protocolFailure(error);
}
},
};
}
+21 -4
View File
@@ -16,19 +16,21 @@ import {
type WorkspaceDescriptor,
} from "../workspaces/schema.js";
import type { RuntimeBindings } from "../workspaces/runtime-renderer.js";
import type { WorkspaceDiagnostics } from "../workspaces/diagnostics.js";
import type { ConnectorDiagnostics } from "../workspaces/diagnostics.js";
import { isPrincipalContext, requirePermission } from "../auth/authorization.js";
import type { AuthDiagnoser } from "../auth/diagnostics.js";
export type WorkspaceDiagnoser = (
workspace: WorkspaceDescriptor,
bindings: RuntimeBindings,
options: { writeProbe: boolean },
) => Promise<WorkspaceDiagnostics>;
) => Promise<ConnectorDiagnostics>;
interface WorkspaceRoutesDeps {
registry: WorkspaceRegistry;
config: WorkspaceRegistryConfig;
diagnose: WorkspaceDiagnoser;
authDiagnoser: AuthDiagnoser;
secretStore: WorkspaceSecretStore;
}
@@ -147,7 +149,14 @@ export function workspaceRoutes(app: FastifyInstance, deps: WorkspaceRoutesDeps)
try {
const { workspace } = workspacePayload.parse(request.body);
const canonical = validateWorkspaceDescriptor(workspace);
return { workspace: canonical, contract: buildInstallationContract(canonical) };
const authentication = await deps.authDiagnoser.inspect({ live: false });
return {
workspace: canonical,
contract: buildInstallationContract(canonical),
activatable: authentication.ready,
diagnostics: [],
authentication,
};
} catch (error) {
return errorReply(reply, error);
}
@@ -222,7 +231,15 @@ export function workspaceRoutes(app: FastifyInstance, deps: WorkspaceRoutesDeps)
deps.secretStore,
);
try {
return await deps.diagnose(operational, lease.bindings, { writeProbe: false });
const [workspaceDiagnostics, authentication] = await Promise.all([
deps.diagnose(operational, lease.bindings, { writeProbe: false }),
deps.authDiagnoser.inspect({ live: true }),
]);
return {
...workspaceDiagnostics,
activatable: workspaceDiagnostics.activatable && authentication.ready,
authentication,
};
} finally {
lease.release();
}
+10 -3
View File
@@ -11,6 +11,7 @@ import {
} from "./schema.js";
import type { WorkspaceErrorCode } from "./types.js";
import type { SemanticRuntimeConfig } from "./runtime-renderer.js";
import type { AuthDiagnostics } from "../auth/diagnostics.js";
export interface Diagnostic {
level: "error" | "warning" | "info";
@@ -20,11 +21,17 @@ export interface Diagnostic {
message: string;
}
export interface WorkspaceDiagnostics {
/** Connector-only result produced before the route aggregates authentication. */
export interface ConnectorDiagnostics {
activatable: boolean;
diagnostics: Diagnostic[];
}
/** The HTTP workspace diagnostic contract always includes the shared authentication report. */
export interface WorkspaceDiagnostics extends ConnectorDiagnostics {
authentication: AuthDiagnostics;
}
interface DiagnosticResource {
database?: string;
schema?: string;
@@ -384,7 +391,7 @@ async function diagnoseValidatedWorkspace(
adapters: DiagnosticAdapters,
timeoutMs: number,
semanticRuntime: SemanticRuntimeConfig,
): Promise<WorkspaceDiagnostics> {
): Promise<ConnectorDiagnostics> {
const evidenceField = descriptor.evidence?.source.type === "http"
? "evidence.source.authentication"
: descriptor.evidence?.source.type === "s3"
@@ -533,7 +540,7 @@ export function createWorkspaceDiagnoser(
workspace: WorkspaceDescriptor,
bindings: RuntimeBindings,
_options: { writeProbe: boolean },
): Promise<WorkspaceDiagnostics> {
): Promise<ConnectorDiagnostics> {
requireSupportedDescriptor(workspace);
const descriptor = validateWorkspaceDescriptor(workspace);
return await diagnoseValidatedWorkspace(descriptor, bindings, adapters, timeoutMs, semanticRuntime);