Guided-auth removal, OAuth smoke testing, and TUI/CLI support (#1531)

* refactor(web): update OAuth E2E tests to use new authentication method

- Replaced `runGuidedAuth` with `authenticate` in the InspectorClient OAuth end-to-end tests to support quick authentication with CIMD pre-registration.
- Updated comments to reflect the change in authentication method and its support for HTTP test metadata URLs.
- Ensured that the tests continue to validate the expected authorization URL after the change.

Additionally, imported `ensureCimdClientRegistration` in the OAuthManager to streamline client registration during the authentication process.

* Guided-auth removal, OAuth smoke testing, and TUI/CLI support

* Added CLI OAuth runner flags tests

* Added clarifying comment on port 0 binding

* README notes on TUI oauth callback port, prettier fixes for CLI tests

* CIMD validation uses inline like EMA IdP

* Fixed TUI test failures from merge

* test: bring 5 OAuth files over the 90 per-file coverage gate

The guided-auth-removal work added/touched several files that fell below
the repo's >=90 per-file coverage gate (npm run coverage). CI doesn't run
the gate (it's local-only), so validate + test:integration passed while
these gaps slipped through. Bring them all back over the line:

- core/auth/node/runner-oauth-callback.ts: test bad-URL/non-http-scheme
  throws, no-port->80 default, and IPv6 hostname bracketing. Annotate the
  provably-dead guards (empty host, out-of-range port, String(err)
  fallback, pathname "/" fallback) -- all rejected by new URL() upstream --
  with justified v8 ignore comments instead of lowering the gate.
- core/auth/cimd.ts: test the AS-doesn't-support-CIMD early return.
- core/react/useClientSettingsDraft.ts: test the functional-updater path,
  the stale-onChange-after-close prev===null guard, and the
  timer-fires-after-close value===null guard.
- ClientSettingsForm.tsx: test the CIMD checkbox toggle and CIMD URL clear.
- clients/tui/src/utils/oauthDisplay.ts: test static/dcr registration
  kinds, the expired IdP session, and the configured-scope/empty-scope
  branches of formatScopes.

npm run coverage now passes (exit 0) across web, cli, tui, launcher.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: cliffhall <cliff@futurescale.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bob Dickinson 2026-06-29 12:35:51 -07:00 • committed by GitHub
commit 13dafe457b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
51 changed files with 5702 additions and 4444 deletions

View file

@ -11,7 +11,10 @@ import { createServer, type InlineConfig } from "vite";
import react from "@vitejs/plugin-react";
import type { WebServerConfig } from "./web-server-config.js";
import { honoMiddlewarePlugin } from "./vite-hono-plugin.js";
import { getViteBaseConfig } from "./vite-base-config.js";
import {
clearViteDepsCache,
getViteDevOptimizeDeps,
} from "./vite-base-config.js";
import { vitestSharedPaths } from "../../../vitest.shared.mts";
import type { WebServerHandle } from "./types.js";
@ -25,9 +28,9 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
export async function startViteDevServer(
config: WebServerConfig,
): Promise<WebServerHandle> {
// Canonicalize so Vite's config hash is stable and matches the deps cache.
// Canonicalize paths under clients/web.
const root = resolve(join(__dirname, ".."));
const baseConfig = getViteBaseConfig();
clearViteDepsCache(root);
// `configFile: false` means this in-process server never loads
// `vite.config.ts`, so it must reproduce that file's `resolve` block and
// `server.fs.allow` here. Without them, App.tsx's `@inspector/core/*`
@ -38,7 +41,7 @@ export async function startViteDevServer(
const { repoRoot, sharedAliases, sharedDedupe, nodeModulesAliases } =
vitestSharedPaths(root);
const inlineConfig: InlineConfig = {
...baseConfig,
optimizeDeps: getViteDevOptimizeDeps(),
configFile: false,
root,
resolve: {

View file

@ -8,35 +8,58 @@
* the parts needed by both the CLI runner and `vite dev` belong here.
*/
import { rmSync } from "node:fs";
import { join } from "node:path";
const NODE_ONLY_OPTIMIZE_DEPS_EXCLUDE = [
"@modelcontextprotocol/sdk/client/stdio.js",
// `atomically` is reached only through `core/storage/store-io.ts`,
// which is imported by `core/mcp/remote/node/server.ts` (the Hono
// app). The module never lands in the browser graph; excluding it
// keeps Vite's dev-time scanner from chasing it through the plugin's
// node-only import chain.
"atomically",
// `chokidar` is only loaded inside `core/mcp/remote/node/server.ts`
// when the lazy mcp.json watcher starts. It transitively imports
// `readdirp` and core node fs/os modules; excluding it keeps Vite's
// dep scanner from walking into them during dev startup.
"chokidar",
"cross-spawn",
"which",
// `@napi-rs/keyring` is loaded only inside
// `core/auth/node/secret-store.ts` from the Hono `/api/servers`
// handlers. It's a native-binding package (no browser code path) so
// excluding it keeps Vite's dep scanner from chasing into the
// platform-specific binaries during dev startup.
"@napi-rs/keyring",
] as const;
export function getViteBaseConfig() {
return {
optimizeDeps: {
// Node-only modules that the dev backend (core/mcp/remote/node/*,
// core/mcp/node/*) consumes. Excluding them from Vite's dep-pre-bundling
// step keeps `vite dev` from trying to scan/bundle them into the
// browser graph during startup.
exclude: [
"@modelcontextprotocol/sdk/client/stdio.js",
// `atomically` is reached only through `core/storage/store-io.ts`,
// which is imported by `core/mcp/remote/node/server.ts` (the Hono
// app). The module never lands in the browser graph; excluding it
// keeps Vite's dev-time scanner from chasing it through the plugin's
// node-only import chain.
"atomically",
// `chokidar` is only loaded inside `core/mcp/remote/node/server.ts`
// when the lazy mcp.json watcher starts. It transitively imports
// `readdirp` and core node fs/os modules; excluding it keeps Vite's
// dep scanner from walking into them during dev startup.
"chokidar",
"cross-spawn",
"which",
// `@napi-rs/keyring` is loaded only inside
// `core/auth/node/secret-store.ts` from the Hono `/api/servers`
// handlers. It's a native-binding package (no browser code path) so
// excluding it keeps Vite's dep scanner from chasing into the
// platform-specific binaries during dev startup.
"@napi-rs/keyring",
],
exclude: [...NODE_ONLY_OPTIMIZE_DEPS_EXCLUDE],
},
};
}
/**
* Dev-server optimizeDeps: rebuild from scratch each launch — no stale
* pre-bundles carried across restarts (avoids 504 Outdated Optimize Dep).
* Do not use under Vitest.
*/
export function getViteDevOptimizeDeps() {
return {
exclude: [...NODE_ONLY_OPTIMIZE_DEPS_EXCLUDE],
force: true,
ignoreOutdatedRequests: true,
include: ["ajv", "@modelcontextprotocol/sdk/validation/ajv"],
};
}
/** Remove Vite's pre-bundle cache under clients/web before a dev launch. */
export function clearViteDepsCache(clientWebRoot: string): void {
rmSync(join(clientWebRoot, "node_modules", ".vite"), {
recursive: true,
force: true,
});
}

View file

@ -118,7 +118,16 @@ export function honoMiddlewarePlugin(config: WebServerConfig): Plugin {
};
server.httpServer.once("listening", () => {
setImmediate(logBanner);
setImmediate(async () => {
// Pre-bundle the app entry before opening the browser so the first
// page load does not race Vite's dep optimizer (504 Outdated Optimize Dep).
try {
await server.warmupRequest("/src/main.tsx");
} catch (err) {
console.warn("[vite] entry warmup failed:", err);
}
logBanner();
});
});
const honoMiddleware = async (

View file

@ -57,7 +57,10 @@ import {
serializeMcpConfig,
} from "@inspector/core/mcp/serverList.js";
import type { ClientConfig } from "@inspector/core/client/types.js";
import { getActiveEnterpriseManagedAuthIdp } from "@inspector/core/client/types.js";
import {
getActiveCimdClientMetadataUrl,
getActiveEnterpriseManagedAuthIdp,
} from "@inspector/core/client/types.js";
import { isEmaClientNotConfiguredError } from "@inspector/core/auth/ema/clientConfigError.js";
import {
loadClientConfigRemote,
@ -146,6 +149,7 @@ import {
OAUTH_PENDING_SERVER_KEY,
isUnauthorizedError,
} from "./utils/oauthFlow";
import { clearServerOAuthState } from "./utils/clearServerOAuthState";
// OAuth redirect URL provider — points at the dev backend's `/oauth/callback`
// handler. The InspectorClient only consults this when the active server
@ -1224,6 +1228,12 @@ function App() {
};
}, [connectionStatus, inspectorClient]);
const connectionInfoCanClearOAuth =
connectionStatus === "connected" &&
!!inspectorClient &&
(connectionInfoTransport === "streamable-http" ||
connectionInfoTransport === "sse");
// Derive log entries from the message log. Filters for
// `notifications/message` (the response to `logging/setLevel`).
const logs = useMemo<LogEntryData[]>(
@ -1315,6 +1325,7 @@ function App() {
// from the InspectorClient options we're about to derive from it.
const savedSettings = server.settings;
const activeIdp = getActiveEnterpriseManagedAuthIdp(clientConfig);
const activeCimdUrl = getActiveCimdClientMetadataUrl(clientConfig);
// Flatten the persisted settings into the InspectorClient options shape.
// Empty / zero values stay unset so the SDK defaults apply.
const defaultMetadata = savedSettings?.metadata
@ -1324,7 +1335,7 @@ function App() {
.map((m) => [m.key, m.value]),
)
: undefined;
const oauth =
const oauthFromServer =
savedSettings &&
(savedSettings.oauthClientId ||
savedSettings.oauthClientSecret ||
@ -1345,6 +1356,13 @@ function App() {
}),
}
: undefined;
const oauth =
oauthFromServer || activeCimdUrl
? {
...(oauthFromServer ?? {}),
...(activeCimdUrl && { clientMetadataUrl: activeCimdUrl }),
}
: undefined;
const client = new InspectorClient(server.config, {
environment,
// The Tasks tab needs the receiver-task pipeline; the
@ -1450,7 +1468,7 @@ function App() {
oauthCallbackHandledRef.current = true;
const params = parseOAuthCallbackParams(window.location.search);
// The OAuth `state` round-trips `{execution}:{authId}`; the authId is the
// The OAuth `state` round-trips the auth session id; the authId is the
// session key the pre-redirect page saved the fetch log under, so the
// rebuilt client can restore those `auth` entries. Read it before the
// URL is cleared below.
@ -2390,6 +2408,42 @@ function App() {
// target isn't resolvable.
const settingsModalIsStdio = settingsModalServerType === "stdio";
const clearServerOAuthAndDisconnect = useCallback(
async (server: { id: string; name: string; config: MCPServerConfig }) => {
const isActive = server.id === activeServerId;
const cleared = clearServerOAuthState({
config: server.config,
inspectorClient: isActive ? inspectorClient : null,
isActiveConnection: isActive,
});
if (!cleared) return;
if (isActive && inspectorClient) {
await inspectorClient.disconnect();
setConnectionInfoOAuthWhenConnected(undefined);
}
notifications.show({
title: "OAuth state cleared",
message: isActive
? "Stored tokens and client registration were removed. Reconnect to run a fresh authorization flow."
: `Stored OAuth state was removed for "${server.name}". Connect to authorize again.`,
color: "blue",
});
},
[activeServerId, inspectorClient],
);
const handleClearConnectionOAuth = useCallback(() => {
if (!activeServer) return;
void clearServerOAuthAndDisconnect(activeServer);
}, [activeServer, clearServerOAuthAndDisconnect]);
const handleClearStoredOAuthFromSettings = useCallback(() => {
if (!settingsModalTarget) return;
void clearServerOAuthAndDisconnect(settingsModalTarget);
}, [settingsModalTarget, clearServerOAuthAndDisconnect]);
const onSettingsModalClose = useCallback(() => {
flushSettingsDraft();
// Apply root edits to the live client once, on close — not on every
@ -2689,6 +2743,9 @@ function App() {
isStdio={settingsModalIsStdio}
onClose={onSettingsModalClose}
onSettingsChange={onSettingsChange}
onClearStoredOAuth={
settingsModalIsStdio ? undefined : handleClearStoredOAuthFromSettings
}
/>
<ClientSettingsModal
key={
@ -2709,6 +2766,9 @@ function App() {
clientCapabilities={clientCapabilities}
transport={connectionInfoTransport}
oauth={connectionInfoOAuth}
onClearOAuth={
connectionInfoCanClearOAuth ? handleClearConnectionOAuth : undefined
}
/>
)}
<ServerRemoveConfirmModal

View file

@ -6,9 +6,14 @@ import {
export interface CopyButtonProps {
value: string;
/**
* Drop ActionIcon padding/height so the glyph top-aligns in tight aside
* rows (e.g. beside a Code block). Icon size is unchanged.
*/
flush?: boolean;
}
export function CopyButton({ value }: CopyButtonProps) {
export function CopyButton({ value, flush = false }: CopyButtonProps) {
return (
<MantineCopyButton value={value}>
{({ copied, copy }) => (
@ -19,6 +24,7 @@ export function CopyButton({ value }: CopyButtonProps) {
onClick={copy}
fz={24}
aria-label={copied ? "Copied" : "Copy"}
{...(flush && { p: 0, h: "auto", w: "auto" })}
>
{copied ? "\u2713" : "\u2398"}
</ActionIcon>

View file

@ -3,23 +3,38 @@ import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { ClientSettingsForm } from "./ClientSettingsForm";
import {
CIMD_METADATA_URL_HTTPS_ERROR,
CIMD_METADATA_URL_INVALID_ERROR,
} from "@inspector/core/client/config-parse.js";
import {
EMPTY_CLIENT_SETTINGS,
ISSUER_URL_ERROR,
type ClientSettingsFormValues,
} from "./clientSettingsValues";
function resolveSettingsChange(
call: unknown,
prev: ClientSettingsFormValues,
): ClientSettingsFormValues {
return typeof call === "function"
? (call as (p: ClientSettingsFormValues) => ClientSettingsFormValues)(prev)
: (call as ClientSettingsFormValues);
}
/** Stateful host so the controlled issuer input reflects edits, like the app. */
function ClientSettingsFormHarness({
initial,
expandedSections = ["ema"],
}: {
initial: ClientSettingsFormValues;
expandedSections?: ClientSettingsSection[];
}) {
const [settings, setSettings] = useState(initial);
return (
<ClientSettingsForm
settings={settings}
expandedSections={["ema"]}
expandedSections={expandedSections}
onExpandedSectionsChange={vi.fn()}
onSettingsChange={setSettings}
emaIdpLoginState="none"
@ -27,6 +42,8 @@ function ClientSettingsFormHarness({
);
}
type ClientSettingsSection = "ema" | "cimd";
describe("ClientSettingsForm EMA IdP session", () => {
it("shows signed-in state and sign out", async () => {
const user = userEvent.setup();
@ -180,6 +197,102 @@ describe("ClientSettingsForm EMA IdP session", () => {
expect(screen.queryByText(ISSUER_URL_ERROR)).not.toBeInTheDocument();
});
it("defers the invalid CIMD URL error until the field is blurred", async () => {
const user = userEvent.setup();
renderWithMantine(
<ClientSettingsForm
settings={{
...EMPTY_CLIENT_SETTINGS,
cimdEnabled: true,
clientMetadataUrl: "not-a-url",
}}
expandedSections={["cimd"]}
onExpandedSectionsChange={vi.fn()}
onSettingsChange={vi.fn()}
emaIdpLoginState="none"
/>,
);
expect(
screen.queryByText(CIMD_METADATA_URL_INVALID_ERROR),
).not.toBeInTheDocument();
await user.click(screen.getByLabelText("Client ID metadata document URL"));
await user.tab();
expect(
screen.getByText(CIMD_METADATA_URL_INVALID_ERROR),
).toBeInTheDocument();
});
it("clears the CIMD URL error live once a valid URL is entered after blur", async () => {
const user = userEvent.setup();
renderWithMantine(
<ClientSettingsFormHarness
initial={{
...EMPTY_CLIENT_SETTINGS,
cimdEnabled: true,
clientMetadataUrl: "not-a-url",
}}
expandedSections={["cimd"]}
/>,
);
const url = screen.getByLabelText("Client ID metadata document URL");
await user.click(url);
await user.tab();
expect(
screen.getByText(CIMD_METADATA_URL_INVALID_ERROR),
).toBeInTheDocument();
await user.clear(url);
await user.type(url, "https://example.com/cimd.json");
expect(
screen.queryByText(CIMD_METADATA_URL_INVALID_ERROR),
).not.toBeInTheDocument();
});
it("reveals the CIMD URL error without blur when revealClientMetadataUrlError is set", () => {
renderWithMantine(
<ClientSettingsForm
settings={{
...EMPTY_CLIENT_SETTINGS,
cimdEnabled: true,
clientMetadataUrl: "http://example.com/cimd.json",
}}
expandedSections={["cimd"]}
onExpandedSectionsChange={vi.fn()}
onSettingsChange={vi.fn()}
emaIdpLoginState="none"
revealClientMetadataUrlError
/>,
);
expect(screen.getByText(CIMD_METADATA_URL_HTTPS_ERROR)).toBeInTheDocument();
});
it("shows no CIMD URL error for a valid URL", () => {
renderWithMantine(
<ClientSettingsForm
settings={{
...EMPTY_CLIENT_SETTINGS,
cimdEnabled: true,
clientMetadataUrl: "https://example.com/cimd.json",
}}
expandedSections={["cimd"]}
onExpandedSectionsChange={vi.fn()}
onSettingsChange={vi.fn()}
emaIdpLoginState="none"
/>,
);
expect(
screen.queryByText(CIMD_METADATA_URL_INVALID_ERROR),
).not.toBeInTheDocument();
expect(
screen.queryByText(CIMD_METADATA_URL_HTTPS_ERROR),
).not.toBeInTheDocument();
});
it("shows expired state with sign out", () => {
renderWithMantine(
<ClientSettingsForm
@ -241,18 +354,79 @@ describe("ClientSettingsForm interactions", () => {
name: "Enable enterprise IdP configuration",
}),
);
expect(onSettingsChange).toHaveBeenCalledWith({
expect(onSettingsChange).toHaveBeenCalledWith(expect.any(Function));
expect(
resolveSettingsChange(
onSettingsChange.mock.calls[0]![0],
EMPTY_CLIENT_SETTINGS,
),
).toEqual({
...EMPTY_CLIENT_SETTINGS,
emaEnabled: true,
});
});
it("edits the IdP text fields via onSettingsChange", async () => {
it("toggles the CIMD enable checkbox via onSettingsChange", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
renderWithMantine(
<ClientSettingsForm
settings={{ ...EMPTY_CLIENT_SETTINGS, emaEnabled: true }}
settings={EMPTY_CLIENT_SETTINGS}
expandedSections={["cimd"]}
onExpandedSectionsChange={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.click(
screen.getByRole("checkbox", {
name: "Use Client ID Metadata Document",
}),
);
expect(onSettingsChange).toHaveBeenCalledWith(expect.any(Function));
expect(
resolveSettingsChange(
onSettingsChange.mock.calls[0]![0],
EMPTY_CLIENT_SETTINGS,
),
).toEqual({
...EMPTY_CLIENT_SETTINGS,
cimdEnabled: true,
});
});
it("clears the CIMD metadata URL via its clear button", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
const filled = {
...EMPTY_CLIENT_SETTINGS,
cimdEnabled: true,
clientMetadataUrl: "https://example.com/cimd.json",
};
renderWithMantine(
<ClientSettingsForm
settings={filled}
expandedSections={["cimd"]}
onExpandedSectionsChange={vi.fn()}
onSettingsChange={onSettingsChange}
/>,
);
await user.click(screen.getByRole("button", { name: "Clear" }));
expect(onSettingsChange).toHaveBeenCalledWith(expect.any(Function));
expect(
resolveSettingsChange(onSettingsChange.mock.calls[0]![0], filled)
.clientMetadataUrl,
).toBe("");
});
it("edits the IdP text fields via onSettingsChange", async () => {
const user = userEvent.setup();
const onSettingsChange = vi.fn();
const initial = { ...EMPTY_CLIENT_SETTINGS, emaEnabled: true };
renderWithMantine(
<ClientSettingsForm
settings={initial}
expandedSections={["ema"]}
onExpandedSectionsChange={vi.fn()}
onSettingsChange={onSettingsChange}
@ -260,19 +434,23 @@ describe("ClientSettingsForm interactions", () => {
);
await user.type(screen.getByLabelText("Client ID"), "x");
expect(onSettingsChange).toHaveBeenLastCalledWith({
...EMPTY_CLIENT_SETTINGS,
emaEnabled: true,
clientId: "x",
});
expect(onSettingsChange).toHaveBeenLastCalledWith(expect.any(Function));
expect(
resolveSettingsChange(
onSettingsChange.mock.calls[onSettingsChange.mock.calls.length - 1]![0],
initial,
).clientId,
).toBe("x");
onSettingsChange.mockClear();
await user.type(screen.getByLabelText("Client Secret"), "s");
expect(onSettingsChange).toHaveBeenLastCalledWith({
...EMPTY_CLIENT_SETTINGS,
emaEnabled: true,
clientSecret: "s",
});
expect(onSettingsChange).toHaveBeenLastCalledWith(expect.any(Function));
expect(
resolveSettingsChange(
onSettingsChange.mock.calls[onSettingsChange.mock.calls.length - 1]![0],
{ ...initial, clientId: "x" },
).clientSecret,
).toBe("s");
});
it("clears each populated IdP field via its clear button", async () => {
@ -300,20 +478,28 @@ describe("ClientSettingsForm interactions", () => {
// Issuer clear -> patch({ issuer: "" })
await user.click(clearButtons[0]);
expect(onSettingsChange).toHaveBeenCalledWith({ ...filled, issuer: "" });
expect(onSettingsChange).toHaveBeenCalledWith(expect.any(Function));
expect(
resolveSettingsChange(onSettingsChange.mock.calls[0]![0], filled).issuer,
).toBe("");
// Client ID clear -> patch({ clientId: "" })
onSettingsChange.mockClear();
await user.click(clearButtons[1]);
expect(onSettingsChange).toHaveBeenCalledWith({ ...filled, clientId: "" });
expect(onSettingsChange).toHaveBeenCalledWith(expect.any(Function));
expect(
resolveSettingsChange(onSettingsChange.mock.calls[0]![0], filled)
.clientId,
).toBe("");
// Client Secret clear -> patch({ clientSecret: "" })
onSettingsChange.mockClear();
await user.click(clearButtons[2]);
expect(onSettingsChange).toHaveBeenCalledWith({
...filled,
clientSecret: "",
});
expect(onSettingsChange).toHaveBeenCalledWith(expect.any(Function));
expect(
resolveSettingsChange(onSettingsChange.mock.calls[0]![0], filled)
.clientSecret,
).toBe("");
});
it("omits clear buttons when IdP fields are empty", () => {

View file

@ -16,13 +16,17 @@ import {
type ClientSettingsFormValues,
} from "./clientSettingsValues.js";
export type ClientSettingsSection = "ema";
export type ClientSettingsSection = "ema" | "cimd";
export interface ClientSettingsFormProps {
settings: ClientSettingsFormValues;
expandedSections: ClientSettingsSection[];
onExpandedSectionsChange: (sections: ClientSettingsSection[]) => void;
onSettingsChange: (settings: ClientSettingsFormValues) => void;
onSettingsChange: (
settings:
| ClientSettingsFormValues
| ((prev: ClientSettingsFormValues) => ClientSettingsFormValues),
) => void;
emaIdpLoginState?: EmaIdpLoginState;
onEmaIdpLogout?: () => void;
/**
@ -31,6 +35,11 @@ export interface ClientSettingsFormProps {
* isn't left with a silently-dropped value and no explanation.
*/
revealIssuerError?: boolean;
/**
* Force the CIMD metadata URL error to show even before the field is blurred.
* Same contract as {@link revealIssuerError} for the CIMD URL field.
*/
revealClientMetadataUrlError?: boolean;
}
const HintText = Text.withProps({
@ -46,9 +55,10 @@ export function ClientSettingsForm({
emaIdpLoginState = "none",
onEmaIdpLogout,
revealIssuerError = false,
revealClientMetadataUrlError = false,
}: ClientSettingsFormProps) {
function patch(partial: Partial<ClientSettingsFormValues>) {
onSettingsChange({ ...settings, ...partial });
onSettingsChange((prev) => ({ ...prev, ...partial }));
}
// Defer the issuer error until the field has been blurred so it doesn't nag
@ -59,12 +69,19 @@ export function ClientSettingsForm({
// without explanation. The persist gate (canPersistClientSettingsDraft)
// validates independently — an invalid issuer is never written regardless.
const [issuerTouched, setIssuerTouched] = useState(false);
const [clientMetadataUrlTouched, setClientMetadataUrlTouched] =
useState(false);
const errors = validateClientSettings(settings);
const showIssuerError =
(issuerTouched || revealIssuerError) && errors.issuer
? errors.issuer
: undefined;
const showClientMetadataUrlError =
(clientMetadataUrlTouched || revealClientMetadataUrlError) &&
errors.clientMetadataUrl
? errors.clientMetadataUrl
: undefined;
const showIdpSession =
settings.emaEnabled &&
@ -197,6 +214,48 @@ export function ClientSettingsForm({
</Stack>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="cimd">
<Accordion.Control>OAuth Client ID Metadata Document</Accordion.Control>
<Accordion.Panel>
<Stack gap="md">
<Checkbox
label="Use Client ID Metadata Document"
description="When the authorization server supports CIMD, Inspector uses this metadata document URL as the client id. The server fetches and verifies the document during OAuth."
checked={settings.cimdEnabled}
onChange={(e) => patch({ cimdEnabled: e.currentTarget.checked })}
/>
{settings.cimdEnabled && (
<>
<HintText>
The metadata document must be served over HTTPS and list this
redirect URI:{" "}
{typeof window !== "undefined"
? `${window.location.origin}/oauth/callback`
: "http://localhost:6274/oauth/callback"}
</HintText>
<TextInput
label="Client ID metadata document URL"
description="Public HTTPS URL of your OAuth client metadata JSON document."
value={settings.clientMetadataUrl}
onChange={(e) =>
patch({ clientMetadataUrl: e.currentTarget.value })
}
onBlur={() => setClientMetadataUrlTouched(true)}
error={showClientMetadataUrlError}
rightSectionPointerEvents="auto"
rightSection={
settings.clientMetadataUrl ? (
<ClearButton
onClick={() => patch({ clientMetadataUrl: "" })}
/>
) : null
}
/>
</>
)}
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
);
}

View file

@ -1,4 +1,9 @@
import { describe, it, expect } from "vitest";
import {
CIMD_METADATA_URL_HTTPS_ERROR,
CIMD_METADATA_URL_INVALID_ERROR,
CIMD_METADATA_URL_PATH_ERROR,
} from "@inspector/core/client/config-parse.js";
import {
canPersistClientSettingsDraft,
clientConfigToFormValues,
@ -8,6 +13,8 @@ import {
validateClientSettings,
} from "./clientSettingsValues";
const emptyCimd = { cimdEnabled: false, clientMetadataUrl: "" };
describe("clientSettingsValues", () => {
it("clientConfigToFormValues returns empty settings when there is no enterpriseManagedAuth", () => {
// Exercises the `!idp` early return (line 24) via an absent EMA block.
@ -30,6 +37,7 @@ describe("clientSettingsValues", () => {
issuer: "https://idp.test",
clientId: "cid",
clientSecret: "sec",
...emptyCimd,
});
});
@ -50,6 +58,60 @@ describe("clientSettingsValues", () => {
issuer: "https://idp.test",
clientId: "cid",
clientSecret: "sec",
...emptyCimd,
});
});
it("clientConfigToFormValues maps cimd when EMA is absent", () => {
expect(
clientConfigToFormValues({
cimd: {
enabled: true,
clientMetadataUrl: "https://example.com/cimd.json",
},
}),
).toEqual({
emaEnabled: false,
issuer: "",
clientId: "",
clientSecret: "",
cimdEnabled: true,
clientMetadataUrl: "https://example.com/cimd.json",
});
});
it("clientConfigToFormValues preserves CIMD URL when disabled", () => {
expect(
clientConfigToFormValues({
cimd: {
enabled: false,
clientMetadataUrl: "https://example.com/cimd.json",
},
}),
).toEqual({
emaEnabled: false,
issuer: "",
clientId: "",
clientSecret: "",
cimdEnabled: false,
clientMetadataUrl: "https://example.com/cimd.json",
});
});
it("formValuesToClientConfig always writes the cimd block from the dialog", () => {
expect(
formValuesToClientConfig({
emaEnabled: false,
issuer: "",
clientId: "",
clientSecret: "",
...emptyCimd,
}),
).toEqual({
cimd: {
enabled: false,
clientMetadataUrl: "",
},
});
});
@ -60,6 +122,7 @@ describe("clientSettingsValues", () => {
issuer: "https://idp.test",
clientId: "cid",
clientSecret: "sec",
...emptyCimd,
}),
).toEqual({
enterpriseManagedAuth: {
@ -70,18 +133,55 @@ describe("clientSettingsValues", () => {
clientSecret: "sec",
},
},
cimd: {
enabled: false,
clientMetadataUrl: "",
},
});
});
it("formValuesToClientConfig omits block when disabled with no stored fields", () => {
it("formValuesToClientConfig keeps CIMD URL when disabled", () => {
expect(
formValuesToClientConfig({
emaEnabled: false,
issuer: "",
clientId: "",
clientSecret: "",
cimdEnabled: false,
clientMetadataUrl: "https://example.com/cimd.json",
}),
).toEqual({});
).toEqual({
cimd: {
enabled: false,
clientMetadataUrl: "https://example.com/cimd.json",
},
});
});
it("formValuesToClientConfig serializes EMA and CIMD together", () => {
expect(
formValuesToClientConfig({
emaEnabled: true,
issuer: "https://idp.test",
clientId: "cid",
clientSecret: "",
cimdEnabled: true,
clientMetadataUrl: "https://example.com/cimd.json",
}),
).toEqual({
enterpriseManagedAuth: {
enabled: true,
idp: {
issuer: "https://idp.test",
clientId: "cid",
clientSecret: "",
},
},
cimd: {
enabled: true,
clientMetadataUrl: "https://example.com/cimd.json",
},
});
});
it("formValuesToClientConfig trims issuer and clientId when enabled", () => {
@ -91,6 +191,7 @@ describe("clientSettingsValues", () => {
issuer: " https://idp.test ",
clientId: " cid ",
clientSecret: "sec",
...emptyCimd,
}),
).toEqual({
enterpriseManagedAuth: {
@ -101,6 +202,28 @@ describe("clientSettingsValues", () => {
clientSecret: "sec",
},
},
cimd: {
enabled: false,
clientMetadataUrl: "",
},
});
});
it("formValuesToClientConfig trims CIMD URL", () => {
expect(
formValuesToClientConfig({
emaEnabled: false,
issuer: "",
clientId: "",
clientSecret: "",
cimdEnabled: true,
clientMetadataUrl: " https://example.com/cimd.json ",
}),
).toEqual({
cimd: {
enabled: true,
clientMetadataUrl: "https://example.com/cimd.json",
},
});
});
@ -111,6 +234,7 @@ describe("clientSettingsValues", () => {
issuer: "",
clientId: "",
clientSecret: "",
...emptyCimd,
}),
).toBe(true);
expect(
@ -119,6 +243,7 @@ describe("clientSettingsValues", () => {
issuer: "https://idp.test",
clientId: "cid",
clientSecret: "",
...emptyCimd,
}),
).toBe(true);
expect(
@ -127,10 +252,47 @@ describe("clientSettingsValues", () => {
issuer: "",
clientId: "cid",
clientSecret: "",
...emptyCimd,
}),
).toBe(false);
});
it("canPersistClientSettingsDraft requires CIMD URL when enabled", () => {
expect(
canPersistClientSettingsDraft({
emaEnabled: false,
issuer: "",
clientId: "",
clientSecret: "",
cimdEnabled: true,
clientMetadataUrl: "",
}),
).toBe(false);
expect(
canPersistClientSettingsDraft({
emaEnabled: false,
issuer: "",
clientId: "",
clientSecret: "",
cimdEnabled: true,
clientMetadataUrl: "https://example.com/cimd.json",
}),
).toBe(true);
});
it("canPersistClientSettingsDraft allows disabling CIMD while keeping the URL", () => {
expect(
canPersistClientSettingsDraft({
emaEnabled: false,
issuer: "",
clientId: "",
clientSecret: "",
cimdEnabled: false,
clientMetadataUrl: "https://example.com/cimd.json",
}),
).toBe(true);
});
it("canPersistClientSettingsDraft blocks an invalid issuer URL", () => {
expect(
canPersistClientSettingsDraft({
@ -138,6 +300,7 @@ describe("clientSettingsValues", () => {
issuer: "not-a-url",
clientId: "cid",
clientSecret: "",
...emptyCimd,
}),
).toBe(false);
});
@ -149,6 +312,7 @@ describe("clientSettingsValues", () => {
issuer: "not-a-url",
clientId: "cid",
clientSecret: "",
...emptyCimd,
}),
).toEqual({ issuer: ISSUER_URL_ERROR });
});
@ -160,6 +324,7 @@ describe("clientSettingsValues", () => {
issuer: "https://idp.test",
clientId: "cid",
clientSecret: "",
...emptyCimd,
}),
).toEqual({});
});
@ -171,6 +336,7 @@ describe("clientSettingsValues", () => {
issuer: "",
clientId: "cid",
clientSecret: "",
...emptyCimd,
}),
).toEqual({});
});
@ -182,6 +348,112 @@ describe("clientSettingsValues", () => {
issuer: "not-a-url",
clientId: "cid",
clientSecret: "",
...emptyCimd,
}),
).toEqual({});
});
it("canPersistClientSettingsDraft blocks an invalid CIMD URL", () => {
expect(
canPersistClientSettingsDraft({
emaEnabled: false,
issuer: "",
clientId: "",
clientSecret: "",
cimdEnabled: true,
clientMetadataUrl: "not-a-url",
}),
).toBe(false);
expect(
canPersistClientSettingsDraft({
emaEnabled: false,
issuer: "",
clientId: "",
clientSecret: "",
cimdEnabled: true,
clientMetadataUrl: "http://example.com/cimd.json",
}),
).toBe(false);
expect(
canPersistClientSettingsDraft({
emaEnabled: false,
issuer: "",
clientId: "",
clientSecret: "",
cimdEnabled: true,
clientMetadataUrl: "https://example.com/",
}),
).toBe(false);
});
it("validateClientSettings flags an invalid CIMD URL", () => {
expect(
validateClientSettings({
emaEnabled: false,
issuer: "",
clientId: "",
clientSecret: "",
cimdEnabled: true,
clientMetadataUrl: "not-a-url",
}),
).toEqual({ clientMetadataUrl: CIMD_METADATA_URL_INVALID_ERROR });
expect(
validateClientSettings({
emaEnabled: false,
issuer: "",
clientId: "",
clientSecret: "",
cimdEnabled: true,
clientMetadataUrl: "http://example.com/cimd.json",
}),
).toEqual({ clientMetadataUrl: CIMD_METADATA_URL_HTTPS_ERROR });
expect(
validateClientSettings({
emaEnabled: false,
issuer: "",
clientId: "",
clientSecret: "",
cimdEnabled: true,
clientMetadataUrl: "https://example.com/",
}),
).toEqual({ clientMetadataUrl: CIMD_METADATA_URL_PATH_ERROR });
});
it("validateClientSettings passes a valid CIMD URL", () => {
expect(
validateClientSettings({
emaEnabled: false,
issuer: "",
clientId: "",
clientSecret: "",
cimdEnabled: true,
clientMetadataUrl: "https://example.com/cimd.json",
}),
).toEqual({});
});
it("validateClientSettings ignores an empty CIMD URL", () => {
expect(
validateClientSettings({
emaEnabled: false,
issuer: "",
clientId: "",
clientSecret: "",
cimdEnabled: true,
clientMetadataUrl: "",
}),
).toEqual({});
});
it("validateClientSettings ignores CIMD URL when CIMD disabled", () => {
expect(
validateClientSettings({
emaEnabled: false,
issuer: "",
clientId: "",
clientSecret: "",
cimdEnabled: false,
clientMetadataUrl: "not-a-url",
}),
).toEqual({});
});

View file

@ -1,5 +1,8 @@
import type { ClientConfig } from "@inspector/core/client/types.js";
import { isAbsoluteHttpUrl } from "@inspector/core/client/config-parse.js";
import {
getCimdClientMetadataUrlError,
isAbsoluteHttpUrl,
} from "@inspector/core/client/config-parse.js";
/** Field-level error message for an issuer that is not an http(s) URL. */
export const ISSUER_URL_ERROR =
@ -8,6 +11,7 @@ export const ISSUER_URL_ERROR =
/** Field-level validation errors for the client settings form. */
export interface ClientSettingsErrors {
issuer?: string;
clientMetadataUrl?: string;
}
/**
@ -26,6 +30,12 @@ export function validateClientSettings(
) {
errors.issuer = ISSUER_URL_ERROR;
}
if (values.cimdEnabled && values.clientMetadataUrl.trim() !== "") {
const cimdError = getCimdClientMetadataUrlError(values.clientMetadataUrl);
if (cimdError) {
errors.clientMetadataUrl = cimdError;
}
}
return errors;
}
@ -35,6 +45,8 @@ export interface ClientSettingsFormValues {
issuer: string;
clientId: string;
clientSecret: string;
cimdEnabled: boolean;
clientMetadataUrl: string;
}
export const EMPTY_CLIENT_SETTINGS: ClientSettingsFormValues = {
@ -42,6 +54,8 @@ export const EMPTY_CLIENT_SETTINGS: ClientSettingsFormValues = {
issuer: "",
clientId: "",
clientSecret: "",
cimdEnabled: false,
clientMetadataUrl: "",
};
export function clientConfigToFormValues(
@ -49,14 +63,15 @@ export function clientConfigToFormValues(
): ClientSettingsFormValues {
const ema = config.enterpriseManagedAuth;
const idp = ema?.idp;
if (!idp) {
return { ...EMPTY_CLIENT_SETTINGS };
}
const cimd = config.cimd;
return {
emaEnabled: ema.enabled !== false,
issuer: idp.issuer,
clientId: idp.clientId,
clientSecret: idp.clientSecret ?? "",
emaEnabled: idp ? ema!.enabled !== false : false,
issuer: idp?.issuer ?? "",
clientId: idp?.clientId ?? "",
clientSecret: idp?.clientSecret ?? "",
cimdEnabled: cimd?.enabled === true,
clientMetadataUrl: cimd?.clientMetadataUrl ?? "",
};
}
@ -68,36 +83,43 @@ function hasStoredIdpFields(values: ClientSettingsFormValues): boolean {
);
}
/** Serialize the full dialog state. POST replaces client.json wholesale. */
export function formValuesToClientConfig(
values: ClientSettingsFormValues,
): ClientConfig {
if (!hasStoredIdpFields(values)) {
return {};
}
const idp = {
issuer: values.issuer.trim(),
clientId: values.clientId.trim(),
clientSecret: values.clientSecret,
};
return {
enterpriseManagedAuth: {
enabled: values.emaEnabled,
idp,
const result: ClientConfig = {
cimd: {
enabled: values.cimdEnabled,
clientMetadataUrl: values.clientMetadataUrl.trim(),
},
};
if (hasStoredIdpFields(values) || values.emaEnabled) {
result.enterpriseManagedAuth = {
enabled: values.emaEnabled,
idp: {
issuer: values.issuer.trim(),
clientId: values.clientId.trim(),
clientSecret: values.clientSecret,
},
};
}
return result;
}
/** Skip debounced persist while EMA is enabled but required IdP fields are blank. */
/** Skip debounced persist while required fields are blank for enabled features. */
export function canPersistClientSettingsDraft(
values: ClientSettingsFormValues,
): boolean {
if (!values.emaEnabled) return true;
if (values.issuer.trim() === "" || values.clientId.trim() === "")
return false;
// Defer to validateClientSettings so the persist gate and the inline field
// errors can never drift: an invalid issuer is never sent to the backend, and
// the field error guides the user instead of a raw validation failure toast.
if (values.emaEnabled) {
if (values.issuer.trim() === "" || values.clientId.trim() === "")
return false;
}
if (values.cimdEnabled) {
if (!values.clientMetadataUrl.trim()) return false;
}
// Defer to validateClientSettings so the persist gate and inline field errors
// can never drift — invalid values are never sent to the backend.
return Object.keys(validateClientSettings(values)).length === 0;
}

View file

@ -13,6 +13,8 @@ const configuredSettings: ClientSettingsFormValues = {
issuer: "https://idp.example.com",
clientId: "inspector-idp-client",
clientSecret: "super-secret-idp-value",
cimdEnabled: true,
clientMetadataUrl: "https://example.com/oauth/client.json",
};
function InteractiveRender(args: ClientSettingsModalProps) {
@ -25,7 +27,11 @@ function InteractiveRender(args: ClientSettingsModalProps) {
{...args}
onSettingsChange={(settings) => {
args.onSettingsChange(settings);
updateArgs({ settings });
const next =
typeof settings === "function"
? settings(args.settings)
: settings;
updateArgs({ settings: next });
}}
/>
</AppShell.Main>
@ -61,6 +67,8 @@ export const Empty: Story = {
issuer: "",
clientId: "",
clientSecret: "",
cimdEnabled: false,
clientMetadataUrl: "",
},
},
};
@ -72,6 +80,8 @@ export const EnabledEmptyFields: Story = {
issuer: "",
clientId: "",
clientSecret: "",
cimdEnabled: false,
clientMetadataUrl: "",
},
},
};

View file

@ -6,7 +6,24 @@ import { ClientSettingsModal } from "./ClientSettingsModal";
import {
EMPTY_CLIENT_SETTINGS,
ISSUER_URL_ERROR,
type ClientSettingsFormValues,
} from "../ClientSettingsForm/clientSettingsValues";
import { CIMD_METADATA_URL_INVALID_ERROR } from "@inspector/core/client/config-parse.js";
function resolveSettingsChange(
call: unknown,
prev: ClientSettingsFormValues,
): ClientSettingsFormValues {
return typeof call === "function"
? (call as (p: ClientSettingsFormValues) => ClientSettingsFormValues)(prev)
: (call as ClientSettingsFormValues);
}
async function expandCimdSection(user: ReturnType<typeof userEvent.setup>) {
await user.click(
screen.getByRole("button", { name: /OAuth Client ID Metadata Document/i }),
);
}
describe("ClientSettingsModal", () => {
it("renders the title when opened", () => {
@ -54,7 +71,13 @@ describe("ClientSettingsModal", () => {
name: "Enable enterprise IdP configuration",
}),
);
expect(onSettingsChange).toHaveBeenCalledWith({
expect(onSettingsChange).toHaveBeenCalledWith(expect.any(Function));
expect(
resolveSettingsChange(
onSettingsChange.mock.calls[0]![0],
EMPTY_CLIENT_SETTINGS,
),
).toEqual({
...EMPTY_CLIENT_SETTINGS,
emaEnabled: true,
});
@ -85,11 +108,12 @@ describe("ClientSettingsModal", () => {
onSettingsChange={onSettingsChange}
/>,
);
const initial = { ...EMPTY_CLIENT_SETTINGS, emaEnabled: true };
await user.type(screen.getByLabelText("Issuer"), "h");
expect(onSettingsChange).toHaveBeenCalled();
const call =
onSettingsChange.mock.calls[onSettingsChange.mock.calls.length - 1][0];
expect(call.issuer).toBe("h");
onSettingsChange.mock.calls[onSettingsChange.mock.calls.length - 1]![0];
expect(resolveSettingsChange(call, initial).issuer).toBe("h");
});
it("collapses then re-expands all sections via the list toggle", async () => {
@ -103,21 +127,23 @@ describe("ClientSettingsModal", () => {
/>,
);
// Starts fully expanded (all sections open), so the toggle offers "Collapse all".
const collapse = screen.getByRole("button", { name: "Collapse all" });
expect(collapse).toBeInTheDocument();
// The EMA section panel content is visible while expanded.
// Default: EMA open, CIMD collapsed — toggle offers "Expand all".
const expand = screen.getByRole("button", { name: "Expand all" });
expect(expand).toBeInTheDocument();
expect(
screen.getByText("Enable enterprise IdP configuration"),
).toBeInTheDocument();
// Collapse all -> handleToggleAll sets expandedSections to [].
await user.click(collapse);
await user.click(expand);
expect(
screen.getByRole("button", { name: "Collapse all" }),
).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Collapse all" }));
expect(
screen.getByRole("button", { name: "Expand all" }),
).toBeInTheDocument();
// Expand all -> handleToggleAll restores ALL_SECTIONS.
await user.click(screen.getByRole("button", { name: "Expand all" }));
expect(
screen.getByRole("button", { name: "Collapse all" }),
@ -212,6 +238,100 @@ describe("ClientSettingsModal", () => {
expect(onClose).toHaveBeenCalledTimes(1);
});
it("blocks close and reveals the CIMD URL error when the URL is invalid", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
renderWithMantine(
<ClientSettingsModal
opened
settings={{
...EMPTY_CLIENT_SETTINGS,
cimdEnabled: true,
clientMetadataUrl: "not-a-url",
}}
onClose={onClose}
onSettingsChange={vi.fn()}
/>,
);
expect(
screen.queryByText(CIMD_METADATA_URL_INVALID_ERROR),
).not.toBeInTheDocument();
await user.click(
document.querySelector("button.mantine-CloseButton-root")!,
);
expect(onClose).not.toHaveBeenCalled();
expect(
screen.getByText(CIMD_METADATA_URL_INVALID_ERROR),
).toBeInTheDocument();
});
it("allows close once an invalid CIMD URL is corrected", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
function Host() {
const [settings, setSettings] = useState({
...EMPTY_CLIENT_SETTINGS,
cimdEnabled: true,
clientMetadataUrl: "not-a-url",
});
return (
<ClientSettingsModal
opened
settings={settings}
onClose={onClose}
onSettingsChange={setSettings}
/>
);
}
renderWithMantine(<Host />);
const close = () =>
user.click(document.querySelector("button.mantine-CloseButton-root")!);
await close();
expect(onClose).not.toHaveBeenCalled();
await expandCimdSection(user);
const url = screen.getByLabelText("Client ID metadata document URL");
await user.clear(url);
await user.type(url, "https://example.com/cimd.json");
expect(
screen.queryByText(CIMD_METADATA_URL_INVALID_ERROR),
).not.toBeInTheDocument();
await close();
expect(onClose).toHaveBeenCalledTimes(1);
});
it("allows close when an invalid CIMD URL is cleared (empty is valid to leave)", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
function Host() {
const [settings, setSettings] = useState({
...EMPTY_CLIENT_SETTINGS,
cimdEnabled: true,
clientMetadataUrl: "not-a-url",
});
return (
<ClientSettingsModal
opened
settings={settings}
onClose={onClose}
onSettingsChange={setSettings}
/>
);
}
renderWithMantine(<Host />);
await expandCimdSection(user);
await user.clear(screen.getByLabelText("Client ID metadata document URL"));
await user.click(
document.querySelector("button.mantine-CloseButton-root")!,
);
expect(onClose).toHaveBeenCalledTimes(1);
});
it("does not render the modal body when closed", () => {
renderWithMantine(
<ClientSettingsModal

View file

@ -11,13 +11,17 @@ import {
type ClientSettingsFormValues,
} from "../ClientSettingsForm/clientSettingsValues.js";
const ALL_SECTIONS: ClientSettingsSection[] = ["ema"];
const ALL_SECTIONS: ClientSettingsSection[] = ["ema", "cimd"];
export interface ClientSettingsModalProps {
opened: boolean;
settings: ClientSettingsFormValues;
onClose: () => void;
onSettingsChange: (settings: ClientSettingsFormValues) => void;
onSettingsChange: (
settings:
| ClientSettingsFormValues
| ((prev: ClientSettingsFormValues) => ClientSettingsFormValues),
) => void;
emaIdpLoginState?: EmaIdpLoginState;
onEmaIdpLogout?: () => void;
}
@ -34,6 +38,8 @@ export function ClientSettingsModal({
ClientSettingsSection[]
>(["ema"]);
const [revealIssuerError, setRevealIssuerError] = useState(false);
const [revealClientMetadataUrlError, setRevealClientMetadataUrlError] =
useState(false);
const allExpanded = expandedSections.length === ALL_SECTIONS.length;
@ -42,14 +48,20 @@ export function ClientSettingsModal({
}
// Closing (X / Esc / overlay) is the implicit "save" for this auto-saving
// modal — the parent flushes the debounced persist on close. If the issuer is
// invalid the persist gate would silently drop it, so instead of closing we
// reveal the issuer error (overriding the form's on-blur gating). The user
// can fix the URL or clear the field — an empty issuer is valid to leave —
// and then close. Resets via the parent's open/close remount key.
// modal — the parent flushes the debounced persist on close. If a validated
// URL field is invalid the persist gate would silently drop it, so instead of
// closing we reveal the field error (overriding the form's on-blur gating).
// The user can fix the URL or clear the field — an empty value is valid to
// leave — and then close. Resets via the parent's open/close remount key.
function handleClose() {
if (validateClientSettings(settings).issuer) {
const errors = validateClientSettings(settings);
if (errors.issuer) {
setRevealIssuerError(true);
}
if (errors.clientMetadataUrl) {
setRevealClientMetadataUrlError(true);
}
if (errors.issuer || errors.clientMetadataUrl) {
return;
}
onClose();
@ -83,6 +95,7 @@ export function ClientSettingsModal({
emaIdpLoginState={emaIdpLoginState}
onEmaIdpLogout={onEmaIdpLogout}
revealIssuerError={revealIssuerError}
revealClientMetadataUrlError={revealClientMetadataUrlError}
/>
</Stack>
</Modal>

View file

@ -1,10 +1,14 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import type {
ClientCapabilities,
InitializeResult,
} from "@modelcontextprotocol/sdk/types.js";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { ConnectionInfoContent } from "./ConnectionInfoContent";
import {
CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL,
ConnectionInfoContent,
} from "./ConnectionInfoContent";
const fullResult: InitializeResult = {
protocolVersion: "2025-03-26",
@ -95,6 +99,25 @@ describe("ConnectionInfoContent", () => {
expect(screen.queryByText("Server Instructions")).not.toBeInTheDocument();
});
it("renders client registration kind when provided", () => {
renderWithMantine(
<ConnectionInfoContent
initializeResult={fullResult}
clientCapabilities={fullClientCaps}
transport="streamable-http"
oauth={{
protocol: "standard",
authorized: true,
clientId:
"https://www.mcpjam.com/.well-known/oauth/client-metadata.json",
clientRegistrationKind: "cimd",
}}
/>,
);
expect(screen.getByText("Client registration")).toBeInTheDocument();
expect(screen.getByText("Client ID Metadata (CIMD)")).toBeInTheDocument();
});
it("renders OAuth details when provided", () => {
renderWithMantine(
<ConnectionInfoContent
@ -214,4 +237,28 @@ describe("ConnectionInfoContent", () => {
);
expect(screen.queryByText("OAuth Details")).not.toBeInTheDocument();
});
it("calls onClearOAuth from the OAuth section", async () => {
const user = userEvent.setup();
const onClearOAuth = vi.fn();
renderWithMantine(
<ConnectionInfoContent
initializeResult={fullResult}
clientCapabilities={fullClientCaps}
transport="streamable-http"
oauth={{
protocol: "standard",
authorized: true,
clientId: "client-abc",
}}
onClearOAuth={onClearOAuth}
/>,
);
await user.click(
screen.getByRole("button", {
name: CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL,
}),
);
expect(onClearOAuth).toHaveBeenCalledTimes(1);
});
});

View file

@ -1,6 +1,8 @@
import {
Badge,
Button,
Code,
Flex,
ScrollArea,
SimpleGrid,
Stack,
@ -12,6 +14,7 @@ import type {
InitializeResult,
} from "@modelcontextprotocol/sdk/types.js";
import type { ServerType } from "@inspector/core/mcp/types.js";
import type { OAuthClientRegistrationKind } from "@inspector/core/auth/types.js";
import {
CapabilityItem,
type CapabilityKey,
@ -23,6 +26,7 @@ export interface OAuthDetails {
protocol: "standard" | "ema";
authorized: boolean;
clientId?: string;
clientRegistrationKind?: OAuthClientRegistrationKind;
authUrl?: string;
scopes?: string[];
accessToken?: string;
@ -35,6 +39,7 @@ export interface ConnectionInfoContentProps {
clientCapabilities: ClientCapabilities;
transport: ServerType;
oauth?: OAuthDetails;
onClearOAuth?: () => void;
}
const ValueText = Text.withProps({
@ -68,6 +73,19 @@ function formatIdpSession(
}
}
function formatClientRegistrationKind(
kind: OAuthClientRegistrationKind,
): string {
switch (kind) {
case "static":
return "Static (preregistered)";
case "dcr":
return "Dynamic (DCR)";
case "cimd":
return "Client ID Metadata (CIMD)";
}
}
const SERVER_CAPABILITY_KEYS: CapabilityKey[] = [
"tools",
"resources",
@ -85,6 +103,9 @@ const CLIENT_CAPABILITY_KEYS: CapabilityKey[] = [
"experimental",
];
export const CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL =
"Clear OAuth state and disconnect";
function getCapabilityEntries(
capabilities: Record<string, unknown>,
knownKeys: CapabilityKey[],
@ -100,6 +121,7 @@ export function ConnectionInfoContent({
clientCapabilities,
transport,
oauth,
onClearOAuth,
}: ConnectionInfoContentProps) {
const { serverInfo, protocolVersion, capabilities, instructions } =
initializeResult;
@ -189,6 +211,14 @@ export function ConnectionInfoContent({
<Code>{oauth.clientId}</Code>
</SimpleGrid>
)}
{oauth.clientRegistrationKind && (
<SimpleGrid cols={2}>
<Text size="sm">Client registration</Text>
<ValueText>
{formatClientRegistrationKind(oauth.clientRegistrationKind)}
</ValueText>
</SimpleGrid>
)}
{oauth.protocol === "ema" && oauth.idpSession && (
<SimpleGrid cols={2}>
<Text size="sm">IdP session</Text>
@ -207,8 +237,25 @@ export function ConnectionInfoContent({
<ValueText>{formatScopes(oauth.scopes)}</ValueText>
</SimpleGrid>
)}
{oauth.accessToken && (
<OAuthAccessTokenField accessToken={oauth.accessToken} />
{oauth.accessToken ? (
<OAuthAccessTokenField
accessToken={oauth.accessToken}
onClear={onClearOAuth}
clearLabel={CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL}
/>
) : (
onClearOAuth && (
<Flex justify="flex-end">
<Button
variant="subtle"
color="red"
size="compact-xs"
onClick={onClearOAuth}
>
{CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL}
</Button>
</Flex>
)
)}
</Stack>
</Stack>

View file

@ -1,16 +1,17 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL } from "./ConnectionInfoContent";
import { OAuthAccessTokenField } from "./OAuthAccessTokenField";
const jwt = "eyJhbGciOiJub25lIn0.eyJzdWIiOiJ1c2VyIn0.";
describe("OAuthAccessTokenField", () => {
it("renders token with copy control beside the label", () => {
it("renders token with copy control beside the content", () => {
renderWithMantine(<OAuthAccessTokenField accessToken={jwt} />);
expect(screen.getByText("Access Token")).toBeInTheDocument();
expect(screen.getByText("eyJhbGciOiJub25lIn0")).toBeInTheDocument();
expect(screen.getByText("eyJzdWIiOiJ1c2VyIn0")).toBeInTheDocument();
expect(screen.getByText(/eyJhbGciOiJub25lIn0/)).toBeInTheDocument();
expect(screen.getByText(/eyJzdWIiOiJ1c2VyIn0/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument();
});
@ -18,13 +19,13 @@ describe("OAuthAccessTokenField", () => {
const user = userEvent.setup();
renderWithMantine(<OAuthAccessTokenField accessToken={jwt} />);
expect(screen.getByText("eyJhbGciOiJub25lIn0")).toBeInTheDocument();
expect(screen.getByText(/eyJhbGciOiJub25lIn0/)).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Decode JWT" }));
expect(screen.getByText(/"sub": "user"/)).toBeInTheDocument();
expect(screen.queryByText("eyJhbGciOiJub25lIn0")).not.toBeInTheDocument();
expect(screen.queryByText(/eyJhbGciOiJub25lIn0/)).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Show token" }));
expect(screen.getByText("eyJhbGciOiJub25lIn0")).toBeInTheDocument();
expect(screen.getByText(/eyJhbGciOiJub25lIn0/)).toBeInTheDocument();
expect(screen.queryByText(/"sub": "user"/)).not.toBeInTheDocument();
});
@ -36,4 +37,54 @@ describe("OAuthAccessTokenField", () => {
screen.queryByRole("button", { name: "Decode JWT" }),
).not.toBeInTheDocument();
});
it("copies decoded JSON while decode view is shown", async () => {
const user = userEvent.setup();
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
value: { writeText },
configurable: true,
});
renderWithMantine(<OAuthAccessTokenField accessToken={jwt} />);
await user.click(screen.getByRole("button", { name: "Decode JWT" }));
await user.click(screen.getByRole("button", { name: "Copy" }));
expect(writeText).toHaveBeenCalledWith(
expect.stringContaining('"sub": "user"'),
);
expect(writeText).not.toHaveBeenCalledWith(jwt);
});
it("copies the raw token while token view is shown", async () => {
const user = userEvent.setup();
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
value: { writeText },
configurable: true,
});
renderWithMantine(<OAuthAccessTokenField accessToken={jwt} />);
await user.click(screen.getByRole("button", { name: "Copy" }));
expect(writeText).toHaveBeenCalledWith(jwt);
});
it("renders clear action on the access token header row", async () => {
const user = userEvent.setup();
const onClear = vi.fn();
renderWithMantine(
<OAuthAccessTokenField
accessToken="opaque-access-token-value"
onClear={onClear}
clearLabel={CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL}
/>,
);
await user.click(
screen.getByRole("button", {
name: CLEAR_OAUTH_STATE_AND_DISCONNECT_LABEL,
}),
);
expect(onClear).toHaveBeenCalledTimes(1);
});
});

View file

@ -5,13 +5,50 @@ import { CopyButton } from "../../elements/CopyButton/CopyButton";
export interface OAuthAccessTokenFieldProps {
accessToken: string;
onClear?: () => void;
clearLabel?: string;
}
const DecodeButton = Button.withProps({
const CaptionRow = Flex.withProps({
justify: "space-between",
align: "center",
gap: "sm",
wrap: "nowrap",
});
const Caption = Text.withProps({ size: "sm" });
const Toolbar = Flex.withProps({
gap: 4,
align: "center",
wrap: "nowrap",
});
const ToolbarButton = Button.withProps({
variant: "subtle",
size: "compact-xs",
});
const TokenRow = Flex.withProps({
align: "flex-start",
gap: 4,
wrap: "nowrap",
});
const TokenColumn = Flex.withProps({
flex: 1,
miw: 0,
direction: "column",
});
const TokenCode = Code.withProps({
block: true,
py: "xs",
ps: "xs",
pe: 0,
variant: "wrapping",
});
/** Wrap JWT at segment boundaries; break long segments without orphaning `.`. */
function JwtTokenText({ token }: { token: string }) {
const parts = token.split(".");
@ -20,7 +57,7 @@ function JwtTokenText({ token }: { token: string }) {
{parts.map((part, index) => (
<Fragment key={index}>
{index > 0 && "."}
<span style={{ wordBreak: "break-all" }}>{part}</span>
{part}
</Fragment>
))}
</>
@ -29,6 +66,8 @@ function JwtTokenText({ token }: { token: string }) {
export function OAuthAccessTokenField({
accessToken,
onClear,
clearLabel = "Clear",
}: OAuthAccessTokenFieldProps) {
const [showDecoded, setShowDecoded] = useState(false);
const isJwt = isJwtFormat(accessToken);
@ -46,31 +85,42 @@ export function OAuthAccessTokenField({
);
}, [jwtDecoded]);
const copyValue = showDecoded && decodedText ? decodedText : accessToken;
return (
<Stack gap="xs">
<Flex justify="space-between" align="center" gap="sm" wrap="nowrap">
<Text size="sm">Access Token</Text>
<Flex gap={4} align="center" wrap="nowrap">
<CaptionRow>
<Caption>Access Token</Caption>
<Toolbar>
{jwtDecoded && (
<DecodeButton
<ToolbarButton
onClick={() => setShowDecoded((open) => !open)}
aria-pressed={showDecoded}
>
{showDecoded ? "Show token" : "Decode JWT"}
</DecodeButton>
</ToolbarButton>
)}
<CopyButton value={accessToken} />
</Flex>
</Flex>
<Code block p="sm" variant="wrapping">
{showDecoded && decodedText ? (
decodedText
) : isJwt ? (
<JwtTokenText token={accessToken} />
) : (
accessToken
)}
</Code>
{onClear && (
<ToolbarButton color="red" onClick={onClear}>
{clearLabel}
</ToolbarButton>
)}
</Toolbar>
</CaptionRow>
<TokenRow>
<TokenColumn>
<TokenCode>
{showDecoded && decodedText ? (
decodedText
) : isJwt ? (
<JwtTokenText token={accessToken} />
) : (
accessToken
)}
</TokenCode>
</TokenColumn>
<CopyButton value={copyValue} flush />
</TokenRow>
</Stack>
);
}

View file

@ -11,7 +11,7 @@ describe("oauthDetailsFromConnectionState", () => {
grantedScope: "read write",
tokens: { access_token: "tok", token_type: "Bearer" },
client: {
source: "preregistered",
registrationKind: "static",
clientId: "client-1",
hasClientSecret: true,
},
@ -27,6 +27,7 @@ describe("oauthDetailsFromConnectionState", () => {
protocol: "standard",
authorized: true,
clientId: "client-1",
clientRegistrationKind: "static",
authUrl: "https://auth.example.com/authorize",
scopes: ["read", "write"],
accessToken: "tok",

View file

@ -12,6 +12,9 @@ export function oauthDetailsFromConnectionState(
protocol: state.protocol,
authorized: state.authorized,
...(state.client?.clientId && { clientId: state.client.clientId }),
...(state.client?.registrationKind && {
clientRegistrationKind: state.client.registrationKind,
}),
...(state.authorizationServerMetadata?.authorization_endpoint && {
authUrl: state.authorizationServerMetadata.authorization_endpoint,
}),

View file

@ -83,6 +83,7 @@ export const WithOAuth: Story = {
scopes: ["read", "write"],
accessToken: "eyJhbGciOiJSUzI1NiIs...truncated",
},
onClearOAuth: fn(),
},
};

View file

@ -16,6 +16,7 @@ export interface ConnectionInfoModalProps {
clientCapabilities: ClientCapabilities;
transport: ServerType;
oauth?: OAuthDetails;
onClearOAuth?: () => void;
}
export function ConnectionInfoModal({
@ -25,6 +26,7 @@ export function ConnectionInfoModal({
clientCapabilities,
transport,
oauth,
onClearOAuth,
}: ConnectionInfoModalProps) {
return (
<Modal
@ -46,6 +48,7 @@ export function ConnectionInfoModal({
clientCapabilities={clientCapabilities}
transport={transport}
oauth={oauth}
onClearOAuth={onClearOAuth}
/>
</Stack>
</Modal>

Some files were not shown because too many files have changed in this diff Show more