From 3ed00ff086cec19113f44626e46da74b3f532653 Mon Sep 17 00:00:00 2001 From: mptyl Date: Mon, 17 Aug 2026 15:51:16 +0200 Subject: [PATCH] feat(auth): include authentication in workspace and tht diagnostics --- backend/src/app.ts | 8 + backend/src/auth/diagnostic-command.ts | 221 ++++++++++++++++++ backend/src/auth/diagnostics.ts | 8 +- backend/src/auth/oidc-client.ts | 41 ++++ backend/src/routes/workspaces.ts | 25 +- backend/src/workspaces/diagnostics.ts | 13 +- backend/test/auth-diagnostic-command.test.ts | 67 ++++++ backend/test/oidc-client.test.ts | 39 ++++ backend/test/routes-workspaces.test.ts | 56 ++++- frontend/src/api/workspaces.test.ts | 38 +++ frontend/src/api/workspaces.ts | 105 ++++++++- frontend/src/shell/WorkspaceManager.test.tsx | 46 +++- frontend/src/shell/WorkspaceManager.tsx | 26 ++- tools/tht/cmd/tht/main_test.go | 1 + tools/tht/internal/authconfig/commands.go | 218 ++++++++++++++++- .../tht/internal/authconfig/commands_test.go | 111 +++++++++ tools/tht/internal/doctor/report.go | 44 +++- tools/tht/internal/doctor/report_test.go | 11 +- tools/tht/internal/setup/run_test.go | 6 +- 19 files changed, 1050 insertions(+), 34 deletions(-) create mode 100644 backend/src/auth/diagnostic-command.ts create mode 100644 backend/test/auth-diagnostic-command.test.ts diff --git a/backend/src/app.ts b/backend/src/app.ts index a027fcec..f7fdfe5f 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -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 }); diff --git a/backend/src/auth/diagnostic-command.ts b/backend/src/auth/diagnostic-command.ts new file mode 100644 index 00000000..c5883a6d --- /dev/null +++ b/backend/src/auth/diagnostic-command.ts @@ -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 => { + 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" ? (() => { + 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(); + 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 { + 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 { + 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(); +} diff --git a/backend/src/auth/diagnostics.ts b/backend/src/auth/diagnostics.ts index 242f1f4a..32858529 100644 --- a/backend/src/auth/diagnostics.ts +++ b/backend/src/auth/diagnostics.ts @@ -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; + 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; } export interface AuthDiagnoserDependencies { diff --git a/backend/src/auth/oidc-client.ts b/backend/src/auth/oidc-client.ts index 85dd39e4..a4a60709 100644 --- a/backend/src/auth/oidc-client.ts +++ b/backend/src/auth/oidc-client.ts @@ -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, options); + } catch (error) { + if (error instanceof OidcDeviceFlowUnavailableError) throw error; + throw protocolFailure(error); + } + }, }; } diff --git a/backend/src/routes/workspaces.ts b/backend/src/routes/workspaces.ts index 73e7c35e..f6b04108 100644 --- a/backend/src/routes/workspaces.ts +++ b/backend/src/routes/workspaces.ts @@ -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; +) => Promise; 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(); } diff --git a/backend/src/workspaces/diagnostics.ts b/backend/src/workspaces/diagnostics.ts index 8534b168..48e9b253 100644 --- a/backend/src/workspaces/diagnostics.ts +++ b/backend/src/workspaces/diagnostics.ts @@ -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 { +): Promise { 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 { + ): Promise { requireSupportedDescriptor(workspace); const descriptor = validateWorkspaceDescriptor(workspace); return await diagnoseValidatedWorkspace(descriptor, bindings, adapters, timeoutMs, semanticRuntime); diff --git a/backend/test/auth-diagnostic-command.test.ts b/backend/test/auth-diagnostic-command.test.ts new file mode 100644 index 00000000..a049b97a --- /dev/null +++ b/backend/test/auth-diagnostic-command.test.ts @@ -0,0 +1,67 @@ +import { expect, test, vi } from "vitest"; +import type { AuthDiagnoser, AuthDiagnostics } from "../src/auth/diagnostics.js"; +import { runDiagnosticCommand } from "../src/auth/diagnostic-command.js"; + +const failure: AuthDiagnostics = { + ready: false, + mode: "oidc", + checks: [{ + level: "error", + code: "oidc_mapped_group_missing", + field: "Thoth Administrators", + message: "A configured authorization group does not exist: command-secret-sentinel.", + }], +}; + +test("writes one redacted JSON diagnostic report and uses a failing diagnostic exit status", async () => { + const stdout: string[] = []; + const stderr: string[] = []; + const diagnoser: AuthDiagnoser = { inspect: vi.fn(async () => failure) }; + + const exitCode = await runDiagnosticCommand(["--json"], { + diagnoser, + secretValues: ["command-secret-sentinel"], + stdout: (line) => stdout.push(line), + stderr: (line) => stderr.push(line), + }); + + expect(exitCode).toBe(1); + expect(diagnoser.inspect).toHaveBeenCalledWith({ live: true }); + expect(stderr).toEqual([]); + expect(stdout).toHaveLength(1); + expect(JSON.parse(stdout[0])).toEqual({ + ...failure, + checks: [{ + ...failure.checks[0], + message: "A configured authorization group does not exist: [REDACTED].", + }], + }); + expect(stdout.join("\n")).not.toContain("command-secret-sentinel"); +}); + +test("delegates an interactive diagnostic to OIDC and keeps its device prompt on stderr", async () => { + const stdout: string[] = []; + const stderr: string[] = []; + const ready: AuthDiagnostics = { + ready: true, + mode: "oidc", + checks: [{ level: "info", code: "auth_ready", message: "Authentication is ready." }], + }; + const diagnoser: AuthDiagnoser = { + inspect: vi.fn(async (options) => { + options.presentDeviceCode?.("https://issuer.example.test/device", "ABCD-EFGH"); + return ready; + }), + }; + + const exitCode = await runDiagnosticCommand(["--json", "--interactive"], { + diagnoser, + stdout: (line) => stdout.push(line), + stderr: (line) => stderr.push(line), + }); + + expect(exitCode).toBe(0); + expect(diagnoser.inspect).toHaveBeenCalledWith(expect.objectContaining({ live: true, interactive: true })); + expect(stderr).toEqual(["Open https://issuer.example.test/device and enter code ABCD-EFGH"]); + expect(JSON.parse(stdout.join(""))).toEqual(ready); +}); diff --git a/backend/test/oidc-client.test.ts b/backend/test/oidc-client.test.ts index bc3be1e9..1051c691 100644 --- a/backend/test/oidc-client.test.ts +++ b/backend/test/oidc-client.test.ts @@ -4,6 +4,7 @@ import { createOidcProtocol, OidcIssuerMismatchError, OidcJwksUnavailableError, + OidcDeviceFlowUnavailableError, OidcProtocolError, OidcProviderUnavailableError, } from "../src/auth/oidc-client.js"; @@ -36,6 +37,7 @@ function protocol(options: { seen?: URL[]; discoveryResponse?: (init?: RequestInit) => Response | Promise; tokenResponse?: (init?: RequestInit) => Response | Promise; + deviceResponse?: (init?: RequestInit) => Response | Promise; jwksResponse?: (init?: RequestInit) => Response | Promise; httpTimeoutMs?: number; jwksTimeoutMs?: number; @@ -71,6 +73,16 @@ function protocol(options: { }); } if (url.pathname === "/jwks") return options.jwksResponse ? await options.jwksResponse(init) : Response.json({ keys: [jwk] }); + if (url.pathname === "/device") { + if (options.deviceResponse) return await options.deviceResponse(init); + return Response.json({ + device_code: "device-code-must-not-be-persisted", + user_code: "ABCD-EFGH", + verification_uri: `${issuer}/device`, + expires_in: 60, + interval: 1, + }); + } if (url.pathname === "/token") { if (options.tokenResponse) return await options.tokenResponse(init); return Response.json({ @@ -131,6 +143,33 @@ test("uses HTTPS discovery, Authorization Code, and PKCE S256 without external n expect(seen.map((url) => url.origin)).toEqual([issuer, issuer, issuer]); }); +test("uses a validated bounded OIDC device flow and returns only a verified direct-group identity", async () => { + const seen: URL[] = []; + const subject = protocol({ + seen, + discoveryMetadata: { device_authorization_endpoint: `${issuer}/device` }, + }); + const presented: Array<[string, string]> = []; + + await expect(subject.verifyDeviceFlow!(new AbortController().signal, (uri, code) => { + presented.push([uri, code]); + })).resolves.toMatchObject({ + issuer, + subject: "user-123", + groups: ["TOT Users", "Unmapped group"], + }); + + expect(presented).toEqual([[`${issuer}/device`, "ABCD-EFGH"]]); + expect(seen.map((url) => url.pathname)).toEqual([ + "/.well-known/openid-configuration", "/device", "/token", "/jwks", + ]); +}); + +test("refuses device flow when discovery has no safe device authorization endpoint", async () => { + await expect(protocol().verifyDeviceFlow!(new AbortController().signal, () => undefined)) + .rejects.toBeInstanceOf(OidcDeviceFlowUnavailableError); +}); + test("rejects a hanging discovery request at the provider transport deadline", async () => { let aborted = false; const subject = protocol({ diff --git a/backend/test/routes-workspaces.test.ts b/backend/test/routes-workspaces.test.ts index 7f2afa10..d32565a1 100644 --- a/backend/test/routes-workspaces.test.ts +++ b/backend/test/routes-workspaces.test.ts @@ -10,6 +10,7 @@ import { createProductionWorkspaceDiagnoser } from "../src/workspaces/diagnostic import { WorkspaceRegistry, type WorkspaceRevision } from "../src/workspaces/registry.js"; import { serializeWorkspaceYaml, type CanonicalWorkspace } from "../src/workspaces/schema.js"; import { WorkspaceSecretStore } from "../src/workspaces/secret-store.js"; +import type { AuthDiagnoser, AuthDiagnostics } from "../src/auth/diagnostics.js"; const workspace: CanonicalWorkspace = { workspace: { @@ -70,11 +71,18 @@ function registryFake(overrides: Partial = {}): RegistryFake { }; } +const readyAuthentication: AuthDiagnostics = { + ready: true, + mode: "none", + checks: [{ level: "info", code: "auth_ready", message: "Authentication is ready." }], +}; + function appFor( registry: RegistryFake, diagnose = vi.fn(async () => ({ activatable: true, diagnostics: [] })), secretStore = testSecretStore(), env: Record = {}, + authDiagnoser: AuthDiagnoser = { inspect: vi.fn(async () => readyAuthentication) }, ) { return buildApp(loadConfig({ THT_HARNESS_DIR: "/missing-harness", @@ -85,6 +93,7 @@ function appFor( workspaceRegistry: registry as WorkspaceRegistry, workspaceDiagnoser: diagnose, workspaceSecretStore: secretStore, + authDiagnoser, } as any); } @@ -196,6 +205,51 @@ test("validates a schema v3 workspace without mutating the repository", async () expect(response.json()).toMatchObject({ workspace }); }); +test("aggregates one static and one live authentication report without reordering connector diagnostics", async () => { + const connectorDiagnostics = [{ + level: "info" as const, + code: "binding_ok" as const, + message: "Installation bindings and diagnostics succeeded.", + }]; + const diagnose = vi.fn(async () => ({ activatable: true, diagnostics: connectorDiagnostics })); + const authentication: AuthDiagnostics = { + ready: false, + mode: "oidc", + checks: [{ + level: "error", + code: "oidc_mapped_group_missing", + field: "Thoth Administrators", + message: "A configured authorization group does not exist.", + }], + }; + const authDiagnoser: AuthDiagnoser = { inspect: vi.fn(async () => authentication) }; + const app = appFor(registryFake(), diagnose, testSecretStore(), {}, authDiagnoser); + + const validation = await app.inject({ + method: "POST", url: "/workspaces/validate", payload: { workspace }, + }); + const connection = await app.inject({ + method: "POST", url: "/workspaces/psd-clinical/test", payload: {}, + }); + + expect(validation.statusCode).toBe(200); + expect(validation.json()).toMatchObject({ + workspace, + activatable: false, + diagnostics: [], + authentication, + }); + expect(connection.statusCode).toBe(200); + expect(connection.json()).toEqual({ + activatable: false, + diagnostics: connectorDiagnostics, + authentication, + }); + expect(diagnose).toHaveBeenCalledTimes(1); + expect(authDiagnoser.inspect).toHaveBeenNthCalledWith(1, { live: false }); + expect(authDiagnoser.inspect).toHaveBeenNthCalledWith(2, { live: true }); +}); + test.each([1, 2])("rejects schema v%s at the validation boundary with a sanitized error", async (version) => { const legacy = { ...workspace, @@ -219,7 +273,7 @@ test("runs diagnostics for a schema v3 workspace", async () => { }); expect(response.statusCode).toBe(200); - expect(response.json()).toEqual({ activatable: true, diagnostics: [] }); + expect(response.json()).toEqual({ activatable: true, diagnostics: [], authentication: readyAuthentication }); expect(diagnose).toHaveBeenCalledWith(workspace, { dwh: expect.objectContaining({ transport: "postgres_direct" }), evidence: { missing: [], values: {} }, diff --git a/frontend/src/api/workspaces.test.ts b/frontend/src/api/workspaces.test.ts index afeb5551..e6f96812 100644 --- a/frontend/src/api/workspaces.test.ts +++ b/frontend/src/api/workspaces.test.ts @@ -8,6 +8,8 @@ import { getWorkspaceRuntimeConfiguration, listWorkspaces, saveWorkspaceSecrets, + testWorkspace, + validateWorkspace, } from "./workspaces"; const workspace = canonicalWorkspaceFixture("psd-clinical"); @@ -98,3 +100,39 @@ test("forget targets one declared requirement", async () => { .resolves.toEqual(runtimeConfiguration); expect(called).toBe(true); }); + +test("decodes the shared authentication diagnostics on static validation and live connection tests", async () => { + const authentication = { + ready: false, + mode: "oidc" as const, + checks: [{ + level: "error" as const, + code: "oidc_mapped_group_missing" as const, + field: "Thoth Administrators", + message: "A configured authorization group does not exist.", + }], + }; + server.use( + http.post("/api/workspaces/validate", () => HttpResponse.json({ + workspace, + contract: {}, + activatable: false, + diagnostics: [], + authentication, + })), + http.post("/api/workspaces/psd-clinical/test", () => HttpResponse.json({ + activatable: false, + diagnostics: [], + authentication, + })), + ); + + await expect(validateWorkspace(workspace)).resolves.toMatchObject({ + activatable: false, + authentication, + }); + await expect(testWorkspace("psd-clinical")).resolves.toMatchObject({ + activatable: false, + authentication, + }); +}); diff --git a/frontend/src/api/workspaces.ts b/frontend/src/api/workspaces.ts index 93c29816..e8c0b382 100644 --- a/frontend/src/api/workspaces.ts +++ b/frontend/src/api/workspaces.ts @@ -138,9 +138,46 @@ export interface WorkspaceDiagnostic { message: string; } +export type AuthDiagnosticCode = + | "auth_ready" + | "auth_config_incomplete" + | "auth_config_invalid" + | "auth_session_store_invalid" + | "local_user_registry_invalid" + | "local_admin_missing" + | "oidc_secret_missing" + | "oidc_discovery_unreachable" + | "oidc_issuer_mismatch" + | "oidc_jwks_unreachable" + | "oidc_group_catalog_unreachable" + | "oidc_group_catalog_unauthorized" + | "oidc_mapped_group_missing" + | "oidc_mapped_group_ambiguous" + | "oidc_groups_claim_invalid" + | "oidc_device_flow_unavailable"; + +export interface AuthDiagnostic { + level: "error" | "info"; + code: AuthDiagnosticCode; + message: string; + field?: string; +} + +export interface AuthDiagnostics { + ready: boolean; + mode: "local" | "oidc" | "upstream" | "none" | "mock"; + checks: AuthDiagnostic[]; +} + export interface WorkspaceDiagnostics { activatable: boolean; diagnostics: WorkspaceDiagnostic[]; + authentication: AuthDiagnostics; +} + +export interface WorkspaceValidationResult extends WorkspaceDiagnostics { + workspace: CanonicalWorkspace; + contract: unknown; } export interface WorkspaceSecretRequirement { @@ -179,6 +216,62 @@ function object(value: unknown): Record | undefined { : undefined; } +const authDiagnosticCodes = new Set([ + "auth_ready", "auth_config_incomplete", "auth_config_invalid", "auth_session_store_invalid", + "local_user_registry_invalid", "local_admin_missing", "oidc_secret_missing", + "oidc_discovery_unreachable", "oidc_issuer_mismatch", "oidc_jwks_unreachable", + "oidc_group_catalog_unreachable", "oidc_group_catalog_unauthorized", "oidc_mapped_group_missing", + "oidc_mapped_group_ambiguous", "oidc_groups_claim_invalid", "oidc_device_flow_unavailable", +]); + +function text(value: unknown): value is string { + return typeof value === "string" && value.length > 0 && value.length <= 512 && !/[\u0000-\u001f\u007f]/.test(value); +} + +function decodeAuthentication(value: unknown): AuthDiagnostics { + const source = object(value); + if (!source || typeof source.ready !== "boolean" + || !["local", "oidc", "upstream", "none", "mock"].includes(String(source.mode)) + || !Array.isArray(source.checks)) throw new Error("Workspace API returned invalid authentication diagnostics"); + const checks = source.checks.map((item): AuthDiagnostic => { + const check = object(item); + if (!check || (check.level !== "error" && check.level !== "info") + || typeof check.code !== "string" || !authDiagnosticCodes.has(check.code as AuthDiagnosticCode) + || !text(check.message) || (check.field !== undefined && !text(check.field))) { + throw new Error("Workspace API returned invalid authentication diagnostics"); + } + return { + level: check.level, + code: check.code as AuthDiagnosticCode, + message: check.message, + ...(check.field === undefined ? {} : { field: check.field }), + }; + }); + return { ready: source.ready, mode: source.mode as AuthDiagnostics["mode"], checks }; +} + +function decodeWorkspaceDiagnostics(value: unknown): WorkspaceDiagnostics { + const source = object(value); + if (!source || typeof source.activatable !== "boolean" || !Array.isArray(source.diagnostics)) { + throw new Error("Workspace API returned an invalid diagnostic result"); + } + const diagnostics = source.diagnostics.map((item): WorkspaceDiagnostic => { + const diagnostic = object(item); + if (!diagnostic || (diagnostic.level !== "error" && diagnostic.level !== "warning" && diagnostic.level !== "info") + || typeof diagnostic.code !== "string" || !text(diagnostic.message) + || (diagnostic.field !== undefined && !text(diagnostic.field))) { + throw new Error("Workspace API returned an invalid diagnostic result"); + } + return { + level: diagnostic.level, + code: diagnostic.code as WorkspaceDiagnostic["code"], + message: diagnostic.message, + ...(diagnostic.field === undefined ? {} : { field: diagnostic.field }), + }; + }); + return { activatable: source.activatable, diagnostics, authentication: decodeAuthentication(source.authentication) }; +} + function exactObject(value: unknown, keys: readonly string[]): Record | undefined { const source = object(value); return source && Object.keys(source).every((key) => keys.includes(key)) ? source : undefined; @@ -278,18 +371,22 @@ export const getWorkspace = async (id: string): Promise => { export const getWorkspaceRegistryStatus = () => apiFetch("/workspace-registry/status"); export const pullWorkspaceRegistry = () => apiFetch("/workspace-registry/pull", { method: "POST" }); -export const validateWorkspace = async (workspace: CanonicalWorkspace) => { +export const validateWorkspace = async (workspace: CanonicalWorkspace): Promise => { const safe = requireCanonicalWorkspace(workspace); const response = await apiFetch("/workspaces/validate", { method: "POST", body: JSON.stringify({ workspace: safe }), }); const source = object(response); if (!source) throw new Error("Workspace API returned an invalid validation result"); - return { workspace: requireCanonicalWorkspace(source.workspace), contract: source.contract }; + return { + workspace: requireCanonicalWorkspace(source.workspace), + contract: source.contract, + ...decodeWorkspaceDiagnostics(source), + }; }; -export const testWorkspace = (id: string) => - apiFetch(`/workspaces/${encodeURIComponent(id)}/test`, { method: "POST" }); +export const testWorkspace = async (id: string): Promise => + decodeWorkspaceDiagnostics(await apiFetch(`/workspaces/${encodeURIComponent(id)}/test`, { method: "POST" })); function runtimeConfiguration(value: unknown, expectedId: string): WorkspaceRuntimeConfiguration { const source = exactObject(value, [ diff --git a/frontend/src/shell/WorkspaceManager.test.tsx b/frontend/src/shell/WorkspaceManager.test.tsx index 73729dd5..0175650b 100644 --- a/frontend/src/shell/WorkspaceManager.test.tsx +++ b/frontend/src/shell/WorkspaceManager.test.tsx @@ -26,6 +26,12 @@ const requirement = { configured: false, }; +const readyAuthentication = { + ready: true, + mode: "none", + checks: [{ level: "info", code: "auth_ready", message: "Authentication is ready." }], +}; + function runtimeConfiguration(configured = false) { return { workspaceId: "psd-clinical", @@ -187,10 +193,13 @@ test("workspace-specific commands remain isolated until a workspace is selected" test("keeps validation and connection results inside their respective action cards", async () => { const user = userEvent.setup(); server.use( - http.post("/api/workspaces/validate", () => HttpResponse.json({ workspace, contract: {} })), + http.post("/api/workspaces/validate", () => HttpResponse.json({ + workspace, contract: {}, activatable: true, diagnostics: [], authentication: readyAuthentication, + })), http.post("/api/workspaces/psd-clinical/test", () => HttpResponse.json({ activatable: false, diagnostics: [{ level: "error", code: "connector_unavailable", message: "Connector diagnostic failed." }], + authentication: readyAuthentication, })), ); renderManager(); @@ -200,9 +209,9 @@ test("keeps validation and connection results inside their respective action car const connectionCard = screen.getByTestId("workspace-connection-card"); await user.click(within(validationCard).getByRole("button", { name: "Validate workspace source" })); const validationStatus = await within(validationCard).findByRole("status"); - expect(validationStatus).toHaveTextContent("Workspace source is valid."); + expect(validationStatus).toHaveTextContent("Workspace source and authentication are valid."); expect(validationStatus).toHaveClass("text-emerald-700"); - expect(within(connectionCard).queryByText("Workspace source is valid.")).not.toBeInTheDocument(); + expect(within(connectionCard).queryByText("Workspace source and authentication are valid.")).not.toBeInTheDocument(); await user.click(within(connectionCard).getByRole("button", { name: "Test workspace connections" })); expect(await within(connectionCard).findByRole("alert")).toHaveTextContent( @@ -211,12 +220,43 @@ test("keeps validation and connection results inside their respective action car expect(within(validationCard).queryByText("connector_unavailable: Connector diagnostic failed.")).not.toBeInTheDocument(); }); +test("renders one authentication section with configured-group errors and no unmapped-group list", async () => { + const user = userEvent.setup(); + server.use(http.post("/api/workspaces/validate", () => HttpResponse.json({ + workspace, + contract: {}, + activatable: false, + diagnostics: [], + authentication: { + ready: false, + mode: "oidc", + checks: [{ + level: "error", + code: "oidc_mapped_group_missing", + field: "Thoth Administrators", + message: "A configured authorization group does not exist.", + }], + }, + }))); + renderManager(); + await user.click(await screen.findByRole("button", { name: "PSD Clinical" })); + + await user.click(screen.getByRole("button", { name: "Validate workspace source" })); + + const section = await screen.findByTestId("workspace-authentication"); + expect(within(section).getByRole("heading", { name: "Authentication" })).toBeVisible(); + expect(within(section).getByText("Failed")).toBeVisible(); + expect(within(section).getByText("oidc_mapped_group_missing: Thoth Administrators — A configured authorization group does not exist.")).toBeVisible(); + expect(within(section).queryByText(/unmapped/i)).not.toBeInTheDocument(); +}); + test("renders binding_ok as a green connection success", async () => { const user = userEvent.setup(); server.use( http.post("/api/workspaces/psd-clinical/test", () => HttpResponse.json({ activatable: true, diagnostics: [{ level: "info", code: "binding_ok", message: "Installation bindings and diagnostics succeeded." }], + authentication: readyAuthentication, })), ); renderManager(); diff --git a/frontend/src/shell/WorkspaceManager.tsx b/frontend/src/shell/WorkspaceManager.tsx index eccdaa3c..52b807a7 100644 --- a/frontend/src/shell/WorkspaceManager.tsx +++ b/frontend/src/shell/WorkspaceManager.tsx @@ -23,6 +23,7 @@ import { saveWorkspaceSecrets, testWorkspace, validateWorkspace, + type AuthDiagnostics, type WorkspaceRuntimeConfiguration, } from "../api/workspaces"; import { captureAuthOperation, isAuthOperationCurrent, StaleAuthOperationError } from "../auth/authOperation"; @@ -81,6 +82,7 @@ export function WorkspaceManager({ const [validationDiagnostics, setValidationDiagnostics] = useState([]); const [connectionNotice, setConnectionNotice] = useState(); const [connectionDiagnostics, setConnectionDiagnostics] = useState([]); + const [authentication, setAuthentication] = useState(); const [busyAction, setBusyAction] = useState(); const operationEpochRef = useRef(0); const selectedIdRef = useRef(selectedId); @@ -135,6 +137,7 @@ export function WorkspaceManager({ setValidationDiagnostics([]); setConnectionNotice(undefined); setConnectionDiagnostics([]); + setAuthentication(undefined); }; const clearGlobalMessages = () => { @@ -194,9 +197,10 @@ export function WorkspaceManager({ setValidationNotice(undefined); setValidationDiagnostics([]); try { - await validateWorkspace(detailQuery.data.workspace); + const result = await validateWorkspace(detailQuery.data.workspace); if (!isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) return; - setValidationNotice("Workspace source is valid."); + setAuthentication(result.authentication); + setValidationNotice(result.activatable ? "Workspace source and authentication are valid." : "Workspace source is valid."); } catch (error) { if (isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) { setValidationDiagnostics([publicError(error, "workspace_invalid: Workspace validation could not be completed")]); @@ -218,6 +222,7 @@ export function WorkspaceManager({ try { const result = await testWorkspace(selectedId); if (!isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) return; + setAuthentication(result.authentication); const issues = result.diagnostics.filter(({ level }) => level !== "info"); const informational = result.diagnostics.find(({ level }) => level === "info"); setConnectionDiagnostics(issues.map(({ code, message }) => `${code}: ${message}`)); @@ -480,6 +485,23 @@ export function WorkspaceManager({ + {authentication &&
+
+

Authentication

+ + {authentication.ready ? "Passed" : "Failed"} + +
+ {!authentication.ready &&
+ {authentication.checks.filter(({ level }) => level === "error").map(({ code, field, message }) => ( +

+ + {code}: {field === undefined ? message : `${field} — ${message}`} +

+ ))} +
} +
} + {canManageSecrets &&
diff --git a/tools/tht/cmd/tht/main_test.go b/tools/tht/cmd/tht/main_test.go index a65803c2..dc18ef6b 100644 --- a/tools/tht/cmd/tht/main_test.go +++ b/tools/tht/cmd/tht/main_test.go @@ -1493,6 +1493,7 @@ case " $* " in *"/settings "*) printf '%s\n' '{"provider":"provider","model":"model","thinking":"medium"}' ;; *"/pi-management/test "*) printf '%s\n' '{"ready":true}' ;; *" tht doctor --json"*) printf '%s\n' '{"ok":true}' ;; + *"dist/auth/diagnostic-command.js --json"*) printf '%s\n' '{"ready":true,"mode":"oidc","checks":[{"level":"info","code":"auth_ready","message":"Authentication is ready."}]}' ;; *"/sessions?scope="*) printf '%s\n' '[]' ;; *"/internal/maintenance/activate "*) printf '%s\n' '{"active":true,"admissions":0}' ;; *"/internal/maintenance/deactivate "*) printf '%s\n' '{"active":false,"admissions":0}' ;; diff --git a/tools/tht/internal/authconfig/commands.go b/tools/tht/internal/authconfig/commands.go index 096016a7..2bf3bbcc 100644 --- a/tools/tht/internal/authconfig/commands.go +++ b/tools/tht/internal/authconfig/commands.go @@ -15,8 +15,11 @@ import ( "os" "path/filepath" "strings" + "time" + "github.com/aritmolab/thothii/tools/tht/internal/compose" "github.com/aritmolab/thothii/tools/tht/internal/config" + "github.com/aritmolab/thothii/tools/tht/internal/output" "github.com/aritmolab/thothii/tools/tht/internal/safeio" "golang.org/x/term" "gopkg.in/yaml.v3" @@ -24,6 +27,12 @@ import ( const maxPasswordFileBytes int64 = 1025 +const authCheckTimeout = 45 * time.Second + +const interactiveAuthCheckTimeout = 12 * time.Minute + +const maxAuthDiagnosticOutputBytes = 64 * 1024 + var errCommandRefused = errors.New("authentication command refused") var writeNewAuthFile = safeio.WriteCanonicalNewFile @@ -32,7 +41,12 @@ var removeAuthFile = safeio.RemoveCanonicalPrivateRegular // Run implements the host-only authentication operator surface. It accepts password bytes only // from an echo-free terminal or a bounded private file, and never writes them to either stream. func Run(ctx context.Context, installation config.Installation, args []string, stdin io.Reader, stdout, stderr io.Writer) int { - _ = ctx + return RunWithRunner(ctx, installation, args, stdin, stdout, stderr, compose.NewRunner("")) +} + +// RunWithRunner retains the operator grammar while providing the shared shell-free runner seam +// used by the aggregate doctor and command tests. +func RunWithRunner(ctx context.Context, installation config.Installation, args []string, stdin io.Reader, stdout, stderr io.Writer, runner compose.Runner) int { if len(args) == 0 { return authFailure(stderr, "auth requires a subcommand") } @@ -54,12 +68,212 @@ func Run(ctx context.Context, installation config.Installation, args []string, s } return 0 case "check": - return authFailure(stderr, "auth check is not available in this release") + return checkCommand(ctx, installation, args[1:], stdout, stderr, runner) default: return authFailure(stderr, "unknown auth subcommand") } } +// AuthDiagnostic is the closed JSON contract emitted by the backend diagnostic command. +type AuthDiagnostic struct { + Level string `json:"level"` + Code string `json:"code"` + Message string `json:"message"` + Field *string `json:"field,omitempty"` +} + +// AuthDiagnostics is intentionally isomorphic to backend AuthDiagnostics. +type AuthDiagnostics struct { + Ready bool `json:"ready"` + Mode string `json:"mode"` + Checks []AuthDiagnostic `json:"checks"` +} + +func parseCheckArgs(args []string) (jsonMode, interactive bool, err error) { + for _, arg := range args { + switch arg { + case "--json": + if jsonMode { + return false, false, errCommandRefused + } + jsonMode = true + case "--interactive": + if interactive { + return false, false, errCommandRefused + } + interactive = true + default: + return false, false, errCommandRefused + } + } + return jsonMode, interactive, nil +} + +func checkCommand(ctx context.Context, installation config.Installation, args []string, stdout, stderr io.Writer, runner compose.Runner) int { + jsonMode, interactive, err := parseCheckArgs(args) + if err != nil { + return authFailure(stderr, authMessage(err)) + } + report, prompt, err := runCheck(ctx, installation, runner, interactive, false) + if err != nil { + fmt.Fprintln(stderr, "tht: authentication diagnostics could not be completed") + return 1 + } + if interactive && prompt != "" { + fmt.Fprintln(stderr, prompt) + } + if jsonMode { + if err := json.NewEncoder(stdout).Encode(report); err != nil { + fmt.Fprintln(stderr, "tht: authentication diagnostic report could not be written") + return 1 + } + } else { + status := "failed" + if report.Ready { + status = "passed" + } + fmt.Fprintf(stdout, "authentication: %s\n", status) + for _, check := range report.Checks { + if check.Level == "error" { + fmt.Fprintf(stdout, "%s: %s\n", check.Code, check.Message) + } + } + } + if report.Ready { + return 0 + } + return 1 +} + +var authDiagnosticCodes = map[string]struct{}{ + "auth_ready": {}, "auth_config_incomplete": {}, "auth_config_invalid": {}, "auth_session_store_invalid": {}, + "local_user_registry_invalid": {}, "local_admin_missing": {}, "oidc_secret_missing": {}, + "oidc_discovery_unreachable": {}, "oidc_issuer_mismatch": {}, "oidc_jwks_unreachable": {}, + "oidc_group_catalog_unreachable": {}, "oidc_group_catalog_unauthorized": {}, "oidc_mapped_group_missing": {}, + "oidc_mapped_group_ambiguous": {}, "oidc_groups_claim_invalid": {}, "oidc_device_flow_unavailable": {}, +} + +func validAuthDiagnostics(report AuthDiagnostics) bool { + if report.Mode != "local" && report.Mode != "oidc" && report.Mode != "upstream" && report.Mode != "none" && report.Mode != "mock" { + return false + } + if len(report.Checks) == 0 || len(report.Checks) > 129 { + return false + } + for _, check := range report.Checks { + if (check.Level != "error" && check.Level != "info") || check.Message == "" || len(check.Message) > 512 { + return false + } + if _, ok := authDiagnosticCodes[check.Code]; !ok { + return false + } + if check.Field != nil && (*check.Field == "" || len(*check.Field) > 512) { + return false + } + } + return true +} + +func authenticationSecretValues(installation config.Installation) []string { + files, err := installation.SecretFiles() + if err != nil { + return nil + } + values, err := output.SecretValuesFromFiles(files) + if err != nil { + return nil + } + return values +} + +func sanitizeAuthDiagnostics(report AuthDiagnostics, secrets []string) AuthDiagnostics { + for index := range report.Checks { + report.Checks[index].Message = output.Sanitize(report.Checks[index].Message, secrets) + if report.Checks[index].Field != nil { + field := output.Sanitize(*report.Checks[index].Field, secrets) + report.Checks[index].Field = &field + } + } + return report +} + +func devicePrompt(stderr string, secrets []string) string { + if len(stderr) > maxAuthDiagnosticOutputBytes { + return "" + } + for _, line := range strings.Split(output.Sanitize(stderr, secrets), "\n") { + const prefix = "Open " + const separator = " and enter code " + if !strings.HasPrefix(line, prefix) { + continue + } + parts := strings.Split(strings.TrimPrefix(line, prefix), separator) + if len(parts) != 2 || !validDeviceVerificationURI(parts[0]) || !validDeviceUserCode(parts[1]) { + continue + } + return prefix + parts[0] + separator + parts[1] + } + return "" +} + +func validDeviceVerificationURI(value string) bool { + parsed, err := url.Parse(value) + return err == nil && parsed.Scheme == "https" && parsed.Host != "" && parsed.User == nil && parsed.Fragment == "" +} + +func validDeviceUserCode(value string) bool { + if len(value) < 4 || len(value) > 256 { + return false + } + for _, character := range value { + letter := (character >= 'A' && character <= 'Z') || (character >= 'a' && character <= 'z') + digit := character >= '0' && character <= '9' + punctuation := character == '-' || character == '.' || character == '_' || character == '~' + if !letter && !digit && !punctuation { + return false + } + } + return true +} + +// Check invokes the exact backend diagnostic command. Normal host checks always use one-shot +// Compose execution; aggregate doctor passes useRunningCore=true only after confirming core runs. +func Check(ctx context.Context, installation config.Installation, runner compose.Runner, interactive, useRunningCore bool) (AuthDiagnostics, error) { + report, _, err := runCheck(ctx, installation, runner, interactive, useRunningCore) + return report, err +} + +func runCheck(ctx context.Context, installation config.Installation, runner compose.Runner, interactive, useRunningCore bool) (AuthDiagnostics, string, error) { + if runner == nil { + return AuthDiagnostics{}, "", errors.New("authentication diagnostic runner is unavailable") + } + timeout := authCheckTimeout + if interactive { + timeout = interactiveAuthCheckTimeout + } + bounded, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + command := []string{"run", "--rm", "--no-deps", "--no-TTY", "core", "node", "dist/auth/diagnostic-command.js", "--json"} + if useRunningCore { + command = []string{"exec", "-T", "core", "node", "dist/auth/diagnostic-command.js", "--json"} + } + if interactive { + command = append(command, "--interactive") + } + result, err := runner.Run(bounded, installation.ComposeArgs(command...), nil) + secrets := authenticationSecretValues(installation) + if err != nil || result.ExitCode != 0 || len(result.Stdout) > maxAuthDiagnosticOutputBytes || len(result.Stderr) > maxAuthDiagnosticOutputBytes { + return AuthDiagnostics{}, "", errors.New("authentication diagnostic command failed") + } + decoder := json.NewDecoder(strings.NewReader(result.Stdout)) + decoder.DisallowUnknownFields() + var report AuthDiagnostics + if err := decoder.Decode(&report); err != nil || decoder.Decode(&struct{}{}) != io.EOF || !validAuthDiagnostics(report) { + return AuthDiagnostics{}, "", errors.New("authentication diagnostic report is invalid") + } + return sanitizeAuthDiagnostics(report, secrets), devicePrompt(result.Stderr, secrets), nil +} + func authFailure(stderr io.Writer, message string) int { fmt.Fprintf(stderr, "tht: %s\n", message) return 2 diff --git a/tools/tht/internal/authconfig/commands_test.go b/tools/tht/internal/authconfig/commands_test.go index 9593ddd9..3c194aeb 100644 --- a/tools/tht/internal/authconfig/commands_test.go +++ b/tools/tht/internal/authconfig/commands_test.go @@ -5,15 +5,126 @@ import ( "context" "encoding/json" "errors" + "io" "os" "path/filepath" "strings" "testing" + "github.com/aritmolab/thothii/tools/tht/internal/compose" "github.com/aritmolab/thothii/tools/tht/internal/config" "github.com/aritmolab/thothii/tools/tht/internal/safeio" ) +func TestAuthCheckRunsOneShotCoreDiagnosticWithPristineJSON(t *testing.T) { + installation := authInstallation(newAuthDirectory(t)) + var calls [][]string + runner := runnerFunc(func(_ context.Context, args []string, _ io.Reader) (compose.Result, error) { + calls = append(calls, append([]string(nil), args...)) + return compose.Result{Stdout: `{"ready":true,"mode":"oidc","checks":[{"level":"info","code":"auth_ready","message":"Authentication is ready."}]}` + "\n"}, nil + }) + var stdout, stderr bytes.Buffer + + code := RunWithRunner(context.Background(), installation, []string{"check", "--json"}, strings.NewReader(""), &stdout, &stderr, runner) + + if code != 0 { + t.Fatalf("auth check = %d, stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } + var report AuthDiagnostics + if err := json.Unmarshal(stdout.Bytes(), &report); err != nil { + t.Fatalf("auth check stdout is not pristine JSON: %q: %v", stdout.String(), err) + } + if !report.Ready || report.Mode != "oidc" || len(report.Checks) != 1 || report.Checks[0].Code != "auth_ready" { + t.Fatalf("auth check report = %#v", report) + } + if stderr.Len() != 0 { + t.Fatalf("auth check stderr = %q", stderr.String()) + } + if len(calls) != 1 { + t.Fatalf("Docker calls = %#v, want one", calls) + } + want := installation.ComposeArgs("run", "--rm", "--no-deps", "--no-TTY", "core", "node", "dist/auth/diagnostic-command.js", "--json") + if strings.Join(calls[0], "\x00") != strings.Join(want, "\x00") { + t.Fatalf("auth check Docker call = %#v, want %#v", calls[0], want) + } +} + +func TestAuthCheckInteractiveForwardsOnlyTheValidatedDevicePrompt(t *testing.T) { + installation := authInstallation(newAuthDirectory(t)) + var calls [][]string + runner := runnerFunc(func(_ context.Context, args []string, _ io.Reader) (compose.Result, error) { + calls = append(calls, append([]string(nil), args...)) + return compose.Result{ + Stdout: `{"ready":true,"mode":"oidc","checks":[{"level":"info","code":"auth_ready","message":"Authentication is ready."}]}` + "\n", + Stderr: "Open https://issuer.example.test/device and enter code ABCD-EFGH\nnot-a-device-prompt secret-sentinel\n", + }, nil + }) + var stdout, stderr bytes.Buffer + + code := RunWithRunner(context.Background(), installation, []string{"check", "--interactive"}, strings.NewReader(""), &stdout, &stderr, runner) + + if code != 0 { + t.Fatalf("interactive auth check = %d, stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } + if stdout.String() != "authentication: passed\n" { + t.Fatalf("interactive auth stdout = %q", stdout.String()) + } + if stderr.String() != "Open https://issuer.example.test/device and enter code ABCD-EFGH\n" { + t.Fatalf("interactive auth stderr = %q", stderr.String()) + } + if strings.Contains(stdout.String()+stderr.String(), "secret-sentinel") { + t.Fatalf("interactive auth output leaked untrusted stderr: stdout=%q stderr=%q", stdout.String(), stderr.String()) + } + if len(calls) != 1 { + t.Fatalf("Docker calls = %#v, want one", calls) + } + want := installation.ComposeArgs("run", "--rm", "--no-deps", "--no-TTY", "core", "node", "dist/auth/diagnostic-command.js", "--json", "--interactive") + if strings.Join(calls[0], "\x00") != strings.Join(want, "\x00") { + t.Fatalf("interactive auth command = %#v, want %#v", calls[0], want) + } +} + +func TestAuthCheckRedactsFailedCoreOutputAndRejectsMalformedReports(t *testing.T) { + installation := authInstallation(newAuthDirectory(t)) + secretPath := filepath.Join(t.TempDir(), "auth-check-secret") + if err := os.WriteFile(secretPath, []byte("auth-check-secret"), 0o600); err != nil { + t.Fatal(err) + } + installation.EnvFile = filepath.Join(t.TempDir(), "operator.env") + if err := os.WriteFile(installation.EnvFile, []byte("AUTH_CHECK_TOKEN_FILE="+secretPath+"\n"), 0o600); err != nil { + t.Fatal(err) + } + runner := runnerFunc(func(_ context.Context, _ []string, _ io.Reader) (compose.Result, error) { + return compose.Result{ + Stdout: "not-json auth-check-secret token-sentinel /private/sentinel $argon2id$hash-sentinel", + Stderr: "auth-check-secret cookie-sentinel /private/sentinel", + ExitCode: 23, + }, errors.New("core failed") + }) + var stdout, stderr bytes.Buffer + + code := RunWithRunner(context.Background(), installation, []string{"check", "--json"}, strings.NewReader(""), &stdout, &stderr, runner) + + if code != 1 { + t.Fatalf("auth check = %d, want 1; stderr=%q", code, stderr.String()) + } + combined := stdout.String() + stderr.String() + leaked := strings.Contains(combined, "auth-check-secret") || + strings.Contains(combined, "token-sentinel") || + strings.Contains(combined, "cookie-sentinel") || + strings.Contains(combined, "/private/sentinel") || + strings.Contains(combined, "$argon2id$hash-sentinel") + if stdout.Len() != 0 || leaked { + t.Fatalf("auth check leaked a core failure: stdout=%q stderr=%q", stdout.String(), stderr.String()) + } +} + +type runnerFunc func(context.Context, []string, io.Reader) (compose.Result, error) + +func (run runnerFunc) Run(ctx context.Context, args []string, input io.Reader) (compose.Result, error) { + return run(ctx, args, input) +} + func TestRunConfiguresLocalRegistryAndRedactsStatusJSON(t *testing.T) { directory := newAuthDirectory(t) passwordFile := writePasswordFile(t, "this is a local test password\n") diff --git a/tools/tht/internal/doctor/report.go b/tools/tht/internal/doctor/report.go index 38cbe42a..c6199e5e 100644 --- a/tools/tht/internal/doctor/report.go +++ b/tools/tht/internal/doctor/report.go @@ -12,6 +12,7 @@ import ( "strings" "time" + "github.com/aritmolab/thothii/tools/tht/internal/authconfig" "github.com/aritmolab/thothii/tools/tht/internal/compose" "github.com/aritmolab/thothii/tools/tht/internal/config" "github.com/aritmolab/thothii/tools/tht/internal/output" @@ -98,7 +99,7 @@ func RunWithProbe(ctx context.Context, installation config.Installation, runner return Report{}, errors.New("doctor requires an HTTP probe") } secretValues := secretValues(installation) - report := Report{Checks: make([]Check, 0, 11)} + report := Report{Checks: make([]Check, 0, 12)} add := func(name, status, detail string) { report.Checks = append(report.Checks, Check{Name: name, Status: status, Detail: output.SanitizeDetail(detail, secretValues)}) } @@ -117,6 +118,7 @@ func RunWithProbe(ctx context.Context, installation config.Installation, runner if !commandCheck(ctx, runner, []string{"version", "--format", "{{.Client.Version}}"}, secretValues, add, "docker", "Docker Engine") { add("compose", StatusSkipped, "Docker Engine is unavailable") add("configuration", StatusSkipped, "Docker Engine is unavailable") + add("authentication", StatusSkipped, "core is unavailable") add("services", StatusSkipped, "Docker Engine is unavailable") add("core-http", StatusSkipped, "core is unavailable") add("frontend-http", StatusSkipped, "frontend is unavailable") @@ -127,6 +129,7 @@ func RunWithProbe(ctx context.Context, installation config.Installation, runner } if !commandCheck(ctx, runner, []string{"compose", "version", "--short"}, secretValues, add, "compose", "Docker Compose") { add("configuration", StatusSkipped, "Docker Compose is unavailable") + add("authentication", StatusSkipped, "core is unavailable") add("services", StatusSkipped, "Docker Compose is unavailable") add("core-http", StatusSkipped, "core is unavailable") add("frontend-http", StatusSkipped, "frontend is unavailable") @@ -150,7 +153,7 @@ func RunWithProbe(ctx context.Context, installation config.Installation, runner add("configuration", StatusPassed, "Compose configuration and required volumes are valid") } - status, statusAvailable := serviceStatus(ctx, installation, runner, secretValues, add) + status, statusAvailable, servicesCheck := serviceStatus(ctx, installation, runner, secretValues) coreRunning := false if statusAvailable { var err error @@ -159,6 +162,14 @@ func RunWithProbe(ctx context.Context, installation config.Installation, runner coreRunning = false } } + if !configReady || !coreRunning { + add("authentication", StatusSkipped, "core is unavailable") + } else if authenticationCheck(ctx, installation, runner, secretValues) { + add("authentication", StatusPassed, "container-local authentication diagnostics passed") + } else { + add("authentication", StatusFailed, "container-local authentication diagnostics failed") + } + add(servicesCheck.Name, servicesCheck.Status, servicesCheck.Detail) if !coreRunning { add("core-http", StatusSkipped, "core is not running") add("frontend-http", StatusSkipped, "core is not running") @@ -226,18 +237,33 @@ func commandCheck(ctx context.Context, runner Runner, args []string, secrets []s return true } -func serviceStatus(ctx context.Context, installation config.Installation, runner Runner, secrets []string, add func(string, string, string)) (string, bool) { +func serviceStatus(ctx context.Context, installation config.Installation, runner Runner, secrets []string) (string, bool, Check) { result, err := runner.Run(ctx, installation.ComposeArgs("ps", "--all", "--format", "json"), nil) if err != nil { - add("services", StatusFailed, commandDetail("Compose service status", result, err, secrets)) - return "", false + return "", false, Check{Name: "services", Status: StatusFailed, Detail: commandDetail("Compose service status", result, err, secrets)} } if err := service.Healthy(result.Stdout); err != nil { - add("services", StatusFailed, err.Error()) - return result.Stdout, true + return result.Stdout, true, Check{Name: "services", Status: StatusFailed, Detail: err.Error()} } - add("services", StatusPassed, "required services are running and reachable through Docker health checks") - return result.Stdout, true + return result.Stdout, true, Check{Name: "services", Status: StatusPassed, Detail: "required services are running and reachable through Docker health checks"} +} + +func authenticationCheck(ctx context.Context, installation config.Installation, runner Runner, secrets []string) bool { + report, err := authconfig.Check(ctx, installation, runner, false, true) + if err != nil { + return false + } + if !report.Ready { + return false + } + // Check performs stream redaction before decoding; retain this sanitization call as a boundary + // if future report fields are added to the backend machine contract. + for _, check := range report.Checks { + if output.Sanitize(check.Message, secrets) != check.Message { + return false + } + } + return true } func workflowCheck(ctx context.Context, installation config.Installation, runner Runner, secrets []string, add func(string, string, string)) { diff --git a/tools/tht/internal/doctor/report_test.go b/tools/tht/internal/doctor/report_test.go index acb8cff0..cee51175 100644 --- a/tools/tht/internal/doctor/report_test.go +++ b/tools/tht/internal/doctor/report_test.go @@ -52,7 +52,7 @@ func TestRunSkipsContainerDiagnosticsWhenCoreIsStopped(t *testing.T) { if err != nil { t.Fatal(err) } - if report.OK || checkStatus(report, "workflow") != "skipped" || checkStatus(report, "pi") != "skipped" { + if report.OK || checkStatus(report, "authentication") != "skipped" || checkStatus(report, "workflow") != "skipped" || checkStatus(report, "pi") != "skipped" { t.Fatalf("Run() report = %#v, want stopped-core skips", report) } if strings.Contains(strings.Join(runner.calls, "\n"), " exec -T core ") { @@ -69,11 +69,14 @@ func TestRunUsesOnlyContainerLocalWorkflowAndPiDiagnosticsWhenCoreRuns(t *testin if err != nil { t.Fatal(err) } - if !report.OK || checkStatus(report, "workflow") != "passed" || checkStatus(report, "pi") != "passed" { + if !report.OK || checkStatus(report, "authentication") != "passed" || checkStatus(report, "workflow") != "passed" || checkStatus(report, "pi") != "passed" { t.Fatalf("Run() report = %#v, want successful container diagnostics", report) } - assertChecklist(t, report, []string{"descriptor", "files", "docker", "compose", "configuration", "services", "core-http", "frontend-http", "workspace-registry", "workflow", "pi"}) + assertChecklist(t, report, []string{"descriptor", "files", "docker", "compose", "configuration", "authentication", "services", "core-http", "frontend-http", "workspace-registry", "workflow", "pi"}) calls := strings.Join(runner.calls, "\n") + if !strings.Contains(calls, "exec -T core node dist/auth/diagnostic-command.js --json") { + t.Fatalf("Run() calls = %s, want core-local authentication diagnostic", calls) + } if !strings.Contains(calls, "exec -T core tht doctor --json") { t.Fatalf("Run() calls = %s, want core-local workflow doctor", calls) } @@ -203,6 +206,8 @@ func (r *doctorRunner) Run(_ context.Context, args []string, _ io.Reader) (compo return compose.Result{Stderr: r.workflowFailure, ExitCode: 23}, errors.New("workflow failed") } return compose.Result{Stdout: `{"ok":true,"checks":[]}`}, nil + case strings.Contains(call, "dist/auth/diagnostic-command.js --json"): + return compose.Result{Stdout: `{"ready":true,"mode":"oidc","checks":[{"level":"info","code":"auth_ready","message":"Authentication is ready."}]}`}, nil case strings.Contains(call, "workspace-registry/state/active.json"): if r.registryInvalid { return compose.Result{ExitCode: 23, Stderr: "invalid workspace registry"}, errors.New("registry invalid") diff --git a/tools/tht/internal/setup/run_test.go b/tools/tht/internal/setup/run_test.go index 8d7f0a5c..6410b2a5 100644 --- a/tools/tht/internal/setup/run_test.go +++ b/tools/tht/internal/setup/run_test.go @@ -36,7 +36,7 @@ func TestRunBuildsStartsAndVerifiesInOrder(t *testing.T) { want := []string{ "docker engine", "docker compose", "architecture", "compose config", "compose build", - "compose up", "health", "health", "doctor docker", "doctor compose", "compose config", "doctor config", "health", "core HTTP", "frontend HTTP", "workspace registry", "workflow doctor", "pi doctor", + "compose up", "health", "health", "doctor docker", "doctor compose", "compose config", "doctor config", "health", "authentication", "core HTTP", "frontend HTTP", "workspace registry", "workflow doctor", "pi doctor", } if got := collapseStages(runner.stages); strings.Join(got, " | ") != strings.Join(want, " | ") { t.Fatalf("runner stages = %v, want %v", got, want) @@ -133,7 +133,7 @@ func TestRunPiDoctorFailurePreservesCauseAndOffersRecovery(t *testing.T) { _, err := Run(context.Background(), runner, request, strings.NewReader(""), io.Discard) assertRecoveryFailure(t, err, "setup doctor reported failed checks", "core") - assertSetupStages(t, runner, "docker engine", "docker compose", "architecture", "compose config", "compose build", "compose up", "health", "doctor docker", "doctor compose", "compose config", "doctor config", "health", "core HTTP", "frontend HTTP", "workspace registry", "workflow doctor", "pi doctor") + assertSetupStages(t, runner, "docker engine", "docker compose", "architecture", "compose config", "compose build", "compose up", "health", "doctor docker", "doctor compose", "compose config", "doctor config", "health", "authentication", "core HTTP", "frontend HTTP", "workspace registry", "workflow doctor", "pi doctor") } func TestRequireVolumesRequiresEveryInstallationVolume(t *testing.T) { @@ -278,6 +278,8 @@ func setupStage(args []string) (string, compose.Result) { return "health", compose.Result{} case strings.HasSuffix(joined, " config --format json"): return "doctor config", compose.Result{Stdout: renderedSetupConfig} + case strings.Contains(joined, "exec -T core node dist/auth/diagnostic-command.js --json"): + return "authentication", compose.Result{Stdout: `{"ready":true,"mode":"oidc","checks":[{"level":"info","code":"auth_ready","message":"Authentication is ready."}]}`} case strings.Contains(joined, "exec -T core tht doctor --json"): return "workflow doctor", compose.Result{Stdout: `{"ok":true,"components":{}}`} case strings.Contains(joined, "exec -T core curl -fsS --max-time 5 http://127.0.0.1:8787/health"):