feat(auth): include authentication in workspace and tht diagnostics
This commit is contained in:
@@ -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