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 type { WindowsAuthStorageBridge } from "./auth/windows-auth-storage.js";
import { registerAuthRoutes } from "./auth/routes.js"; import { registerAuthRoutes } from "./auth/routes.js";
import { createOidcProtocol, type OidcProtocol, type OidcProtocolOptions } from "./auth/oidc-client.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 { isUsableAuthenticationSecret } from "./auth/secret-policy.js";
import { secretValue } from "./config/secret-bundle.js"; import { secretValue } from "./config/secret-bundle.js";
import { sessionRoutes } from "./routes/sessions.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. */ /** Explicit test-only transport seam; production always invokes the hidden tht bridge. */
authStorageBridgeForTest?: WindowsAuthStorageBridge; authStorageBridgeForTest?: WindowsAuthStorageBridge;
oidcProtocol?: OidcProtocol; oidcProtocol?: OidcProtocol;
authDiagnoser?: AuthDiagnoser;
/** Explicit test seam; production uses the provider-neutral OIDC constructor. */ /** Explicit test seam; production uses the provider-neutral OIDC constructor. */
oidcProtocolFactory?: (options: OidcProtocolOptions) => OidcProtocol; oidcProtocolFactory?: (options: OidcProtocolOptions) => OidcProtocol;
} }
@@ -219,6 +222,10 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
return undefined; return undefined;
} }
}; };
const authDiagnoser = deps?.authDiagnoser ?? createConfiguredAuthDiagnoser(config, {
localUserRegistry: resolveLocalUserRegistry,
oidcProtocol: resolveOidcProtocol,
});
const authSessionStore = deps?.authSessionStore ?? (config.authMode === "local" || config.authMode === "oidc" const authSessionStore = deps?.authSessionStore ?? (config.authMode === "local" || config.authMode === "oidc"
? createFileAuthSessionStore(config.authStateRoot, { ? createFileAuthSessionStore(config.authStateRoot, {
currentAuthConfigRevision: () => { currentAuthConfigRevision: () => {
@@ -324,6 +331,7 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
registry: workspaceRegistry, registry: workspaceRegistry,
config: config.workspaceRegistry, config: config.workspaceRegistry,
diagnose: workspaceDiagnoser, diagnose: workspaceDiagnoser,
authDiagnoser,
secretStore: workspaceSecretStore, secretStore: workspaceSecretStore,
}); });
settingsRoutes(app, { cfg: config, listModels, getSettings }); 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; const LIVE_DIAGNOSTIC_TIMEOUT_MS = 30_000;
export interface AuthDiagnoser { 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 { export interface AuthDiagnoserDependencies {
+41
View File
@@ -4,6 +4,8 @@ import {
calculatePKCECodeChallenge, calculatePKCECodeChallenge,
customFetch, customFetch,
discovery, discovery,
initiateDeviceAuthorization,
pollDeviceAuthorizationGrant,
type Configuration, type Configuration,
type CustomFetch, type CustomFetch,
} from "openid-client"; } 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 { export interface OidcProtocolOptions {
issuer: string; issuer: string;
clientId: string; clientId: string;
@@ -77,6 +87,7 @@ const DEFAULT_HTTP_TIMEOUT_MS = 5_000;
const MAX_HTTP_TIMEOUT_MS = 30_000; const MAX_HTTP_TIMEOUT_MS = 30_000;
const DEFAULT_JWKS_TIMEOUT_MS = 5_000; const DEFAULT_JWKS_TIMEOUT_MS = 5_000;
const MAX_JWKS_TIMEOUT_MS = 30_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 => const text = (value: unknown, maximum = 2048): value is string =>
typeof value === "string" && value.length > 0 && value.length <= maximum && !/\p{Cc}/u.test(value); 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(); 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, type WorkspaceDescriptor,
} from "../workspaces/schema.js"; } from "../workspaces/schema.js";
import type { RuntimeBindings } from "../workspaces/runtime-renderer.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 { isPrincipalContext, requirePermission } from "../auth/authorization.js";
import type { AuthDiagnoser } from "../auth/diagnostics.js";
export type WorkspaceDiagnoser = ( export type WorkspaceDiagnoser = (
workspace: WorkspaceDescriptor, workspace: WorkspaceDescriptor,
bindings: RuntimeBindings, bindings: RuntimeBindings,
options: { writeProbe: boolean }, options: { writeProbe: boolean },
) => Promise<WorkspaceDiagnostics>; ) => Promise<ConnectorDiagnostics>;
interface WorkspaceRoutesDeps { interface WorkspaceRoutesDeps {
registry: WorkspaceRegistry; registry: WorkspaceRegistry;
config: WorkspaceRegistryConfig; config: WorkspaceRegistryConfig;
diagnose: WorkspaceDiagnoser; diagnose: WorkspaceDiagnoser;
authDiagnoser: AuthDiagnoser;
secretStore: WorkspaceSecretStore; secretStore: WorkspaceSecretStore;
} }
@@ -147,7 +149,14 @@ export function workspaceRoutes(app: FastifyInstance, deps: WorkspaceRoutesDeps)
try { try {
const { workspace } = workspacePayload.parse(request.body); const { workspace } = workspacePayload.parse(request.body);
const canonical = validateWorkspaceDescriptor(workspace); 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) { } catch (error) {
return errorReply(reply, error); return errorReply(reply, error);
} }
@@ -222,7 +231,15 @@ export function workspaceRoutes(app: FastifyInstance, deps: WorkspaceRoutesDeps)
deps.secretStore, deps.secretStore,
); );
try { 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 { } finally {
lease.release(); lease.release();
} }
+10 -3
View File
@@ -11,6 +11,7 @@ import {
} from "./schema.js"; } from "./schema.js";
import type { WorkspaceErrorCode } from "./types.js"; import type { WorkspaceErrorCode } from "./types.js";
import type { SemanticRuntimeConfig } from "./runtime-renderer.js"; import type { SemanticRuntimeConfig } from "./runtime-renderer.js";
import type { AuthDiagnostics } from "../auth/diagnostics.js";
export interface Diagnostic { export interface Diagnostic {
level: "error" | "warning" | "info"; level: "error" | "warning" | "info";
@@ -20,11 +21,17 @@ export interface Diagnostic {
message: string; message: string;
} }
export interface WorkspaceDiagnostics { /** Connector-only result produced before the route aggregates authentication. */
export interface ConnectorDiagnostics {
activatable: boolean; activatable: boolean;
diagnostics: Diagnostic[]; diagnostics: Diagnostic[];
} }
/** The HTTP workspace diagnostic contract always includes the shared authentication report. */
export interface WorkspaceDiagnostics extends ConnectorDiagnostics {
authentication: AuthDiagnostics;
}
interface DiagnosticResource { interface DiagnosticResource {
database?: string; database?: string;
schema?: string; schema?: string;
@@ -384,7 +391,7 @@ async function diagnoseValidatedWorkspace(
adapters: DiagnosticAdapters, adapters: DiagnosticAdapters,
timeoutMs: number, timeoutMs: number,
semanticRuntime: SemanticRuntimeConfig, semanticRuntime: SemanticRuntimeConfig,
): Promise<WorkspaceDiagnostics> { ): Promise<ConnectorDiagnostics> {
const evidenceField = descriptor.evidence?.source.type === "http" const evidenceField = descriptor.evidence?.source.type === "http"
? "evidence.source.authentication" ? "evidence.source.authentication"
: descriptor.evidence?.source.type === "s3" : descriptor.evidence?.source.type === "s3"
@@ -533,7 +540,7 @@ export function createWorkspaceDiagnoser(
workspace: WorkspaceDescriptor, workspace: WorkspaceDescriptor,
bindings: RuntimeBindings, bindings: RuntimeBindings,
_options: { writeProbe: boolean }, _options: { writeProbe: boolean },
): Promise<WorkspaceDiagnostics> { ): Promise<ConnectorDiagnostics> {
requireSupportedDescriptor(workspace); requireSupportedDescriptor(workspace);
const descriptor = validateWorkspaceDescriptor(workspace); const descriptor = validateWorkspaceDescriptor(workspace);
return await diagnoseValidatedWorkspace(descriptor, bindings, adapters, timeoutMs, semanticRuntime); return await diagnoseValidatedWorkspace(descriptor, bindings, adapters, timeoutMs, semanticRuntime);
@@ -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);
});
+39
View File
@@ -4,6 +4,7 @@ import {
createOidcProtocol, createOidcProtocol,
OidcIssuerMismatchError, OidcIssuerMismatchError,
OidcJwksUnavailableError, OidcJwksUnavailableError,
OidcDeviceFlowUnavailableError,
OidcProtocolError, OidcProtocolError,
OidcProviderUnavailableError, OidcProviderUnavailableError,
} from "../src/auth/oidc-client.js"; } from "../src/auth/oidc-client.js";
@@ -36,6 +37,7 @@ function protocol(options: {
seen?: URL[]; seen?: URL[];
discoveryResponse?: (init?: RequestInit) => Response | Promise<Response>; discoveryResponse?: (init?: RequestInit) => Response | Promise<Response>;
tokenResponse?: (init?: RequestInit) => Response | Promise<Response>; tokenResponse?: (init?: RequestInit) => Response | Promise<Response>;
deviceResponse?: (init?: RequestInit) => Response | Promise<Response>;
jwksResponse?: (init?: RequestInit) => Response | Promise<Response>; jwksResponse?: (init?: RequestInit) => Response | Promise<Response>;
httpTimeoutMs?: number; httpTimeoutMs?: number;
jwksTimeoutMs?: 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 === "/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 (url.pathname === "/token") {
if (options.tokenResponse) return await options.tokenResponse(init); if (options.tokenResponse) return await options.tokenResponse(init);
return Response.json({ 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]); 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 () => { test("rejects a hanging discovery request at the provider transport deadline", async () => {
let aborted = false; let aborted = false;
const subject = protocol({ const subject = protocol({
+55 -1
View File
@@ -10,6 +10,7 @@ import { createProductionWorkspaceDiagnoser } from "../src/workspaces/diagnostic
import { WorkspaceRegistry, type WorkspaceRevision } from "../src/workspaces/registry.js"; import { WorkspaceRegistry, type WorkspaceRevision } from "../src/workspaces/registry.js";
import { serializeWorkspaceYaml, type CanonicalWorkspace } from "../src/workspaces/schema.js"; import { serializeWorkspaceYaml, type CanonicalWorkspace } from "../src/workspaces/schema.js";
import { WorkspaceSecretStore } from "../src/workspaces/secret-store.js"; import { WorkspaceSecretStore } from "../src/workspaces/secret-store.js";
import type { AuthDiagnoser, AuthDiagnostics } from "../src/auth/diagnostics.js";
const workspace: CanonicalWorkspace = { const workspace: CanonicalWorkspace = {
workspace: { workspace: {
@@ -70,11 +71,18 @@ function registryFake(overrides: Partial<RegistryFake> = {}): RegistryFake {
}; };
} }
const readyAuthentication: AuthDiagnostics = {
ready: true,
mode: "none",
checks: [{ level: "info", code: "auth_ready", message: "Authentication is ready." }],
};
function appFor( function appFor(
registry: RegistryFake, registry: RegistryFake,
diagnose = vi.fn(async () => ({ activatable: true, diagnostics: [] })), diagnose = vi.fn(async () => ({ activatable: true, diagnostics: [] })),
secretStore = testSecretStore(), secretStore = testSecretStore(),
env: Record<string, string> = {}, env: Record<string, string> = {},
authDiagnoser: AuthDiagnoser = { inspect: vi.fn(async () => readyAuthentication) },
) { ) {
return buildApp(loadConfig({ return buildApp(loadConfig({
THT_HARNESS_DIR: "/missing-harness", THT_HARNESS_DIR: "/missing-harness",
@@ -85,6 +93,7 @@ function appFor(
workspaceRegistry: registry as WorkspaceRegistry, workspaceRegistry: registry as WorkspaceRegistry,
workspaceDiagnoser: diagnose, workspaceDiagnoser: diagnose,
workspaceSecretStore: secretStore, workspaceSecretStore: secretStore,
authDiagnoser,
} as any); } as any);
} }
@@ -196,6 +205,51 @@ test("validates a schema v3 workspace without mutating the repository", async ()
expect(response.json()).toMatchObject({ workspace }); 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) => { test.each([1, 2])("rejects schema v%s at the validation boundary with a sanitized error", async (version) => {
const legacy = { const legacy = {
...workspace, ...workspace,
@@ -219,7 +273,7 @@ test("runs diagnostics for a schema v3 workspace", async () => {
}); });
expect(response.statusCode).toBe(200); 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, { expect(diagnose).toHaveBeenCalledWith(workspace, {
dwh: expect.objectContaining({ transport: "postgres_direct" }), dwh: expect.objectContaining({ transport: "postgres_direct" }),
evidence: { missing: [], values: {} }, evidence: { missing: [], values: {} },
+38
View File
@@ -8,6 +8,8 @@ import {
getWorkspaceRuntimeConfiguration, getWorkspaceRuntimeConfiguration,
listWorkspaces, listWorkspaces,
saveWorkspaceSecrets, saveWorkspaceSecrets,
testWorkspace,
validateWorkspace,
} from "./workspaces"; } from "./workspaces";
const workspace = canonicalWorkspaceFixture("psd-clinical"); const workspace = canonicalWorkspaceFixture("psd-clinical");
@@ -98,3 +100,39 @@ test("forget targets one declared requirement", async () => {
.resolves.toEqual(runtimeConfiguration); .resolves.toEqual(runtimeConfiguration);
expect(called).toBe(true); 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,
});
});
+101 -4
View File
@@ -138,9 +138,46 @@ export interface WorkspaceDiagnostic {
message: string; 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 { export interface WorkspaceDiagnostics {
activatable: boolean; activatable: boolean;
diagnostics: WorkspaceDiagnostic[]; diagnostics: WorkspaceDiagnostic[];
authentication: AuthDiagnostics;
}
export interface WorkspaceValidationResult extends WorkspaceDiagnostics {
workspace: CanonicalWorkspace;
contract: unknown;
} }
export interface WorkspaceSecretRequirement { export interface WorkspaceSecretRequirement {
@@ -179,6 +216,62 @@ function object(value: unknown): Record<string, unknown> | undefined {
: undefined; : undefined;
} }
const authDiagnosticCodes = new Set<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",
]);
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<string, unknown> | undefined { function exactObject(value: unknown, keys: readonly string[]): Record<string, unknown> | undefined {
const source = object(value); const source = object(value);
return source && Object.keys(source).every((key) => keys.includes(key)) ? source : undefined; return source && Object.keys(source).every((key) => keys.includes(key)) ? source : undefined;
@@ -278,18 +371,22 @@ export const getWorkspace = async (id: string): Promise<WorkspaceRecord> => {
export const getWorkspaceRegistryStatus = () => apiFetch<WorkspaceRegistryStatus>("/workspace-registry/status"); export const getWorkspaceRegistryStatus = () => apiFetch<WorkspaceRegistryStatus>("/workspace-registry/status");
export const pullWorkspaceRegistry = () => apiFetch<WorkspaceRegistryStatus>("/workspace-registry/pull", { method: "POST" }); export const pullWorkspaceRegistry = () => apiFetch<WorkspaceRegistryStatus>("/workspace-registry/pull", { method: "POST" });
export const validateWorkspace = async (workspace: CanonicalWorkspace) => { export const validateWorkspace = async (workspace: CanonicalWorkspace): Promise<WorkspaceValidationResult> => {
const safe = requireCanonicalWorkspace(workspace); const safe = requireCanonicalWorkspace(workspace);
const response = await apiFetch<unknown>("/workspaces/validate", { const response = await apiFetch<unknown>("/workspaces/validate", {
method: "POST", body: JSON.stringify({ workspace: safe }), method: "POST", body: JSON.stringify({ workspace: safe }),
}); });
const source = object(response); const source = object(response);
if (!source) throw new Error("Workspace API returned an invalid validation result"); 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) => export const testWorkspace = async (id: string): Promise<WorkspaceDiagnostics> =>
apiFetch<WorkspaceDiagnostics>(`/workspaces/${encodeURIComponent(id)}/test`, { method: "POST" }); decodeWorkspaceDiagnostics(await apiFetch<unknown>(`/workspaces/${encodeURIComponent(id)}/test`, { method: "POST" }));
function runtimeConfiguration(value: unknown, expectedId: string): WorkspaceRuntimeConfiguration { function runtimeConfiguration(value: unknown, expectedId: string): WorkspaceRuntimeConfiguration {
const source = exactObject(value, [ const source = exactObject(value, [
+43 -3
View File
@@ -26,6 +26,12 @@ const requirement = {
configured: false, configured: false,
}; };
const readyAuthentication = {
ready: true,
mode: "none",
checks: [{ level: "info", code: "auth_ready", message: "Authentication is ready." }],
};
function runtimeConfiguration(configured = false) { function runtimeConfiguration(configured = false) {
return { return {
workspaceId: "psd-clinical", 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 () => { test("keeps validation and connection results inside their respective action cards", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
server.use( 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({ http.post("/api/workspaces/psd-clinical/test", () => HttpResponse.json({
activatable: false, activatable: false,
diagnostics: [{ level: "error", code: "connector_unavailable", message: "Connector diagnostic failed." }], diagnostics: [{ level: "error", code: "connector_unavailable", message: "Connector diagnostic failed." }],
authentication: readyAuthentication,
})), })),
); );
renderManager(); renderManager();
@@ -200,9 +209,9 @@ test("keeps validation and connection results inside their respective action car
const connectionCard = screen.getByTestId("workspace-connection-card"); const connectionCard = screen.getByTestId("workspace-connection-card");
await user.click(within(validationCard).getByRole("button", { name: "Validate workspace source" })); await user.click(within(validationCard).getByRole("button", { name: "Validate workspace source" }));
const validationStatus = await within(validationCard).findByRole("status"); 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(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" })); await user.click(within(connectionCard).getByRole("button", { name: "Test workspace connections" }));
expect(await within(connectionCard).findByRole("alert")).toHaveTextContent( 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(); 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 () => { test("renders binding_ok as a green connection success", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
server.use( server.use(
http.post("/api/workspaces/psd-clinical/test", () => HttpResponse.json({ http.post("/api/workspaces/psd-clinical/test", () => HttpResponse.json({
activatable: true, activatable: true,
diagnostics: [{ level: "info", code: "binding_ok", message: "Installation bindings and diagnostics succeeded." }], diagnostics: [{ level: "info", code: "binding_ok", message: "Installation bindings and diagnostics succeeded." }],
authentication: readyAuthentication,
})), })),
); );
renderManager(); renderManager();
+24 -2
View File
@@ -23,6 +23,7 @@ import {
saveWorkspaceSecrets, saveWorkspaceSecrets,
testWorkspace, testWorkspace,
validateWorkspace, validateWorkspace,
type AuthDiagnostics,
type WorkspaceRuntimeConfiguration, type WorkspaceRuntimeConfiguration,
} from "../api/workspaces"; } from "../api/workspaces";
import { captureAuthOperation, isAuthOperationCurrent, StaleAuthOperationError } from "../auth/authOperation"; import { captureAuthOperation, isAuthOperationCurrent, StaleAuthOperationError } from "../auth/authOperation";
@@ -81,6 +82,7 @@ export function WorkspaceManager({
const [validationDiagnostics, setValidationDiagnostics] = useState<string[]>([]); const [validationDiagnostics, setValidationDiagnostics] = useState<string[]>([]);
const [connectionNotice, setConnectionNotice] = useState<string>(); const [connectionNotice, setConnectionNotice] = useState<string>();
const [connectionDiagnostics, setConnectionDiagnostics] = useState<string[]>([]); const [connectionDiagnostics, setConnectionDiagnostics] = useState<string[]>([]);
const [authentication, setAuthentication] = useState<AuthDiagnostics>();
const [busyAction, setBusyAction] = useState<string>(); const [busyAction, setBusyAction] = useState<string>();
const operationEpochRef = useRef(0); const operationEpochRef = useRef(0);
const selectedIdRef = useRef(selectedId); const selectedIdRef = useRef(selectedId);
@@ -135,6 +137,7 @@ export function WorkspaceManager({
setValidationDiagnostics([]); setValidationDiagnostics([]);
setConnectionNotice(undefined); setConnectionNotice(undefined);
setConnectionDiagnostics([]); setConnectionDiagnostics([]);
setAuthentication(undefined);
}; };
const clearGlobalMessages = () => { const clearGlobalMessages = () => {
@@ -194,9 +197,10 @@ export function WorkspaceManager({
setValidationNotice(undefined); setValidationNotice(undefined);
setValidationDiagnostics([]); setValidationDiagnostics([]);
try { try {
await validateWorkspace(detailQuery.data.workspace); const result = await validateWorkspace(detailQuery.data.workspace);
if (!isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) return; 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) { } catch (error) {
if (isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) { if (isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) {
setValidationDiagnostics([publicError(error, "workspace_invalid: Workspace validation could not be completed")]); setValidationDiagnostics([publicError(error, "workspace_invalid: Workspace validation could not be completed")]);
@@ -218,6 +222,7 @@ export function WorkspaceManager({
try { try {
const result = await testWorkspace(selectedId); const result = await testWorkspace(selectedId);
if (!isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) return; if (!isAuthOperationCurrent(guard, { sessionId: selectedIdRef.current, disposalEpoch: operationEpochRef.current })) return;
setAuthentication(result.authentication);
const issues = result.diagnostics.filter(({ level }) => level !== "info"); const issues = result.diagnostics.filter(({ level }) => level !== "info");
const informational = result.diagnostics.find(({ level }) => level === "info"); const informational = result.diagnostics.find(({ level }) => level === "info");
setConnectionDiagnostics(issues.map(({ code, message }) => `${code}: ${message}`)); setConnectionDiagnostics(issues.map(({ code, message }) => `${code}: ${message}`));
@@ -480,6 +485,23 @@ export function WorkspaceManager({
</div> </div>
</div> </div>
{authentication && <section data-testid="workspace-authentication" className="rounded-lg border border-border p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<h4 className="font-heading font-semibold">Authentication</h4>
<span className={authentication.ready ? "text-sm font-medium text-emerald-700" : "text-sm font-medium text-amber-700"}>
{authentication.ready ? "Passed" : "Failed"}
</span>
</div>
{!authentication.ready && <div role="alert" className="mt-3 grid gap-1 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm">
{authentication.checks.filter(({ level }) => level === "error").map(({ code, field, message }) => (
<p key={`${code}:${field ?? ""}`} className="flex items-start gap-2">
<AlertCircle className="mt-0.5 size-4 shrink-0 text-amber-700" />
{code}: {field === undefined ? message : `${field} — ${message}`}
</p>
))}
</div>}
</section>}
{canManageSecrets && <div className="rounded-lg border border-border p-4"> {canManageSecrets && <div className="rounded-lg border border-border p-4">
<div className="mb-4 flex items-start gap-3"> <div className="mb-4 flex items-start gap-3">
<KeyRound className="mt-0.5 size-5 text-primary" /> <KeyRound className="mt-0.5 size-5 text-primary" />
+1
View File
@@ -1493,6 +1493,7 @@ case " $* " in
*"/settings "*) printf '%s\n' '{"provider":"provider","model":"model","thinking":"medium"}' ;; *"/settings "*) printf '%s\n' '{"provider":"provider","model":"model","thinking":"medium"}' ;;
*"/pi-management/test "*) printf '%s\n' '{"ready":true}' ;; *"/pi-management/test "*) printf '%s\n' '{"ready":true}' ;;
*" tht doctor --json"*) printf '%s\n' '{"ok":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' '[]' ;; *"/sessions?scope="*) printf '%s\n' '[]' ;;
*"/internal/maintenance/activate "*) printf '%s\n' '{"active":true,"admissions":0}' ;; *"/internal/maintenance/activate "*) printf '%s\n' '{"active":true,"admissions":0}' ;;
*"/internal/maintenance/deactivate "*) printf '%s\n' '{"active":false,"admissions":0}' ;; *"/internal/maintenance/deactivate "*) printf '%s\n' '{"active":false,"admissions":0}' ;;
+216 -2
View File
@@ -15,8 +15,11 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "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/config"
"github.com/aritmolab/thothii/tools/tht/internal/output"
"github.com/aritmolab/thothii/tools/tht/internal/safeio" "github.com/aritmolab/thothii/tools/tht/internal/safeio"
"golang.org/x/term" "golang.org/x/term"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
@@ -24,6 +27,12 @@ import (
const maxPasswordFileBytes int64 = 1025 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 errCommandRefused = errors.New("authentication command refused")
var writeNewAuthFile = safeio.WriteCanonicalNewFile 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 // 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. // 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 { 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 { if len(args) == 0 {
return authFailure(stderr, "auth requires a subcommand") return authFailure(stderr, "auth requires a subcommand")
} }
@@ -54,12 +68,212 @@ func Run(ctx context.Context, installation config.Installation, args []string, s
} }
return 0 return 0
case "check": case "check":
return authFailure(stderr, "auth check is not available in this release") return checkCommand(ctx, installation, args[1:], stdout, stderr, runner)
default: default:
return authFailure(stderr, "unknown auth subcommand") 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 { func authFailure(stderr io.Writer, message string) int {
fmt.Fprintf(stderr, "tht: %s\n", message) fmt.Fprintf(stderr, "tht: %s\n", message)
return 2 return 2
@@ -5,15 +5,126 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"io"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"github.com/aritmolab/thothii/tools/tht/internal/compose"
"github.com/aritmolab/thothii/tools/tht/internal/config" "github.com/aritmolab/thothii/tools/tht/internal/config"
"github.com/aritmolab/thothii/tools/tht/internal/safeio" "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) { func TestRunConfiguresLocalRegistryAndRedactsStatusJSON(t *testing.T) {
directory := newAuthDirectory(t) directory := newAuthDirectory(t)
passwordFile := writePasswordFile(t, "this is a local test password\n") passwordFile := writePasswordFile(t, "this is a local test password\n")
+35 -9
View File
@@ -12,6 +12,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/aritmolab/thothii/tools/tht/internal/authconfig"
"github.com/aritmolab/thothii/tools/tht/internal/compose" "github.com/aritmolab/thothii/tools/tht/internal/compose"
"github.com/aritmolab/thothii/tools/tht/internal/config" "github.com/aritmolab/thothii/tools/tht/internal/config"
"github.com/aritmolab/thothii/tools/tht/internal/output" "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") return Report{}, errors.New("doctor requires an HTTP probe")
} }
secretValues := secretValues(installation) secretValues := secretValues(installation)
report := Report{Checks: make([]Check, 0, 11)} report := Report{Checks: make([]Check, 0, 12)}
add := func(name, status, detail string) { add := func(name, status, detail string) {
report.Checks = append(report.Checks, Check{Name: name, Status: status, Detail: output.SanitizeDetail(detail, secretValues)}) 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") { if !commandCheck(ctx, runner, []string{"version", "--format", "{{.Client.Version}}"}, secretValues, add, "docker", "Docker Engine") {
add("compose", StatusSkipped, "Docker Engine is unavailable") add("compose", StatusSkipped, "Docker Engine is unavailable")
add("configuration", 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("services", StatusSkipped, "Docker Engine is unavailable")
add("core-http", StatusSkipped, "core is unavailable") add("core-http", StatusSkipped, "core is unavailable")
add("frontend-http", StatusSkipped, "frontend 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") { if !commandCheck(ctx, runner, []string{"compose", "version", "--short"}, secretValues, add, "compose", "Docker Compose") {
add("configuration", StatusSkipped, "Docker Compose is unavailable") add("configuration", StatusSkipped, "Docker Compose is unavailable")
add("authentication", StatusSkipped, "core is unavailable")
add("services", StatusSkipped, "Docker Compose is unavailable") add("services", StatusSkipped, "Docker Compose is unavailable")
add("core-http", StatusSkipped, "core is unavailable") add("core-http", StatusSkipped, "core is unavailable")
add("frontend-http", StatusSkipped, "frontend 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") 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 coreRunning := false
if statusAvailable { if statusAvailable {
var err error var err error
@@ -159,6 +162,14 @@ func RunWithProbe(ctx context.Context, installation config.Installation, runner
coreRunning = false 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 { if !coreRunning {
add("core-http", StatusSkipped, "core is not running") add("core-http", StatusSkipped, "core is not running")
add("frontend-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 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) result, err := runner.Run(ctx, installation.ComposeArgs("ps", "--all", "--format", "json"), nil)
if err != nil { if err != nil {
add("services", StatusFailed, commandDetail("Compose service status", result, err, secrets)) return "", false, Check{Name: "services", Status: StatusFailed, Detail: commandDetail("Compose service status", result, err, secrets)}
return "", false
} }
if err := service.Healthy(result.Stdout); err != nil { if err := service.Healthy(result.Stdout); err != nil {
add("services", StatusFailed, err.Error()) return result.Stdout, true, Check{Name: "services", Status: StatusFailed, Detail: err.Error()}
return result.Stdout, true
} }
add("services", StatusPassed, "required services are running and reachable through Docker health checks") return result.Stdout, true, Check{Name: "services", Status: StatusPassed, Detail: "required services are running and reachable through Docker health checks"}
return result.Stdout, true }
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)) { func workflowCheck(ctx context.Context, installation config.Installation, runner Runner, secrets []string, add func(string, string, string)) {
+8 -3
View File
@@ -52,7 +52,7 @@ func TestRunSkipsContainerDiagnosticsWhenCoreIsStopped(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) 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) t.Fatalf("Run() report = %#v, want stopped-core skips", report)
} }
if strings.Contains(strings.Join(runner.calls, "\n"), " exec -T core ") { if strings.Contains(strings.Join(runner.calls, "\n"), " exec -T core ") {
@@ -69,11 +69,14 @@ func TestRunUsesOnlyContainerLocalWorkflowAndPiDiagnosticsWhenCoreRuns(t *testin
if err != nil { if err != nil {
t.Fatal(err) 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) 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") 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") { if !strings.Contains(calls, "exec -T core tht doctor --json") {
t.Fatalf("Run() calls = %s, want core-local workflow doctor", calls) 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{Stderr: r.workflowFailure, ExitCode: 23}, errors.New("workflow failed")
} }
return compose.Result{Stdout: `{"ok":true,"checks":[]}`}, nil 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"): case strings.Contains(call, "workspace-registry/state/active.json"):
if r.registryInvalid { if r.registryInvalid {
return compose.Result{ExitCode: 23, Stderr: "invalid workspace registry"}, errors.New("registry invalid") return compose.Result{ExitCode: 23, Stderr: "invalid workspace registry"}, errors.New("registry invalid")
+4 -2
View File
@@ -36,7 +36,7 @@ func TestRunBuildsStartsAndVerifiesInOrder(t *testing.T) {
want := []string{ want := []string{
"docker engine", "docker compose", "architecture", "compose config", "compose build", "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, " | ") { if got := collapseStages(runner.stages); strings.Join(got, " | ") != strings.Join(want, " | ") {
t.Fatalf("runner stages = %v, want %v", got, 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) _, err := Run(context.Background(), runner, request, strings.NewReader(""), io.Discard)
assertRecoveryFailure(t, err, "setup doctor reported failed checks", "core") 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) { func TestRequireVolumesRequiresEveryInstallationVolume(t *testing.T) {
@@ -278,6 +278,8 @@ func setupStage(args []string) (string, compose.Result) {
return "health", compose.Result{} return "health", compose.Result{}
case strings.HasSuffix(joined, " config --format json"): case strings.HasSuffix(joined, " config --format json"):
return "doctor config", compose.Result{Stdout: renderedSetupConfig} 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"): case strings.Contains(joined, "exec -T core tht doctor --json"):
return "workflow doctor", compose.Result{Stdout: `{"ok":true,"components":{}}`} 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"): case strings.Contains(joined, "exec -T core curl -fsS --max-time 5 http://127.0.0.1:8787/health"):