Adds enterprise-managed authorization (EMA): browser-side IdP login + RFC 8693 token-exchange and JWT-bearer resource-token mint, per-server EMA opt-in in Server Settings, IdP config in Client Settings, and IdP clientSecret keychain storage. Includes the v2/main merge and review follow-ups (400 on malformed client config; normalizeIdpIssuer import consolidation).
This commit is contained in:
parent
5ee347571f
commit
5de4e911e3
121 changed files with 7585 additions and 595 deletions
30
clients/cli/package-lock.json
generated
30
clients/cli/package-lock.json
generated
|
|
@ -1035,9 +1035,6 @@
|
|||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1055,9 +1052,6 @@
|
|||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1075,9 +1069,6 @@
|
|||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1095,9 +1086,6 @@
|
|||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1115,9 +1103,6 @@
|
|||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -1135,9 +1120,6 @@
|
|||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -3677,9 +3659,6 @@
|
|||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -3701,9 +3680,6 @@
|
|||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -3725,9 +3701,6 @@
|
|||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -3749,9 +3722,6 @@
|
|||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
|
|||
30
clients/launcher/package-lock.json
generated
30
clients/launcher/package-lock.json
generated
|
|
@ -618,9 +618,6 @@
|
|||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -638,9 +635,6 @@
|
|||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -658,9 +652,6 @@
|
|||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -678,9 +669,6 @@
|
|||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -698,9 +686,6 @@
|
|||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -718,9 +703,6 @@
|
|||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2192,9 +2174,6 @@
|
|||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2216,9 +2195,6 @@
|
|||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2240,9 +2216,6 @@
|
|||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -2264,9 +2237,6 @@
|
|||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
|
|||
|
|
@ -561,7 +561,7 @@ function App({
|
|||
// Shared ref for OAuth callback server; stop before starting new (avoids EADDRINUSE when prior auth failed without redirect)
|
||||
const callbackServerRef = useRef<OAuthCallbackServer | null>(null);
|
||||
|
||||
// OAuth Quick Auth (normal mode; callback server + open URL)
|
||||
// OAuth Quick Auth (quick execution; callback server + open URL)
|
||||
const handleQuickAuth = useCallback(async () => {
|
||||
if (
|
||||
!selectedServer ||
|
||||
|
|
@ -620,8 +620,10 @@ function App({
|
|||
if (redirectUrlProvider) {
|
||||
redirectUrlProvider.redirectUrl = redirectUrl;
|
||||
}
|
||||
await selectedInspectorClient.authenticate();
|
||||
await flowDone;
|
||||
const authUrl = await selectedInspectorClient.authenticate();
|
||||
if (authUrl !== undefined) {
|
||||
await flowDone;
|
||||
}
|
||||
setOauthStatus("success");
|
||||
setOauthMessage("OAuth complete. Press C to connect.");
|
||||
} catch (err) {
|
||||
|
|
@ -719,7 +721,7 @@ function App({
|
|||
);
|
||||
try {
|
||||
await selectedInspectorClient.proceedOAuthStep();
|
||||
const state = selectedInspectorClient.getOAuthState();
|
||||
const state = selectedInspectorClient.getOAuthFlowState();
|
||||
if (state?.oauthStep === "authorization_code" && state.authorizationUrl) {
|
||||
await openUrl(state.authorizationUrl);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import type {
|
|||
MCPServerConfig,
|
||||
InspectorClient,
|
||||
} from "@inspector/core/mcp/index.js";
|
||||
import type { AuthGuidedState, OAuthStep } from "@inspector/core/auth/index.js";
|
||||
import type { OAuthFlowState, OAuthStep } from "@inspector/core/auth/index.js";
|
||||
|
||||
const STEP_LABELS: Record<OAuthStep, string> = {
|
||||
metadata_discovery: "Metadata Discovery",
|
||||
|
|
@ -68,21 +68,21 @@ export function AuthTab({
|
|||
isOAuthCapable,
|
||||
}: AuthTabProps) {
|
||||
const scrollViewRef = useRef<ScrollViewRef>(null);
|
||||
const [oauthState, setOauthState] = useState<AuthGuidedState | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [oauthFlowState, setOauthFlowState] = useState<
|
||||
OAuthFlowState | undefined
|
||||
>(undefined);
|
||||
const [guidedStarted, setGuidedStarted] = useState(false);
|
||||
const [clearedConfirmation, setClearedConfirmation] = useState(false);
|
||||
|
||||
// Sync oauthState from InspectorClient
|
||||
// Sync oauthFlowState from InspectorClient
|
||||
useEffect(() => {
|
||||
if (!inspectorClient) {
|
||||
setOauthState(undefined);
|
||||
setOauthFlowState(undefined);
|
||||
setGuidedStarted(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const update = () => setOauthState(inspectorClient.getOAuthState());
|
||||
const update = () => setOauthFlowState(inspectorClient.getOAuthFlowState());
|
||||
update();
|
||||
|
||||
const onStepChange = () => update();
|
||||
|
|
@ -106,10 +106,10 @@ export function AuthTab({
|
|||
}
|
||||
}, [selectedAction]);
|
||||
|
||||
const guidedFlowStarted = !!oauthState?.oauthStep;
|
||||
const currentStep = oauthState?.oauthStep ?? "metadata_discovery";
|
||||
const guidedFlowStarted = !!oauthFlowState?.oauthStep;
|
||||
const currentStep = oauthFlowState?.oauthStep ?? "metadata_discovery";
|
||||
const needsAuthCode =
|
||||
currentStep === "authorization_code" && oauthState?.authorizationUrl;
|
||||
currentStep === "authorization_code" && oauthFlowState?.authorizationUrl;
|
||||
const isComplete = currentStep === "complete";
|
||||
|
||||
const handleContinue = useCallback(async () => {
|
||||
|
|
@ -268,8 +268,8 @@ export function AuthTab({
|
|||
(step === currentStep && isComplete));
|
||||
const inProgress =
|
||||
guidedFlowStarted && step === currentStep && !isComplete;
|
||||
const details = oauthState
|
||||
? getStepDetails(oauthState, step)
|
||||
const details = oauthFlowState
|
||||
? getStepDetails(oauthFlowState, step)
|
||||
: null;
|
||||
|
||||
const icon = completed ? "✓" : inProgress ? "→" : "○";
|
||||
|
|
@ -305,22 +305,24 @@ export function AuthTab({
|
|||
})}
|
||||
|
||||
{/* Waiting for auth - URL was opened when we reached this step */}
|
||||
{oauthState && needsAuthCode && oauthState?.authorizationUrl && (
|
||||
<Box marginTop={2} flexDirection="column">
|
||||
<Text bold>Authorization URL opened in browser</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>
|
||||
{oauthState.authorizationUrl.toString()}
|
||||
</Text>
|
||||
{oauthFlowState &&
|
||||
needsAuthCode &&
|
||||
oauthFlowState?.authorizationUrl && (
|
||||
<Box marginTop={2} flexDirection="column">
|
||||
<Text bold>Authorization URL opened in browser</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>
|
||||
{oauthFlowState.authorizationUrl.toString()}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>
|
||||
Complete authorization in the browser. You will be
|
||||
redirected and the flow will complete automatically.
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>
|
||||
Complete authorization in the browser. You will be
|
||||
redirected and the flow will complete automatically.
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
|
|
@ -333,24 +335,33 @@ export function AuthTab({
|
|||
<Text color="red">{oauthMessage}</Text>
|
||||
)}
|
||||
{oauthStatus === "success" &&
|
||||
oauthState &&
|
||||
oauthState.authType === "normal" &&
|
||||
(oauthState.oauthTokens || oauthState.oauthClientInfo) && (
|
||||
oauthFlowState &&
|
||||
oauthFlowState.execution === "quick" &&
|
||||
(oauthFlowState.oauthTokens ||
|
||||
oauthFlowState.oauthClientInfo) && (
|
||||
<>
|
||||
<Text bold>Quick Auth Results</Text>
|
||||
{oauthState.oauthClientInfo && (
|
||||
{oauthFlowState.oauthClientInfo && (
|
||||
<Box marginTop={1} flexDirection="column" paddingLeft={2}>
|
||||
<Text dimColor>
|
||||
Client:{" "}
|
||||
{JSON.stringify(oauthState.oauthClientInfo, null, 2)}
|
||||
{JSON.stringify(
|
||||
oauthFlowState.oauthClientInfo,
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{oauthState.oauthTokens && (
|
||||
{oauthFlowState.oauthTokens && (
|
||||
<Box marginTop={1} flexDirection="column" paddingLeft={2}>
|
||||
<Text dimColor>
|
||||
Access Token:{" "}
|
||||
{oauthState.oauthTokens.access_token?.slice(0, 20)}...
|
||||
{oauthFlowState.oauthTokens.access_token?.slice(
|
||||
0,
|
||||
20,
|
||||
)}
|
||||
...
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
|
@ -383,10 +394,7 @@ export function AuthTab({
|
|||
);
|
||||
}
|
||||
|
||||
function getStepDetails(
|
||||
state: AuthGuidedState,
|
||||
step: OAuthStep,
|
||||
): string | null {
|
||||
function getStepDetails(state: OAuthFlowState, step: OAuthStep): string | null {
|
||||
switch (step) {
|
||||
case "metadata_discovery":
|
||||
if (state.resourceMetadata || state.oauthMetadata) {
|
||||
|
|
|
|||
|
|
@ -41,7 +41,9 @@ const { messageLogClear } = vi.hoisted(() => ({ messageLogClear: vi.fn() }));
|
|||
// Extends EventTarget so the App's `addEventListener("disconnect", …)` wiring
|
||||
// is real; the test fires `dispatchEvent(new Event("disconnect"))` to simulate
|
||||
// any of the three disconnect paths (toggle, header button, transport failure).
|
||||
vi.mock("@inspector/core/mcp/index.js", () => {
|
||||
vi.mock("@inspector/core/mcp/index.js", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@inspector/core/mcp/index.js")>();
|
||||
class FakeInspectorClient extends EventTarget {
|
||||
connect = vi.fn().mockResolvedValue(undefined);
|
||||
disconnect = vi.fn().mockResolvedValue(undefined);
|
||||
|
|
@ -66,7 +68,8 @@ vi.mock("@inspector/core/mcp/index.js", () => {
|
|||
.mockResolvedValue({ resourceTemplates: [] });
|
||||
listRequestorTasks = vi.fn().mockResolvedValue({ tasks: [] });
|
||||
ping = vi.fn().mockResolvedValue(undefined);
|
||||
getOAuthState = vi.fn().mockReturnValue(undefined);
|
||||
getOAuthFlowState = vi.fn().mockReturnValue(undefined);
|
||||
getOAuthState = vi.fn().mockResolvedValue(undefined);
|
||||
getPendingSamples = vi.fn().mockReturnValue([]);
|
||||
getPendingElicitations = vi.fn().mockReturnValue([]);
|
||||
getRoots = vi.fn().mockReturnValue([]);
|
||||
|
|
@ -75,6 +78,7 @@ vi.mock("@inspector/core/mcp/index.js", () => {
|
|||
}
|
||||
const instances: FakeInspectorClient[] = [];
|
||||
return {
|
||||
...actual,
|
||||
InspectorClient: vi.fn(function () {
|
||||
const client = new FakeInspectorClient();
|
||||
instances.push(client);
|
||||
|
|
|
|||
|
|
@ -56,6 +56,14 @@ import {
|
|||
cleanRoots,
|
||||
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 { isEmaClientNotConfiguredError } from "@inspector/core/auth/ema/clientConfigError.js";
|
||||
import {
|
||||
loadClientConfigRemote,
|
||||
saveClientConfigRemote,
|
||||
} from "@inspector/core/client/remote.js";
|
||||
import { formatClientConfigLoadError } from "@inspector/core/client/config-parse.js";
|
||||
import { MessageLogState } from "@inspector/core/mcp/state/messageLogState.js";
|
||||
import { FetchRequestLogState } from "@inspector/core/mcp/state/fetchRequestLogState.js";
|
||||
import type { FetchRequestLogStateEventMap } from "@inspector/core/mcp/state/fetchRequestLogState.js";
|
||||
|
|
@ -70,6 +78,9 @@ import { RemoteInspectorClientStorage } from "@inspector/core/mcp/remote/index.j
|
|||
import { useInspectorClient } from "@inspector/core/react/useInspectorClient.js";
|
||||
import { useServers } from "@inspector/core/react/useServers.js";
|
||||
import { useSettingsDraft } from "@inspector/core/react/useSettingsDraft.js";
|
||||
import { useClientSettingsDraft } from "@inspector/core/react/useClientSettingsDraft.js";
|
||||
import { useEmaIdpLoginState } from "@inspector/core/react/useEmaIdpLoginState.js";
|
||||
import { getBrowserOAuthStorage } from "@inspector/core/auth/browser/index.js";
|
||||
import { useManagedTools } from "@inspector/core/react/useManagedTools.js";
|
||||
import { useManagedPrompts } from "@inspector/core/react/useManagedPrompts.js";
|
||||
import { useManagedResources } from "@inspector/core/react/useManagedResources.js";
|
||||
|
|
@ -108,9 +119,17 @@ import {
|
|||
type ServerConfigModalMode,
|
||||
} from "./components/groups/ServerConfigModal/ServerConfigModal";
|
||||
import { ServerSettingsModal } from "./components/groups/ServerSettingsModal/ServerSettingsModal";
|
||||
import { ClientSettingsModal } from "./components/groups/ClientSettingsModal/ClientSettingsModal";
|
||||
import {
|
||||
canPersistClientSettingsDraft,
|
||||
clientConfigToFormValues,
|
||||
EMPTY_CLIENT_SETTINGS,
|
||||
formValuesToClientConfig,
|
||||
} from "./components/groups/ClientSettingsForm/clientSettingsValues";
|
||||
import { ServerImportConfigModal } from "./components/groups/ServerImportConfigModal/ServerImportConfigModal";
|
||||
import { ServerImportJsonModal } from "./components/groups/ServerImportJsonModal/ServerImportJsonModal";
|
||||
import { ConnectionInfoModal } from "./components/groups/ConnectionInfoModal/ConnectionInfoModal";
|
||||
import { oauthDetailsFromConnectionState } from "./components/groups/ConnectionInfoContent/oauthDetailsFromConnectionState";
|
||||
import { OutputValidationModal } from "./components/groups/OutputValidationModal/OutputValidationModal";
|
||||
import { UrlElicitationErrorModal } from "./components/groups/UrlElicitationErrorModal/UrlElicitationErrorModal";
|
||||
import { isReplayableHistoryMethod } from "./components/groups/historyUtils.js";
|
||||
|
|
@ -300,6 +319,8 @@ function bodyDroppedToastId(serverId: string): string {
|
|||
return `fetch-body-dropped-${serverId}`;
|
||||
}
|
||||
|
||||
const CLIENT_CONFIG_LOAD_ERROR_NOTIFICATION_ID = "client-config-load-error";
|
||||
|
||||
// Body of the "response body dropped" warning toast: a one-line summary of what
|
||||
// happened, the likely causes, and a link that opens this server's settings
|
||||
// (on the Options section) so the user can raise the Network Log Size if it's
|
||||
|
|
@ -528,6 +549,7 @@ function App() {
|
|||
const [settingsModalTargetId, setSettingsModalTargetId] = useState<
|
||||
string | undefined
|
||||
>(undefined);
|
||||
const [clientSettingsOpen, setClientSettingsOpen] = useState(false);
|
||||
const [connectionInfoModalOpen, setConnectionInfoModalOpen] = useState(false);
|
||||
const [removeTarget, setRemoveTarget] = useState<ServerEntry | null>(null);
|
||||
// Details for the output-schema-mismatch modal opened from the warning toast.
|
||||
|
|
@ -572,6 +594,27 @@ function App() {
|
|||
baseUrl: configBaseUrl,
|
||||
authToken: getAuthToken(),
|
||||
});
|
||||
|
||||
const [clientConfig, setClientConfig] = useState<ClientConfig>({});
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
void loadClientConfigRemote({
|
||||
baseUrl: configBaseUrl,
|
||||
authToken: getAuthToken(),
|
||||
})
|
||||
.then(setClientConfig)
|
||||
.catch((err) => {
|
||||
setClientConfig({});
|
||||
notifications.show({
|
||||
id: CLIENT_CONFIG_LOAD_ERROR_NOTIFICATION_ID,
|
||||
title: "Could not load Client Settings",
|
||||
message: `${formatClientConfigLoadError(err)}\n\nCheck ~/.mcp-inspector/storage/client.json or re-enter settings in Client Settings.`,
|
||||
color: "red",
|
||||
autoClose: false,
|
||||
});
|
||||
});
|
||||
}, [configBaseUrl]);
|
||||
|
||||
const sandboxBridgeFactory = useMemo(
|
||||
() =>
|
||||
createAppBridgeFactory({
|
||||
|
|
@ -1147,55 +1190,39 @@ function App() {
|
|||
const connectionInfoTransport: ServerType =
|
||||
activeServer?.config.type ?? "stdio";
|
||||
|
||||
// OAuth details rendered in the Connection Info modal — read from the
|
||||
// active InspectorClient's guided-OAuth state machine snapshot
|
||||
// (synchronous), with configured scopes pulled from the server's
|
||||
// persisted settings. All three fields are independently optional; if
|
||||
// none are populated we return undefined so the modal hides the OAuth
|
||||
// section entirely.
|
||||
//
|
||||
// Snapshot-at-last-derivation semantics: the memo deps
|
||||
// (`connectionStatus`, `inspectorClient`, `activeServer`) don't
|
||||
// include `oauthStepChange` / `oauthComplete`, so a token refresh
|
||||
// that happens while none of those change won't update the rendered
|
||||
// token. The memo will still re-run on the natural triggers — server
|
||||
// switch, reconnect, or a settings edit that re-references
|
||||
// `activeServer` — which matches the modal's overall framing
|
||||
// ("info about the connection at this moment") and avoids
|
||||
// subscribing to a third event source from a dialog whose primary
|
||||
// job is read-only. If we ever surface live token refresh state,
|
||||
// switch to subscribing on those events.
|
||||
//
|
||||
// For `authUrl` we prefer the authorization-server-advertised
|
||||
// `authorization_endpoint` over the full `authorizationUrl`. The
|
||||
// latter is the per-flight URL the user was redirected to (with
|
||||
// `state`, `code_challenge`, etc.) — informative for a debugger,
|
||||
// noisy for a connection summary; the endpoint is the stable
|
||||
// identifier of "which AS is in use here."
|
||||
//
|
||||
// Scope splitter: OAuth 2.1 §3.3 specifies space-separated scopes;
|
||||
// the persisted value is config-controlled (user-typed into the
|
||||
// server settings form), so the literal `" "` split is sufficient
|
||||
// and matches the spec.
|
||||
const connectionInfoOAuth = useMemo<OAuthDetails | undefined>(() => {
|
||||
if (connectionStatus !== "connected" || !inspectorClient) return undefined;
|
||||
const oauthState = inspectorClient.getOAuthState();
|
||||
const authUrl =
|
||||
oauthState?.oauthMetadata?.authorization_endpoint ??
|
||||
oauthState?.authorizationUrl?.toString();
|
||||
const accessToken = oauthState?.oauthTokens?.access_token;
|
||||
const scopes = activeServer?.settings?.oauthScopes
|
||||
?.split(" ")
|
||||
.filter(Boolean);
|
||||
if (!authUrl && !accessToken && !(scopes && scopes.length > 0)) {
|
||||
return undefined;
|
||||
const [
|
||||
connectionInfoOAuthWhenConnected,
|
||||
setConnectionInfoOAuthWhenConnected,
|
||||
] = useState<OAuthDetails | undefined>(undefined);
|
||||
|
||||
const connectionInfoOAuth =
|
||||
connectionStatus === "connected" && inspectorClient
|
||||
? connectionInfoOAuthWhenConnected
|
||||
: undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (connectionStatus !== "connected" || !inspectorClient) {
|
||||
return;
|
||||
}
|
||||
return {
|
||||
...(authUrl && { authUrl }),
|
||||
...(scopes && scopes.length > 0 && { scopes }),
|
||||
...(accessToken && { accessToken }),
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const refresh = (): void => {
|
||||
void inspectorClient.getOAuthState().then((state) => {
|
||||
if (cancelled) return;
|
||||
setConnectionInfoOAuthWhenConnected(
|
||||
state ? oauthDetailsFromConnectionState(state) : undefined,
|
||||
);
|
||||
});
|
||||
};
|
||||
}, [connectionStatus, inspectorClient, activeServer]);
|
||||
|
||||
refresh();
|
||||
inspectorClient.addEventListener("oauthComplete", refresh);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
inspectorClient.removeEventListener("oauthComplete", refresh);
|
||||
};
|
||||
}, [connectionStatus, inspectorClient]);
|
||||
|
||||
// Derive log entries from the message log. Filters for
|
||||
// `notifications/message` (the response to `logging/setLevel`).
|
||||
|
|
@ -1287,6 +1314,7 @@ function App() {
|
|||
// The settings node persisted in mcp.json for this server — distinct
|
||||
// from the InspectorClient options we're about to derive from it.
|
||||
const savedSettings = server.settings;
|
||||
const activeIdp = getActiveEnterpriseManagedAuthIdp(clientConfig);
|
||||
// Flatten the persisted settings into the InspectorClient options shape.
|
||||
// Empty / zero values stay unset so the SDK defaults apply.
|
||||
const defaultMetadata = savedSettings?.metadata
|
||||
|
|
@ -1300,7 +1328,8 @@ function App() {
|
|||
savedSettings &&
|
||||
(savedSettings.oauthClientId ||
|
||||
savedSettings.oauthClientSecret ||
|
||||
savedSettings.oauthScopes)
|
||||
savedSettings.oauthScopes ||
|
||||
savedSettings.enterpriseManaged)
|
||||
? {
|
||||
...(savedSettings.oauthClientId && {
|
||||
clientId: savedSettings.oauthClientId,
|
||||
|
|
@ -1311,6 +1340,9 @@ function App() {
|
|||
...(savedSettings.oauthScopes && {
|
||||
scope: savedSettings.oauthScopes,
|
||||
}),
|
||||
...(savedSettings.enterpriseManaged && {
|
||||
enterpriseManaged: true,
|
||||
}),
|
||||
}
|
||||
: undefined;
|
||||
const client = new InspectorClient(server.config, {
|
||||
|
|
@ -1335,6 +1367,12 @@ function App() {
|
|||
defaultMetadata,
|
||||
}),
|
||||
...(oauth && { oauth }),
|
||||
...(activeIdp && {
|
||||
enterpriseManagedAuth: { idp: activeIdp },
|
||||
}),
|
||||
...(clientConfig.enterpriseManagedAuth && {
|
||||
installEnterpriseManagedAuth: clientConfig.enterpriseManagedAuth,
|
||||
}),
|
||||
...(savedSettings && { serverSettings: savedSettings }),
|
||||
// Set on the `/oauth/callback` rebuild so the client's `saveSession`
|
||||
// events (and any later persistence) key off the same OAuth authId
|
||||
|
|
@ -1390,6 +1428,7 @@ function App() {
|
|||
stderrLogState,
|
||||
sessionStorageAdapter,
|
||||
onBeforeOAuthRedirect,
|
||||
clientConfig,
|
||||
],
|
||||
);
|
||||
|
||||
|
|
@ -1411,7 +1450,7 @@ function App() {
|
|||
oauthCallbackHandledRef.current = true;
|
||||
|
||||
const params = parseOAuthCallbackParams(window.location.search);
|
||||
// The OAuth `state` round-trips `{mode}:{authId}`; the authId is the
|
||||
// The OAuth `state` round-trips `{execution}:{authId}`; 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.
|
||||
|
|
@ -1480,6 +1519,15 @@ function App() {
|
|||
await client.connect();
|
||||
} catch (err) {
|
||||
connectStartRef.current = undefined;
|
||||
if (isEmaClientNotConfiguredError(err)) {
|
||||
notifications.show({
|
||||
title: `Cannot connect to "${server.name}"`,
|
||||
message: err.message,
|
||||
color: "red",
|
||||
autoClose: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
notifications.show({
|
||||
title: `Failed to connect to "${server.name}"`,
|
||||
|
|
@ -1529,6 +1577,16 @@ function App() {
|
|||
// (#1323).
|
||||
connectStartRef.current = undefined;
|
||||
|
||||
if (isEmaClientNotConfiguredError(err)) {
|
||||
notifications.show({
|
||||
title: `Cannot connect to "${target.name}"`,
|
||||
message: err.message,
|
||||
color: "red",
|
||||
autoClose: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// A 401 from an OAuth-protected server means we have no (valid) token
|
||||
// yet. Kick off the authorization-code flow: `authenticate()` runs
|
||||
// discovery + DCR (proxied through the backend), then redirects the
|
||||
|
|
@ -1539,10 +1597,23 @@ function App() {
|
|||
if (isUnauthorizedError(err)) {
|
||||
try {
|
||||
window.sessionStorage.setItem(OAUTH_PENDING_SERVER_KEY, id);
|
||||
await client.authenticate();
|
||||
const authUrl = await client.authenticate();
|
||||
if (authUrl === undefined) {
|
||||
connectStartRef.current = Date.now();
|
||||
await client.connect();
|
||||
}
|
||||
return;
|
||||
} catch (authErr) {
|
||||
window.sessionStorage.removeItem(OAUTH_PENDING_SERVER_KEY);
|
||||
if (isEmaClientNotConfiguredError(authErr)) {
|
||||
notifications.show({
|
||||
title: `Cannot connect to "${target.name}"`,
|
||||
message: authErr.message,
|
||||
color: "red",
|
||||
autoClose: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const message =
|
||||
authErr instanceof Error ? authErr.message : String(authErr);
|
||||
notifications.show({
|
||||
|
|
@ -2217,6 +2288,15 @@ function App() {
|
|||
return servers.find((s) => s.id === configModal.targetId);
|
||||
}, [configModal, servers]);
|
||||
|
||||
const settingsModalTarget = useMemo(() => {
|
||||
if (!settingsModalTargetId) return undefined;
|
||||
return servers.find((s) => s.id === settingsModalTargetId);
|
||||
}, [settingsModalTargetId, servers]);
|
||||
|
||||
const settingsModalServerType = settingsModalTarget
|
||||
? getServerType(settingsModalTarget.config)
|
||||
: "stdio";
|
||||
|
||||
// The settings modal is fully controlled — every input change fires
|
||||
// `onSettingsChange` back up here, and the input's `value` prop only
|
||||
// updates when this component re-renders with a new `settings` prop.
|
||||
|
|
@ -2259,15 +2339,53 @@ function App() {
|
|||
const settingsModalValue: InspectorServerSettings =
|
||||
settingsDraft ?? EMPTY_SETTINGS;
|
||||
|
||||
const {
|
||||
draft: clientSettingsDraft,
|
||||
onChange: onClientSettingsChange,
|
||||
flush: flushClientSettingsDraft,
|
||||
} = useClientSettingsDraft({
|
||||
opened: clientSettingsOpen,
|
||||
resolveInitial: () => clientConfigToFormValues(clientConfig),
|
||||
onPersist: async (values) => {
|
||||
if (!canPersistClientSettingsDraft(values)) return;
|
||||
const next = formValuesToClientConfig(values);
|
||||
await saveClientConfigRemote(next, {
|
||||
baseUrl: configBaseUrl,
|
||||
authToken: getAuthToken(),
|
||||
});
|
||||
setClientConfig(next);
|
||||
},
|
||||
onError: (err) => {
|
||||
notifications.show({
|
||||
title: "Failed to save client settings",
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const clientSettingsModalValue = clientSettingsDraft ?? EMPTY_CLIENT_SETTINGS;
|
||||
|
||||
const emaOAuthStorage = useMemo(() => getBrowserOAuthStorage(), []);
|
||||
const { loginState: emaIdpLoginState, logout: logoutEmaIdp } =
|
||||
useEmaIdpLoginState(
|
||||
emaOAuthStorage,
|
||||
clientSettingsModalValue.emaEnabled
|
||||
? clientSettingsModalValue.issuer
|
||||
: undefined,
|
||||
clientSettingsOpen,
|
||||
);
|
||||
|
||||
const onClientSettingsModalClose = useCallback(() => {
|
||||
flushClientSettingsDraft();
|
||||
setClientSettingsOpen(false);
|
||||
}, [flushClientSettingsDraft]);
|
||||
|
||||
// Gate the stdio-only Working Directory / Environment Variables controls in
|
||||
// the settings modal. Resolve the target server's transport from its config;
|
||||
// default to non-stdio when the target isn't resolvable (modal closed).
|
||||
const settingsModalTarget = servers.find(
|
||||
(s) => s.id === settingsModalTargetId,
|
||||
);
|
||||
const settingsModalIsStdio = settingsModalTarget
|
||||
? getServerType(settingsModalTarget.config) === "stdio"
|
||||
: false;
|
||||
// the settings modal. Derived from the resolved target server's transport
|
||||
// (see `settingsModalServerType` above), which defaults to "stdio" when the
|
||||
// target isn't resolvable.
|
||||
const settingsModalIsStdio = settingsModalServerType === "stdio";
|
||||
|
||||
const onSettingsModalClose = useCallback(() => {
|
||||
flushSettingsDraft();
|
||||
|
|
@ -2431,6 +2549,7 @@ function App() {
|
|||
bridgeFactory={sandboxBridgeFactory}
|
||||
appRendererRef={appRendererRef}
|
||||
onToggleTheme={onToggleTheme}
|
||||
onOpenClientSettings={() => setClientSettingsOpen(true)}
|
||||
onToggleConnection={(id) => {
|
||||
void onToggleConnection(id);
|
||||
}}
|
||||
|
|
@ -2560,13 +2679,25 @@ function App() {
|
|||
// Remount per open (and per target server) so the accordion resets to
|
||||
// its initial "options" section — the body-dropped toast deep-links
|
||||
// here expecting the Network Log Size control to be visible.
|
||||
key={settingsModalTargetId ?? "closed"}
|
||||
key={settingsModalTargetId ?? "server-settings-closed"}
|
||||
opened={settingsModalTargetId !== undefined}
|
||||
settings={settingsModalValue}
|
||||
serverType={settingsModalServerType}
|
||||
isStdio={settingsModalIsStdio}
|
||||
onClose={onSettingsModalClose}
|
||||
onSettingsChange={onSettingsChange}
|
||||
/>
|
||||
<ClientSettingsModal
|
||||
key={
|
||||
clientSettingsOpen ? "client-settings-open" : "client-settings-closed"
|
||||
}
|
||||
opened={clientSettingsOpen}
|
||||
settings={clientSettingsModalValue}
|
||||
onClose={onClientSettingsModalClose}
|
||||
onSettingsChange={onClientSettingsChange}
|
||||
emaIdpLoginState={emaIdpLoginState}
|
||||
onEmaIdpLogout={logoutEmaIdp}
|
||||
/>
|
||||
{initializeResult && activeServer && (
|
||||
<ConnectionInfoModal
|
||||
opened={connectionInfoModalOpen}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ export function CopyButton({ value }: CopyButtonProps) {
|
|||
color={copied ? "green" : "var(--inspector-text-primary)"}
|
||||
onClick={copy}
|
||||
fz={24}
|
||||
aria-label={copied ? "Copied" : "Copy"}
|
||||
>
|
||||
{copied ? "\u2713" : "\u2398"}
|
||||
</ActionIcon>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,74 @@
|
|||
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 { EMPTY_CLIENT_SETTINGS } from "./clientSettingsValues";
|
||||
|
||||
describe("ClientSettingsForm EMA IdP session", () => {
|
||||
it("shows signed-in state and sign out", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onLogout = vi.fn();
|
||||
renderWithMantine(
|
||||
<ClientSettingsForm
|
||||
settings={{
|
||||
...EMPTY_CLIENT_SETTINGS,
|
||||
emaEnabled: true,
|
||||
issuer: "https://idp.test",
|
||||
}}
|
||||
expandedSections={["ema"]}
|
||||
onExpandedSectionsChange={vi.fn()}
|
||||
onSettingsChange={vi.fn()}
|
||||
emaIdpLoginState="logged_in"
|
||||
onEmaIdpLogout={onLogout}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Signed in")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Sign out" }));
|
||||
expect(onLogout).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows not signed in without sign out button", () => {
|
||||
renderWithMantine(
|
||||
<ClientSettingsForm
|
||||
settings={{
|
||||
...EMPTY_CLIENT_SETTINGS,
|
||||
emaEnabled: true,
|
||||
issuer: "https://idp.test",
|
||||
}}
|
||||
expandedSections={["ema"]}
|
||||
onExpandedSectionsChange={vi.fn()}
|
||||
onSettingsChange={vi.fn()}
|
||||
emaIdpLoginState="none"
|
||||
onEmaIdpLogout={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Not signed in/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Sign out" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows expired state with sign out", () => {
|
||||
renderWithMantine(
|
||||
<ClientSettingsForm
|
||||
settings={{
|
||||
...EMPTY_CLIENT_SETTINGS,
|
||||
emaEnabled: true,
|
||||
issuer: "https://idp.test",
|
||||
}}
|
||||
expandedSections={["ema"]}
|
||||
onExpandedSectionsChange={vi.fn()}
|
||||
onSettingsChange={vi.fn()}
|
||||
emaIdpLoginState="expired"
|
||||
onEmaIdpLogout={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Session expired")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Sign out" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
import {
|
||||
Accordion,
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { ClearButton } from "../../elements/ClearButton/ClearButton";
|
||||
import type { EmaIdpLoginState } from "@inspector/core/auth/ema/idpSession.js";
|
||||
import type { ClientSettingsFormValues } from "./clientSettingsValues.js";
|
||||
|
||||
export type ClientSettingsSection = "ema";
|
||||
|
||||
export interface ClientSettingsFormProps {
|
||||
settings: ClientSettingsFormValues;
|
||||
expandedSections: ClientSettingsSection[];
|
||||
onExpandedSectionsChange: (sections: ClientSettingsSection[]) => void;
|
||||
onSettingsChange: (settings: ClientSettingsFormValues) => void;
|
||||
emaIdpLoginState?: EmaIdpLoginState;
|
||||
onEmaIdpLogout?: () => void;
|
||||
}
|
||||
|
||||
const HintText = Text.withProps({
|
||||
size: "sm",
|
||||
c: "dimmed",
|
||||
});
|
||||
|
||||
export function ClientSettingsForm({
|
||||
settings,
|
||||
expandedSections,
|
||||
onExpandedSectionsChange,
|
||||
onSettingsChange,
|
||||
emaIdpLoginState = "none",
|
||||
onEmaIdpLogout,
|
||||
}: ClientSettingsFormProps) {
|
||||
function patch(partial: Partial<ClientSettingsFormValues>) {
|
||||
onSettingsChange({ ...settings, ...partial });
|
||||
}
|
||||
|
||||
const showIdpSession =
|
||||
settings.emaEnabled &&
|
||||
settings.issuer.trim() !== "" &&
|
||||
emaIdpLoginState !== "none";
|
||||
|
||||
return (
|
||||
<Accordion
|
||||
multiple
|
||||
value={expandedSections}
|
||||
onChange={(value) =>
|
||||
onExpandedSectionsChange(value as ClientSettingsSection[])
|
||||
}
|
||||
variant="separated"
|
||||
>
|
||||
<Accordion.Item value="ema">
|
||||
<Accordion.Control>Enterprise-Managed Authorization</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack gap="md">
|
||||
<Checkbox
|
||||
label="Enable enterprise IdP configuration"
|
||||
description="Enterprise-managed authorization signs you in through your organization's IdP instead of each MCP server's own OAuth login. Enable enterprise-managed authorization on participating MCP server's settings."
|
||||
checked={settings.emaEnabled}
|
||||
onChange={(e) => patch({ emaEnabled: e.currentTarget.checked })}
|
||||
/>
|
||||
{settings.emaEnabled && (
|
||||
<>
|
||||
<HintText>
|
||||
Register this redirect URI with your IdP:{" "}
|
||||
{typeof window !== "undefined"
|
||||
? `${window.location.origin}/oauth/callback`
|
||||
: "http://localhost:6274/oauth/callback"}
|
||||
</HintText>
|
||||
<TextInput
|
||||
label="Issuer"
|
||||
description="Your enterprise IdP issuer URL."
|
||||
value={settings.issuer}
|
||||
onChange={(e) => patch({ issuer: e.currentTarget.value })}
|
||||
rightSectionPointerEvents="auto"
|
||||
rightSection={
|
||||
settings.issuer ? (
|
||||
<ClearButton onClick={() => patch({ issuer: "" })} />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
label="Client ID"
|
||||
description="Client id registered with your enterprise IdP."
|
||||
value={settings.clientId}
|
||||
onChange={(e) => patch({ clientId: e.currentTarget.value })}
|
||||
rightSectionPointerEvents="auto"
|
||||
rightSection={
|
||||
settings.clientId ? (
|
||||
<ClearButton onClick={() => patch({ clientId: "" })} />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
label="Client Secret"
|
||||
description="Client secret for your enterprise IdP (if required)."
|
||||
type="password"
|
||||
value={settings.clientSecret}
|
||||
onChange={(e) =>
|
||||
patch({ clientSecret: e.currentTarget.value })
|
||||
}
|
||||
rightSectionPointerEvents="auto"
|
||||
rightSection={
|
||||
settings.clientSecret ? (
|
||||
<ClearButton
|
||||
onClick={() => patch({ clientSecret: "" })}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
{settings.issuer.trim() !== "" && (
|
||||
<Group
|
||||
justify="space-between"
|
||||
align="flex-start"
|
||||
wrap="nowrap"
|
||||
>
|
||||
<Stack gap={4} flex={1}>
|
||||
<Text size="sm" fw={500}>
|
||||
IdP sign-in
|
||||
</Text>
|
||||
{emaIdpLoginState === "logged_in" ? (
|
||||
<>
|
||||
<Badge w="fit-content" color="green" variant="light">
|
||||
Signed in
|
||||
</Badge>
|
||||
<HintText>
|
||||
Your enterprise IdP session is active. Connecting to
|
||||
EMA-enabled MCP servers will not prompt for IdP
|
||||
login until you sign out or the session expires.
|
||||
</HintText>
|
||||
</>
|
||||
) : emaIdpLoginState === "expired" ? (
|
||||
<>
|
||||
<Badge w="fit-content" color="yellow" variant="light">
|
||||
Session expired
|
||||
</Badge>
|
||||
<HintText>
|
||||
Your cached IdP session has expired. The next
|
||||
connect to an EMA-enabled server will prompt for IdP
|
||||
login.
|
||||
</HintText>
|
||||
</>
|
||||
) : (
|
||||
<HintText>
|
||||
Not signed in to your enterprise IdP. Connecting to an
|
||||
EMA-enabled MCP server will open IdP login.
|
||||
</HintText>
|
||||
)}
|
||||
</Stack>
|
||||
{showIdpSession && onEmaIdpLogout ? (
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
size="compact-sm"
|
||||
onClick={onEmaIdpLogout}
|
||||
>
|
||||
Sign out
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
canPersistClientSettingsDraft,
|
||||
clientConfigToFormValues,
|
||||
formValuesToClientConfig,
|
||||
} from "./clientSettingsValues";
|
||||
|
||||
describe("clientSettingsValues", () => {
|
||||
it("clientConfigToFormValues maps enterpriseManagedAuth.idp", () => {
|
||||
expect(
|
||||
clientConfigToFormValues({
|
||||
enterpriseManagedAuth: {
|
||||
idp: {
|
||||
issuer: "https://idp.test",
|
||||
clientId: "cid",
|
||||
clientSecret: "sec",
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
emaEnabled: true,
|
||||
issuer: "https://idp.test",
|
||||
clientId: "cid",
|
||||
clientSecret: "sec",
|
||||
});
|
||||
});
|
||||
|
||||
it("clientConfigToFormValues preserves IdP fields when EMA is disabled", () => {
|
||||
expect(
|
||||
clientConfigToFormValues({
|
||||
enterpriseManagedAuth: {
|
||||
enabled: false,
|
||||
idp: {
|
||||
issuer: "https://idp.test",
|
||||
clientId: "cid",
|
||||
clientSecret: "sec",
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
emaEnabled: false,
|
||||
issuer: "https://idp.test",
|
||||
clientId: "cid",
|
||||
clientSecret: "sec",
|
||||
});
|
||||
});
|
||||
|
||||
it("formValuesToClientConfig keeps IdP credentials when disabled", () => {
|
||||
expect(
|
||||
formValuesToClientConfig({
|
||||
emaEnabled: false,
|
||||
issuer: "https://idp.test",
|
||||
clientId: "cid",
|
||||
clientSecret: "sec",
|
||||
}),
|
||||
).toEqual({
|
||||
enterpriseManagedAuth: {
|
||||
enabled: false,
|
||||
idp: {
|
||||
issuer: "https://idp.test",
|
||||
clientId: "cid",
|
||||
clientSecret: "sec",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("formValuesToClientConfig omits block when disabled with no stored fields", () => {
|
||||
expect(
|
||||
formValuesToClientConfig({
|
||||
emaEnabled: false,
|
||||
issuer: "",
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
}),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
it("formValuesToClientConfig trims issuer and clientId when enabled", () => {
|
||||
expect(
|
||||
formValuesToClientConfig({
|
||||
emaEnabled: true,
|
||||
issuer: " https://idp.test ",
|
||||
clientId: " cid ",
|
||||
clientSecret: "sec",
|
||||
}),
|
||||
).toEqual({
|
||||
enterpriseManagedAuth: {
|
||||
enabled: true,
|
||||
idp: {
|
||||
issuer: "https://idp.test",
|
||||
clientId: "cid",
|
||||
clientSecret: "sec",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("canPersistClientSettingsDraft allows disabled or complete IdP fields", () => {
|
||||
expect(
|
||||
canPersistClientSettingsDraft({
|
||||
emaEnabled: false,
|
||||
issuer: "",
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
canPersistClientSettingsDraft({
|
||||
emaEnabled: true,
|
||||
issuer: "https://idp.test",
|
||||
clientId: "cid",
|
||||
clientSecret: "",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
canPersistClientSettingsDraft({
|
||||
emaEnabled: true,
|
||||
issuer: "",
|
||||
clientId: "cid",
|
||||
clientSecret: "",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import type { ClientConfig } from "@inspector/core/client/types.js";
|
||||
|
||||
/** Form shape for install-level client settings (client.json). */
|
||||
export interface ClientSettingsFormValues {
|
||||
emaEnabled: boolean;
|
||||
issuer: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
}
|
||||
|
||||
export const EMPTY_CLIENT_SETTINGS: ClientSettingsFormValues = {
|
||||
emaEnabled: false,
|
||||
issuer: "",
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
};
|
||||
|
||||
export function clientConfigToFormValues(
|
||||
config: ClientConfig,
|
||||
): ClientSettingsFormValues {
|
||||
const ema = config.enterpriseManagedAuth;
|
||||
const idp = ema?.idp;
|
||||
if (!idp) {
|
||||
return { ...EMPTY_CLIENT_SETTINGS };
|
||||
}
|
||||
return {
|
||||
emaEnabled: ema.enabled !== false,
|
||||
issuer: idp.issuer,
|
||||
clientId: idp.clientId,
|
||||
clientSecret: idp.clientSecret ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function hasStoredIdpFields(values: ClientSettingsFormValues): boolean {
|
||||
return (
|
||||
values.issuer.trim() !== "" ||
|
||||
values.clientId.trim() !== "" ||
|
||||
values.clientSecret !== ""
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Skip debounced persist while EMA is enabled but required IdP fields are blank. */
|
||||
export function canPersistClientSettingsDraft(
|
||||
values: ClientSettingsFormValues,
|
||||
): boolean {
|
||||
if (!values.emaEnabled) return true;
|
||||
return values.issuer.trim() !== "" && values.clientId.trim() !== "";
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
import { AppShell } from "@mantine/core";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { fn } from "storybook/test";
|
||||
import { useArgs } from "storybook/preview-api";
|
||||
import {
|
||||
ClientSettingsModal,
|
||||
type ClientSettingsModalProps,
|
||||
} from "./ClientSettingsModal";
|
||||
import type { ClientSettingsFormValues } from "../ClientSettingsForm/clientSettingsValues";
|
||||
|
||||
const configuredSettings: ClientSettingsFormValues = {
|
||||
emaEnabled: true,
|
||||
issuer: "https://idp.example.com",
|
||||
clientId: "inspector-idp-client",
|
||||
clientSecret: "super-secret-idp-value",
|
||||
};
|
||||
|
||||
function InteractiveRender(args: ClientSettingsModalProps) {
|
||||
const [, updateArgs] = useArgs<ClientSettingsModalProps>();
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<AppShell.Main>
|
||||
<ClientSettingsModal
|
||||
{...args}
|
||||
onSettingsChange={(settings) => {
|
||||
args.onSettingsChange(settings);
|
||||
updateArgs({ settings });
|
||||
}}
|
||||
/>
|
||||
</AppShell.Main>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta<typeof ClientSettingsModal> = {
|
||||
title: "Groups/ClientSettingsModal",
|
||||
component: ClientSettingsModal,
|
||||
parameters: { layout: "fullscreen" },
|
||||
render: InteractiveRender,
|
||||
args: {
|
||||
opened: true,
|
||||
onClose: fn(),
|
||||
onSettingsChange: fn(),
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ClientSettingsModal>;
|
||||
|
||||
export const Configured: Story = {
|
||||
args: {
|
||||
settings: configuredSettings,
|
||||
},
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
args: {
|
||||
settings: {
|
||||
emaEnabled: false,
|
||||
issuer: "",
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const EnabledEmptyFields: Story = {
|
||||
args: {
|
||||
settings: {
|
||||
emaEnabled: true,
|
||||
issuer: "",
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
||||
import { ClientSettingsModal } from "./ClientSettingsModal";
|
||||
import { EMPTY_CLIENT_SETTINGS } from "../ClientSettingsForm/clientSettingsValues";
|
||||
|
||||
describe("ClientSettingsModal", () => {
|
||||
it("renders the title when opened", () => {
|
||||
renderWithMantine(
|
||||
<ClientSettingsModal
|
||||
opened
|
||||
settings={EMPTY_CLIENT_SETTINGS}
|
||||
onClose={vi.fn()}
|
||||
onSettingsChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Client Settings")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("invokes onClose when the CloseButton is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
renderWithMantine(
|
||||
<ClientSettingsModal
|
||||
opened
|
||||
settings={EMPTY_CLIENT_SETTINGS}
|
||||
onClose={onClose}
|
||||
onSettingsChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await user.click(
|
||||
document.querySelector("button.mantine-CloseButton-root")!,
|
||||
);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onSettingsChange when toggling EMA enable", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSettingsChange = vi.fn();
|
||||
renderWithMantine(
|
||||
<ClientSettingsModal
|
||||
opened
|
||||
settings={EMPTY_CLIENT_SETTINGS}
|
||||
onClose={vi.fn()}
|
||||
onSettingsChange={onSettingsChange}
|
||||
/>,
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("checkbox", {
|
||||
name: "Enable enterprise IdP configuration",
|
||||
}),
|
||||
);
|
||||
expect(onSettingsChange).toHaveBeenCalledWith({
|
||||
...EMPTY_CLIENT_SETTINGS,
|
||||
emaEnabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("shows IdP fields when EMA is enabled", () => {
|
||||
renderWithMantine(
|
||||
<ClientSettingsModal
|
||||
opened
|
||||
settings={{ ...EMPTY_CLIENT_SETTINGS, emaEnabled: true }}
|
||||
onClose={vi.fn()}
|
||||
onSettingsChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByLabelText("Issuer")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Client ID")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Client Secret")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onSettingsChange when typing issuer", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSettingsChange = vi.fn();
|
||||
renderWithMantine(
|
||||
<ClientSettingsModal
|
||||
opened
|
||||
settings={{ ...EMPTY_CLIENT_SETTINGS, emaEnabled: true }}
|
||||
onClose={vi.fn()}
|
||||
onSettingsChange={onSettingsChange}
|
||||
/>,
|
||||
);
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
import { useState } from "react";
|
||||
import { CloseButton, Group, Modal, Stack, Title } from "@mantine/core";
|
||||
import { ListToggle } from "../../elements/ListToggle/ListToggle";
|
||||
import {
|
||||
ClientSettingsForm,
|
||||
type ClientSettingsSection,
|
||||
} from "../ClientSettingsForm/ClientSettingsForm";
|
||||
import type { EmaIdpLoginState } from "@inspector/core/auth/ema/idpSession.js";
|
||||
import type { ClientSettingsFormValues } from "../ClientSettingsForm/clientSettingsValues.js";
|
||||
|
||||
const ALL_SECTIONS: ClientSettingsSection[] = ["ema"];
|
||||
|
||||
export interface ClientSettingsModalProps {
|
||||
opened: boolean;
|
||||
settings: ClientSettingsFormValues;
|
||||
onClose: () => void;
|
||||
onSettingsChange: (settings: ClientSettingsFormValues) => void;
|
||||
emaIdpLoginState?: EmaIdpLoginState;
|
||||
onEmaIdpLogout?: () => void;
|
||||
}
|
||||
|
||||
export function ClientSettingsModal({
|
||||
opened,
|
||||
settings,
|
||||
onClose,
|
||||
onSettingsChange,
|
||||
emaIdpLoginState,
|
||||
onEmaIdpLogout,
|
||||
}: ClientSettingsModalProps) {
|
||||
const [expandedSections, setExpandedSections] = useState<
|
||||
ClientSettingsSection[]
|
||||
>(["ema"]);
|
||||
|
||||
const allExpanded = expandedSections.length === ALL_SECTIONS.length;
|
||||
|
||||
function handleToggleAll() {
|
||||
setExpandedSections(allExpanded ? [] : ALL_SECTIONS);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
withCloseButton={false}
|
||||
size="lg"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<ListToggle
|
||||
compact={!allExpanded}
|
||||
variant="subtle"
|
||||
onToggle={handleToggleAll}
|
||||
/>
|
||||
<Title order={4} ta="center" flex={1}>
|
||||
Client Settings
|
||||
</Title>
|
||||
<CloseButton onClick={onClose} />
|
||||
</Group>
|
||||
<ClientSettingsForm
|
||||
settings={settings}
|
||||
expandedSections={expandedSections}
|
||||
onExpandedSectionsChange={setExpandedSections}
|
||||
onSettingsChange={onSettingsChange}
|
||||
emaIdpLoginState={emaIdpLoginState}
|
||||
onEmaIdpLogout={onEmaIdpLogout}
|
||||
/>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -84,6 +84,8 @@ export const WithOAuth: Story = {
|
|||
},
|
||||
transport: "streamable-http",
|
||||
oauth: {
|
||||
protocol: "standard",
|
||||
authorized: true,
|
||||
authUrl: "https://auth.example.com/oauth2/authorize",
|
||||
scopes: ["read", "write", "admin"],
|
||||
accessToken: "eyJhbGciOiJSUzI1NiIs...truncated",
|
||||
|
|
|
|||
|
|
@ -102,6 +102,9 @@ describe("ConnectionInfoContent", () => {
|
|||
clientCapabilities={fullClientCaps}
|
||||
transport="streamable-http"
|
||||
oauth={{
|
||||
protocol: "standard",
|
||||
authorized: true,
|
||||
clientId: "client-abc",
|
||||
authUrl: "https://auth.example.com/authorize",
|
||||
scopes: ["read", "write"],
|
||||
accessToken: "token-123",
|
||||
|
|
@ -109,6 +112,9 @@ describe("ConnectionInfoContent", () => {
|
|||
/>,
|
||||
);
|
||||
expect(screen.getByText("OAuth Details")).toBeInTheDocument();
|
||||
expect(screen.getByText("Standard")).toBeInTheDocument();
|
||||
expect(screen.getByText("Authorized")).toBeInTheDocument();
|
||||
expect(screen.getByText("client-abc")).toBeInTheDocument();
|
||||
expect(screen.getByText("Auth URL")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("https://auth.example.com/authorize"),
|
||||
|
|
@ -117,16 +123,35 @@ describe("ConnectionInfoContent", () => {
|
|||
expect(screen.getByText("token-123")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides OAuth fields that are not provided", () => {
|
||||
it("renders EMA idp session when provided", () => {
|
||||
renderWithMantine(
|
||||
<ConnectionInfoContent
|
||||
initializeResult={fullResult}
|
||||
clientCapabilities={fullClientCaps}
|
||||
transport="streamable-http"
|
||||
oauth={{
|
||||
protocol: "ema",
|
||||
authorized: true,
|
||||
idpSession: "logged_in",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Enterprise-managed")).toBeInTheDocument();
|
||||
expect(screen.getByText("IdP session")).toBeInTheDocument();
|
||||
expect(screen.getByText("Signed in")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides optional OAuth fields that are not provided", () => {
|
||||
renderWithMantine(
|
||||
<ConnectionInfoContent
|
||||
initializeResult={fullResult}
|
||||
clientCapabilities={fullClientCaps}
|
||||
transport="stdio"
|
||||
oauth={{ scopes: [] }}
|
||||
oauth={{ protocol: "standard", authorized: false }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("OAuth Details")).toBeInTheDocument();
|
||||
expect(screen.getByText("Not authorized")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Auth URL")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Scopes")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Access Token")).not.toBeInTheDocument();
|
||||
|
|
|
|||
|
|
@ -17,11 +17,17 @@ import {
|
|||
type CapabilityKey,
|
||||
} from "../../elements/CapabilityItem/CapabilityItem";
|
||||
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
|
||||
import { OAuthAccessTokenField } from "./OAuthAccessTokenField";
|
||||
|
||||
export interface OAuthDetails {
|
||||
protocol: "standard" | "ema";
|
||||
authorized: boolean;
|
||||
clientId?: string;
|
||||
authUrl?: string;
|
||||
scopes?: string[];
|
||||
accessToken?: string;
|
||||
/** EMA only — install-level IdP session for legs 1–2. */
|
||||
idpSession?: "none" | "logged_in" | "expired";
|
||||
}
|
||||
|
||||
export interface ConnectionInfoContentProps {
|
||||
|
|
@ -45,6 +51,23 @@ function formatScopes(scopes: string[]): string {
|
|||
return scopes.join(", ");
|
||||
}
|
||||
|
||||
function formatProtocol(protocol: OAuthDetails["protocol"]): string {
|
||||
return protocol === "ema" ? "Enterprise-managed" : "Standard";
|
||||
}
|
||||
|
||||
function formatIdpSession(
|
||||
session: NonNullable<OAuthDetails["idpSession"]>,
|
||||
): string {
|
||||
switch (session) {
|
||||
case "logged_in":
|
||||
return "Signed in";
|
||||
case "expired":
|
||||
return "Session expired";
|
||||
default:
|
||||
return "Not signed in";
|
||||
}
|
||||
}
|
||||
|
||||
const SERVER_CAPABILITY_KEYS: CapabilityKey[] = [
|
||||
"tools",
|
||||
"resources",
|
||||
|
|
@ -148,6 +171,30 @@ export function ConnectionInfoContent({
|
|||
<Stack gap="xs">
|
||||
<SectionHeading>OAuth Details</SectionHeading>
|
||||
<Stack gap="xs">
|
||||
<SimpleGrid cols={2}>
|
||||
<Text size="sm">Protocol</Text>
|
||||
<ValueText>{formatProtocol(oauth.protocol)}</ValueText>
|
||||
|
||||
<Text size="sm">Status</Text>
|
||||
<Badge
|
||||
variant="outline"
|
||||
color={oauth.authorized ? "green" : "gray"}
|
||||
>
|
||||
{oauth.authorized ? "Authorized" : "Not authorized"}
|
||||
</Badge>
|
||||
</SimpleGrid>
|
||||
{oauth.clientId && (
|
||||
<SimpleGrid cols={2}>
|
||||
<Text size="sm">Client ID</Text>
|
||||
<Code>{oauth.clientId}</Code>
|
||||
</SimpleGrid>
|
||||
)}
|
||||
{oauth.protocol === "ema" && oauth.idpSession && (
|
||||
<SimpleGrid cols={2}>
|
||||
<Text size="sm">IdP session</Text>
|
||||
<ValueText>{formatIdpSession(oauth.idpSession)}</ValueText>
|
||||
</SimpleGrid>
|
||||
)}
|
||||
{oauth.authUrl && (
|
||||
<SimpleGrid cols={2}>
|
||||
<Text size="sm">Auth URL</Text>
|
||||
|
|
@ -161,10 +208,7 @@ export function ConnectionInfoContent({
|
|||
</SimpleGrid>
|
||||
)}
|
||||
{oauth.accessToken && (
|
||||
<SimpleGrid cols={2}>
|
||||
<Text size="sm">Access Token</Text>
|
||||
<Code>{oauth.accessToken}</Code>
|
||||
</SimpleGrid>
|
||||
<OAuthAccessTokenField accessToken={oauth.accessToken} />
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
||||
import { OAuthAccessTokenField } from "./OAuthAccessTokenField";
|
||||
|
||||
const jwt = "eyJhbGciOiJub25lIn0.eyJzdWIiOiJ1c2VyIn0.";
|
||||
|
||||
describe("OAuthAccessTokenField", () => {
|
||||
it("renders token with copy control beside the label", () => {
|
||||
renderWithMantine(<OAuthAccessTokenField accessToken={jwt} />);
|
||||
expect(screen.getByText("Access Token")).toBeInTheDocument();
|
||||
expect(screen.getByText("eyJhbGciOiJub25lIn0")).toBeInTheDocument();
|
||||
expect(screen.getByText("eyJzdWIiOiJ1c2VyIn0")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("replaces raw token with decoded JSON and restores on toggle", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(<OAuthAccessTokenField accessToken={jwt} />);
|
||||
|
||||
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();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Show token" }));
|
||||
expect(screen.getByText("eyJhbGciOiJub25lIn0")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/"sub": "user"/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("omits decode toggle for opaque tokens", () => {
|
||||
renderWithMantine(
|
||||
<OAuthAccessTokenField accessToken="opaque-access-token-value" />,
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Decode JWT" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
import { Fragment, useMemo, useState } from "react";
|
||||
import { Button, Code, Flex, Stack, Text } from "@mantine/core";
|
||||
import { decodeJwtPayload, isJwtFormat } from "@inspector/core/auth/ema/jwt.js";
|
||||
import { CopyButton } from "../../elements/CopyButton/CopyButton";
|
||||
|
||||
export interface OAuthAccessTokenFieldProps {
|
||||
accessToken: string;
|
||||
}
|
||||
|
||||
const DecodeButton = Button.withProps({
|
||||
variant: "subtle",
|
||||
size: "compact-xs",
|
||||
});
|
||||
|
||||
/** Wrap JWT at segment boundaries; break long segments without orphaning `.`. */
|
||||
function JwtTokenText({ token }: { token: string }) {
|
||||
const parts = token.split(".");
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, index) => (
|
||||
<Fragment key={index}>
|
||||
{index > 0 && "."}
|
||||
<span style={{ wordBreak: "break-all" }}>{part}</span>
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function OAuthAccessTokenField({
|
||||
accessToken,
|
||||
}: OAuthAccessTokenFieldProps) {
|
||||
const [showDecoded, setShowDecoded] = useState(false);
|
||||
const isJwt = isJwtFormat(accessToken);
|
||||
const jwtDecoded = useMemo(
|
||||
() => (isJwt ? decodeJwtPayload(accessToken) : undefined),
|
||||
[accessToken, isJwt],
|
||||
);
|
||||
|
||||
const decodedText = useMemo(() => {
|
||||
if (!jwtDecoded) return undefined;
|
||||
return JSON.stringify(
|
||||
{ header: jwtDecoded.header, payload: jwtDecoded.payload },
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}, [jwtDecoded]);
|
||||
|
||||
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">
|
||||
{jwtDecoded && (
|
||||
<DecodeButton
|
||||
onClick={() => setShowDecoded((open) => !open)}
|
||||
aria-pressed={showDecoded}
|
||||
>
|
||||
{showDecoded ? "Show token" : "Decode JWT"}
|
||||
</DecodeButton>
|
||||
)}
|
||||
<CopyButton value={accessToken} />
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Code block p="sm" variant="wrapping">
|
||||
{showDecoded && decodedText ? (
|
||||
decodedText
|
||||
) : isJwt ? (
|
||||
<JwtTokenText token={accessToken} />
|
||||
) : (
|
||||
accessToken
|
||||
)}
|
||||
</Code>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import type { OAuthConnectionState } from "@inspector/core/auth/types.js";
|
||||
import { oauthDetailsFromConnectionState } from "./oauthDetailsFromConnectionState";
|
||||
|
||||
describe("oauthDetailsFromConnectionState", () => {
|
||||
it("maps authorized standard connection state", () => {
|
||||
const state: OAuthConnectionState = {
|
||||
authorized: true,
|
||||
protocol: "standard",
|
||||
serverUrl: "https://mcp.example.com/mcp",
|
||||
grantedScope: "read write",
|
||||
tokens: { access_token: "tok", token_type: "Bearer" },
|
||||
client: {
|
||||
source: "preregistered",
|
||||
clientId: "client-1",
|
||||
hasClientSecret: true,
|
||||
},
|
||||
authorizationServerMetadata: {
|
||||
issuer: "https://auth.example.com",
|
||||
authorization_endpoint: "https://auth.example.com/authorize",
|
||||
token_endpoint: "https://auth.example.com/token",
|
||||
response_types_supported: ["code"],
|
||||
},
|
||||
};
|
||||
|
||||
expect(oauthDetailsFromConnectionState(state)).toEqual({
|
||||
protocol: "standard",
|
||||
authorized: true,
|
||||
clientId: "client-1",
|
||||
authUrl: "https://auth.example.com/authorize",
|
||||
scopes: ["read", "write"],
|
||||
accessToken: "tok",
|
||||
});
|
||||
});
|
||||
|
||||
it("includes EMA idp session when present", () => {
|
||||
const state: OAuthConnectionState = {
|
||||
authorized: false,
|
||||
protocol: "ema",
|
||||
serverUrl: "https://mcp.example.com/mcp",
|
||||
ema: {
|
||||
idpIssuer: "https://idp.example.com",
|
||||
idpClientId: "idp-client",
|
||||
idpSession: "logged_in",
|
||||
},
|
||||
};
|
||||
|
||||
expect(oauthDetailsFromConnectionState(state)).toEqual({
|
||||
protocol: "ema",
|
||||
authorized: false,
|
||||
idpSession: "logged_in",
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue