feat(auth): include authentication in workspace and tht diagnostics
This commit is contained in:
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -14,7 +14,13 @@ export type { AuthDiagnostic, AuthDiagnosticCode, AuthDiagnostics } from "./grou
|
||||
const LIVE_DIAGNOSTIC_TIMEOUT_MS = 30_000;
|
||||
|
||||
export interface AuthDiagnoser {
|
||||
inspect(options: { live: boolean; interactive?: boolean; signal?: AbortSignal }): Promise<AuthDiagnostics>;
|
||||
inspect(options: {
|
||||
live: boolean;
|
||||
interactive?: boolean;
|
||||
signal?: AbortSignal;
|
||||
/** Device-code presentation is transient operator output, never persisted diagnostic state. */
|
||||
presentDeviceCode?: (uri: string, code: string) => void;
|
||||
}): Promise<AuthDiagnostics>;
|
||||
}
|
||||
|
||||
export interface AuthDiagnoserDependencies {
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
calculatePKCECodeChallenge,
|
||||
customFetch,
|
||||
discovery,
|
||||
initiateDeviceAuthorization,
|
||||
pollDeviceAuthorizationGrant,
|
||||
type Configuration,
|
||||
type CustomFetch,
|
||||
} from "openid-client";
|
||||
@@ -57,6 +59,14 @@ export class OidcIssuerMismatchError extends OidcProtocolError {
|
||||
}
|
||||
}
|
||||
|
||||
/** Device authorization is optional OIDC metadata and must never fall back to a browser flow. */
|
||||
export class OidcDeviceFlowUnavailableError extends OidcProtocolError {
|
||||
constructor() {
|
||||
super("oidc_device_flow_unavailable");
|
||||
this.name = "OidcDeviceFlowUnavailableError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface OidcProtocolOptions {
|
||||
issuer: string;
|
||||
clientId: string;
|
||||
@@ -77,6 +87,7 @@ const DEFAULT_HTTP_TIMEOUT_MS = 5_000;
|
||||
const MAX_HTTP_TIMEOUT_MS = 30_000;
|
||||
const DEFAULT_JWKS_TIMEOUT_MS = 5_000;
|
||||
const MAX_JWKS_TIMEOUT_MS = 30_000;
|
||||
const MAX_DEVICE_FLOW_TIMEOUT_MS = 10 * 60_000;
|
||||
const text = (value: unknown, maximum = 2048): value is string =>
|
||||
typeof value === "string" && value.length > 0 && value.length <= maximum && !/\p{Cc}/u.test(value);
|
||||
|
||||
@@ -599,5 +610,35 @@ export function createOidcProtocol(options: OidcProtocolOptions): OidcProtocol {
|
||||
}
|
||||
signal.throwIfAborted();
|
||||
},
|
||||
async verifyDeviceFlow(signal, present) {
|
||||
let config: Configuration;
|
||||
try {
|
||||
config = await configuration();
|
||||
const endpoint = config.serverMetadata().device_authorization_endpoint;
|
||||
httpsEndpoint(endpoint);
|
||||
} catch (error) {
|
||||
if (error instanceof OidcIssuerMismatchError || error instanceof OidcProviderUnavailableError) throw error;
|
||||
throw new OidcDeviceFlowUnavailableError();
|
||||
}
|
||||
const deadline = AbortSignal.timeout(MAX_DEVICE_FLOW_TIMEOUT_MS);
|
||||
const deviceSignal = AbortSignal.any([signal, deadline]);
|
||||
try {
|
||||
deviceSignal.throwIfAborted();
|
||||
const device = await initiateDeviceAuthorization(config, { scope: options.scopes.join(" ") });
|
||||
if (!text(device.verification_uri, 2048) || !text(device.user_code, 256)) {
|
||||
throw new OidcDeviceFlowUnavailableError();
|
||||
}
|
||||
const verificationUri = httpsEndpoint(device.verification_uri);
|
||||
present(verificationUri.href, device.user_code);
|
||||
const tokens = await pollDeviceAuthorizationGrant(config, device, undefined, { signal: deviceSignal });
|
||||
await verifyIdTokenSignature(tokens.id_token, config, transport, jwksTimeoutMs);
|
||||
const claims = tokens.claims();
|
||||
if (!claims || Array.isArray(claims)) throw new OidcProtocolError();
|
||||
return identityFromClaims(claims as Record<string, unknown>, options);
|
||||
} catch (error) {
|
||||
if (error instanceof OidcDeviceFlowUnavailableError) throw error;
|
||||
throw protocolFailure(error);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -16,19 +16,21 @@ import {
|
||||
type WorkspaceDescriptor,
|
||||
} from "../workspaces/schema.js";
|
||||
import type { RuntimeBindings } from "../workspaces/runtime-renderer.js";
|
||||
import type { WorkspaceDiagnostics } from "../workspaces/diagnostics.js";
|
||||
import type { ConnectorDiagnostics } from "../workspaces/diagnostics.js";
|
||||
import { isPrincipalContext, requirePermission } from "../auth/authorization.js";
|
||||
import type { AuthDiagnoser } from "../auth/diagnostics.js";
|
||||
|
||||
export type WorkspaceDiagnoser = (
|
||||
workspace: WorkspaceDescriptor,
|
||||
bindings: RuntimeBindings,
|
||||
options: { writeProbe: boolean },
|
||||
) => Promise<WorkspaceDiagnostics>;
|
||||
) => Promise<ConnectorDiagnostics>;
|
||||
|
||||
interface WorkspaceRoutesDeps {
|
||||
registry: WorkspaceRegistry;
|
||||
config: WorkspaceRegistryConfig;
|
||||
diagnose: WorkspaceDiagnoser;
|
||||
authDiagnoser: AuthDiagnoser;
|
||||
secretStore: WorkspaceSecretStore;
|
||||
}
|
||||
|
||||
@@ -147,7 +149,14 @@ export function workspaceRoutes(app: FastifyInstance, deps: WorkspaceRoutesDeps)
|
||||
try {
|
||||
const { workspace } = workspacePayload.parse(request.body);
|
||||
const canonical = validateWorkspaceDescriptor(workspace);
|
||||
return { workspace: canonical, contract: buildInstallationContract(canonical) };
|
||||
const authentication = await deps.authDiagnoser.inspect({ live: false });
|
||||
return {
|
||||
workspace: canonical,
|
||||
contract: buildInstallationContract(canonical),
|
||||
activatable: authentication.ready,
|
||||
diagnostics: [],
|
||||
authentication,
|
||||
};
|
||||
} catch (error) {
|
||||
return errorReply(reply, error);
|
||||
}
|
||||
@@ -222,7 +231,15 @@ export function workspaceRoutes(app: FastifyInstance, deps: WorkspaceRoutesDeps)
|
||||
deps.secretStore,
|
||||
);
|
||||
try {
|
||||
return await deps.diagnose(operational, lease.bindings, { writeProbe: false });
|
||||
const [workspaceDiagnostics, authentication] = await Promise.all([
|
||||
deps.diagnose(operational, lease.bindings, { writeProbe: false }),
|
||||
deps.authDiagnoser.inspect({ live: true }),
|
||||
]);
|
||||
return {
|
||||
...workspaceDiagnostics,
|
||||
activatable: workspaceDiagnostics.activatable && authentication.ready,
|
||||
authentication,
|
||||
};
|
||||
} finally {
|
||||
lease.release();
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "./schema.js";
|
||||
import type { WorkspaceErrorCode } from "./types.js";
|
||||
import type { SemanticRuntimeConfig } from "./runtime-renderer.js";
|
||||
import type { AuthDiagnostics } from "../auth/diagnostics.js";
|
||||
|
||||
export interface Diagnostic {
|
||||
level: "error" | "warning" | "info";
|
||||
@@ -20,11 +21,17 @@ export interface Diagnostic {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceDiagnostics {
|
||||
/** Connector-only result produced before the route aggregates authentication. */
|
||||
export interface ConnectorDiagnostics {
|
||||
activatable: boolean;
|
||||
diagnostics: Diagnostic[];
|
||||
}
|
||||
|
||||
/** The HTTP workspace diagnostic contract always includes the shared authentication report. */
|
||||
export interface WorkspaceDiagnostics extends ConnectorDiagnostics {
|
||||
authentication: AuthDiagnostics;
|
||||
}
|
||||
|
||||
interface DiagnosticResource {
|
||||
database?: string;
|
||||
schema?: string;
|
||||
@@ -384,7 +391,7 @@ async function diagnoseValidatedWorkspace(
|
||||
adapters: DiagnosticAdapters,
|
||||
timeoutMs: number,
|
||||
semanticRuntime: SemanticRuntimeConfig,
|
||||
): Promise<WorkspaceDiagnostics> {
|
||||
): Promise<ConnectorDiagnostics> {
|
||||
const evidenceField = descriptor.evidence?.source.type === "http"
|
||||
? "evidence.source.authentication"
|
||||
: descriptor.evidence?.source.type === "s3"
|
||||
@@ -533,7 +540,7 @@ export function createWorkspaceDiagnoser(
|
||||
workspace: WorkspaceDescriptor,
|
||||
bindings: RuntimeBindings,
|
||||
_options: { writeProbe: boolean },
|
||||
): Promise<WorkspaceDiagnostics> {
|
||||
): Promise<ConnectorDiagnostics> {
|
||||
requireSupportedDescriptor(workspace);
|
||||
const descriptor = validateWorkspaceDescriptor(workspace);
|
||||
return await diagnoseValidatedWorkspace(descriptor, bindings, adapters, timeoutMs, semanticRuntime);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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<Response>;
|
||||
tokenResponse?: (init?: RequestInit) => Response | Promise<Response>;
|
||||
deviceResponse?: (init?: RequestInit) => Response | Promise<Response>;
|
||||
jwksResponse?: (init?: RequestInit) => Response | Promise<Response>;
|
||||
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({
|
||||
|
||||
@@ -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> = {}): 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<string, string> = {},
|
||||
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: {} },
|
||||
|
||||
Reference in New Issue
Block a user