diff --git a/clients/cli/package-lock.json b/clients/cli/package-lock.json index 7923015d..4514ee2a 100644 --- a/clients/cli/package-lock.json +++ b/clients/cli/package-lock.json @@ -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": [ diff --git a/clients/launcher/package-lock.json b/clients/launcher/package-lock.json index 49659628..ece3eed9 100644 --- a/clients/launcher/package-lock.json +++ b/clients/launcher/package-lock.json @@ -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": [ diff --git a/clients/tui/src/App.tsx b/clients/tui/src/App.tsx index cdd651db..0464533d 100644 --- a/clients/tui/src/App.tsx +++ b/clients/tui/src/App.tsx @@ -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(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); } diff --git a/clients/tui/src/components/AuthTab.tsx b/clients/tui/src/components/AuthTab.tsx index 2af7d13b..4b8d868b 100644 --- a/clients/tui/src/components/AuthTab.tsx +++ b/clients/tui/src/components/AuthTab.tsx @@ -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 = { metadata_discovery: "Metadata Discovery", @@ -68,21 +68,21 @@ export function AuthTab({ isOAuthCapable, }: AuthTabProps) { const scrollViewRef = useRef(null); - const [oauthState, setOauthState] = useState( - 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 && ( - - Authorization URL opened in browser - - - {oauthState.authorizationUrl.toString()} - + {oauthFlowState && + needsAuthCode && + oauthFlowState?.authorizationUrl && ( + + Authorization URL opened in browser + + + {oauthFlowState.authorizationUrl.toString()} + + + + + Complete authorization in the browser. You will be + redirected and the flow will complete automatically. + + - - - Complete authorization in the browser. You will be - redirected and the flow will complete automatically. - - - - )} + )} )} @@ -333,24 +335,33 @@ export function AuthTab({ {oauthMessage} )} {oauthStatus === "success" && - oauthState && - oauthState.authType === "normal" && - (oauthState.oauthTokens || oauthState.oauthClientInfo) && ( + oauthFlowState && + oauthFlowState.execution === "quick" && + (oauthFlowState.oauthTokens || + oauthFlowState.oauthClientInfo) && ( <> Quick Auth Results - {oauthState.oauthClientInfo && ( + {oauthFlowState.oauthClientInfo && ( Client:{" "} - {JSON.stringify(oauthState.oauthClientInfo, null, 2)} + {JSON.stringify( + oauthFlowState.oauthClientInfo, + null, + 2, + )} )} - {oauthState.oauthTokens && ( + {oauthFlowState.oauthTokens && ( Access Token:{" "} - {oauthState.oauthTokens.access_token?.slice(0, 20)}... + {oauthFlowState.oauthTokens.access_token?.slice( + 0, + 20, + )} + ... )} @@ -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) { diff --git a/clients/web/src/App.test.tsx b/clients/web/src/App.test.tsx index 55559799..23808833 100644 --- a/clients/web/src/App.test.tsx +++ b/clients/web/src/App.test.tsx @@ -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(); 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); diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 7b07379d..a6fb788b 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -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(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({}); + 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(() => { - 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(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} /> + {initializeResult && activeServer && ( {copied ? "\u2713" : "\u2398"} diff --git a/clients/web/src/components/groups/ClientSettingsForm/ClientSettingsForm.test.tsx b/clients/web/src/components/groups/ClientSettingsForm/ClientSettingsForm.test.tsx new file mode 100644 index 00000000..2be09c98 --- /dev/null +++ b/clients/web/src/components/groups/ClientSettingsForm/ClientSettingsForm.test.tsx @@ -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( + , + ); + + 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( + , + ); + + 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( + , + ); + + expect(screen.getByText("Session expired")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Sign out" }), + ).toBeInTheDocument(); + }); +}); diff --git a/clients/web/src/components/groups/ClientSettingsForm/ClientSettingsForm.tsx b/clients/web/src/components/groups/ClientSettingsForm/ClientSettingsForm.tsx new file mode 100644 index 00000000..daead7a9 --- /dev/null +++ b/clients/web/src/components/groups/ClientSettingsForm/ClientSettingsForm.tsx @@ -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) { + onSettingsChange({ ...settings, ...partial }); + } + + const showIdpSession = + settings.emaEnabled && + settings.issuer.trim() !== "" && + emaIdpLoginState !== "none"; + + return ( + + onExpandedSectionsChange(value as ClientSettingsSection[]) + } + variant="separated" + > + + Enterprise-Managed Authorization + + + patch({ emaEnabled: e.currentTarget.checked })} + /> + {settings.emaEnabled && ( + <> + + Register this redirect URI with your IdP:{" "} + {typeof window !== "undefined" + ? `${window.location.origin}/oauth/callback` + : "http://localhost:6274/oauth/callback"} + + patch({ issuer: e.currentTarget.value })} + rightSectionPointerEvents="auto" + rightSection={ + settings.issuer ? ( + patch({ issuer: "" })} /> + ) : null + } + /> + patch({ clientId: e.currentTarget.value })} + rightSectionPointerEvents="auto" + rightSection={ + settings.clientId ? ( + patch({ clientId: "" })} /> + ) : null + } + /> + + patch({ clientSecret: e.currentTarget.value }) + } + rightSectionPointerEvents="auto" + rightSection={ + settings.clientSecret ? ( + patch({ clientSecret: "" })} + /> + ) : null + } + /> + {settings.issuer.trim() !== "" && ( + + + + IdP sign-in + + {emaIdpLoginState === "logged_in" ? ( + <> + + Signed in + + + 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. + + + ) : emaIdpLoginState === "expired" ? ( + <> + + Session expired + + + Your cached IdP session has expired. The next + connect to an EMA-enabled server will prompt for IdP + login. + + + ) : ( + + Not signed in to your enterprise IdP. Connecting to an + EMA-enabled MCP server will open IdP login. + + )} + + {showIdpSession && onEmaIdpLogout ? ( + + ) : null} + + )} + + )} + + + + + ); +} diff --git a/clients/web/src/components/groups/ClientSettingsForm/clientSettingsValues.test.ts b/clients/web/src/components/groups/ClientSettingsForm/clientSettingsValues.test.ts new file mode 100644 index 00000000..c00391e1 --- /dev/null +++ b/clients/web/src/components/groups/ClientSettingsForm/clientSettingsValues.test.ts @@ -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); + }); +}); diff --git a/clients/web/src/components/groups/ClientSettingsForm/clientSettingsValues.ts b/clients/web/src/components/groups/ClientSettingsForm/clientSettingsValues.ts new file mode 100644 index 00000000..a72975d9 --- /dev/null +++ b/clients/web/src/components/groups/ClientSettingsForm/clientSettingsValues.ts @@ -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() !== ""; +} diff --git a/clients/web/src/components/groups/ClientSettingsModal/ClientSettingsModal.stories.tsx b/clients/web/src/components/groups/ClientSettingsModal/ClientSettingsModal.stories.tsx new file mode 100644 index 00000000..c41ba4db --- /dev/null +++ b/clients/web/src/components/groups/ClientSettingsModal/ClientSettingsModal.stories.tsx @@ -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(); + + return ( + + + { + args.onSettingsChange(settings); + updateArgs({ settings }); + }} + /> + + + ); +} + +const meta: Meta = { + title: "Groups/ClientSettingsModal", + component: ClientSettingsModal, + parameters: { layout: "fullscreen" }, + render: InteractiveRender, + args: { + opened: true, + onClose: fn(), + onSettingsChange: fn(), + }, +}; + +export default meta; +type Story = StoryObj; + +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: "", + }, + }, +}; diff --git a/clients/web/src/components/groups/ClientSettingsModal/ClientSettingsModal.test.tsx b/clients/web/src/components/groups/ClientSettingsModal/ClientSettingsModal.test.tsx new file mode 100644 index 00000000..1411d217 --- /dev/null +++ b/clients/web/src/components/groups/ClientSettingsModal/ClientSettingsModal.test.tsx @@ -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( + , + ); + expect(screen.getByText("Client Settings")).toBeInTheDocument(); + }); + + it("invokes onClose when the CloseButton is clicked", async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + renderWithMantine( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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"); + }); +}); diff --git a/clients/web/src/components/groups/ClientSettingsModal/ClientSettingsModal.tsx b/clients/web/src/components/groups/ClientSettingsModal/ClientSettingsModal.tsx new file mode 100644 index 00000000..e003186f --- /dev/null +++ b/clients/web/src/components/groups/ClientSettingsModal/ClientSettingsModal.tsx @@ -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 ( + + + + + + Client Settings + + + + + + + ); +} diff --git a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.stories.tsx b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.stories.tsx index 2aec12e6..6127e243 100644 --- a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.stories.tsx +++ b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.stories.tsx @@ -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", diff --git a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx index 687c51d1..27e1a970 100644 --- a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx +++ b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.test.tsx @@ -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( + , + ); + 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( , ); 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(); diff --git a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.tsx b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.tsx index 9e3e5601..790220cf 100644 --- a/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.tsx +++ b/clients/web/src/components/groups/ConnectionInfoContent/ConnectionInfoContent.tsx @@ -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, +): 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({ OAuth Details + + Protocol + {formatProtocol(oauth.protocol)} + + Status + + {oauth.authorized ? "Authorized" : "Not authorized"} + + + {oauth.clientId && ( + + Client ID + {oauth.clientId} + + )} + {oauth.protocol === "ema" && oauth.idpSession && ( + + IdP session + {formatIdpSession(oauth.idpSession)} + + )} {oauth.authUrl && ( Auth URL @@ -161,10 +208,7 @@ export function ConnectionInfoContent({ )} {oauth.accessToken && ( - - Access Token - {oauth.accessToken} - + )} diff --git a/clients/web/src/components/groups/ConnectionInfoContent/OAuthAccessTokenField.test.tsx b/clients/web/src/components/groups/ConnectionInfoContent/OAuthAccessTokenField.test.tsx new file mode 100644 index 00000000..bed9eaf8 --- /dev/null +++ b/clients/web/src/components/groups/ConnectionInfoContent/OAuthAccessTokenField.test.tsx @@ -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(); + 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(); + + 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( + , + ); + expect( + screen.queryByRole("button", { name: "Decode JWT" }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/clients/web/src/components/groups/ConnectionInfoContent/OAuthAccessTokenField.tsx b/clients/web/src/components/groups/ConnectionInfoContent/OAuthAccessTokenField.tsx new file mode 100644 index 00000000..d27336e5 --- /dev/null +++ b/clients/web/src/components/groups/ConnectionInfoContent/OAuthAccessTokenField.tsx @@ -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) => ( + + {index > 0 && "."} + {part} + + ))} + + ); +} + +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 ( + + + Access Token + + {jwtDecoded && ( + setShowDecoded((open) => !open)} + aria-pressed={showDecoded} + > + {showDecoded ? "Show token" : "Decode JWT"} + + )} + + + + + {showDecoded && decodedText ? ( + decodedText + ) : isJwt ? ( + + ) : ( + accessToken + )} + + + ); +} diff --git a/clients/web/src/components/groups/ConnectionInfoContent/oauthDetailsFromConnectionState.test.ts b/clients/web/src/components/groups/ConnectionInfoContent/oauthDetailsFromConnectionState.test.ts new file mode 100644 index 00000000..4aced4da --- /dev/null +++ b/clients/web/src/components/groups/ConnectionInfoContent/oauthDetailsFromConnectionState.test.ts @@ -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", + }); + }); +}); diff --git a/clients/web/src/components/groups/ConnectionInfoContent/oauthDetailsFromConnectionState.ts b/clients/web/src/components/groups/ConnectionInfoContent/oauthDetailsFromConnectionState.ts new file mode 100644 index 00000000..f6de192a --- /dev/null +++ b/clients/web/src/components/groups/ConnectionInfoContent/oauthDetailsFromConnectionState.ts @@ -0,0 +1,25 @@ +import type { OAuthConnectionState } from "@inspector/core/auth/types.js"; +import type { OAuthDetails } from "./ConnectionInfoContent"; + +/** Map persisted connection auth state into Connection Info display props. */ +export function oauthDetailsFromConnectionState( + state: OAuthConnectionState, +): OAuthDetails { + const scopeSource = state.grantedScope ?? state.configuredScope; + const scopes = scopeSource?.split(" ").filter(Boolean); + + return { + protocol: state.protocol, + authorized: state.authorized, + ...(state.client?.clientId && { clientId: state.client.clientId }), + ...(state.authorizationServerMetadata?.authorization_endpoint && { + authUrl: state.authorizationServerMetadata.authorization_endpoint, + }), + ...(scopes && scopes.length > 0 && { scopes }), + ...(state.tokens?.access_token && { + accessToken: state.tokens.access_token, + }), + ...(state.protocol === "ema" && + state.ema?.idpSession && { idpSession: state.ema.idpSession }), + }; +} diff --git a/clients/web/src/components/groups/ConnectionInfoModal/ConnectionInfoModal.stories.tsx b/clients/web/src/components/groups/ConnectionInfoModal/ConnectionInfoModal.stories.tsx index f0e28eb2..73b7ae24 100644 --- a/clients/web/src/components/groups/ConnectionInfoModal/ConnectionInfoModal.stories.tsx +++ b/clients/web/src/components/groups/ConnectionInfoModal/ConnectionInfoModal.stories.tsx @@ -77,6 +77,8 @@ export const WithOAuth: Story = { clientCapabilities, transport: "streamable-http", oauth: { + protocol: "standard", + authorized: true, authUrl: "https://auth.example.com/oauth2/authorize", scopes: ["read", "write"], accessToken: "eyJhbGciOiJSUzI1NiIs...truncated", diff --git a/clients/web/src/components/groups/ConnectionInfoModal/ConnectionInfoModal.test.tsx b/clients/web/src/components/groups/ConnectionInfoModal/ConnectionInfoModal.test.tsx index 9eb0f0ef..82ca4b29 100644 --- a/clients/web/src/components/groups/ConnectionInfoModal/ConnectionInfoModal.test.tsx +++ b/clients/web/src/components/groups/ConnectionInfoModal/ConnectionInfoModal.test.tsx @@ -63,6 +63,8 @@ describe("ConnectionInfoModal", () => { clientCapabilities={clientCapabilities} transport="streamable-http" oauth={{ + protocol: "standard", + authorized: true, authUrl: "https://auth.example.com/authorize", scopes: ["read", "write"], accessToken: "token-abc", diff --git a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx index 29ac0c97..a4d21f77 100644 --- a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx +++ b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx @@ -492,9 +492,47 @@ describe("ServerSettingsForm", () => { clientId: "a", clientSecret: "", scopes: "", + enterpriseManaged: false, }); }); + it("invokes onOAuthChange when enterprise-managed is toggled", async () => { + const user = userEvent.setup(); + const onOAuthChange = vi.fn(); + renderWithMantine( + , + ); + await user.click( + screen.getByRole("checkbox", { + name: /Enterprise-managed authorization/i, + }), + ); + expect(onOAuthChange).toHaveBeenCalledWith({ + clientId: "", + clientSecret: "", + scopes: "", + enterpriseManaged: true, + }); + }); + + it("hides the OAuth Settings section for stdio servers", () => { + renderWithMantine( + , + ); + expect(screen.queryByText("OAuth Settings")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("Client ID")).not.toBeInTheDocument(); + }); + it("invokes onOAuthChange when typing in scopes (uses existing oauth values)", async () => { const user = userEvent.setup(); const onOAuthChange = vi.fn(); @@ -531,6 +569,7 @@ describe("ServerSettingsForm", () => { clientId: "", clientSecret: "z", scopes: "", + enterpriseManaged: false, }); }); diff --git a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx index aa511eae..0b9817da 100644 --- a/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx +++ b/clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx @@ -13,7 +13,9 @@ import { ClearButton } from "../../elements/ClearButton/ClearButton"; import type { InspectorServerSettings, OAuthSettings, + ServerType, } from "@inspector/core/mcp/types.js"; +import { isOAuthCapableServerType } from "@inspector/core/mcp/config.js"; import type { Root } from "@modelcontextprotocol/sdk/types.js"; export type ServerSettingsSection = @@ -27,6 +29,8 @@ export type ServerSettingsSection = export interface ServerSettingsFormProps { settings: InspectorServerSettings; + /** Transport type — EMA checkbox is hidden for stdio. Defaults to streamable-http. */ + serverType?: ServerType; /** * Whether the target server uses the stdio transport. Gates the Working * Directory field and Environment Variables section in the Options area — @@ -176,6 +180,7 @@ function RootRows({ export function ServerSettingsForm({ settings, + serverType = "streamable-http", isStdio, expandedSections, onExpandedSectionsChange, @@ -197,6 +202,7 @@ export function ServerSettingsForm({ onRemoveRoot, onRootChange, }: ServerSettingsFormProps) { + const oauthCapable = isOAuthCapableServerType(serverType); const handleMaxFetchRequestsChange = (value: number | string) => { if (typeof value === "number") { onMaxFetchRequestsChange(value); @@ -225,6 +231,7 @@ export function ServerSettingsForm({ clientId: settings.oauthClientId ?? "", clientSecret: settings.oauthClientSecret ?? "", scopes: settings.oauthScopes ?? "", + enterpriseManaged: settings.enterpriseManaged ?? false, }; } @@ -400,78 +407,91 @@ export function ServerSettingsForm({ - - OAuth Settings - - - - Pre-configure OAuth credentials for servers requiring - authentication - - - onOAuthChange({ - ...currentOAuth(), - clientId: e.currentTarget.value, - }) - } - rightSectionPointerEvents="auto" - rightSection={ - settings.oauthClientId ? ( - - onOAuthChange({ ...currentOAuth(), clientId: "" }) - } - /> - ) : null - } - /> - - onOAuthChange({ - ...currentOAuth(), - clientSecret: e.currentTarget.value, - }) - } - rightSectionPointerEvents="auto" - rightSection={ - settings.oauthClientSecret ? ( - - onOAuthChange({ ...currentOAuth(), clientSecret: "" }) - } - /> - ) : null - } - /> - - onOAuthChange({ - ...currentOAuth(), - scopes: e.currentTarget.value, - }) - } - rightSectionPointerEvents="auto" - rightSection={ - settings.oauthScopes ? ( - - onOAuthChange({ ...currentOAuth(), scopes: "" }) - } - /> - ) : null - } - /> - - - + {oauthCapable ? ( + + OAuth Settings + + + + onOAuthChange({ + ...currentOAuth(), + enterpriseManaged: e.currentTarget.checked, + }) + } + /> + + Pre-configure OAuth credentials for servers requiring + authentication + + + onOAuthChange({ + ...currentOAuth(), + clientId: e.currentTarget.value, + }) + } + rightSectionPointerEvents="auto" + rightSection={ + settings.oauthClientId ? ( + + onOAuthChange({ ...currentOAuth(), clientId: "" }) + } + /> + ) : null + } + /> + + onOAuthChange({ + ...currentOAuth(), + clientSecret: e.currentTarget.value, + }) + } + rightSectionPointerEvents="auto" + rightSection={ + settings.oauthClientSecret ? ( + + onOAuthChange({ ...currentOAuth(), clientSecret: "" }) + } + /> + ) : null + } + /> + + onOAuthChange({ + ...currentOAuth(), + scopes: e.currentTarget.value, + }) + } + rightSectionPointerEvents="auto" + rightSection={ + settings.oauthScopes ? ( + + onOAuthChange({ ...currentOAuth(), scopes: "" }) + } + /> + ) : null + } + /> + + + + ) : null} ); } diff --git a/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.stories.tsx b/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.stories.tsx index 0d249114..e6cb9e14 100644 --- a/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.stories.tsx +++ b/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.stories.tsx @@ -53,6 +53,7 @@ const meta: Meta = { render: InteractiveRender, args: { opened: true, + serverType: "streamable-http", isStdio: false, onClose: fn(), onSettingsChange: fn(), @@ -87,6 +88,7 @@ export const EmptySettings: Story = { // Environment Variables section. export const StdioServer: Story = { args: { + serverType: "stdio", isStdio: true, settings: { headers: [], diff --git a/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.test.tsx b/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.test.tsx index 7454d721..35cac4f2 100644 --- a/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.test.tsx +++ b/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.test.tsx @@ -35,6 +35,7 @@ describe("ServerSettingsModal", () => { { { expect(screen.getByText("Custom Headers")).toBeInTheDocument(); }); + it("hides the OAuth Settings section for stdio servers", () => { + renderWithMantine( + , + ); + expect(screen.queryByText("OAuth Settings")).not.toBeInTheDocument(); + }); + it("invokes onClose when the CloseButton is clicked", async () => { const user = userEvent.setup(); const onClose = vi.fn(); @@ -64,6 +80,7 @@ describe("ServerSettingsModal", () => { { { { { { { { { { expect(call.oauthClientId).toBe("a"); }); + it("calls onSettingsChange when toggling enterprise-managed authorization", async () => { + const user = userEvent.setup(); + const onSettingsChange = vi.fn(); + renderWithMantine( + , + ); + await user.click(screen.getByRole("button", { name: "OAuth Settings" })); + await user.click( + screen.getByRole("checkbox", { + name: "Enterprise-managed authorization", + }), + ); + expect(onSettingsChange).toHaveBeenCalledWith({ + ...emptySettings, + oauthClientId: "", + oauthClientSecret: "", + oauthScopes: "", + enterpriseManaged: true, + }); + }); + it("calls onSettingsChange with a blank row when adding a root", async () => { const user = userEvent.setup(); const onSettingsChange = vi.fn(); @@ -252,6 +305,7 @@ describe("ServerSettingsModal", () => { { { { { { { { { (["options"]); - const allSections = allSectionsFor(isStdio); - const allExpanded = expandedSections.length === allSections.length; + const allExpanded = expandedSections.length === sections.length; function handleToggleAll() { - setExpandedSections(allExpanded ? [] : allSections); + setExpandedSections(allExpanded ? [] : sections); } function handleAddHeader() { @@ -139,6 +147,7 @@ export function ServerSettingsModal({ oauthClientId: oauth.clientId, oauthClientSecret: oauth.clientSecret, oauthScopes: oauth.scopes, + enterpriseManaged: oauth.enterpriseManaged ? true : undefined, }); } @@ -195,6 +204,7 @@ export function ServerSettingsModal({ { @@ -39,19 +40,44 @@ describe("ViewHeader", () => { describe("when not connected", () => { it("renders the title and theme toggle", () => { renderWithMantine( - , + , ); expect(screen.getByText("MCP Inspector")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Client settings" }), + ).toBeInTheDocument(); expect( screen.getByRole("button", { name: "Toggle color scheme" }), ).toBeInTheDocument(); }); + it("invokes onOpenClientSettings when the client settings button is clicked", async () => { + const user = userEvent.setup(); + const onOpenClientSettings = vi.fn(); + renderWithMantine( + , + ); + await user.click(screen.getByRole("button", { name: "Client settings" })); + expect(onOpenClientSettings).toHaveBeenCalled(); + }); + it("invokes onToggleTheme when the theme button is clicked", async () => { const user = userEvent.setup(); const onToggleTheme = vi.fn(); renderWithMantine( - , + , ); await user.click( screen.getByRole("button", { name: "Toggle color scheme" }), @@ -141,7 +167,13 @@ describe("ViewHeader", () => { // Disconnect: the connected header is replaced, but the tab bar stays in // the DOM (now marked for the exit animation) until the keep-alive // Transition's exit window elapses — so it isn't removed synchronously. - rerender(); + rerender( + , + ); const exitingCell = screen .getAllByRole("radio")[0]! .closest("[data-anim]"); @@ -215,7 +247,13 @@ describe("ViewHeader", () => { ).toBe("in"); // Disconnect: both stay mounted (animating out) before unmounting. - rerender(); + rerender( + , + ); expect( screen .getByText("my-mcp-server") diff --git a/clients/web/src/components/groups/ViewHeader/ViewHeader.tsx b/clients/web/src/components/groups/ViewHeader/ViewHeader.tsx index a6e06306..522d1c21 100644 --- a/clients/web/src/components/groups/ViewHeader/ViewHeader.tsx +++ b/clients/web/src/components/groups/ViewHeader/ViewHeader.tsx @@ -15,7 +15,7 @@ import { } from "@mantine/core"; import { useMediaQuery } from "@mantine/hooks"; import type { Implementation } from "@modelcontextprotocol/sdk/types.js"; -import { MdLightMode, MdDarkMode, MdLinkOff } from "react-icons/md"; +import { MdLightMode, MdDarkMode, MdLinkOff, MdSettings } from "react-icons/md"; import type { ConnectionStatus } from "@inspector/core/mcp/types.js"; import { ServerStatusIndicator } from "../../elements/ServerStatusIndicator/ServerStatusIndicator"; import mcpLogo from "../../../theme/assets/MCP.svg"; @@ -31,11 +31,13 @@ interface ConnectedProps { onTabChange: (tab: string) => void; onDisconnect: () => void; onToggleTheme: () => void; + onOpenClientSettings: () => void; } interface UnconnectedProps { connected: false; onToggleTheme: () => void; + onOpenClientSettings: () => void; } export type ViewHeaderProps = ConnectedProps | UnconnectedProps; @@ -178,6 +180,12 @@ const DisconnectIcon = ActionIcon.withProps({ "aria-label": "Disconnect", }); +const ClientSettingsToggle = ActionIcon.withProps({ + variant: "subtle", + size: 36, + "aria-label": "Client settings", +}); + const ThemeToggle = ActionIcon.withProps({ variant: "subtle", size: 36, @@ -374,6 +382,9 @@ export function ViewHeader(props: ViewHeaderProps) { ) } + + + diff --git a/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx b/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx index af95fc26..108fdc98 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx @@ -345,6 +345,7 @@ const meta: Meta = { // on dispatch. Real wiring routes these to InspectorClient methods (the // app shell at clients/web/src/App.tsx). onToggleTheme: fn(), + onOpenClientSettings: fn(), onToggleConnection: fn(), onDisconnect: fn(), onServerAdd: fn(), diff --git a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx index 3f26dd66..4b1f999a 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx @@ -80,6 +80,7 @@ function makeProps( historyUi: EMPTY_HISTORY_UI, networkUi: EMPTY_NETWORK_UI, onToggleTheme: vi.fn(), + onOpenClientSettings: vi.fn(), onToggleConnection: vi.fn(), onDisconnect: vi.fn(), onServerAdd: vi.fn(), diff --git a/clients/web/src/components/views/InspectorView/InspectorView.tsx b/clients/web/src/components/views/InspectorView/InspectorView.tsx index 29fd8c16..3ed89b6b 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.tsx @@ -259,6 +259,8 @@ export interface InspectorViewProps { // Theme toggle (lives in the parent so the color scheme can also flow // into other top-level UI later). onToggleTheme: () => void; + /** Open install-level client settings (client.json / EMA IdP). */ + onOpenClientSettings: () => void; // Connection lifecycle (dispatched to `useInspectorClient.connect/disconnect`). onToggleConnection: (id: string) => void; @@ -394,6 +396,7 @@ export function InspectorView({ appRendererRef, pinnedHistoryIds, onToggleTheme, + onOpenClientSettings, onToggleConnection, onDisconnect, onServerAdd, @@ -593,9 +596,14 @@ export function InspectorView({ onTabChange={setSelectedTab} onDisconnect={onDisconnect} onToggleTheme={onToggleTheme} + onOpenClientSettings={onOpenClientSettings} /> ) : ( - + )} diff --git a/clients/web/src/lib/environmentFactory.ts b/clients/web/src/lib/environmentFactory.ts index bba83fc3..e64cadef 100644 --- a/clients/web/src/lib/environmentFactory.ts +++ b/clients/web/src/lib/environmentFactory.ts @@ -5,7 +5,7 @@ import { createRemoteLogger, } from "@inspector/core/mcp/remote/index.js"; import { - BrowserOAuthStorage, + getBrowserOAuthStorage, BrowserNavigation, } from "@inspector/core/auth/browser/index.js"; import type { RedirectUrlProvider } from "@inspector/core/auth/index.js"; @@ -67,7 +67,7 @@ export function createWebEnvironment( }), logger, oauth: { - storage: new BrowserOAuthStorage(), + storage: getBrowserOAuthStorage(), navigation: new BrowserNavigation(undefined, onBeforeOAuthRedirect), redirectUrlProvider, }, diff --git a/clients/web/src/test/core/auth/connection-state.test.ts b/clients/web/src/test/core/auth/connection-state.test.ts new file mode 100644 index 00000000..064aa259 --- /dev/null +++ b/clients/web/src/test/core/auth/connection-state.test.ts @@ -0,0 +1,173 @@ +import { describe, it, expect, vi } from "vitest"; +import { + buildOAuthConnectionState, + isServerOAuthConfigured, + protocolFromOAuthConfig, +} from "@inspector/core/auth/connection-state.js"; +import type { OAuthStorage } from "@inspector/core/auth/storage.js"; + +const SERVER_URL = "https://mcp.example.com/mcp"; + +function createStorage( + overrides: Partial<{ + tokens: Awaited>; + preregistered: Awaited>; + dynamic: Awaited>; + scope: string | undefined; + serverMetadata: ReturnType; + idpSession: Awaited>; + idpMetadata: ReturnType; + }> = {}, +): OAuthStorage { + return { + getTokens: vi.fn().mockResolvedValue(overrides.tokens), + getClientInformation: vi.fn(async (_url, isPreregistered) => + isPreregistered ? overrides.preregistered : overrides.dynamic, + ), + getScope: vi.fn().mockReturnValue(overrides.scope), + getServerMetadata: vi.fn((url: string) => { + if (url.startsWith("ema-idp:")) { + return overrides.idpMetadata ?? null; + } + return overrides.serverMetadata ?? null; + }), + getIdpSession: vi.fn().mockResolvedValue(overrides.idpSession), + saveClientInformation: vi.fn(), + savePreregisteredClientInformation: vi.fn(), + saveTokens: vi.fn(), + saveScope: vi.fn(), + saveCodeVerifier: vi.fn(), + saveServerMetadata: vi.fn(), + saveIdpSession: vi.fn(), + clear: vi.fn(), + clearClientInformation: vi.fn(), + clearTokens: vi.fn(), + clearCodeVerifier: vi.fn(), + clearScope: vi.fn(), + clearServerMetadata: vi.fn(), + clearIdpSession: vi.fn(), + clearEnterpriseManagedResourceServers: vi.fn(), + getCodeVerifier: vi.fn(), + }; +} + +describe("isServerOAuthConfigured", () => { + it("returns true when enterpriseManaged is set", () => { + expect(isServerOAuthConfigured({ enterpriseManaged: true })).toBe(true); + }); + + it("returns false when all oauth fields are empty", () => { + expect(isServerOAuthConfigured({})).toBe(false); + }); +}); + +describe("protocolFromOAuthConfig", () => { + it("maps enterpriseManaged to ema", () => { + expect(protocolFromOAuthConfig({ enterpriseManaged: true })).toBe("ema"); + }); +}); + +describe("buildOAuthConnectionState", () => { + it("returns authorized standard state from storage tokens", async () => { + const storage = createStorage({ + tokens: { access_token: "opaque-token", token_type: "Bearer" }, + preregistered: { client_id: "cfg-client" }, + scope: "mcp", + serverMetadata: { + issuer: "https://auth.example.com", + authorization_endpoint: "https://auth.example.com/authorize", + token_endpoint: "https://auth.example.com/token", + response_types_supported: ["code"], + }, + }); + + const state = await buildOAuthConnectionState({ + serverUrl: SERVER_URL, + protocol: "standard", + configuredScope: "mcp", + storage, + }); + + expect(state.authorized).toBe(true); + expect(state.protocol).toBe("standard"); + expect(state.client).toEqual({ + source: "preregistered", + clientId: "cfg-client", + hasClientSecret: false, + }); + expect(state.grantedScope).toBe("mcp"); + expect(state.authorizationServerMetadata?.authorization_endpoint).toBe( + "https://auth.example.com/authorize", + ); + }); + + it("returns unauthorized when tokens are missing", async () => { + const storage = createStorage(); + const state = await buildOAuthConnectionState({ + serverUrl: SERVER_URL, + protocol: "standard", + configuredScope: "mcp", + storage, + }); + expect(state.authorized).toBe(false); + expect(state.tokens).toBeUndefined(); + expect(state.configuredScope).toBe("mcp"); + }); + + it("prefers usable in-memory flow tokens over storage", async () => { + const storage = createStorage({ + tokens: { access_token: "stored", token_type: "Bearer" }, + }); + const state = await buildOAuthConnectionState({ + serverUrl: SERVER_URL, + protocol: "standard", + storage, + flowState: { + oauthTokens: { access_token: "flow", token_type: "Bearer" }, + } as never, + }); + expect(state.tokens?.access_token).toBe("flow"); + expect(state.authorized).toBe(true); + }); + + it("includes EMA idp session summary without raw id token", async () => { + const storage = createStorage({ + tokens: { access_token: "resource-token", token_type: "Bearer" }, + idpSession: { + idToken: "eyJhbGciOiJub25lIn0.eyJleHAiOjk5OTk5OTk5OTl9.", + refreshToken: "rt", + }, + idpMetadata: { + issuer: "https://idp.example.com", + authorization_endpoint: "https://idp.example.com/authorize", + token_endpoint: "https://idp.example.com/token", + response_types_supported: ["code"], + }, + }); + + const state = await buildOAuthConnectionState({ + serverUrl: SERVER_URL, + protocol: "ema", + storage, + enterpriseManagedAuth: { + idp: { + issuer: "https://idp.example.com", + clientId: "idp-client", + clientSecret: "secret", + }, + }, + }); + + expect(state.protocol).toBe("ema"); + expect(state.enterpriseManaged).toBe(true); + expect(state.ema).toEqual({ + idpIssuer: "https://idp.example.com", + idpClientId: "idp-client", + idpSession: "logged_in", + idpMetadata: expect.objectContaining({ + issuer: "https://idp.example.com", + }), + }); + expect(state.ema).not.toHaveProperty("idToken"); + }); +}); diff --git a/clients/web/src/test/core/auth/ema/clientConfigError.test.ts b/clients/web/src/test/core/auth/ema/clientConfigError.test.ts new file mode 100644 index 00000000..6fcd626a --- /dev/null +++ b/clients/web/src/test/core/auth/ema/clientConfigError.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from "vitest"; +import { + EmaClientNotConfiguredError, + emaClientNotConfiguredMessage, + isEmaClientNotConfiguredError, +} from "@inspector/core/auth/ema/clientConfigError.js"; + +describe("clientConfigError", () => { + describe("emaClientNotConfiguredMessage", () => { + it("returns disabled guidance when Enterprise IdP is turned off", () => { + expect(emaClientNotConfiguredMessage("disabled")).toContain( + "Enterprise IdP is turned off in Client Settings", + ); + }); + + it("returns setup guidance when IdP is not configured", () => { + expect(emaClientNotConfiguredMessage("not_configured")).toContain( + "client IdP is not configured", + ); + }); + }); + + describe("EmaClientNotConfiguredError", () => { + it("uses the reason-specific message", () => { + const err = new EmaClientNotConfiguredError("disabled"); + expect(err.message).toBe(emaClientNotConfiguredMessage("disabled")); + expect(err.reason).toBe("disabled"); + expect(err.name).toBe("EmaClientNotConfiguredError"); + }); + }); + + describe("isEmaClientNotConfiguredError", () => { + it("returns true for EmaClientNotConfiguredError", () => { + expect( + isEmaClientNotConfiguredError( + new EmaClientNotConfiguredError("not_configured"), + ), + ).toBe(true); + }); + + it("returns false for other errors", () => { + expect(isEmaClientNotConfiguredError(new Error("other"))).toBe(false); + }); + }); +}); diff --git a/clients/web/src/test/core/auth/ema/emaFlow.test.ts b/clients/web/src/test/core/auth/ema/emaFlow.test.ts new file mode 100644 index 00000000..ea45753c --- /dev/null +++ b/clients/web/src/test/core/auth/ema/emaFlow.test.ts @@ -0,0 +1,296 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { OAuthStorage } from "@inspector/core/auth/storage.js"; +import { + mintEmaResourceTokens, + trySilentEmaAuth, + type EmaFlowConfig, +} from "@inspector/core/auth/ema/emaFlow.js"; +import type { EmaResourceContext } from "@inspector/core/auth/ema/resourceContext.js"; +import { + GRANT_TYPE_JWT_BEARER, + GRANT_TYPE_TOKEN_EXCHANGE, + TOKEN_TYPE_ID_JAG, +} from "@inspector/core/auth/ema/constants.js"; +import { + EMA_MOCK_IDP_CLIENT_ID, + EMA_MOCK_IDP_CLIENT_SECRET, + EMA_MOCK_RESOURCE_CLIENT_ID, + EMA_MOCK_RESOURCE_CLIENT_SECRET, + minimalOAuthAsMetadata, +} from "../../../integration/mcp/ema-mock-servers.js"; + +const IDP_ISSUER = "https://idp.ema.test"; +const AS_ISSUER = "https://as.ema.test"; +const MCP_RESOURCE = "http://127.0.0.1:9999/"; +const SERVER_URL = "http://127.0.0.1:9999/mcp"; + +function jwtWithExp(expSec: number): string { + const payload = btoa(JSON.stringify({ exp: expSec })) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + return `header.${payload}.sig`; +} + +function createMemoryStorage( + idpSessions: Record = {}, + savedTokens: Record = {}, +): OAuthStorage { + return { + getIdpSession: vi.fn(async (issuer: string) => idpSessions[issuer]), + saveIdpSession: vi.fn(async (issuer: string, updates) => { + idpSessions[issuer] = { ...idpSessions[issuer], ...updates }; + }), + clearIdpSession: vi.fn(), + getServerMetadata: vi.fn(() => undefined), + getTokens: vi.fn(async (url: string) => savedTokens[url] as never), + saveTokens: vi.fn(async (url: string, tokens) => { + savedTokens[url] = tokens; + }), + clear: vi.fn(), + clearEnterpriseManagedResourceServers: vi.fn(), + } as unknown as OAuthStorage; +} + +function baseConfig(storage: OAuthStorage): EmaFlowConfig { + return { + serverUrl: SERVER_URL, + idp: { + issuer: IDP_ISSUER, + clientId: EMA_MOCK_IDP_CLIENT_ID, + clientSecret: EMA_MOCK_IDP_CLIENT_SECRET, + }, + resourceClientId: EMA_MOCK_RESOURCE_CLIENT_ID, + resourceClientSecret: EMA_MOCK_RESOURCE_CLIENT_SECRET, + scope: "mcp", + redirectUrl: "http://127.0.0.1:3000/oauth/callback", + storage, + }; +} + +function resourceContext(): EmaResourceContext { + return { + resourceMetadata: { + resource: MCP_RESOURCE, + authorization_servers: [AS_ISSUER], + scopes_supported: ["mcp"], + }, + resourceAsUrl: new URL(AS_ISSUER), + resourceUrl: new URL(MCP_RESOURCE), + scope: "mcp", + }; +} + +function mockEmaFetch(idToken: string) { + return vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.includes("/.well-known/oauth-protected-resource")) { + return new Response( + JSON.stringify({ + resource: MCP_RESOURCE, + authorization_servers: [AS_ISSUER], + scopes_supported: ["mcp"], + }), + ); + } + if ( + url.includes("/.well-known/oauth-authorization-server") && + url.startsWith(IDP_ISSUER) + ) { + return new Response(JSON.stringify(minimalOAuthAsMetadata(IDP_ISSUER))); + } + if (url === `${IDP_ISSUER}/token`) { + const body = new URLSearchParams(init?.body as string); + if (body.get("grant_type") === "refresh_token") { + const refreshed = jwtWithExp(Math.floor(Date.now() / 1000) + 3600); + return new Response( + JSON.stringify({ id_token: refreshed, token_type: "Bearer" }), + ); + } + expect(body.get("grant_type")).toBe(GRANT_TYPE_TOKEN_EXCHANGE); + expect(body.get("subject_token")).toBe(idToken); + return new Response( + JSON.stringify({ + access_token: "id-jag-from-mock", + issued_token_type: TOKEN_TYPE_ID_JAG, + }), + ); + } + if ( + url.includes("/.well-known/oauth-authorization-server") && + url.startsWith(AS_ISSUER) + ) { + return new Response(JSON.stringify(minimalOAuthAsMetadata(AS_ISSUER))); + } + if (url === `${AS_ISSUER}/token`) { + const body = new URLSearchParams(init?.body as string); + expect(body.get("grant_type")).toBe(GRANT_TYPE_JWT_BEARER); + return new Response( + JSON.stringify({ + access_token: "resource-access-token", + token_type: "Bearer", + }), + ); + } + throw new Error(`unexpected fetch: ${url}`); + }); +} + +describe("emaFlow", () => { + let storage: OAuthStorage; + const idpSessions: Record< + string, + { idToken?: string; refreshToken?: string } + > = {}; + + beforeEach(() => { + Object.keys(idpSessions).forEach((k) => delete idpSessions[k]); + storage = createMemoryStorage(idpSessions); + }); + + it("mintEmaResourceTokens runs legs 2–3 when IdP session is valid", async () => { + const exp = Math.floor(Date.now() / 1000) + 3600; + const idToken = jwtWithExp(exp); + idpSessions[IDP_ISSUER] = { idToken }; + + const tokens = await mintEmaResourceTokens( + { ...baseConfig(storage), fetchFn: mockEmaFetch(idToken) }, + resourceContext(), + ); + + expect(tokens.access_token).toBe("resource-access-token"); + }); + + it("mintEmaResourceTokens throws when IdP session is missing", async () => { + await expect( + mintEmaResourceTokens(baseConfig(storage), resourceContext()), + ).rejects.toThrow("Valid IdP ID Token required for EMA token mint"); + }); + + it("mintEmaResourceTokens throws when resource client secret is missing", async () => { + const exp = Math.floor(Date.now() / 1000) + 3600; + idpSessions[IDP_ISSUER] = { idToken: jwtWithExp(exp) }; + + await expect( + mintEmaResourceTokens( + { + ...baseConfig(storage), + resourceClientSecret: "", + fetchFn: mockEmaFetch(jwtWithExp(exp)), + }, + resourceContext(), + ), + ).rejects.toThrow(/resource authorization server client secret/); + }); + + it("trySilentEmaAuth saves tagged tokens when mint succeeds", async () => { + const exp = Math.floor(Date.now() / 1000) + 3600; + const idToken = jwtWithExp(exp); + idpSessions[IDP_ISSUER] = { idToken }; + + const result = await trySilentEmaAuth({ + ...baseConfig(storage), + fetchFn: mockEmaFetch(idToken), + }); + + expect(result).toEqual({ status: "success" }); + expect(storage.saveTokens).toHaveBeenCalledWith( + SERVER_URL, + expect.objectContaining({ access_token: "resource-access-token" }), + { enterpriseManaged: true }, + ); + }); + + it("trySilentEmaAuth returns no_idp_session when IdP session is absent", async () => { + expect(await trySilentEmaAuth(baseConfig(storage))).toEqual({ + status: "no_idp_session", + }); + }); + + it("trySilentEmaAuth returns mint_failed when IdP session is valid but mint fails", async () => { + const exp = Math.floor(Date.now() / 1000) + 3600; + const idToken = jwtWithExp(exp); + idpSessions[IDP_ISSUER] = { idToken }; + + const result = await trySilentEmaAuth({ + ...baseConfig(storage), + resourceClientSecret: "", + fetchFn: mockEmaFetch(idToken), + }); + + expect(result.status).toBe("mint_failed"); + if (result.status === "mint_failed") { + expect(result.error.message).toMatch( + /EMA legs 2–3 \(resource token mint\)/, + ); + expect(result.error.message).toMatch( + /resource authorization server client secret/, + ); + } + expect(storage.saveTokens).not.toHaveBeenCalled(); + }); + + it("mintEmaResourceTokens refreshes expired ID Token via refresh_token", async () => { + const expired = jwtWithExp(Math.floor(Date.now() / 1000) - 60); + const refreshed = jwtWithExp(Math.floor(Date.now() / 1000) + 3600); + idpSessions[IDP_ISSUER] = { + idToken: expired, + refreshToken: "rt-1", + }; + + const fetchFn = vi.fn( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if ( + url.includes("/.well-known/oauth-authorization-server") && + url.startsWith(IDP_ISSUER) + ) { + return new Response( + JSON.stringify(minimalOAuthAsMetadata(IDP_ISSUER)), + ); + } + if (url === `${IDP_ISSUER}/token`) { + const body = new URLSearchParams(init?.body as string); + if (body.get("grant_type") === "refresh_token") { + return new Response( + JSON.stringify({ id_token: refreshed, token_type: "Bearer" }), + ); + } + expect(body.get("grant_type")).toBe(GRANT_TYPE_TOKEN_EXCHANGE); + expect(body.get("subject_token")).toBe(refreshed); + return new Response( + JSON.stringify({ + access_token: "id-jag-from-mock", + issued_token_type: TOKEN_TYPE_ID_JAG, + }), + ); + } + if ( + url.includes("/.well-known/oauth-authorization-server") && + url.startsWith(AS_ISSUER) + ) { + return new Response( + JSON.stringify(minimalOAuthAsMetadata(AS_ISSUER)), + ); + } + if (url === `${AS_ISSUER}/token`) { + return new Response( + JSON.stringify({ + access_token: "resource-access-token", + token_type: "Bearer", + }), + ); + } + throw new Error(`unexpected fetch: ${url}`); + }, + ); + + const tokens = await mintEmaResourceTokens( + { ...baseConfig(storage), fetchFn }, + resourceContext(), + ); + + expect(tokens.access_token).toBe("resource-access-token"); + expect(idpSessions[IDP_ISSUER]?.idToken).toBe(refreshed); + }); +}); diff --git a/clients/web/src/test/core/auth/ema/idpOidc.test.ts b/clients/web/src/test/core/auth/ema/idpOidc.test.ts new file mode 100644 index 00000000..ae669faf --- /dev/null +++ b/clients/web/src/test/core/auth/ema/idpOidc.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { OAuthStorage } from "@inspector/core/auth/storage.js"; +import { + getValidIdToken, + refreshIdpOidcSession, +} from "@inspector/core/auth/ema/idpOidc.js"; +import { minimalOAuthAsMetadata } from "../../../integration/mcp/ema-mock-servers.js"; + +const IDP_ISSUER = "https://idp.refresh.test"; + +function jwtWithExp(expSec: number): string { + const payload = btoa(JSON.stringify({ exp: expSec })) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + return `header.${payload}.sig`; +} + +describe("idpOidc refresh", () => { + const idpSessions: Record< + string, + { idToken?: string; refreshToken?: string; idTokenExpiresAt?: number } + > = {}; + let storage: OAuthStorage; + + beforeEach(() => { + Object.keys(idpSessions).forEach((k) => delete idpSessions[k]); + storage = { + getIdpSession: vi.fn(async (issuer: string) => idpSessions[issuer]), + saveIdpSession: vi.fn(async (issuer: string, updates) => { + idpSessions[issuer] = { ...idpSessions[issuer], ...updates }; + }), + getServerMetadata: vi.fn(() => minimalOAuthAsMetadata(IDP_ISSUER)), + clearIdpSession: vi.fn(), + } as unknown as OAuthStorage; + }); + + const idp = { + issuer: IDP_ISSUER, + clientId: "idp-client", + clientSecret: "idp-secret", + }; + + it("refreshIdpOidcSession redeems refresh_token and updates IdP session", async () => { + const expired = jwtWithExp(Math.floor(Date.now() / 1000) - 60); + const refreshed = jwtWithExp(Math.floor(Date.now() / 1000) + 3600); + idpSessions[IDP_ISSUER] = { + idToken: expired, + refreshToken: "rt-abc", + }; + + const fetchFn = vi.fn( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url === `${IDP_ISSUER}/token`) { + const body = new URLSearchParams(init?.body as string); + expect(body.get("grant_type")).toBe("refresh_token"); + expect(body.get("refresh_token")).toBe("rt-abc"); + return new Response( + JSON.stringify({ + id_token: refreshed, + refresh_token: "rt-rotated", + token_type: "Bearer", + }), + ); + } + throw new Error(`unexpected fetch: ${url}`); + }, + ); + + const idToken = await refreshIdpOidcSession({ + idp, + storage, + fetchFn, + }); + + expect(idToken).toBe(refreshed); + expect(idpSessions[IDP_ISSUER]?.idToken).toBe(refreshed); + expect(idpSessions[IDP_ISSUER]?.refreshToken).toBe("rt-rotated"); + }); + + it("getValidIdToken refreshes when ID Token is expired but refresh_token remains", async () => { + const expired = jwtWithExp(Math.floor(Date.now() / 1000) - 60); + const refreshed = jwtWithExp(Math.floor(Date.now() / 1000) + 3600); + idpSessions[IDP_ISSUER] = { + idToken: expired, + refreshToken: "rt-abc", + }; + + const fetchFn = vi.fn(async (input: RequestInfo | URL) => { + if (String(input) === `${IDP_ISSUER}/token`) { + return new Response( + JSON.stringify({ id_token: refreshed, token_type: "Bearer" }), + ); + } + throw new Error(`unexpected fetch: ${String(input)}`); + }); + + const idToken = await getValidIdToken({ idp, storage, fetchFn }); + expect(idToken).toBe(refreshed); + expect(fetchFn).toHaveBeenCalledTimes(1); + }); + + it("getValidIdToken returns undefined when refresh fails", async () => { + const expired = jwtWithExp(Math.floor(Date.now() / 1000) - 60); + idpSessions[IDP_ISSUER] = { + idToken: expired, + refreshToken: "rt-bad", + }; + + const fetchFn = vi.fn( + async () => + new Response(JSON.stringify({ error: "invalid_grant" }), { + status: 400, + }), + ); + + expect(await getValidIdToken({ idp, storage, fetchFn })).toBeUndefined(); + }); +}); diff --git a/clients/web/src/test/core/auth/ema/idpSession.test.ts b/clients/web/src/test/core/auth/ema/idpSession.test.ts new file mode 100644 index 00000000..cf187229 --- /dev/null +++ b/clients/web/src/test/core/auth/ema/idpSession.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { OAuthStorage } from "@inspector/core/auth/storage.js"; +import { + clearEmaIdpSession, + getEmaIdpLoginState, + normalizeIdpIssuer, +} from "@inspector/core/auth/ema/idpSession.js"; + +function jwtWithExp(expSec: number): string { + const payload = btoa(JSON.stringify({ exp: expSec })) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + return `header.${payload}.sig`; +} + +describe("idpSession", () => { + let storage: OAuthStorage; + + beforeEach(() => { + storage = { + getIdpSession: vi.fn(), + saveIdpSession: vi.fn(), + clearIdpSession: vi.fn(), + clear: vi.fn(), + clearEnterpriseManagedResourceServers: vi.fn(), + } as unknown as OAuthStorage; + }); + + it("normalizeIdpIssuer strips trailing slash", () => { + expect(normalizeIdpIssuer("https://idp.test/")).toBe("https://idp.test"); + }); + + it("getEmaIdpLoginState returns none when no session", async () => { + vi.mocked(storage.getIdpSession).mockResolvedValue(undefined); + expect(await getEmaIdpLoginState(storage, "https://idp.test")).toBe("none"); + }); + + it("getEmaIdpLoginState returns logged_in for valid id token", async () => { + const exp = Math.floor(Date.now() / 1000) + 3600; + vi.mocked(storage.getIdpSession).mockResolvedValue({ + idToken: jwtWithExp(exp), + }); + expect(await getEmaIdpLoginState(storage, "https://idp.test/")).toBe( + "logged_in", + ); + expect(storage.getIdpSession).toHaveBeenCalledWith("https://idp.test"); + }); + + it("getEmaIdpLoginState returns expired for expired id token without refresh", async () => { + const exp = Math.floor(Date.now() / 1000) - 3600; + vi.mocked(storage.getIdpSession).mockResolvedValue({ + idToken: jwtWithExp(exp), + }); + expect(await getEmaIdpLoginState(storage, "https://idp.test")).toBe( + "expired", + ); + }); + + it("getEmaIdpLoginState returns logged_in when id token expired but refresh_token remains", async () => { + const exp = Math.floor(Date.now() / 1000) - 3600; + vi.mocked(storage.getIdpSession).mockResolvedValue({ + idToken: jwtWithExp(exp), + refreshToken: "rt-1", + }); + expect(await getEmaIdpLoginState(storage, "https://idp.test")).toBe( + "logged_in", + ); + }); + + it("clearEmaIdpSession clears idp session, leg-1 key, and tagged resource servers", () => { + clearEmaIdpSession(storage, "https://idp.test/"); + expect(storage.clearIdpSession).toHaveBeenCalledWith("https://idp.test"); + expect(storage.clear).toHaveBeenCalledWith("ema-idp:https://idp.test"); + expect(storage.clearEnterpriseManagedResourceServers).toHaveBeenCalled(); + }); +}); diff --git a/clients/web/src/test/core/auth/ema/wire.test.ts b/clients/web/src/test/core/auth/ema/wire.test.ts new file mode 100644 index 00000000..b77226f6 --- /dev/null +++ b/clients/web/src/test/core/auth/ema/wire.test.ts @@ -0,0 +1,150 @@ +import { describe, it, expect, vi } from "vitest"; +import { + exchangeIdJag, + redeemIdJagForAccessToken, +} from "@inspector/core/auth/ema/wire.js"; +import { + GRANT_TYPE_JWT_BEARER, + GRANT_TYPE_TOKEN_EXCHANGE, + TOKEN_TYPE_ID_JAG, + TOKEN_TYPE_ID_TOKEN, +} from "@inspector/core/auth/ema/constants.js"; +import { + EMA_MOCK_IDP_CLIENT_ID, + EMA_MOCK_IDP_CLIENT_SECRET, + EMA_MOCK_RESOURCE_CLIENT_ID, + EMA_MOCK_RESOURCE_CLIENT_SECRET, + minimalOAuthAsMetadata, +} from "../../../integration/mcp/ema-mock-servers.js"; + +const IDP_ISSUER = "https://mock-idp.test"; +const AS_ISSUER = "https://mock-as.test"; +const ID_TOKEN = "header.idpayload.sig"; +const ID_JAG = "mock-id-jag-token"; +const ACCESS_TOKEN = "mock-resource-access-token"; + +function idpMetadata() { + return minimalOAuthAsMetadata(IDP_ISSUER); +} + +function asMetadata() { + return { + ...minimalOAuthAsMetadata(AS_ISSUER), + jwks_uri: `${AS_ISSUER}/jwks`, + }; +} + +describe("ema wire", () => { + describe("exchangeIdJag", () => { + it("posts RFC 8693 token exchange and returns ID-JAG", async () => { + const fetchFn = vi.fn( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.includes("/.well-known/oauth-authorization-server")) { + return new Response(JSON.stringify(idpMetadata())); + } + if (url === `${IDP_ISSUER}/token`) { + const body = new URLSearchParams(init?.body as string); + expect(body.get("grant_type")).toBe(GRANT_TYPE_TOKEN_EXCHANGE); + expect(body.get("subject_token")).toBe(ID_TOKEN); + expect(body.get("subject_token_type")).toBe(TOKEN_TYPE_ID_TOKEN); + expect(body.get("requested_token_type")).toBe(TOKEN_TYPE_ID_JAG); + expect(body.get("audience")).toBe(AS_ISSUER); + expect(body.get("client_id")).toBe(EMA_MOCK_IDP_CLIENT_ID); + expect(body.get("client_secret")).toBe(EMA_MOCK_IDP_CLIENT_SECRET); + return new Response( + JSON.stringify({ + access_token: ID_JAG, + issued_token_type: TOKEN_TYPE_ID_JAG, + }), + ); + } + throw new Error(`unexpected fetch: ${url}`); + }, + ); + + const idJag = await exchangeIdJag({ + idp: { + issuer: IDP_ISSUER, + clientId: EMA_MOCK_IDP_CLIENT_ID, + clientSecret: EMA_MOCK_IDP_CLIENT_SECRET, + }, + idToken: ID_TOKEN, + audience: AS_ISSUER, + fetchFn, + }); + + expect(idJag).toBe(ID_JAG); + }); + + it("throws when IdP token exchange fails", async () => { + const fetchFn = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/.well-known/oauth-authorization-server")) { + return new Response(JSON.stringify(idpMetadata())); + } + if (url === `${IDP_ISSUER}/token`) { + return new Response(JSON.stringify({ error: "invalid_grant" }), { + status: 400, + }); + } + throw new Error(`unexpected fetch: ${url}`); + }); + + await expect( + exchangeIdJag({ + idp: { + issuer: IDP_ISSUER, + clientId: EMA_MOCK_IDP_CLIENT_ID, + clientSecret: EMA_MOCK_IDP_CLIENT_SECRET, + }, + idToken: ID_TOKEN, + audience: AS_ISSUER, + fetchFn, + }), + ).rejects.toThrow(/EMA leg 2/); + }); + }); + + describe("redeemIdJagForAccessToken", () => { + it("posts JWT bearer grant and returns OAuth tokens", async () => { + const fetchFn = vi.fn( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.includes("/.well-known/oauth-authorization-server")) { + return new Response(JSON.stringify(asMetadata())); + } + if (url === `${AS_ISSUER}/token`) { + const body = new URLSearchParams(init?.body as string); + expect(body.get("grant_type")).toBe(GRANT_TYPE_JWT_BEARER); + expect(body.get("assertion")).toBe(ID_JAG); + expect(body.get("client_id")).toBe(EMA_MOCK_RESOURCE_CLIENT_ID); + expect(body.get("client_secret")).toBe( + EMA_MOCK_RESOURCE_CLIENT_SECRET, + ); + return new Response( + JSON.stringify({ + access_token: ACCESS_TOKEN, + token_type: "Bearer", + expires_in: 3600, + }), + ); + } + throw new Error(`unexpected fetch: ${url}`); + }, + ); + + const tokens = await redeemIdJagForAccessToken({ + resourceAsUrl: new URL(AS_ISSUER), + idJag: ID_JAG, + resourceClientId: EMA_MOCK_RESOURCE_CLIENT_ID, + resourceClientSecret: EMA_MOCK_RESOURCE_CLIENT_SECRET, + scope: "mcp", + fetchFn, + }); + + expect(tokens.access_token).toBe(ACCESS_TOKEN); + expect(tokens.token_type).toBe("Bearer"); + }); + }); +}); diff --git a/clients/web/src/test/core/auth/jwt.test.ts b/clients/web/src/test/core/auth/jwt.test.ts new file mode 100644 index 00000000..dfedb28f --- /dev/null +++ b/clients/web/src/test/core/auth/jwt.test.ts @@ -0,0 +1,18 @@ +import { describe, it, expect } from "vitest"; +import { decodeJwtPayload, isJwtFormat } from "@inspector/core/auth/ema/jwt.js"; + +describe("jwt display helpers", () => { + const jwt = "eyJhbGciOiJub25lIn0.eyJzdWIiOiJ1c2VyIn0."; + + it("isJwtFormat accepts three segments", () => { + expect(isJwtFormat(jwt)).toBe(true); + expect(isJwtFormat("opaque-token")).toBe(false); + }); + + it("decodeJwtPayload returns header and payload", () => { + expect(decodeJwtPayload(jwt)).toEqual({ + header: { alg: "none" }, + payload: { sub: "user" }, + }); + }); +}); diff --git a/clients/web/src/test/core/auth/storage-browser.test.ts b/clients/web/src/test/core/auth/storage-browser.test.ts index f78f74b2..c0aef384 100644 --- a/clients/web/src/test/core/auth/storage-browser.test.ts +++ b/clients/web/src/test/core/auth/storage-browser.test.ts @@ -155,6 +155,23 @@ describe("BrowserOAuthStorage", () => { expect(result).toEqual(tokens); }); + + it("should tag EMA resource tokens", async () => { + const emaUrl = "https://mcp.test/mcp"; + const standardUrl = "https://other.test/sse"; + const tokens: OAuthTokens = { + access_token: "ema-token", + token_type: "Bearer", + }; + + await storage.saveTokens(emaUrl, tokens, { enterpriseManaged: true }); + await storage.saveTokens(standardUrl, tokens); + + storage.clearEnterpriseManagedResourceServers(); + + expect(await storage.getTokens(emaUrl)).toBeUndefined(); + expect(await storage.getTokens(standardUrl)).toEqual(tokens); + }); }); describe("getCodeVerifier", () => { diff --git a/clients/web/src/test/core/auth/utils.test.ts b/clients/web/src/test/core/auth/utils.test.ts index a1bbe2f5..45e7592d 100644 --- a/clients/web/src/test/core/auth/utils.test.ts +++ b/clients/web/src/test/core/auth/utils.test.ts @@ -1,146 +1,107 @@ import { describe, it, expect } from "vitest"; import { + parseHttpUrl, parseOAuthCallbackParams, generateOAuthState, + generateOAuthStateWithExecution, generateOAuthStateWithMode, parseOAuthState, generateOAuthErrorDescription, } from "@inspector/core/auth/utils.js"; -describe("OAuth Utils", () => { +describe("auth utils", () => { describe("parseOAuthCallbackParams", () => { it("should parse successful callback with code", () => { - const location = "?code=abc123&state=xyz789"; - const result = parseOAuthCallbackParams(location); - - expect(result.successful).toBe(true); - if (result.successful) { - expect(result.code).toBe("abc123"); - } + const params = parseOAuthCallbackParams("?code=abc123"); + expect(params).toEqual({ successful: true, code: "abc123" }); }); it("should parse error callback", () => { - const location = - "?error=access_denied&error_description=User%20denied%20access"; - const result = parseOAuthCallbackParams(location); - - expect(result.successful).toBe(false); - if (!result.successful) { - expect(result.error).toBe("access_denied"); - expect(result.error_description).toBe("User denied access"); - } + const params = parseOAuthCallbackParams( + "?error=access_denied&error_description=User%20denied", + ); + expect(params).toEqual({ + successful: false, + error: "access_denied", + error_description: "User denied", + error_uri: null, + }); }); - it("should parse error callback with error_uri", () => { - const location = - "?error=invalid_request&error_description=Invalid%20request&error_uri=https://example.com/error"; - const result = parseOAuthCallbackParams(location); - - expect(result.successful).toBe(false); - if (!result.successful) { - expect(result.error).toBe("invalid_request"); - expect(result.error_description).toBe("Invalid request"); - expect(result.error_uri).toBe("https://example.com/error"); - } - }); - - it("should return invalid_request when neither code nor error is present", () => { - const location = "?state=xyz789"; - const result = parseOAuthCallbackParams(location); - - expect(result.successful).toBe(false); - if (!result.successful) { - expect(result.error).toBe("invalid_request"); - expect(result.error_description).toBe( - "Missing code or error in response", - ); - } - }); - - it("should handle empty query string", () => { - const location = ""; - const result = parseOAuthCallbackParams(location); - - expect(result.successful).toBe(false); - if (!result.successful) { - expect(result.error).toBe("invalid_request"); - } - }); - - it("should handle URL-encoded values", () => { - const location = "?code=abc%20123&error_description=Test%20%26%20More"; - const result = parseOAuthCallbackParams(location); - - expect(result.successful).toBe(true); - if (result.successful) { - expect(result.code).toBe("abc 123"); - } + it("should return invalid_request when code and error are missing", () => { + const params = parseOAuthCallbackParams("?foo=bar"); + expect(params).toEqual({ + successful: false, + error: "invalid_request", + error_description: "Missing code or error in response", + error_uri: null, + }); }); }); describe("generateOAuthState", () => { - it("should generate a random state string", () => { - const state1 = generateOAuthState(); - const state2 = generateOAuthState(); - - expect(typeof state1).toBe("string"); - expect(state1.length).toBeGreaterThan(0); - expect(state1).not.toBe(state2); // Should be different each time - }); - - it("should generate state with consistent length", () => { - const states = Array.from({ length: 10 }, () => generateOAuthState()); - const lengths = states.map((s) => s.length); - const uniqueLengths = new Set(lengths); - - // All states should have the same length (64 hex characters for 32 bytes) - expect(uniqueLengths.size).toBe(1); - expect(lengths[0]).toBe(64); - }); - - it("should generate valid hex string", () => { + it("should generate 64-char hex string", () => { const state = generateOAuthState(); - const hexPattern = /^[0-9a-f]+$/; + expect(state).toMatch(/^[0-9a-f]{64}$/); + }); - expect(hexPattern.test(state)).toBe(true); + it("should generate unique states", () => { + const s1 = generateOAuthState(); + const s2 = generateOAuthState(); + expect(s1).not.toBe(s2); }); }); - describe("generateOAuthStateWithMode", () => { - it("should generate state with normal prefix", () => { - const state = generateOAuthStateWithMode("normal"); - expect(state.startsWith("normal:")).toBe(true); - expect(state.slice(7)).toMatch(/^[0-9a-f]{64}$/); + describe("generateOAuthStateWithExecution", () => { + it("should generate state with quick prefix", () => { + const state = generateOAuthStateWithExecution("quick"); + expect(state.startsWith("quick:")).toBe(true); + expect(state.slice(6)).toMatch(/^[0-9a-f]{64}$/); }); it("should generate state with guided prefix", () => { - const state = generateOAuthStateWithMode("guided"); + const state = generateOAuthStateWithExecution("guided"); expect(state.startsWith("guided:")).toBe(true); expect(state.slice(7)).toMatch(/^[0-9a-f]{64}$/); }); it("should generate unique states", () => { - const s1 = generateOAuthStateWithMode("normal"); - const s2 = generateOAuthStateWithMode("normal"); + const s1 = generateOAuthStateWithExecution("quick"); + const s2 = generateOAuthStateWithExecution("quick"); expect(s1).not.toBe(s2); }); + + it("generateOAuthStateWithMode alias matches quick execution", () => { + const state = generateOAuthStateWithMode("quick"); + expect(state.startsWith("quick:")).toBe(true); + }); }); describe("parseOAuthState", () => { - it("should parse normal prefix", () => { - const parsed = parseOAuthState("normal:abc123def456"); - expect(parsed).toEqual({ mode: "normal", authId: "abc123def456" }); + it("should parse quick prefix", () => { + const parsed = parseOAuthState("quick:abc123def456"); + expect(parsed).toEqual({ execution: "quick", authId: "abc123def456" }); }); it("should parse guided prefix", () => { const parsed = parseOAuthState("guided:a1b2c3d4e5f6"); - expect(parsed).toEqual({ mode: "guided", authId: "a1b2c3d4e5f6" }); + expect(parsed).toEqual({ execution: "guided", authId: "a1b2c3d4e5f6" }); }); - it("should parse legacy 64-char hex as normal", () => { + it("should map legacy normal prefix to quick", () => { + const parsed = parseOAuthState("normal:abc123def456"); + expect(parsed).toEqual({ execution: "quick", authId: "abc123def456" }); + }); + + it("should map legacy ema-idp prefix to quick", () => { + const parsed = parseOAuthState("ema-idp:abc123def456"); + expect(parsed).toEqual({ execution: "quick", authId: "abc123def456" }); + }); + + it("should parse legacy 64-char hex as quick", () => { const hex = "a".repeat(64); const parsed = parseOAuthState(hex); - expect(parsed).toEqual({ mode: "normal", authId: hex }); + expect(parsed).toEqual({ execution: "quick", authId: hex }); }); it("should return null for invalid state", () => { @@ -229,4 +190,20 @@ describe("OAuth Utils", () => { expect(description).not.toContain("More info:"); }); }); + + describe("parseHttpUrl", () => { + it("parses a valid absolute URL", () => { + expect(parseHttpUrl("https://idp.example.com", "test").href).toBe( + "https://idp.example.com/", + ); + }); + + it("throws with label and value when URL is invalid", () => { + expect(() => + parseHttpUrl("https;//idp.xaa.dev", "EMA IdP issuer (Client Settings)"), + ).toThrow( + 'Invalid EMA IdP issuer (Client Settings): "https;//idp.xaa.dev"', + ); + }); + }); }); diff --git a/clients/web/src/test/core/client/config.test.ts b/clients/web/src/test/core/client/config.test.ts new file mode 100644 index 00000000..5e5da2fd --- /dev/null +++ b/clients/web/src/test/core/client/config.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect, afterEach } from "vitest"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { readFileSync } from "node:fs"; +import { + InMemorySecretStore, + SECRET_FIELD_IDP_CLIENT_SECRET, +} from "@inspector/core/auth/node/secret-store.js"; +import { + CLIENT_KEYCHAIN_ID, + extractSecretsFromClientConfig, + mergeSecretsIntoClientConfig, +} from "@inspector/core/client/secrets.js"; +import { + loadClientConfig, + parseClientConfig, + saveClientConfig, +} from "@inspector/core/client/config.js"; +import { formatClientConfigLoadError } from "@inspector/core/client/config-parse.js"; +import { + getActiveEnterpriseManagedAuthIdp, + isEnterpriseManagedAuthEnabled, +} from "@inspector/core/client/types.js"; + +describe("client config", () => { + let tmpDir: string; + + afterEach(async () => { + if (tmpDir) { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + + it("parseClientConfig accepts enterpriseManagedAuth.idp", () => { + const config = parseClientConfig({ + enterpriseManagedAuth: { + idp: { + issuer: "https://idp.example.com", + clientId: "cid", + clientSecret: "secret", + }, + }, + }); + expect(config.enterpriseManagedAuth?.idp.issuer).toBe( + "https://idp.example.com", + ); + }); + + it("parseClientConfig rejects invalid issuer URL", () => { + expect(() => + parseClientConfig({ + enterpriseManagedAuth: { + idp: { + issuer: "https;//idp.xaa.dev", + clientId: "cid", + clientSecret: "secret", + }, + }, + }), + ).toThrow(/Invalid URL.*https;\/\//); + }); + + it("parseClientConfig accepts enabled: false with stored IdP", () => { + const config = parseClientConfig({ + enterpriseManagedAuth: { + enabled: false, + idp: { + issuer: "https://idp.example.com", + clientId: "cid", + clientSecret: "secret", + }, + }, + }); + expect(config.enterpriseManagedAuth?.enabled).toBe(false); + expect(isEnterpriseManagedAuthEnabled(config)).toBe(false); + expect(getActiveEnterpriseManagedAuthIdp(config)).toBeUndefined(); + }); + + it("parseClientConfig rejects missing issuer", () => { + expect(() => + parseClientConfig({ + enterpriseManagedAuth: { + idp: { clientId: "c", clientSecret: "s" }, + }, + }), + ).toThrow(); + }); + + it("loadClientConfig returns {} when file is absent", async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "client-config-")); + const filePath = path.join(tmpDir, "client.json"); + const config = await loadClientConfig({ filePath }); + expect(config).toEqual({}); + }); + + it("formatClientConfigLoadError summarizes Zod validation failures", () => { + try { + parseClientConfig({ + enterpriseManagedAuth: { + idp: { issuer: "not-a-url", clientId: "c", clientSecret: "s" }, + }, + }); + } catch (err) { + const message = formatClientConfigLoadError(err); + expect(message).toContain("issuer"); + expect(message).toContain("Invalid URL"); + return; + } + throw new Error("expected parseClientConfig to throw"); + }); + + it("formatClientConfigLoadError passes through Error message", () => { + expect(formatClientConfigLoadError(new Error("network down"))).toBe( + "network down", + ); + }); + + it("extractSecretsFromClientConfig strips IdP clientSecret", () => { + const input = { + enterpriseManagedAuth: { + idp: { + issuer: "https://idp.example.com", + clientId: "cid", + clientSecret: "secret", + }, + }, + }; + const { stripped, secrets } = extractSecretsFromClientConfig(input); + expect(stripped.enterpriseManagedAuth?.idp).toEqual({ + issuer: "https://idp.example.com", + clientId: "cid", + }); + expect(secrets[SECRET_FIELD_IDP_CLIENT_SECRET]).toBe("secret"); + expect( + mergeSecretsIntoClientConfig(stripped, secrets).enterpriseManagedAuth?.idp + .clientSecret, + ).toBe("secret"); + }); + + it("saveClientConfig round-trips via loadClientConfig with keychain", async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "client-config-")); + const filePath = path.join(tmpDir, "client.json"); + const secretStore = new InMemorySecretStore(); + const input = { + enterpriseManagedAuth: { + idp: { + issuer: "https://idp.example.com", + clientId: "inspector", + clientSecret: "shh", + }, + }, + }; + await saveClientConfig(input, { filePath, secretStore }); + const loaded = await loadClientConfig({ filePath, secretStore }); + expect(loaded).toEqual(input); + + const onDisk = JSON.parse(readFileSync(filePath, "utf-8")) as { + enterpriseManagedAuth?: { idp?: Record }; + }; + expect(onDisk.enterpriseManagedAuth?.idp?.clientSecret).toBeUndefined(); + expect(readFileSync(filePath, "utf-8")).not.toContain("shh"); + expect( + await secretStore.get(CLIENT_KEYCHAIN_ID, SECRET_FIELD_IDP_CLIENT_SECRET), + ).toBe("shh"); + }); +}); diff --git a/clients/web/src/test/core/mcp/config.test.ts b/clients/web/src/test/core/mcp/config.test.ts new file mode 100644 index 00000000..a375fc90 --- /dev/null +++ b/clients/web/src/test/core/mcp/config.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from "vitest"; +import { + getServerType, + isOAuthCapableServerType, +} from "@inspector/core/mcp/config.js"; + +describe("isOAuthCapableServerType", () => { + it("returns true for remote HTTP transports", () => { + expect(isOAuthCapableServerType("sse")).toBe(true); + expect(isOAuthCapableServerType("streamable-http")).toBe(true); + }); + + it("returns false for stdio", () => { + expect(isOAuthCapableServerType("stdio")).toBe(false); + }); + + it("aligns with getServerType for configs without an explicit type", () => { + const type = getServerType({ type: "stdio", command: "node" }); + expect(isOAuthCapableServerType(type)).toBe(false); + }); +}); diff --git a/clients/web/src/test/core/mcp/oauthManager.test.ts b/clients/web/src/test/core/mcp/oauthManager.test.ts index f45e8527..5e738966 100644 --- a/clients/web/src/test/core/mcp/oauthManager.test.ts +++ b/clients/web/src/test/core/mcp/oauthManager.test.ts @@ -1,7 +1,7 @@ /** * OAuthManager unit tests. Uses mocked getServerUrl, fetch, storage, and * dispatch callbacks to verify config merge, callback invocation, clearOAuthTokens, - * error propagation, and getOAuthState/getOAuthStep after beginGuidedAuth. + * error propagation, and getOAuthFlowState/getOAuthFlowStep after beginGuidedAuth. */ import { describe, it, expect, vi } from "vitest"; import { @@ -9,6 +9,11 @@ import { type OAuthManagerConfig, type OAuthManagerParams, } from "@inspector/core/mcp/oauthManager.js"; +import { + EmaClientNotConfiguredError, + emaClientNotConfiguredMessage, +} from "@inspector/core/auth/ema/clientConfigError.js"; +import * as emaFlow from "@inspector/core/auth/ema/emaFlow.js"; const SERVER_URL = "https://example.com/mcp"; @@ -21,7 +26,7 @@ function createMockParams( const dispatchOAuthError = vi.fn(); const storage = { - getScope: vi.fn().mockResolvedValue(undefined), + getScope: vi.fn().mockReturnValue(undefined), getClientInformation: vi.fn().mockResolvedValue(undefined), saveClientInformation: vi.fn().mockResolvedValue(undefined), savePreregisteredClientInformation: vi.fn().mockResolvedValue(undefined), @@ -38,6 +43,10 @@ function createMockParams( clearServerMetadata: vi.fn(), getServerMetadata: vi.fn().mockReturnValue(null), saveServerMetadata: vi.fn().mockResolvedValue(undefined), + getIdpSession: vi.fn().mockResolvedValue(undefined), + saveIdpSession: vi.fn().mockResolvedValue(undefined), + clearIdpSession: vi.fn(), + clearEnterpriseManagedResourceServers: vi.fn(), }; const redirectUrlProvider = { @@ -103,8 +112,8 @@ describe("OAuthManager", () => { expect(params.initialConfig.storage!.clear).toHaveBeenCalledWith( SERVER_URL, ); - expect(manager.getOAuthState()).toBeUndefined(); - expect(manager.getOAuthStep()).toBeUndefined(); + expect(manager.getOAuthFlowState()).toBeUndefined(); + expect(manager.getOAuthFlowStep()).toBeUndefined(); }); it("no-ops when storage is not configured", () => { @@ -122,12 +131,47 @@ describe("OAuthManager", () => { }); }); - describe("getOAuthState / getOAuthStep", () => { + describe("getOAuthState", () => { + it("returns undefined when oauth is not configured for the server", async () => { + const params = createMockParams({ + initialConfig: { + storage: createMockParams().initialConfig.storage, + redirectUrlProvider: { + getRedirectUrl: vi + .fn() + .mockReturnValue("http://localhost/callback"), + }, + navigation: { navigateToAuthorization: vi.fn() }, + } as OAuthManagerConfig, + }); + const manager = new OAuthManager(params); + await expect(manager.getOAuthState()).resolves.toBeUndefined(); + }); + + it("returns connection state from storage", async () => { + const params = createMockParams(); + ( + params.initialConfig.storage as unknown as { + getTokens: ReturnType; + } + ).getTokens.mockResolvedValue({ + access_token: "tok", + token_type: "Bearer", + }); + const manager = new OAuthManager(params); + const state = await manager.getOAuthState(); + expect(state?.authorized).toBe(true); + expect(state?.serverUrl).toBe(SERVER_URL); + expect(state?.protocol).toBe("standard"); + }); + }); + + describe("getOAuthFlowState / getOAuthFlowStep", () => { it("returns undefined before any flow", () => { const params = createMockParams(); const manager = new OAuthManager(params); - expect(manager.getOAuthState()).toBeUndefined(); - expect(manager.getOAuthStep()).toBeUndefined(); + expect(manager.getOAuthFlowState()).toBeUndefined(); + expect(manager.getOAuthFlowStep()).toBeUndefined(); }); }); @@ -221,4 +265,80 @@ describe("OAuthManager", () => { ); }); }); + + describe("enterprise-managed auth", () => { + function createEmaManager( + overrides?: Partial, + ): OAuthManager { + const params = createMockParams(overrides); + const manager = new OAuthManager(params); + manager.setOAuthConfig({ enterpriseManaged: true }); + return manager; + } + + it("throws not_configured when connecting without install IdP", async () => { + const manager = createEmaManager(); + await expect(manager.authenticate()).rejects.toThrow( + EmaClientNotConfiguredError, + ); + await expect(manager.authenticate()).rejects.toThrow( + emaClientNotConfiguredMessage("not_configured"), + ); + }); + + it("throws disabled when Enterprise IdP is turned off in Client Settings", async () => { + const manager = createEmaManager({ + installEnterpriseManagedAuth: { + enabled: false, + idp: { + issuer: "https://idp.example.com", + clientId: "app-client", + clientSecret: "secret", + }, + }, + }); + await expect(manager.authenticate()).rejects.toThrow( + emaClientNotConfiguredMessage("disabled"), + ); + }); + + it("surfaces mint failures without redirecting to the IdP", async () => { + const exp = Math.floor(Date.now() / 1000) + 3600; + const payload = btoa(JSON.stringify({ exp })) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + const idToken = `header.${payload}.sig`; + const issuer = "https://idp.example.com"; + + const params = createMockParams({ + enterpriseManagedAuth: { + idp: { + issuer, + clientId: "app-client", + clientSecret: "secret", + }, + }, + }); + params.initialConfig.storage!.getIdpSession = vi + .fn() + .mockResolvedValue({ idToken }); + params.initialConfig.clientSecret = ""; + + const manager = new OAuthManager(params); + manager.setOAuthConfig({ enterpriseManaged: true }); + + const startIdpSpy = vi.spyOn(emaFlow, "startEmaIdpAuthorization"); + + await expect(manager.authenticate()).rejects.toThrow( + /EMA legs 2–3 \(resource token mint\)/, + ); + expect(startIdpSpy).not.toHaveBeenCalled(); + expect( + params.initialConfig.navigation!.navigateToAuthorization, + ).not.toHaveBeenCalled(); + + startIdpSpy.mockRestore(); + }); + }); }); diff --git a/clients/web/src/test/core/mcp/serverList.test.ts b/clients/web/src/test/core/mcp/serverList.test.ts index 03465d2b..5006da05 100644 --- a/clients/web/src/test/core/mcp/serverList.test.ts +++ b/clients/web/src/test/core/mcp/serverList.test.ts @@ -635,6 +635,35 @@ describe("extractSecretsFromStored", () => { }); }); + it("preserves oauth.enterpriseManaged when lifting clientSecret to keychain", () => { + const stored: StoredMCPServer = { + type: "streamable-http", + url: "https://x.test", + oauth: { + clientId: "resource-as", + clientSecret: "shh", + enterpriseManaged: true, + }, + }; + const { stripped, secrets } = extractSecretsFromStored(stored); + expect(secrets).toEqual({ [SECRET_FIELD_OAUTH_CLIENT_SECRET]: "shh" }); + expect(stripped.oauth).toEqual({ + clientId: "resource-as", + enterpriseManaged: true, + }); + }); + + it("keeps oauth.enterpriseManaged on disk when it was the only non-secret field", () => { + const stored: StoredMCPServer = { + type: "streamable-http", + url: "https://x.test", + oauth: { clientSecret: "shh", enterpriseManaged: true }, + }; + const { stripped, secrets } = extractSecretsFromStored(stored); + expect(secrets).toEqual({ [SECRET_FIELD_OAUTH_CLIENT_SECRET]: "shh" }); + expect(stripped.oauth).toEqual({ enterpriseManaged: true }); + }); + it("removes the oauth block entirely when clientSecret was its only property", () => { const stored: StoredMCPServer = { type: "streamable-http", @@ -789,6 +818,51 @@ describe("expectedSecretFields", () => { }); }); +describe("enterpriseManaged oauth settings", () => { + it("lifts oauth.enterpriseManaged to settings.enterpriseManaged", () => { + const settings = storedFieldsToInspectorSettings({ + oauth: { + clientId: "resource-as", + enterpriseManaged: true, + }, + }); + expect(settings?.enterpriseManaged).toBe(true); + expect(settings?.oauthClientId).toBe("resource-as"); + }); + + it("persists enterpriseManaged under oauth on disk", () => { + const stored = inspectorSettingsToStoredFields({ + headers: [], + env: [], + metadata: [], + connectionTimeout: 0, + requestTimeout: 0, + taskTtl: 60000, + maxFetchRequests: 1000, + roots: [], + oauthClientId: "resource-as", + enterpriseManaged: true, + }); + expect(stored.oauth?.enterpriseManaged).toBe(true); + expect(stored.oauth?.clientId).toBe("resource-as"); + }); + + it("omits enterpriseManaged when false", () => { + const stored = inspectorSettingsToStoredFields({ + headers: [], + env: [], + metadata: [], + connectionTimeout: 0, + requestTimeout: 0, + taskTtl: 60000, + maxFetchRequests: 1000, + roots: [], + enterpriseManaged: false, + }); + expect(stored.oauth).toBeUndefined(); + }); +}); + describe("envRecordToPairs / envPairsToRecord", () => { it("envRecordToPairs preserves key insertion order", () => { expect(envRecordToPairs({ B: "2", A: "1" })).toEqual([ diff --git a/clients/web/src/test/core/react/useEmaIdpLoginState.test.tsx b/clients/web/src/test/core/react/useEmaIdpLoginState.test.tsx new file mode 100644 index 00000000..ffc5601d --- /dev/null +++ b/clients/web/src/test/core/react/useEmaIdpLoginState.test.tsx @@ -0,0 +1,64 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, act, waitFor } from "@testing-library/react"; +import type { OAuthStorage } from "@inspector/core/auth/storage.js"; +import { useEmaIdpLoginState } from "@inspector/core/react/useEmaIdpLoginState.js"; + +describe("useEmaIdpLoginState", () => { + let storage: OAuthStorage; + + beforeEach(() => { + storage = { + getIdpSession: vi.fn().mockResolvedValue(undefined), + clearIdpSession: vi.fn(), + clear: vi.fn(), + clearEnterpriseManagedResourceServers: vi.fn(), + } as unknown as OAuthStorage; + }); + + it("loads login state when active", async () => { + const exp = Math.floor(Date.now() / 1000) + 3600; + const payload = btoa(JSON.stringify({ exp })) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + vi.mocked(storage.getIdpSession).mockResolvedValue({ + idToken: `h.${payload}.s`, + }); + + const { result } = renderHook(() => + useEmaIdpLoginState(storage, "https://idp.test", true), + ); + + await waitFor(() => { + expect(result.current.loginState).toBe("logged_in"); + }); + }); + + it("logout clears session and resets state", async () => { + const exp = Math.floor(Date.now() / 1000) + 3600; + const payload = btoa(JSON.stringify({ exp })) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + vi.mocked(storage.getIdpSession).mockResolvedValue({ + idToken: `h.${payload}.s`, + }); + + const { result } = renderHook(() => + useEmaIdpLoginState(storage, "https://idp.test", true), + ); + + await waitFor(() => { + expect(result.current.loginState).toBe("logged_in"); + }); + + act(() => { + result.current.logout(); + }); + + expect(storage.clearIdpSession).toHaveBeenCalledWith("https://idp.test"); + expect(storage.clear).toHaveBeenCalledWith("ema-idp:https://idp.test"); + expect(storage.clearEnterpriseManagedResourceServers).toHaveBeenCalled(); + expect(result.current.loginState).toBe("none"); + }); +}); diff --git a/clients/web/src/test/core/remote-tokenAuthProvider.test.ts b/clients/web/src/test/core/remote-tokenAuthProvider.test.ts index ff522cdc..cfd12dc7 100644 --- a/clients/web/src/test/core/remote-tokenAuthProvider.test.ts +++ b/clients/web/src/test/core/remote-tokenAuthProvider.test.ts @@ -31,7 +31,7 @@ describe("createTokenAuthProvider", () => { codeVerifier: () => string | undefined; saveCodeVerifier: (v: string) => Promise; clear: () => void; - redirectToAuthorization: (url: URL) => void; + redirectToAuthorization: (url: URL) => Promise; state: () => string; }; @@ -42,9 +42,17 @@ describe("createTokenAuthProvider", () => { expect(p.codeVerifier()).toBeUndefined(); await expect(p.saveCodeVerifier("v")).resolves.toBeUndefined(); expect(() => p.clear()).not.toThrow(); - expect(() => + await expect( p.redirectToAuthorization(new URL("https://example.com/")), - ).not.toThrow(); + ).rejects.toThrow(/remote server cannot complete OAuth flows/); expect(p.state()).toBe(""); }); + + it("exposes clientMetadata so SDK auth() does not throw on 401 retry", () => { + const provider = createTokenAuthProvider({ + access_token: "abc", + token_type: "Bearer", + }); + expect(provider!.clientMetadata.scope).toBe(""); + }); }); diff --git a/clients/web/src/test/integration/auth/node/storage.test.ts b/clients/web/src/test/integration/auth/node/storage.test.ts index 12e317f3..38c2ff17 100644 --- a/clients/web/src/test/integration/auth/node/storage.test.ts +++ b/clients/web/src/test/integration/auth/node/storage.test.ts @@ -496,6 +496,41 @@ describe("OAuth Store (Zustand)", () => { }); }); +describe("NodeOAuthStorage idpSessions (EMA)", () => { + let storage: NodeOAuthStorage; + const testStatePath = path.join( + os.tmpdir(), + `mcp-inspector-oauth-idp-${Date.now()}-${Math.random().toString(36).slice(2)}.json`, + ); + const issuer = "https://idp.example.com"; + + afterEach(async () => { + try { + await fs.unlink(testStatePath); + } catch { + /* ignore */ + } + }); + + it("persists IdP session keyed by issuer", async () => { + storage = new NodeOAuthStorage(testStatePath); + await storage.saveIdpSession(issuer, { + idToken: "eyJ.id.token", + refreshToken: "rt-1", + idTokenExpiresAt: 1_700_000_000_000, + }); + await flushStoreFileWrites(testStatePath); + + const session = await storage.getIdpSession(issuer); + expect(session?.idToken).toBe("eyJ.id.token"); + expect(session?.refreshToken).toBe("rt-1"); + + storage.clearIdpSession(issuer); + await flushStoreFileWrites(testStatePath); + expect(await storage.getIdpSession(issuer)).toBeUndefined(); + }); +}); + describe("NodeOAuthStorage with custom storagePath", () => { const testServerUrl = "http://localhost:3999"; diff --git a/clients/web/src/test/integration/auth/state-machine.test.ts b/clients/web/src/test/integration/auth/state-machine.test.ts index a13164bb..35777132 100644 --- a/clients/web/src/test/integration/auth/state-machine.test.ts +++ b/clients/web/src/test/integration/auth/state-machine.test.ts @@ -3,8 +3,8 @@ import { OAuthStateMachine, oauthTransitions, } from "@inspector/core/auth/state-machine.js"; -import type { AuthGuidedState, OAuthStep } from "@inspector/core/auth/types.js"; -import { EMPTY_GUIDED_STATE } from "@inspector/core/auth/types.js"; +import type { OAuthFlowState, OAuthStep } from "@inspector/core/auth/types.js"; +import { EMPTY_OAUTH_FLOW_STATE } from "@inspector/core/auth/types.js"; import type { BaseOAuthClientProvider } from "@inspector/core/auth/providers.js"; import type { OAuthMetadata, @@ -23,12 +23,12 @@ vi.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({ describe("OAuthStateMachine", () => { let mockProvider: BaseOAuthClientProvider; - let updateState: (updates: Partial) => void; - let state: AuthGuidedState; + let updateState: (updates: Partial) => void; + let state: OAuthFlowState; beforeEach(() => { - state = { ...EMPTY_GUIDED_STATE }; - updateState = vi.fn((updates: Partial) => { + state = { ...EMPTY_OAUTH_FLOW_STATE }; + updateState = vi.fn((updates: Partial) => { state = { ...state, ...updates }; }); @@ -347,8 +347,8 @@ describe("OAuthStateMachine", () => { getServerMetadata: vi.fn(() => metadata), } as unknown as BaseOAuthClientProvider; - const tokenRequestState: AuthGuidedState = { - ...EMPTY_GUIDED_STATE, + const tokenRequestState: OAuthFlowState = { + ...EMPTY_OAUTH_FLOW_STATE, oauthStep: "token_request", oauthMetadata: metadata as OAuthMetadata, oauthClientInfo: { client_id: "test-client" }, @@ -384,8 +384,8 @@ describe("OAuthStateMachine", () => { clientInformation: vi.fn(async () => undefined), } as unknown as BaseOAuthClientProvider; - const tokenState: AuthGuidedState = { - ...EMPTY_GUIDED_STATE, + const tokenState: OAuthFlowState = { + ...EMPTY_OAUTH_FLOW_STATE, oauthStep: "token_request", oauthMetadata: metadata as OAuthMetadata, authorizationCode: "code-without-client", @@ -403,7 +403,7 @@ describe("OAuthStateMachine", () => { it("complete.canTransition always returns false (terminal state)", async () => { const result = await oauthTransitions.complete.canTransition({ - state: { ...EMPTY_GUIDED_STATE, oauthStep: "complete" }, + state: { ...EMPTY_OAUTH_FLOW_STATE, oauthStep: "complete" }, serverUrl: "http://localhost:3000", provider: mockProvider, updateState, @@ -412,7 +412,7 @@ describe("OAuthStateMachine", () => { // execute is a no-op await expect( oauthTransitions.complete.execute({ - state: { ...EMPTY_GUIDED_STATE, oauthStep: "complete" }, + state: { ...EMPTY_OAUTH_FLOW_STATE, oauthStep: "complete" }, serverUrl: "http://localhost:3000", provider: mockProvider, updateState, @@ -429,8 +429,8 @@ describe("OAuthStateMachine", () => { mockProvider, updateState, ); - const blockedState: AuthGuidedState = { - ...EMPTY_GUIDED_STATE, + const blockedState: OAuthFlowState = { + ...EMPTY_OAUTH_FLOW_STATE, oauthStep: "token_request", }; await expect(stateMachine.executeStep(blockedState)).rejects.toThrow( diff --git a/clients/web/src/test/integration/mcp/ema-mock-servers.ts b/clients/web/src/test/integration/mcp/ema-mock-servers.ts new file mode 100644 index 00000000..4e40b41d --- /dev/null +++ b/clients/web/src/test/integration/mcp/ema-mock-servers.ts @@ -0,0 +1,264 @@ +/** + * Mock IdP and resource authorization servers for EMA integration tests. + * Topology mirrors xaa.dev staging (separate IdP + resource AS) — see + * test-servers/configs/xaa-ema-http.json and specification/v2_enterprise_managed_auth.md. + */ + +import { + createServer, + type IncomingMessage, + type ServerResponse, +} from "node:http"; +import { exportJWK, generateKeyPair, SignJWT } from "jose"; +import { + GRANT_TYPE_JWT_BEARER, + GRANT_TYPE_TOKEN_EXCHANGE, + TOKEN_TYPE_ID_JAG, +} from "@inspector/core/auth/ema/constants.js"; + +export const EMA_MOCK_IDP_CLIENT_ID = "ema-mock-idp-client"; +export const EMA_MOCK_IDP_CLIENT_SECRET = "ema-mock-idp-secret"; +export const EMA_MOCK_RESOURCE_CLIENT_ID = "ema-mock-resource-client"; +export const EMA_MOCK_RESOURCE_CLIENT_SECRET = "ema-mock-resource-secret"; + +/** Fields required by SDK `OAuthMetadataSchema` for discovery in tests. */ +export function minimalOAuthAsMetadata( + baseUrl: string, + authorizationEndpoint = `${baseUrl}/authorize`, +) { + return { + issuer: baseUrl, + authorization_endpoint: authorizationEndpoint, + token_endpoint: `${baseUrl}/token`, + response_types_supported: ["code"], + token_endpoint_auth_methods_supported: ["client_secret_post"], + }; +} + +export interface EmaMockKeyMaterial { + privateKey: Awaited>["privateKey"]; + publicKey: Awaited>["publicKey"]; + kid: string; +} + +export async function createEmaMockKeyMaterial(): Promise { + const { publicKey, privateKey } = await generateKeyPair("RS256"); + return { privateKey, publicKey, kid: "ema-mock-key" }; +} + +export interface StoppableMockServer { + baseUrl: string; + stop: () => Promise; +} + +async function readFormBody(req: IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(chunk as Buffer); + } + return new URLSearchParams(Buffer.concat(chunks).toString()); +} + +function sendJson(res: ServerResponse, status: number, body: unknown): void { + res.writeHead(status, { "Content-Type": "application/json" }); + res.end(JSON.stringify(body)); +} + +function startHttpServer( + createHandler: ( + baseUrl: string, + ) => (req: IncomingMessage, res: ServerResponse) => Promise | void, +): Promise { + return new Promise((resolve, reject) => { + let baseUrl = ""; + const server = createServer((req, res) => { + void Promise.resolve(createHandler(baseUrl)(req, res)).catch((err) => { + if (!res.headersSent) { + sendJson(res, 500, { + error: "server_error", + error_description: err instanceof Error ? err.message : String(err), + }); + } + }); + }); + server.listen(0, "127.0.0.1", () => { + const addr = server.address(); + const port = + addr && typeof addr === "object" && "port" in addr ? addr.port : 0; + baseUrl = `http://127.0.0.1:${port}`; + resolve({ + baseUrl, + stop: () => + new Promise((done, fail) => { + server.close((err) => (err ? fail(err) : done())); + }), + }); + }); + server.on("error", reject); + }); +} + +/** Mock enterprise IdP — OIDC discovery + RFC 8693 token exchange (leg 2). */ +export async function startMockIdpServer(): Promise { + return startHttpServer((baseUrl) => async (req, res) => { + const url = new URL(req.url ?? "/", baseUrl); + + if ( + req.method === "GET" && + (url.pathname === "/.well-known/oauth-authorization-server" || + url.pathname === "/.well-known/openid-configuration") + ) { + sendJson( + res, + 200, + minimalOAuthAsMetadata(baseUrl, `${baseUrl}/authorize`), + ); + return; + } + + if (req.method === "POST" && url.pathname === "/token") { + const body = await readFormBody(req); + if (body.get("grant_type") === "refresh_token") { + if ( + body.get("client_id") !== EMA_MOCK_IDP_CLIENT_ID || + body.get("client_secret") !== EMA_MOCK_IDP_CLIENT_SECRET + ) { + sendJson(res, 401, { error: "invalid_client" }); + return; + } + if (!body.get("refresh_token")) { + sendJson(res, 400, { error: "invalid_request" }); + return; + } + const exp = Math.floor(Date.now() / 1000) + 3600; + const refreshedIdToken = await createMockIdToken(baseUrl, exp); + sendJson(res, 200, { + id_token: refreshedIdToken, + refresh_token: body.get("refresh_token"), + token_type: "Bearer", + }); + return; + } + if (body.get("grant_type") !== GRANT_TYPE_TOKEN_EXCHANGE) { + sendJson(res, 400, { + error: "unsupported_grant_type", + error_description: `expected ${GRANT_TYPE_TOKEN_EXCHANGE}`, + }); + return; + } + if ( + body.get("client_id") !== EMA_MOCK_IDP_CLIENT_ID || + body.get("client_secret") !== EMA_MOCK_IDP_CLIENT_SECRET + ) { + sendJson(res, 401, { error: "invalid_client" }); + return; + } + if (!body.get("subject_token")) { + sendJson(res, 400, { error: "invalid_request" }); + return; + } + const idJag = `mock-id-jag.${Buffer.from(body.get("audience") ?? "").toString("base64url")}`; + sendJson(res, 200, { + access_token: idJag, + issued_token_type: TOKEN_TYPE_ID_JAG, + token_type: "Bearer", + }); + return; + } + + sendJson(res, 404, { error: "not_found" }); + }); +} + +export interface MockResourceAsOptions { + keys: EmaMockKeyMaterial; + /** Resource identifier echoed in access-token `aud` when set. */ + resourceAudience?: string; +} + +/** Mock resource authorization server — AS discovery, JWKS, JWT bearer grant (leg 3). */ +export async function startMockResourceAsServer( + options: MockResourceAsOptions, +): Promise { + const { keys, resourceAudience } = options; + const jwk = await exportJWK(keys.publicKey); + + return startHttpServer((baseUrl) => async (req, res) => { + const url = new URL(req.url ?? "/", baseUrl); + const issuer = baseUrl; + + if ( + req.method === "GET" && + url.pathname === "/.well-known/oauth-authorization-server" + ) { + sendJson(res, 200, { + ...minimalOAuthAsMetadata(baseUrl), + jwks_uri: `${baseUrl}/jwks`, + }); + return; + } + + if (req.method === "GET" && url.pathname === "/jwks") { + sendJson(res, 200, { + keys: [{ ...jwk, kid: keys.kid, alg: "RS256", use: "sig" }], + }); + return; + } + + if (req.method === "POST" && url.pathname === "/token") { + const body = await readFormBody(req); + if (body.get("grant_type") !== GRANT_TYPE_JWT_BEARER) { + sendJson(res, 400, { + error: "unsupported_grant_type", + error_description: `expected ${GRANT_TYPE_JWT_BEARER}`, + }); + return; + } + if ( + body.get("client_id") !== EMA_MOCK_RESOURCE_CLIENT_ID || + body.get("client_secret") !== EMA_MOCK_RESOURCE_CLIENT_SECRET + ) { + sendJson(res, 401, { error: "invalid_client" }); + return; + } + if (!body.get("assertion")) { + sendJson(res, 400, { error: "invalid_request" }); + return; + } + + const exp = Math.floor(Date.now() / 1000) + 3600; + const accessToken = await new SignJWT({ + scope: body.get("scope") ?? "mcp", + ...(resourceAudience ? { aud: resourceAudience } : {}), + }) + .setProtectedHeader({ alg: "RS256", kid: keys.kid }) + .setIssuer(issuer) + .setExpirationTime(exp) + .sign(keys.privateKey); + + sendJson(res, 200, { + access_token: accessToken, + token_type: "Bearer", + expires_in: 3600, + scope: body.get("scope") ?? "mcp", + }); + return; + } + + sendJson(res, 404, { error: "not_found" }); + }); +} + +/** Non-expired ID Token JWT for seeding IdP session in storage (leg 1 shortcut). */ +export async function createMockIdToken( + issuer: string, + expSec?: number, +): Promise { + const { privateKey } = await generateKeyPair("RS256"); + const exp = expSec ?? Math.floor(Date.now() / 1000) + 3600; + return new SignJWT({ sub: "ema-test-user" }) + .setProtectedHeader({ alg: "RS256" }) + .setIssuer(issuer.replace(/\/$/, "")) + .setExpirationTime(exp) + .sign(privateKey); +} diff --git a/clients/web/src/test/integration/mcp/inspectorClient-ema-e2e.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-ema-e2e.test.ts new file mode 100644 index 00000000..8ce12e79 --- /dev/null +++ b/clients/web/src/test/integration/mcp/inspectorClient-ema-e2e.test.ts @@ -0,0 +1,213 @@ +/** + * End-to-end EMA (enterprise-managed authorization) tests for InspectorClient. + * Mock IdP + mock resource AS + protected-resource MCP test server. + */ + +import { + describe, + it, + expect, + beforeEach, + afterEach, + afterAll, + vi, +} from "vitest"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; +import { createTransportNode } from "@inspector/core/mcp/node/transport.js"; +import { ConsoleNavigation } from "@inspector/core/auth/providers.js"; +import { + clearAllOAuthClientState, + NodeOAuthStorage, +} from "@inspector/core/auth/node/index.js"; +import { flushStoreFileWrites } from "@inspector/core/storage/store-io.js"; +import { + TestServerHttp, + getDefaultServerConfig, + createExternalResourceOAuthTestServerConfig, +} from "@modelcontextprotocol/inspector-test-server"; +import type { InspectorClientOptions } from "@inspector/core/mcp/inspectorClient.js"; +import type { MCPServerConfig } from "@inspector/core/mcp/types.js"; +import { + createEmaMockKeyMaterial, + createMockIdToken, + EMA_MOCK_IDP_CLIENT_ID, + EMA_MOCK_IDP_CLIENT_SECRET, + EMA_MOCK_RESOURCE_CLIENT_ID, + EMA_MOCK_RESOURCE_CLIENT_SECRET, + startMockIdpServer, + startMockResourceAsServer, + type StoppableMockServer, +} from "./ema-mock-servers.js"; + +const testRedirectUrl = "http://127.0.0.1:3001/oauth/callback"; +const oauthTestStatePath = path.join( + os.tmpdir(), + `mcp-ema-${process.pid}-inspectorClient-ema-e2e.json`, +); + +function createStaticRedirectUrlProvider(redirectUrl: string) { + return { getRedirectUrl: () => redirectUrl }; +} + +async function waitForProtectedResourceMetadata( + serverBase: string, +): Promise { + const url = `${serverBase.replace(/\/$/, "")}/.well-known/oauth-protected-resource`; + const start = Date.now(); + while (Date.now() - start < 5000) { + const res = await fetch(url); + if (res.ok) return; + await new Promise((r) => setTimeout(r, 50)); + } + throw new Error(`protected resource metadata not ready: ${url}`); +} + +describe("InspectorClient EMA E2E", () => { + let mcpServer: TestServerHttp; + let client: InspectorClient; + let mockIdp: StoppableMockServer; + let mockAs: StoppableMockServer; + let storage: NodeOAuthStorage; + let mcpPort: number; + let mcpUrl: string; + + afterAll(async () => { + try { + await fs.unlink(oauthTestStatePath); + } catch { + // ignore + } + }); + + beforeEach(async () => { + clearAllOAuthClientState(); + vi.spyOn(console, "log").mockImplementation(() => {}); + + const keys = await createEmaMockKeyMaterial(); + mockAs = await startMockResourceAsServer({ keys }); + mockIdp = await startMockIdpServer(); + + const serverConfig = { + ...getDefaultServerConfig(), + serverType: "streamable-http" as const, + ...createExternalResourceOAuthTestServerConfig({ + authorizationServers: [mockAs.baseUrl], + requireAuth: true, + scopesSupported: ["mcp"], + accessTokenIssuers: [mockAs.baseUrl], + jwksUri: `${mockAs.baseUrl}/jwks`, + }), + }; + + mcpServer = new TestServerHttp(serverConfig); + mcpPort = await mcpServer.start(); + const serverBase = `http://127.0.0.1:${mcpPort}`; + mcpUrl = `${serverBase}/mcp`; + await waitForProtectedResourceMetadata(serverBase); + + storage = new NodeOAuthStorage(oauthTestStatePath); + const idToken = await createMockIdToken(mockIdp.baseUrl); + await storage.saveIdpSession(mockIdp.baseUrl, { idToken }); + await flushStoreFileWrites(oauthTestStatePath); + }); + + afterEach(async () => { + if (client) { + await client.disconnect(); + } + if (mcpServer) { + await mcpServer.stop(); + } + if (mockIdp) { + await mockIdp.stop(); + } + if (mockAs) { + await mockAs.stop(); + } + vi.restoreAllMocks(); + }); + + function createEmaClient(): InspectorClient { + const clientConfig: InspectorClientOptions = { + environment: { + transport: createTransportNode, + oauth: { + storage, + navigation: new ConsoleNavigation(), + redirectUrlProvider: createStaticRedirectUrlProvider(testRedirectUrl), + }, + }, + oauth: { + enterpriseManaged: true, + clientId: EMA_MOCK_RESOURCE_CLIENT_ID, + clientSecret: EMA_MOCK_RESOURCE_CLIENT_SECRET, + scope: "mcp", + }, + enterpriseManagedAuth: { + idp: { + issuer: mockIdp.baseUrl, + clientId: EMA_MOCK_IDP_CLIENT_ID, + clientSecret: EMA_MOCK_IDP_CLIENT_SECRET, + }, + }, + }; + + return new InspectorClient( + { + type: "streamable-http", + url: mcpUrl, + } as MCPServerConfig, + clientConfig, + ); + } + + it("connects via silent EMA (cached IdP session + legs 2–3)", async () => { + client = createEmaClient(); + await client.connect(); + + expect(client.getStatus()).toBe("connected"); + + const tokens = await client.getOAuthTokens(); + expect(tokens?.access_token).toBeDefined(); + expect(tokens?.token_type).toBe("Bearer"); + + const oauthState = await client.getOAuthState(); + expect(oauthState?.protocol).toBe("ema"); + expect(oauthState?.authorized).toBe(true); + expect(oauthState?.ema?.idpSession).toBe("logged_in"); + }); + + it("reuses silent EMA on a second connect", async () => { + client = createEmaClient(); + await client.connect(); + await client.disconnect(); + + await client.connect(); + expect(client.getStatus()).toBe("connected"); + + const oauthState = await client.getOAuthState(); + expect(oauthState?.protocol).toBe("ema"); + expect(oauthState?.authorized).toBe(true); + }); + + it("persists EMA resource tokens tagged enterpriseManaged in storage", async () => { + client = createEmaClient(); + await client.connect(); + await flushStoreFileWrites(oauthTestStatePath); + + const raw = JSON.parse(await fs.readFile(oauthTestStatePath, "utf-8")) as { + state: { + servers: Record< + string, + { tokens?: { access_token?: string }; enterpriseManaged?: boolean } + >; + }; + }; + const entry = raw.state.servers[mcpUrl]; + expect(entry?.tokens?.access_token).toBeDefined(); + expect(entry?.enterpriseManaged).toBe(true); + }); +}); diff --git a/clients/web/src/test/integration/mcp/inspectorClient-oauth-e2e.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-oauth-e2e.test.ts index 14d177cb..4588003e 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-oauth-e2e.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-oauth-e2e.test.ts @@ -247,10 +247,11 @@ describe("InspectorClient OAuth E2E", () => { // Use authenticate() (normal mode) - should use SDK's auth() const authUrl = await client.authenticate(); + if (!authUrl) throw new Error("Expected authorization URL"); expect(authUrl.href).toContain("/oauth/authorize"); - const stateAfterAuth = client.getOAuthState(); - expect(stateAfterAuth?.authType).toBe("normal"); + const stateAfterAuth = client.getOAuthFlowState(); + expect(stateAfterAuth?.execution).toBe("quick"); expect(stateAfterAuth?.oauthStep).toBe("authorization_code"); expect(stateAfterAuth?.authorizationUrl?.href).toBe(authUrl.href); expect(stateAfterAuth?.oauthClientInfo).toBeDefined(); @@ -260,8 +261,8 @@ describe("InspectorClient OAuth E2E", () => { await client.completeOAuthFlow(authCode); await client.connect(); - const stateAfterComplete = client.getOAuthState(); - expect(stateAfterComplete?.authType).toBe("normal"); + const stateAfterComplete = client.getOAuthFlowState(); + expect(stateAfterComplete?.execution).toBe("quick"); expect(stateAfterComplete?.oauthStep).toBe("complete"); expect(stateAfterComplete?.oauthTokens).toBeDefined(); expect(stateAfterComplete?.completedAt).toBeDefined(); @@ -333,6 +334,7 @@ describe("InspectorClient OAuth E2E", () => { // Auth-provider flow: authenticate first, complete OAuth, then connect. const authUrl = await client.authenticate(); + if (!authUrl) throw new Error("Expected authorization URL"); expect(authUrl.href).toContain("/oauth/authorize"); const authCode = await completeOAuthAuthorization(authUrl); await client.completeOAuthFlow(authCode); @@ -564,6 +566,7 @@ describe("InspectorClient OAuth E2E", () => { ); const authUrl = await client.authenticate(); + if (!authUrl) throw new Error("Expected authorization URL"); expect(authUrl.href).toContain("/oauth/authorize"); const authCode = await completeOAuthAuthorization(authUrl); await client.completeOAuthFlow(authCode); @@ -622,10 +625,11 @@ describe("InspectorClient OAuth E2E", () => { // Use authenticate() (normal mode) - should trigger DCR via SDK's auth() const authUrl = await client.authenticate(); + if (!authUrl) throw new Error("Expected authorization URL"); expect(authUrl.href).toContain("/oauth/authorize"); - const stateAfterAuth = client.getOAuthState(); - expect(stateAfterAuth?.authType).toBe("normal"); + const stateAfterAuth = client.getOAuthFlowState(); + expect(stateAfterAuth?.execution).toBe("quick"); expect(stateAfterAuth?.oauthStep).toBe("authorization_code"); expect(stateAfterAuth?.oauthClientInfo).toBeDefined(); expect(stateAfterAuth?.oauthClientInfo?.client_id).toBeDefined(); @@ -634,8 +638,8 @@ describe("InspectorClient OAuth E2E", () => { await client.completeOAuthFlow(authCode); await client.connect(); - const stateAfterComplete = client.getOAuthState(); - expect(stateAfterComplete?.authType).toBe("normal"); + const stateAfterComplete = client.getOAuthFlowState(); + expect(stateAfterComplete?.execution).toBe("quick"); expect(stateAfterComplete?.oauthStep).toBe("complete"); expect(stateAfterComplete?.oauthTokens).toBeDefined(); expect(stateAfterComplete?.completedAt).toBeDefined(); @@ -699,8 +703,8 @@ describe("InspectorClient OAuth E2E", () => { await client.completeOAuthFlow(authCode); await client.connect(); - const stateAfterComplete = client.getOAuthState(); - expect(stateAfterComplete?.authType).toBe("guided"); + const stateAfterComplete = client.getOAuthFlowState(); + expect(stateAfterComplete?.execution).toBe("guided"); expect(stateAfterComplete?.oauthStep).toBe("complete"); expect(stateAfterComplete?.completedAt).toBeDefined(); @@ -769,7 +773,7 @@ describe("InspectorClient OAuth E2E", () => { await client.beginGuidedAuth(); while (true) { - const state = client.getOAuthState(); + const state = client.getOAuthFlowState(); if ( state?.oauthStep === "authorization_code" || state?.oauthStep === "complete" @@ -779,7 +783,7 @@ describe("InspectorClient OAuth E2E", () => { await client.proceedOAuthStep(); } - const state = client.getOAuthState(); + const state = client.getOAuthFlowState(); const authUrl = state?.authorizationUrl; if (!authUrl) throw new Error("Expected authorizationUrl"); expect(authUrl.href).toContain("/oauth/authorize"); @@ -788,8 +792,8 @@ describe("InspectorClient OAuth E2E", () => { await client.completeOAuthFlow(authCode); await client.connect(); - const stateAfterComplete = client.getOAuthState(); - expect(stateAfterComplete?.authType).toBe("guided"); + const stateAfterComplete = client.getOAuthFlowState(); + expect(stateAfterComplete?.execution).toBe("guided"); expect(stateAfterComplete?.oauthStep).toBe("complete"); const tokens = await client.getOAuthTokens(); @@ -857,14 +861,14 @@ describe("InspectorClient OAuth E2E", () => { // Start guided auth and progress to authorization_code step await client.beginGuidedAuth(); while (true) { - const state = client.getOAuthState(); + const state = client.getOAuthFlowState(); if (state?.oauthStep === "authorization_code") { break; } await client.proceedOAuthStep(); } - const stateBefore = client.getOAuthState(); + const stateBefore = client.getOAuthFlowState(); expect(stateBefore?.oauthStep).toBe("authorization_code"); expect(stateBefore?.authorizationCode).toBe(""); @@ -884,7 +888,7 @@ describe("InspectorClient OAuth E2E", () => { await client.setGuidedAuthorizationCode(authCode, false); // Verify code was set but flow didn't complete - const stateAfter = client.getOAuthState(); + const stateAfter = client.getOAuthFlowState(); expect(stateAfter?.oauthStep).toBe("authorization_code"); expect(stateAfter?.authorizationCode).toBe(authCode); expect(stateAfter?.oauthTokens).toBeFalsy(); @@ -898,7 +902,7 @@ describe("InspectorClient OAuth E2E", () => { await client.proceedOAuthStep(); // authorization_code -> token_request await client.proceedOAuthStep(); // token_request -> complete - const finalState = client.getOAuthState(); + const finalState = client.getOAuthFlowState(); expect(finalState?.oauthStep).toBe("complete"); expect(finalState?.oauthTokens).toBeDefined(); }); @@ -962,14 +966,14 @@ describe("InspectorClient OAuth E2E", () => { // Start guided auth and progress to authorization_code step await client.beginGuidedAuth(); while (true) { - const state = client.getOAuthState(); + const state = client.getOAuthFlowState(); if (state?.oauthStep === "authorization_code") { break; } await client.proceedOAuthStep(); } - const stateBefore = client.getOAuthState(); + const stateBefore = client.getOAuthFlowState(); expect(stateBefore?.oauthStep).toBe("authorization_code"); expect(stateBefore?.authorizationCode).toBe(""); @@ -989,7 +993,7 @@ describe("InspectorClient OAuth E2E", () => { await client.setGuidedAuthorizationCode(authCode, true); // Verify flow completed automatically - const stateAfter = client.getOAuthState(); + const stateAfter = client.getOAuthFlowState(); expect(stateAfter?.oauthStep).toBe("complete"); expect(stateAfter?.authorizationCode).toBe(authCode); expect(stateAfter?.oauthTokens).toBeDefined(); @@ -1060,7 +1064,7 @@ describe("InspectorClient OAuth E2E", () => { await client.beginGuidedAuth(); await client.proceedOAuthStep(); - const stateBeforeRun = client.getOAuthState(); + const stateBeforeRun = client.getOAuthFlowState(); expect(stateBeforeRun?.oauthStep).not.toBe("authorization_code"); expect(stateBeforeRun?.oauthStep).not.toBe("complete"); @@ -1139,7 +1143,7 @@ describe("InspectorClient OAuth E2E", () => { const authCode = await completeOAuthAuthorization(authUrl); await client.completeOAuthFlow(authCode); - const stateAfterComplete = client.getOAuthState(); + const stateAfterComplete = client.getOAuthFlowState(); expect(stateAfterComplete?.oauthStep).toBe("complete"); const authUrlAgain = await client.runGuidedAuth(); @@ -1199,6 +1203,7 @@ describe("InspectorClient OAuth E2E", () => { ); const authUrl = await client.authenticate(); + if (!authUrl) throw new Error("Expected authorization URL"); const authCode = await completeOAuthAuthorization(authUrl); await client.completeOAuthFlow(authCode); await client.connect(); @@ -1255,6 +1260,7 @@ describe("InspectorClient OAuth E2E", () => { ); const authUrlNormal = await client.authenticate(); + if (!authUrlNormal) throw new Error("Expected authorization URL"); const authCodeNormal = await completeOAuthAuthorization(authUrlNormal); await client.completeOAuthFlow(authCodeNormal); await client.connect(); @@ -1401,9 +1407,9 @@ describe("InspectorClient OAuth E2E", () => { await client.runGuidedAuth(); - const state = client.getOAuthState(); + const state = client.getOAuthFlowState(); expect(state).toBeDefined(); - expect(state?.authType).toBe("guided"); + expect(state?.execution).toBe("guided"); expect(state?.resourceMetadata).toBeDefined(); expect(state?.resourceMetadata?.resource).toBeDefined(); expect( @@ -1513,8 +1519,8 @@ describe("InspectorClient OAuth E2E", () => { expect(typeof e?.state === "object" && e?.state !== null).toBe(true); } - const finalState = client.getOAuthState(); - expect(finalState?.authType).toBe("guided"); + const finalState = client.getOAuthFlowState(); + expect(finalState?.execution).toBe("guided"); expect(finalState?.oauthStep).toBe("complete"); expect(finalState?.oauthTokens).toBeDefined(); expect(finalState?.completedAt).toBeDefined(); @@ -1584,6 +1590,7 @@ describe("InspectorClient OAuth E2E", () => { ); const authUrl = await client.authenticate(); + if (!authUrl) throw new Error("Expected authorization URL"); const authCode = await completeOAuthAuthorization(authUrl); await client.completeOAuthFlow(authCode); await client.connect(); @@ -1663,6 +1670,7 @@ describe("InspectorClient OAuth E2E", () => { ); const authUrl = await client.authenticate(); + if (!authUrl) throw new Error("Expected authorization URL"); const authCode = await completeOAuthAuthorization(authUrl); await client.completeOAuthFlow(authCode); await client.connect(); @@ -1741,6 +1749,7 @@ describe("InspectorClient OAuth E2E", () => { try { const authUrl = await client.authenticate(); + if (!authUrl) throw new Error("Expected authorization URL"); const authCode = await completeOAuthAuthorization(authUrl); await client.completeOAuthFlow(authCode); await client.connect(); diff --git a/clients/web/src/test/integration/mcp/inspectorClient-oauth.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-oauth.test.ts index 77667441..a26512f9 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-oauth.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-oauth.test.ts @@ -494,6 +494,7 @@ describe("InspectorClient OAuth", () => { // Auth-provider flow: authenticate first, complete OAuth, then connect. // connect() creates transport with authProvider; tokens are already in storage. const authorizationUrl = await testClient.authenticate(); + if (!authorizationUrl) throw new Error("Expected authorization URL"); const authCode = await completeOAuthAuthorization(authorizationUrl); await testClient.completeOAuthFlow(authCode); diff --git a/clients/web/src/test/integration/mcp/inspectorClient.test.ts b/clients/web/src/test/integration/mcp/inspectorClient.test.ts index a92ee91a..dbc6739e 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient.test.ts @@ -4918,8 +4918,9 @@ describe("InspectorClient", () => { ); await expect(c.getOAuthTokens()).resolves.toBeUndefined(); await expect(c.isOAuthAuthorized()).resolves.toBe(false); - expect(c.getOAuthStep()).toBeUndefined(); - expect(c.getOAuthState()).toBeUndefined(); + expect(c.getOAuthFlowStep()).toBeUndefined(); + expect(c.getOAuthFlowState()).toBeUndefined(); + await expect(c.getOAuthState()).resolves.toBeUndefined(); // clearOAuthTokens is a no-op when there is no manager expect(() => c.clearOAuthTokens()).not.toThrow(); }); diff --git a/clients/web/src/test/integration/mcp/remote/client-store-route.test.ts b/clients/web/src/test/integration/mcp/remote/client-store-route.test.ts new file mode 100644 index 00000000..8624c5a6 --- /dev/null +++ b/clients/web/src/test/integration/mcp/remote/client-store-route.test.ts @@ -0,0 +1,225 @@ +/** + * Integration tests for /api/storage/client IdP secret keychain handling. + */ + +import { describe, it, expect, afterEach, beforeEach } from "vitest"; +import { + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { serve } from "@hono/node-server"; +import type { ServerType } from "@hono/node-server"; +import { createRemoteApp } from "@inspector/core/mcp/remote/node/server.js"; +import { CLIENT_KEYCHAIN_ID } from "@inspector/core/client/secrets.js"; +import { + InMemorySecretStore, + SECRET_FIELD_IDP_CLIENT_SECRET, +} from "@inspector/core/auth/node/secret-store.js"; +import { getStoreFilePath } from "@inspector/core/storage/store-io.js"; + +interface Harness { + baseUrl: string; + server: ServerType; + storageDir: string; + clientPath: string; + tempDir: string; + secretStore: InMemorySecretStore; +} + +async function startHarness(): Promise { + const tempDir = mkdtempSync(join(tmpdir(), "inspector-client-store-")); + const storageDir = join(tempDir, "storage"); + mkdirSync(storageDir, { recursive: true }); + const secretStore = new InMemorySecretStore(); + const { app } = createRemoteApp({ + dangerouslyOmitAuth: true, + storageDir, + initialConfig: { defaultEnvironment: {} }, + secretStore, + }); + const { baseUrl, server } = await new Promise<{ + baseUrl: string; + server: ServerType; + }>((resolve, reject) => { + const server = serve( + { fetch: app.fetch, port: 0, hostname: "127.0.0.1" }, + (info) => { + const port = + info && typeof info === "object" && "port" in info + ? (info as { port: number }).port + : 0; + resolve({ baseUrl: `http://127.0.0.1:${port}`, server }); + }, + ); + server.on("error", reject); + }); + return { + baseUrl, + server, + storageDir, + clientPath: getStoreFilePath(storageDir, "client"), + tempDir, + secretStore, + }; +} + +async function teardown(h: Harness): Promise { + await new Promise((resolve) => h.server.close(() => resolve())); + try { + rmSync(h.tempDir, { recursive: true }); + } catch { + /* ignore */ + } +} + +const sampleClientConfig = { + enterpriseManagedAuth: { + idp: { + issuer: "https://idp.example.com", + clientId: "inspector-app", + clientSecret: "very-secret-idp", + }, + }, +}; + +describe("/api/storage/client keychain", () => { + let h: Harness; + + beforeEach(async () => { + h = await startHarness(); + }); + + afterEach(async () => { + await teardown(h); + }); + + it("POST writes IdP clientSecret to keychain, not client.json", async () => { + const res = await fetch(`${h.baseUrl}/api/storage/client`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(sampleClientConfig), + }); + expect(res.status).toBe(200); + + const onDisk = JSON.parse(readFileSync(h.clientPath, "utf-8")) as { + enterpriseManagedAuth?: { idp?: Record }; + }; + expect(onDisk.enterpriseManagedAuth?.idp).toEqual({ + issuer: "https://idp.example.com", + clientId: "inspector-app", + }); + expect(onDisk.enterpriseManagedAuth?.idp?.clientSecret).toBeUndefined(); + + const raw = readFileSync(h.clientPath, "utf-8"); + expect(raw).not.toContain("very-secret-idp"); + + expect( + await h.secretStore.get( + CLIENT_KEYCHAIN_ID, + SECRET_FIELD_IDP_CLIENT_SECRET, + ), + ).toBe("very-secret-idp"); + }); + + it("GET rehydrates IdP clientSecret from keychain", async () => { + await fetch(`${h.baseUrl}/api/storage/client`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(sampleClientConfig), + }); + + const res = await fetch(`${h.baseUrl}/api/storage/client`); + expect(res.status).toBe(200); + const json = (await res.json()) as typeof sampleClientConfig; + expect(json.enterpriseManagedAuth?.idp.clientSecret).toBe( + "very-secret-idp", + ); + }); + + it("GET migrates legacy plaintext clientSecret into keychain", async () => { + writeFileSync( + h.clientPath, + JSON.stringify(sampleClientConfig, null, 2), + "utf-8", + ); + + const res = await fetch(`${h.baseUrl}/api/storage/client`); + expect(res.status).toBe(200); + const json = (await res.json()) as typeof sampleClientConfig; + expect(json.enterpriseManagedAuth?.idp.clientSecret).toBe( + "very-secret-idp", + ); + + const onDisk = JSON.parse(readFileSync(h.clientPath, "utf-8")) as { + enterpriseManagedAuth?: { idp?: Record }; + }; + expect(onDisk.enterpriseManagedAuth?.idp?.clientSecret).toBeUndefined(); + expect( + await h.secretStore.get( + CLIENT_KEYCHAIN_ID, + SECRET_FIELD_IDP_CLIENT_SECRET, + ), + ).toBe("very-secret-idp"); + }); + + it("POST rejects a malformed client config with 400 and a formatted message", async () => { + // `issuer` is required and must be an absolute URL; an empty string fails + // the schema, so `parseClientConfig` throws a ZodError. The route should + // surface that as a 400 (client error) with the load-path formatting, + // not a generic 500 "Failed to write store". + const res = await fetch(`${h.baseUrl}/api/storage/client`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + enterpriseManagedAuth: { + idp: { issuer: "", clientId: "inspector-app" }, + }, + }), + }); + expect(res.status).toBe(400); + const json = (await res.json()) as { error: string }; + // Formatted as ": " by formatClientConfigLoadError, and + // not the generic 500 wording. + expect(json.error).toContain("enterpriseManagedAuth.idp.issuer"); + expect(json.error).not.toContain("Failed to write store"); + + // Nothing was persisted — neither to disk nor the keychain. + expect(existsSync(h.clientPath)).toBe(false); + expect( + await h.secretStore.get( + CLIENT_KEYCHAIN_ID, + SECRET_FIELD_IDP_CLIENT_SECRET, + ), + ).toBeNull(); + }); + + it("DELETE removes client.json and keychain IdP secret", async () => { + await fetch(`${h.baseUrl}/api/storage/client`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(sampleClientConfig), + }); + + const deleteRes = await fetch(`${h.baseUrl}/api/storage/client`, { + method: "DELETE", + }); + expect(deleteRes.status).toBe(200); + + const readRes = await fetch(`${h.baseUrl}/api/storage/client`); + expect(readRes.status).toBe(200); + expect(await readRes.json()).toEqual({}); + + expect( + await h.secretStore.get( + CLIENT_KEYCHAIN_ID, + SECRET_FIELD_IDP_CLIENT_SECRET, + ), + ).toBeNull(); + }); +}); diff --git a/clients/web/src/test/integration/mcp/remote/servers-route.test.ts b/clients/web/src/test/integration/mcp/remote/servers-route.test.ts index ca67a721..9c76288f 100644 --- a/clients/web/src/test/integration/mcp/remote/servers-route.test.ts +++ b/clients/web/src/test/integration/mcp/remote/servers-route.test.ts @@ -1799,6 +1799,86 @@ describe("/api/servers routes", () => { ); }); + it("PUT rename moves stdio env secret when it exists only in the keychain", async () => { + writeFileSync( + h.configPath, + JSON.stringify({ + mcpServers: { + "old-name": { + type: "stdio", + command: "node", + env: { K: "" }, + }, + }, + }), + ); + await h.secretStore.set("old-name", envSecretField("K"), "v"); + + const res = await fetch(`${h.baseUrl}/api/servers/old-name`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + id: "new-name", + config: { type: "stdio", command: "node", env: { K: "" } }, + }), + }); + expect(res.status).toBe(200); + + expect(await h.secretStore.get("old-name", envSecretField("K"))).toBe( + null, + ); + expect(await h.secretStore.get("new-name", envSecretField("K"))).toBe( + "v", + ); + }); + + it("PUT rename moves OAuth client secret when it exists only in the keychain", async () => { + writeFileSync( + h.configPath, + JSON.stringify({ + mcpServers: { + "old-name": { + type: "streamable-http", + url: "https://x.test/mcp", + oauth: { clientId: "cid", scopes: "read" }, + }, + }, + }), + ); + await h.secretStore.set( + "old-name", + SECRET_FIELD_OAUTH_CLIENT_SECRET, + "keychain-only-secret", + ); + + const res = await fetch(`${h.baseUrl}/api/servers/old-name`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + id: "new-name", + config: { type: "streamable-http", url: "https://x.test/mcp" }, + }), + }); + expect(res.status).toBe(200); + + expect( + await h.secretStore.get("old-name", SECRET_FIELD_OAUTH_CLIENT_SECRET), + ).toBe(null); + expect( + await h.secretStore.get("new-name", SECRET_FIELD_OAUTH_CLIENT_SECRET), + ).toBe("keychain-only-secret"); + + const getRes = await fetch(`${h.baseUrl}/api/servers`); + expect(getRes.status).toBe(200); + const cfg = (await getRes.json()) as MCPConfig; + const srv = cfg.mcpServers["new-name"] as { + oauth?: { clientId?: string; clientSecret?: string; scopes?: string }; + }; + expect(srv.oauth?.clientId).toBe("cid"); + expect(srv.oauth?.clientSecret).toBe("keychain-only-secret"); + expect(srv.oauth?.scopes).toBe("read"); + }); + it("DELETE sweeps every keychain entry for the deleted server", async () => { writeFileSync( h.configPath, diff --git a/clients/web/src/test/integration/mcp/test-server-protected-resource.test.ts b/clients/web/src/test/integration/mcp/test-server-protected-resource.test.ts new file mode 100644 index 00000000..f9dd93c3 --- /dev/null +++ b/clients/web/src/test/integration/mcp/test-server-protected-resource.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect, vi } from "vitest"; +import * as path from "node:path"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import { generateKeyPair, exportJWK, SignJWT } from "jose"; +import { + loadConfig, + resolveConfig, + ExternalAccessTokenValidator, + TestServerHttp, + createExternalResourceOAuthTestServerConfig, +} from "@modelcontextprotocol/inspector-test-server"; + +const repoRoot = path.resolve(import.meta.dirname, "../../../../../.."); +const xaaConfigPath = path.join( + repoRoot, + "test-servers/configs/xaa-ema-http.json", +); + +describe("protected-resource OAuth config", () => { + it("loads xaa-ema-http.json and resolves oauth fields", () => { + const config = loadConfig(xaaConfigPath); + const serverConfig = resolveConfig(config); + + expect(serverConfig.oauth).toMatchObject({ + enabled: true, + mode: "protected-resource", + authorizationServers: ["https://auth.resource.xaa.dev"], + requireAuth: true, + accessTokenIssuers: ["https://auth.resource.xaa.dev"], + jwksUri: "https://auth.resource.xaa.dev/jwks", + resource: "http://localhost:8080/", + resourceAudience: "http://localhost:8080/", + }); + expect(serverConfig.serverType).toBe("streamable-http"); + }); + + it("rejects protected-resource config without authorizationServers", () => { + const invalidPath = path.join( + os.tmpdir(), + `invalid-oauth-${process.pid}.json`, + ); + fs.writeFileSync( + invalidPath, + JSON.stringify({ + serverInfo: { name: "bad", version: "1.0.0" }, + transport: { type: "streamable-http" }, + oauth: { enabled: true, mode: "protected-resource" }, + }), + ); + try { + expect(() => loadConfig(invalidPath)).toThrow( + /authorizationServers is required/, + ); + } finally { + fs.unlinkSync(invalidPath); + } + }); +}); + +describe("ExternalAccessTokenValidator", () => { + it("accepts JWTs signed by the AS and rejects wrong issuer", async () => { + const issuer = "https://as.example"; + const jwksUri = "https://as.example/jwks"; + const { publicKey, privateKey } = await generateKeyPair("RS256"); + const jwk = await exportJWK(publicKey); + + const mockFetch = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url === jwksUri) { + return new Response( + JSON.stringify({ + keys: [{ ...jwk, kid: "test", alg: "RS256", use: "sig" }], + }), + ); + } + throw new Error(`unexpected fetch: ${url}`); + }); + + const validator = new ExternalAccessTokenValidator( + { + authorizationServers: [issuer], + jwksUri, + }, + mockFetch as typeof fetch, + ); + + const validToken = await new SignJWT({ scope: "mcp" }) + .setProtectedHeader({ alg: "RS256", kid: "test" }) + .setIssuer(issuer) + .setExpirationTime("1h") + .sign(privateKey); + + const wrongIssuerToken = await new SignJWT({ scope: "mcp" }) + .setProtectedHeader({ alg: "RS256", kid: "test" }) + .setIssuer("https://evil.example") + .setExpirationTime("1h") + .sign(privateKey); + + expect(await validator.validateAccessToken(validToken)).toBe(true); + expect(await validator.validateAccessToken(wrongIssuerToken)).toBe(false); + expect(await validator.validateAccessToken("not-a-jwt")).toBe(false); + }); +}); + +describe("TestServerHttp protected-resource metadata", () => { + it("advertises external authorization_servers and skips local token endpoint", async () => { + const server = new TestServerHttp({ + serverInfo: { name: "ema-rs", version: "1.0.0" }, + serverType: "streamable-http", + ...createExternalResourceOAuthTestServerConfig({ + authorizationServers: ["https://xaa.dev"], + requireAuth: false, + }), + }); + + const port = await server.start(); + try { + const base = `http://127.0.0.1:${port}`; + const prRes = await fetch(`${base}/.well-known/oauth-protected-resource`); + expect(prRes.ok).toBe(true); + const pr = (await prRes.json()) as { + authorization_servers: string[]; + }; + expect(pr.authorization_servers).toEqual(["https://xaa.dev"]); + + const asRes = await fetch( + `${base}/.well-known/oauth-authorization-server`, + ); + expect(asRes.status).toBe(404); + + const tokenRes = await fetch(`${base}/oauth/token`, { method: "POST" }); + expect(tokenRes.status).toBe(404); + } finally { + await server.stop(); + } + }); +}); diff --git a/clients/web/src/utils/oauthFlow.ts b/clients/web/src/utils/oauthFlow.ts index 928ccd25..a595cc3a 100644 --- a/clients/web/src/utils/oauthFlow.ts +++ b/clients/web/src/utils/oauthFlow.ts @@ -14,7 +14,7 @@ export const OAUTH_CALLBACK_PATH = "/oauth/callback"; /** * sessionStorage key holding the id of the server whose OAuth flow is in - * flight. The OAuth `state` parameter only carries `{mode}:{authId}`, not which + * flight. The OAuth `state` parameter only carries `{execution}:{authId}`, not which * configured server initiated the flow, and the full-page redirect to the auth * server wipes all in-memory React state. The id is stashed here right before * redirecting so the post-callback page load can rebuild the right diff --git a/core/auth/browser/index.ts b/core/auth/browser/index.ts index e0fd3411..8d744e90 100644 --- a/core/auth/browser/index.ts +++ b/core/auth/browser/index.ts @@ -1,3 +1,3 @@ -export { BrowserOAuthStorage } from "./storage.js"; +export { BrowserOAuthStorage, getBrowserOAuthStorage } from "./storage.js"; export { BrowserNavigation, BrowserOAuthClientProvider } from "./providers.js"; export type { OAuthNavigationCallback } from "./providers.js"; diff --git a/core/auth/browser/providers.ts b/core/auth/browser/providers.ts index 7f30b2cf..13728725 100644 --- a/core/auth/browser/providers.ts +++ b/core/auth/browser/providers.ts @@ -3,7 +3,7 @@ import type { OAuthNavigationCallback, } from "../providers.js"; import { CallbackNavigation, BaseOAuthClientProvider } from "../providers.js"; -import { BrowserOAuthStorage } from "./storage.js"; +import { getBrowserOAuthStorage } from "./storage.js"; export type { OAuthNavigationCallback } from "../providers.js"; @@ -49,12 +49,12 @@ export class BrowserOAuthClientProvider extends BaseOAuthClientProvider { "BrowserOAuthClientProvider requires browser environment", ); } - const storage = new BrowserOAuthStorage(); + const storage = getBrowserOAuthStorage(); const redirectUrlProvider: RedirectUrlProvider = { getRedirectUrl: () => `${window.location.origin}/oauth/callback`, }; const navigation = new BrowserNavigation(); - super(serverUrl, { storage, redirectUrlProvider, navigation }, "normal"); + super(serverUrl, { storage, redirectUrlProvider, navigation }, "quick"); } } diff --git a/core/auth/browser/storage.ts b/core/auth/browser/storage.ts index 820b72b6..09ccee92 100644 --- a/core/auth/browser/storage.ts +++ b/core/auth/browser/storage.ts @@ -13,3 +13,13 @@ export class BrowserOAuthStorage extends OAuthStorageBase { super(createOAuthStore(createJSONStorage(() => sessionStorage))); } } + +let sharedBrowserOAuthStorage: BrowserOAuthStorage | undefined; + +/** Shared sessionStorage-backed OAuth store for the web client (single in-memory view). */ +export function getBrowserOAuthStorage(): BrowserOAuthStorage { + if (!sharedBrowserOAuthStorage) { + sharedBrowserOAuthStorage = new BrowserOAuthStorage(); + } + return sharedBrowserOAuthStorage; +} diff --git a/core/auth/connection-state.ts b/core/auth/connection-state.ts new file mode 100644 index 00000000..8fccf5d1 --- /dev/null +++ b/core/auth/connection-state.ts @@ -0,0 +1,130 @@ +import type { + OAuthClientInformation, + OAuthTokens, +} from "@modelcontextprotocol/sdk/shared/auth.js"; +import type { EnterpriseManagedAuthIdpConfig } from "../client/types.js"; +import { getEmaIdpLoginState, normalizeIdpIssuer } from "./ema/idpSession.js"; +import { idpOAuthStorageKey } from "./ema/storage.js"; +import { isJwtExpired } from "./ema/jwt.js"; +import type { OAuthStorage } from "./storage.js"; +import type { AuthProtocol, OAuthConnectionState, OAuthFlowState } from "./types.js"; +import { authProtocolFromEnterpriseManaged } from "./types.js"; + +export interface BuildOAuthConnectionStateParams { + serverUrl: string; + protocol: AuthProtocol; + configuredScope?: string; + enterpriseManagedAuth?: { idp: EnterpriseManagedAuthIdpConfig }; + storage: OAuthStorage; + flowState?: OAuthFlowState; +} + +function isAccessTokenUsable(tokens: OAuthTokens | undefined): boolean { + if (!tokens?.access_token) return false; + return !isJwtExpired(tokens.access_token); +} + +function resolveClient( + preregistered: OAuthClientInformation | undefined, + dynamic: OAuthClientInformation | undefined, +): OAuthConnectionState["client"] | undefined { + const info = preregistered ?? dynamic; + if (!info?.client_id) return undefined; + return { + source: preregistered ? "preregistered" : "dynamic", + clientId: info.client_id, + hasClientSecret: info.client_secret !== undefined && info.client_secret !== "", + }; +} + +function resolveGrantedScope( + tokens: OAuthTokens | undefined, + storageScope: string | undefined, +): string | undefined { + const tokenScope = + typeof tokens?.scope === "string" ? tokens.scope.trim() : undefined; + if (tokenScope) return tokenScope; + const stored = + typeof storageScope === "string" ? storageScope.trim() : undefined; + return stored || undefined; +} + +/** + * Assembles persisted OAuth connection state for an HTTP MCP server. + * Reads storage and config only — no network discovery. + */ +export async function buildOAuthConnectionState( + params: BuildOAuthConnectionStateParams, +): Promise { + const { serverUrl, storage, flowState } = params; + const protocol = params.protocol; + + const storedTokens = await storage.getTokens(serverUrl); + const flowTokens = flowState?.oauthTokens ?? undefined; + const tokens = + flowTokens && isAccessTokenUsable(flowTokens) + ? flowTokens + : storedTokens && isAccessTokenUsable(storedTokens) + ? storedTokens + : flowTokens ?? storedTokens; + + const preregistered = await storage.getClientInformation(serverUrl, true); + const dynamic = await storage.getClientInformation(serverUrl); + const storageScope = storage.getScope(serverUrl); + const serverMetadata = storage.getServerMetadata(serverUrl); + + const authorized = isAccessTokenUsable(tokens); + const grantedScope = resolveGrantedScope(tokens, storageScope); + + const client = resolveClient(preregistered, dynamic); + + const state: OAuthConnectionState = { + authorized, + protocol, + serverUrl, + ...(params.configuredScope?.trim() && { + configuredScope: params.configuredScope.trim(), + }), + ...(grantedScope && { grantedScope }), + ...(tokens && { tokens }), + ...(client && { client }), + ...(serverMetadata && { authorizationServerMetadata: serverMetadata }), + }; + + if (protocol === "ema" && params.enterpriseManagedAuth?.idp) { + const idp = params.enterpriseManagedAuth.idp; + const issuer = normalizeIdpIssuer(idp.issuer); + const idpSession = await getEmaIdpLoginState(storage, issuer); + const idpMetadata = storage.getServerMetadata(idpOAuthStorageKey(issuer)); + state.ema = { + idpIssuer: issuer, + idpClientId: idp.clientId, + idpSession, + ...(idpMetadata && { idpMetadata }), + }; + state.enterpriseManaged = true; + } + + return state; +} + +/** Whether server-level OAuth options are configured on the client. */ +export function isServerOAuthConfigured(config: { + clientId?: string; + clientSecret?: string; + scope?: string; + enterpriseManaged?: boolean; +}): boolean { + return ( + config.enterpriseManaged === true || + !!config.clientId?.trim() || + !!config.clientSecret?.trim() || + !!config.scope?.trim() + ); +} + +export function protocolFromOAuthConfig(config: { + enterpriseManaged?: boolean; +}): AuthProtocol { + return authProtocolFromEnterpriseManaged(config.enterpriseManaged); +} diff --git a/core/auth/discovery.ts b/core/auth/discovery.ts index f2d9194b..07367a12 100644 --- a/core/auth/discovery.ts +++ b/core/auth/discovery.ts @@ -1,5 +1,6 @@ import { discoverAuthorizationServerMetadata } from "@modelcontextprotocol/sdk/client/auth.js"; import type { OAuthProtectedResourceMetadata } from "@modelcontextprotocol/sdk/shared/auth.js"; +import { parseHttpUrl } from "./utils.js"; /** * Returns the URL to use for OAuth authorization server metadata discovery. @@ -11,7 +12,15 @@ export function getAuthorizationServerUrl( ): URL { const first = resourceMetadata?.authorization_servers?.[0]; // Use truthy check to match original state-machine: empty string falls back to serverUrl - return first ? new URL(first) : new URL("/", serverUrl); + if (first) { + return parseHttpUrl(first, "protected resource authorization_servers[0]"); + } + try { + return new URL("/", serverUrl); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + throw new Error(`Invalid MCP server URL: "${serverUrl}" (${detail})`); + } } /** diff --git a/core/auth/ema/clientConfigError.ts b/core/auth/ema/clientConfigError.ts new file mode 100644 index 00000000..7695d1de --- /dev/null +++ b/core/auth/ema/clientConfigError.ts @@ -0,0 +1,35 @@ +export type EmaClientNotConfiguredReason = "disabled" | "not_configured"; + +export function emaClientNotConfiguredMessage( + reason: EmaClientNotConfiguredReason, +): string { + if (reason === "disabled") { + return ( + "This server uses enterprise-managed authorization, but Enterprise IdP is " + + "turned off in Client Settings. Open Client Settings, enable " + + '"Enterprise IdP configuration", then try connecting again.' + ); + } + return ( + "This server uses enterprise-managed authorization, but the Inspector " + + "client IdP is not configured. Open Client Settings, enable Enterprise IdP, " + + "and set issuer, client ID, and client secret." + ); +} + +/** Thrown when connecting to an EMA server without active install-level IdP config. */ +export class EmaClientNotConfiguredError extends Error { + readonly reason: EmaClientNotConfiguredReason; + + constructor(reason: EmaClientNotConfiguredReason) { + super(emaClientNotConfiguredMessage(reason)); + this.name = "EmaClientNotConfiguredError"; + this.reason = reason; + } +} + +export function isEmaClientNotConfiguredError( + err: unknown, +): err is EmaClientNotConfiguredError { + return err instanceof EmaClientNotConfiguredError; +} diff --git a/core/auth/ema/constants.ts b/core/auth/ema/constants.ts new file mode 100644 index 00000000..53b5d108 --- /dev/null +++ b/core/auth/ema/constants.ts @@ -0,0 +1,16 @@ +/** RFC 8693 token exchange grant. */ +export const GRANT_TYPE_TOKEN_EXCHANGE = + "urn:ietf:params:oauth:grant-type:token-exchange"; + +/** RFC 7523 JWT bearer grant. */ +export const GRANT_TYPE_JWT_BEARER = + "urn:ietf:params:oauth:grant-type:jwt-bearer"; + +/** ID Token as subject_token (OIDC). */ +export const TOKEN_TYPE_ID_TOKEN = "urn:ietf:params:oauth:token-type:id_token"; + +/** Identity Assertion JWT Authorization Grant (ID-JAG). */ +export const TOKEN_TYPE_ID_JAG = "urn:ietf:params:oauth:token-type:id-jag"; + +/** OIDC scopes for IdP login (leg 1). */ +export const IDP_OIDC_SCOPES = "openid offline_access"; diff --git a/core/auth/ema/emaFlow.ts b/core/auth/ema/emaFlow.ts new file mode 100644 index 00000000..69374986 --- /dev/null +++ b/core/auth/ema/emaFlow.ts @@ -0,0 +1,174 @@ +import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"; +import type { OAuthStorage } from "../storage.js"; +import type { EnterpriseManagedAuthIdpConfig } from "../../client/types.js"; +import { + completeIdpOidcAuthorization, + getValidIdToken, + startIdpOidcAuthorization, +} from "./idpOidc.js"; +import { + discoverEmaResourceContext, + type EmaResourceContext, +} from "./resourceContext.js"; +import { exchangeIdJag, redeemIdJagForAccessToken } from "./wire.js"; + +export interface EmaFlowConfig { + serverUrl: string; + idp: EnterpriseManagedAuthIdpConfig; + resourceClientId?: string; + resourceClientSecret?: string; + scope?: string; + redirectUrl: string; + storage: OAuthStorage; + fetchFn?: typeof fetch; +} + +export type TrySilentEmaAuthResult = + | { status: "success" } + | { status: "no_idp_session" } + | { status: "mint_failed"; error: Error }; + +function wrapEmaMintError(err: unknown): Error { + const message = err instanceof Error ? err.message : String(err); + return new Error(`EMA legs 2–3 (resource token mint): ${message}`, { + cause: err, + }); +} + +export async function mintEmaResourceTokens( + config: EmaFlowConfig, + resourceContext?: EmaResourceContext, +): Promise { + const ctx = + resourceContext ?? + (await discoverEmaResourceContext( + config.serverUrl, + config.scope, + config.fetchFn, + )); + if (!config.resourceClientId) { + throw new Error( + "EMA requires resource authorization server clientId (per-server oauth.clientId)", + ); + } + if (!config.resourceClientSecret?.trim()) { + throw new Error( + "EMA requires resource authorization server client secret in server OAuth settings (Test Client secret from xaa.dev resource registration)", + ); + } + + const idToken = await getValidIdToken({ + idp: config.idp, + storage: config.storage, + fetchFn: config.fetchFn, + }); + if (!idToken) { + throw new Error("Valid IdP ID Token required for EMA token mint"); + } + + const audience = ctx.resourceAsUrl.href.replace(/\/$/, ""); + const idJag = await exchangeIdJag({ + idp: config.idp, + idToken, + audience, + resource: ctx.resourceUrl?.href, + scope: ctx.scope, + fetchFn: config.fetchFn, + }); + + return redeemIdJagForAccessToken({ + resourceAsUrl: ctx.resourceAsUrl, + idJag, + resourceClientId: config.resourceClientId, + resourceClientSecret: config.resourceClientSecret, + resource: ctx.resourceUrl?.href, + scope: ctx.scope, + fetchFn: config.fetchFn, + }); +} + +/** Silent path: cached IdP session + legs 2–3. */ +export async function trySilentEmaAuth( + config: EmaFlowConfig, +): Promise { + const idToken = await getValidIdToken({ + idp: config.idp, + storage: config.storage, + fetchFn: config.fetchFn, + }); + if (!idToken) { + return { status: "no_idp_session" }; + } + try { + const tokens = await mintEmaResourceTokens(config); + await config.storage.saveTokens(config.serverUrl, tokens, { + enterpriseManaged: true, + }); + return { status: "success" }; + } catch (err) { + return { status: "mint_failed", error: wrapEmaMintError(err) }; + } +} + +export async function startEmaIdpAuthorization( + config: EmaFlowConfig, +): Promise { + const { authorizationUrl } = await startIdpOidcAuthorization({ + idp: config.idp, + redirectUrl: config.redirectUrl, + storage: config.storage, + fetchFn: config.fetchFn, + }); + return authorizationUrl; +} + +export async function completeEmaIdpAuthorizationAndMint( + config: EmaFlowConfig, + authorizationCode: string, +): Promise { + try { + await completeIdpOidcAuthorization({ + idp: config.idp, + authorizationCode, + redirectUrl: config.redirectUrl, + storage: config.storage, + fetchFn: config.fetchFn, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new Error(`EMA leg 1 (IdP authorization code exchange): ${message}`, { + cause: err, + }); + } + + let tokens: OAuthTokens; + try { + tokens = await mintEmaResourceTokens(config); + } catch (err) { + throw wrapEmaMintError(err); + } + + await config.storage.saveTokens(config.serverUrl, tokens, { + enterpriseManaged: true, + }); + return tokens; +} + +/** Re-run legs 2–3 on 401 when IdP session is still valid. */ +export async function refreshEmaResourceTokens( + config: EmaFlowConfig, +): Promise { + const idToken = await getValidIdToken({ + idp: config.idp, + storage: config.storage, + fetchFn: config.fetchFn, + }); + if (!idToken) { + return undefined; + } + const tokens = await mintEmaResourceTokens(config); + await config.storage.saveTokens(config.serverUrl, tokens, { + enterpriseManaged: true, + }); + return tokens; +} diff --git a/core/auth/ema/idpOidc.ts b/core/auth/ema/idpOidc.ts new file mode 100644 index 00000000..5b7aea3b --- /dev/null +++ b/core/auth/ema/idpOidc.ts @@ -0,0 +1,235 @@ +import { + discoverAuthorizationServerMetadata, + exchangeAuthorization, + startAuthorization, +} from "@modelcontextprotocol/sdk/client/auth.js"; +import type { + OAuthClientInformation, + OAuthMetadata, +} from "@modelcontextprotocol/sdk/shared/auth.js"; +import { OAuthMetadataSchema } from "@modelcontextprotocol/sdk/shared/auth.js"; +import type { OAuthStorage } from "../storage.js"; +import type { EnterpriseManagedAuthIdpConfig } from "../../client/types.js"; +import { generateOAuthStateWithExecution, parseHttpUrl } from "../utils.js"; +import { IDP_OIDC_SCOPES } from "./constants.js"; +import { isJwtExpired, jwtExpiresAtMs } from "./jwt.js"; +import { idpOAuthStorageKey, normalizeIdpIssuer } from "./storage.js"; +import { + parseOAuthTokenErrorResponse, + postOAuthTokenRequest, +} from "./tokenEndpoint.js"; + +function idpClientInformation( + idp: EnterpriseManagedAuthIdpConfig, +): OAuthClientInformation { + return { + client_id: idp.clientId, + client_secret: idp.clientSecret, + token_endpoint_auth_method: "client_secret_post", + } as OAuthClientInformation; +} + +async function resolveIdpMetadata( + issuer: string, + storage: OAuthStorage, + fetchFn?: typeof fetch, +): Promise { + const storageKey = idpOAuthStorageKey(issuer); + const cached = storage.getServerMetadata(storageKey); + if (cached?.token_endpoint) { + return cached; + } + return discoverIdpMetadata(issuer, fetchFn); +} + +export async function discoverIdpMetadata( + issuer: string, + fetchFn?: typeof fetch, +): Promise { + const issuerUrl = parseHttpUrl(issuer, "EMA IdP issuer (Client Settings)"); + const metadata = await discoverAuthorizationServerMetadata(issuerUrl, { + fetchFn, + }); + if (!metadata) { + throw new Error(`Failed to discover OIDC metadata for IdP issuer ${issuer}`); + } + return OAuthMetadataSchema.parse(metadata); +} + +export async function startIdpOidcAuthorization(params: { + idp: EnterpriseManagedAuthIdpConfig; + redirectUrl: string; + storage: OAuthStorage; + fetchFn?: typeof fetch; +}): Promise<{ authorizationUrl: URL }> { + const issuer = normalizeIdpIssuer(params.idp.issuer); + const metadata = await discoverIdpMetadata(issuer, params.fetchFn); + const clientInformation = idpClientInformation(params.idp); + const storageKey = idpOAuthStorageKey(issuer); + const state = generateOAuthStateWithExecution("quick"); + const issuerUrl = parseHttpUrl(issuer, "EMA IdP issuer (Client Settings)"); + const { authorizationUrl, codeVerifier } = await startAuthorization( + issuerUrl, + { + metadata, + clientInformation, + redirectUrl: params.redirectUrl, + scope: IDP_OIDC_SCOPES, + state, + }, + ); + await params.storage.saveCodeVerifier(storageKey, codeVerifier); + await params.storage.savePreregisteredClientInformation( + storageKey, + clientInformation, + ); + // Stash IdP metadata for token exchange on callback. + await params.storage.saveServerMetadata(storageKey, metadata); + return { authorizationUrl }; +} + +export async function completeIdpOidcAuthorization(params: { + idp: EnterpriseManagedAuthIdpConfig; + authorizationCode: string; + redirectUrl: string; + storage: OAuthStorage; + fetchFn?: typeof fetch; +}): Promise<{ + idToken: string; + refreshToken?: string; + idTokenExpiresAt?: number; +}> { + const issuer = normalizeIdpIssuer(params.idp.issuer); + const storageKey = idpOAuthStorageKey(issuer); + const metadata = params.storage.getServerMetadata(storageKey); + if (!metadata) { + throw new Error("IdP OAuth metadata not found — restart EMA IdP login"); + } + const clientInformation = + (await params.storage.getClientInformation(storageKey, true)) ?? + (await params.storage.getClientInformation(storageKey)); + if (!clientInformation) { + throw new Error("IdP client information not found — restart EMA IdP login"); + } + const codeVerifier = params.storage.getCodeVerifier(storageKey); + if (!codeVerifier) { + throw new Error("IdP PKCE verifier not found — restart EMA IdP login"); + } + + const issuerUrl = parseHttpUrl(issuer, "EMA IdP issuer (Client Settings)"); + const tokens = await exchangeAuthorization(issuerUrl, { + metadata, + clientInformation, + authorizationCode: params.authorizationCode, + codeVerifier, + redirectUri: params.redirectUrl, + fetchFn: params.fetchFn, + }); + + const idToken = tokens.id_token; + if (!idToken) { + throw new Error("IdP token response did not include an ID Token"); + } + + params.storage.clearCodeVerifier(storageKey); + const idTokenExpiresAt = jwtExpiresAtMs(idToken); + await params.storage.saveIdpSession(issuer, { + idToken, + refreshToken: tokens.refresh_token, + idTokenExpiresAt, + }); + + return { + idToken, + refreshToken: tokens.refresh_token, + idTokenExpiresAt, + }; +} + +/** Redeem a cached IdP refresh token for a new ID Token (OIDC refresh grant). */ +export async function refreshIdpOidcSession(params: { + idp: EnterpriseManagedAuthIdpConfig; + storage: OAuthStorage; + fetchFn?: typeof fetch; +}): Promise { + const issuer = normalizeIdpIssuer(params.idp.issuer); + const session = await params.storage.getIdpSession(issuer); + if (!session?.refreshToken) { + throw new Error("IdP refresh token not available"); + } + + const metadata = await resolveIdpMetadata( + issuer, + params.storage, + params.fetchFn, + ); + if (!metadata.token_endpoint) { + throw new Error("IdP metadata missing token_endpoint"); + } + + const body = new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: session.refreshToken, + }); + + const response = await postOAuthTokenRequest( + parseHttpUrl( + metadata.token_endpoint, + "IdP token_endpoint (from OIDC discovery)", + ), + body, + metadata, + idpClientInformation(params.idp), + params.fetchFn, + ); + if (!response.ok) { + throw await parseOAuthTokenErrorResponse( + response, + "EMA IdP refresh (OIDC refresh_token grant)", + ); + } + + const json = (await response.json()) as { + id_token?: string; + refresh_token?: string; + }; + const idToken = json.id_token; + if (!idToken) { + throw new Error("IdP refresh did not return an ID Token"); + } + + await params.storage.saveIdpSession(issuer, { + idToken, + refreshToken: json.refresh_token ?? session.refreshToken, + idTokenExpiresAt: jwtExpiresAtMs(idToken), + }); + + return idToken; +} + +/** + * Returns a non-expired IdP ID Token from storage, refreshing via refresh_token + * when the cached ID Token has expired but a refresh token remains. + */ +export async function getValidIdToken(params: { + idp: EnterpriseManagedAuthIdpConfig; + storage: OAuthStorage; + fetchFn?: typeof fetch; +}): Promise { + const issuer = normalizeIdpIssuer(params.idp.issuer); + const session = await params.storage.getIdpSession(issuer); + if (!session?.idToken) { + return undefined; + } + if (!isJwtExpired(session.idToken)) { + return session.idToken; + } + if (!session.refreshToken) { + return undefined; + } + try { + return await refreshIdpOidcSession(params); + } catch { + return undefined; + } +} diff --git a/core/auth/ema/idpSession.ts b/core/auth/ema/idpSession.ts new file mode 100644 index 00000000..21b47578 --- /dev/null +++ b/core/auth/ema/idpSession.ts @@ -0,0 +1,30 @@ +import type { OAuthStorage } from "../storage.js"; +import { isJwtExpired } from "./jwt.js"; +import { idpOAuthStorageKey, normalizeIdpIssuer } from "./storage.js"; + +export type EmaIdpLoginState = "none" | "logged_in" | "expired"; + +export { normalizeIdpIssuer }; + +/** Whether a cached IdP OIDC session exists for the configured issuer. */ +export async function getEmaIdpLoginState( + storage: OAuthStorage, + issuer: string, +): Promise { + const normalized = normalizeIdpIssuer(issuer); + if (!normalized) return "none"; + + const session = await storage.getIdpSession(normalized); + if (!session?.idToken) return "none"; + if (!isJwtExpired(session.idToken)) return "logged_in"; + if (session.refreshToken) return "logged_in"; + return "expired"; +} + +export function clearEmaIdpSession(storage: OAuthStorage, issuer: string): void { + const normalized = normalizeIdpIssuer(issuer); + if (!normalized) return; + storage.clearIdpSession(normalized); + storage.clear(idpOAuthStorageKey(normalized)); + storage.clearEnterpriseManagedResourceServers(); +} diff --git a/core/auth/ema/index.ts b/core/auth/ema/index.ts new file mode 100644 index 00000000..77fc34cc --- /dev/null +++ b/core/auth/ema/index.ts @@ -0,0 +1,31 @@ +export { + GRANT_TYPE_JWT_BEARER, + GRANT_TYPE_TOKEN_EXCHANGE, + IDP_OIDC_SCOPES, + TOKEN_TYPE_ID_JAG, + TOKEN_TYPE_ID_TOKEN, +} from "./constants.js"; +export { + completeEmaIdpAuthorizationAndMint, + mintEmaResourceTokens, + refreshEmaResourceTokens, + startEmaIdpAuthorization, + trySilentEmaAuth, + type EmaFlowConfig, + type TrySilentEmaAuthResult, +} from "./emaFlow.js"; +export { EmaTransportOAuthProvider } from "./transportProvider.js"; +export { isJwtExpired, jwtExpiresAtMs } from "./jwt.js"; +export { + clearEmaIdpSession, + getEmaIdpLoginState, + normalizeIdpIssuer, + type EmaIdpLoginState, +} from "./idpSession.js"; +export { discoverEmaResourceContext, resolveEmaScopes } from "./resourceContext.js"; +export { + EmaClientNotConfiguredError, + emaClientNotConfiguredMessage, + isEmaClientNotConfiguredError, + type EmaClientNotConfiguredReason, +} from "./clientConfigError.js"; diff --git a/core/auth/ema/jwt.ts b/core/auth/ema/jwt.ts new file mode 100644 index 00000000..f9df1344 --- /dev/null +++ b/core/auth/ema/jwt.ts @@ -0,0 +1,70 @@ +function decodeBase64Url(segment: string): string { + const base64 = segment.replace(/-/g, "+").replace(/_/g, "/"); + const padded = base64.padEnd( + base64.length + ((4 - (base64.length % 4)) % 4), + "=", + ); + const binary = atob(padded); + const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0)); + return new TextDecoder().decode(bytes); +} + +/** Best-effort `exp` (epoch seconds) from an unparsed JWT payload. */ +export function jwtExpiresAtMs(token: string): number | undefined { + const parts = token.split("."); + if (parts.length < 2) return undefined; + try { + const payload = JSON.parse(decodeBase64Url(parts[1]!)) as { exp?: number }; + if (typeof payload.exp === "number" && Number.isFinite(payload.exp)) { + return payload.exp * 1000; + } + } catch { + return undefined; + } + return undefined; +} + +export function isJwtExpired( + token: string, + skewMs = 60_000, + nowMs = Date.now(), +): boolean { + const exp = jwtExpiresAtMs(token); + if (exp === undefined) return false; + return nowMs >= exp - skewMs; +} + +/** True when `token` has the three-segment JWT shape (no signature verification). */ +export function isJwtFormat(token: string): boolean { + const parts = token.split("."); + return ( + parts.length === 3 && + parts[0]!.length > 0 && + parts[1]!.length > 0 && + parts[2] !== undefined + ); +} + +/** Best-effort decode of JWT header and payload for display (no verification). */ +export function decodeJwtPayload(token: string): + | { + header: Record; + payload: Record; + } + | undefined { + if (!isJwtFormat(token)) return undefined; + const parts = token.split("."); + try { + const header = JSON.parse(decodeBase64Url(parts[0]!)) as Record< + string, + unknown + >; + const payload = JSON.parse(decodeBase64Url(parts[1]!)) as Record< + string, + unknown + >; + return { header, payload }; + } catch { + return undefined; + } +} diff --git a/core/auth/ema/resourceContext.ts b/core/auth/ema/resourceContext.ts new file mode 100644 index 00000000..25f13c77 --- /dev/null +++ b/core/auth/ema/resourceContext.ts @@ -0,0 +1,76 @@ +import { + discoverAuthorizationServerMetadata, + discoverOAuthProtectedResourceMetadata, + selectResourceURL, +} from "@modelcontextprotocol/sdk/client/auth.js"; +import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; +import type { OAuthProtectedResourceMetadata } from "@modelcontextprotocol/sdk/shared/auth.js"; +import { getAuthorizationServerUrl } from "../discovery.js"; + +export interface EmaResourceContext { + resourceMetadata: OAuthProtectedResourceMetadata; + resourceAsUrl: URL; + resourceUrl?: URL; + scope?: string; +} + +export function resolveEmaScopes( + resourceMetadata: OAuthProtectedResourceMetadata | null | undefined, + configuredScope?: string, +): string | undefined { + const trimmed = configuredScope?.trim(); + if (trimmed) return trimmed; + const supported = resourceMetadata?.scopes_supported; + if (supported && supported.length > 0) { + return supported.join(" "); + } + return undefined; +} + +export async function discoverEmaResourceContext( + serverUrl: string, + configuredScope?: string, + fetchFn?: typeof fetch, +): Promise { + const resourceMetadata = await discoverOAuthProtectedResourceMetadata( + serverUrl, + undefined, + fetchFn, + ); + if (!resourceMetadata.resource) { + throw new Error( + "EMA requires protected resource metadata with a resource identifier", + ); + } + const resourceAsUrl = getAuthorizationServerUrl(serverUrl, resourceMetadata); + const resourceUrl = await selectResourceURL( + serverUrl, + { + clientMetadata: { + scope: configuredScope ?? "", + redirect_uris: [], + }, + } as unknown as OAuthClientProvider, + resourceMetadata, + ); + const scope = resolveEmaScopes(resourceMetadata, configuredScope); + return { + resourceMetadata, + resourceAsUrl, + resourceUrl: resourceUrl ?? undefined, + scope, + }; +} + +export async function discoverResourceAsMetadata( + resourceAsUrl: URL, + fetchFn?: typeof fetch, +) { + const metadata = await discoverAuthorizationServerMetadata(resourceAsUrl, { + fetchFn, + }); + if (!metadata) { + throw new Error("Failed to discover resource authorization server metadata"); + } + return metadata; +} diff --git a/core/auth/ema/storage.ts b/core/auth/ema/storage.ts new file mode 100644 index 00000000..0f4148ae --- /dev/null +++ b/core/auth/ema/storage.ts @@ -0,0 +1,9 @@ +/** Strip trailing slash so issuer URLs match across discovery, storage keys, and sessions. */ +export function normalizeIdpIssuer(issuer: string): string { + return issuer.replace(/\/$/, ""); +} + +/** OAuth storage key for in-flight IdP OIDC (PKCE, metadata). Not an OAuth `state` param prefix. */ +export function idpOAuthStorageKey(issuer: string): string { + return `ema-idp:${normalizeIdpIssuer(issuer)}`; +} diff --git a/core/auth/ema/tokenEndpoint.ts b/core/auth/ema/tokenEndpoint.ts new file mode 100644 index 00000000..3652cb7f --- /dev/null +++ b/core/auth/ema/tokenEndpoint.ts @@ -0,0 +1,70 @@ +import { selectClientAuthMethod } from "@modelcontextprotocol/sdk/client/auth.js"; +import type { + OAuthClientInformation, + OAuthMetadata, +} from "@modelcontextprotocol/sdk/shared/auth.js"; + +export async function parseOAuthTokenErrorResponse( + response: Response, + step: string, +): Promise { + let body: unknown; + try { + body = await response.json(); + } catch { + return new Error(`${step}: token request failed (HTTP ${response.status})`); + } + if (typeof body === "object" && body !== null) { + const record = body as { error?: string; error_description?: string }; + const parts = [step]; + if (record.error) parts.push(`error=${record.error}`); + if (record.error_description) parts.push(record.error_description); + if (parts.length > 1) { + return new Error(parts.join(": ")); + } + } + return new Error(`${step}: token request failed (HTTP ${response.status})`); +} + +function applyClientAuth( + metadata: OAuthMetadata | undefined, + clientInformation: OAuthClientInformation, + headers: Headers, + body: URLSearchParams, +): void { + const supported = metadata?.token_endpoint_auth_methods_supported ?? []; + const method = selectClientAuthMethod(clientInformation, supported); + if (method === "client_secret_basic" && clientInformation.client_secret) { + const credentials = btoa( + `${clientInformation.client_id}:${clientInformation.client_secret}`, + ); + headers.set("Authorization", `Basic ${credentials}`); + return; + } + if (method === "client_secret_post" && clientInformation.client_secret) { + body.set("client_id", clientInformation.client_id); + body.set("client_secret", clientInformation.client_secret); + return; + } + body.set("client_id", clientInformation.client_id); +} + +/** POST application/x-www-form-urlencoded to an OAuth token endpoint. */ +export async function postOAuthTokenRequest( + tokenUrl: URL, + body: URLSearchParams, + metadata: OAuthMetadata | undefined, + clientInformation: OAuthClientInformation, + fetchFn?: typeof fetch, +): Promise { + const headers = new Headers({ + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }); + applyClientAuth(metadata, clientInformation, headers, body); + return (fetchFn ?? fetch)(tokenUrl, { + method: "POST", + headers, + body, + }); +} diff --git a/core/auth/ema/transportProvider.ts b/core/auth/ema/transportProvider.ts new file mode 100644 index 00000000..5a16226b --- /dev/null +++ b/core/auth/ema/transportProvider.ts @@ -0,0 +1,103 @@ +import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; +import type { + OAuthClientInformationMixed, + OAuthClientMetadata, + OAuthTokens, +} from "@modelcontextprotocol/sdk/shared/auth.js"; +import type { BaseOAuthClientProvider } from "../providers.js"; +import { + refreshEmaResourceTokens, + startEmaIdpAuthorization, + type EmaFlowConfig, +} from "./emaFlow.js"; +import { isJwtExpired } from "./jwt.js"; + +function isAccessTokenUsable( + tokens: OAuthTokens, + skewMs = 60_000, +): boolean { + if (!tokens.access_token) return false; + return !isJwtExpired(tokens.access_token, skewMs); +} + +/** + * OAuth provider for MCP transport when enterprise-managed authorization is enabled. + * + * Proactively refreshes resource tokens (EMA legs 2–3) via {@link tokens} before + * requests. On 401 when the IdP session is still valid, the SDK {@link auth} + * call may still reach {@link redirectToAuthorization}; we ignore the resource-AS + * URL and redirect to the IdP instead. + */ +export class EmaTransportOAuthProvider implements OAuthClientProvider { + private readonly inner: BaseOAuthClientProvider; + private readonly emaConfig: EmaFlowConfig; + + constructor( + inner: BaseOAuthClientProvider, + emaConfig: EmaFlowConfig, + ) { + this.inner = inner; + this.emaConfig = emaConfig; + } + + get redirectUrl(): string { + return this.inner.redirectUrl; + } + + get clientMetadataUrl(): string | undefined { + return this.inner.clientMetadataUrl; + } + + get clientMetadata(): OAuthClientMetadata { + return this.inner.clientMetadata; + } + + state(): string | Promise { + return this.inner.state(); + } + + clientInformation(): + | OAuthClientInformationMixed + | undefined + | Promise { + return this.inner.clientInformation(); + } + + saveClientInformation( + clientInformation: OAuthClientInformationMixed, + ): void | Promise { + return this.inner.saveClientInformation(clientInformation); + } + + async tokens(): Promise { + const stored = await this.inner.tokens(); + if (stored && isAccessTokenUsable(stored)) { + return stored; + } + const refreshed = await refreshEmaResourceTokens(this.emaConfig); + if (refreshed) { + return refreshed; + } + return undefined; + } + + saveTokens(tokens: OAuthTokens): void | Promise { + return this.emaConfig.storage.saveTokens(this.emaConfig.serverUrl, tokens, { + enterpriseManaged: true, + }); + } + + async redirectToAuthorization(_authorizationUrl: URL): Promise { + const idpAuthorizationUrl = await startEmaIdpAuthorization(this.emaConfig); + this.inner.clearCapturedAuthUrl(); + this.inner.redirectToAuthorization(idpAuthorizationUrl); + } + + saveCodeVerifier(codeVerifier: string): void | Promise { + return this.inner.saveCodeVerifier(codeVerifier); + } + + codeVerifier(): string | Promise { + return this.inner.codeVerifier(); + } +} diff --git a/core/auth/ema/wire.ts b/core/auth/ema/wire.ts new file mode 100644 index 00000000..a56456cf --- /dev/null +++ b/core/auth/ema/wire.ts @@ -0,0 +1,145 @@ +import { discoverAuthorizationServerMetadata } from "@modelcontextprotocol/sdk/client/auth.js"; +import type { + OAuthClientInformation, + OAuthTokens, +} from "@modelcontextprotocol/sdk/shared/auth.js"; +import { OAuthTokensSchema } from "@modelcontextprotocol/sdk/shared/auth.js"; +import type { EnterpriseManagedAuthIdpConfig } from "../../client/types.js"; +import { + GRANT_TYPE_JWT_BEARER, + GRANT_TYPE_TOKEN_EXCHANGE, + TOKEN_TYPE_ID_JAG, + TOKEN_TYPE_ID_TOKEN, +} from "./constants.js"; +import { parseHttpUrl } from "../utils.js"; +import { discoverResourceAsMetadata } from "./resourceContext.js"; +import { normalizeIdpIssuer } from "./storage.js"; +import { + parseOAuthTokenErrorResponse, + postOAuthTokenRequest, +} from "./tokenEndpoint.js"; + +/** Leg 2 — exchange ID Token for ID-JAG at the enterprise IdP (RFC 8693). */ +export async function exchangeIdJag(params: { + idp: EnterpriseManagedAuthIdpConfig; + idToken: string; + audience: string; + resource?: string; + scope?: string; + fetchFn?: typeof fetch; +}): Promise { + const issuer = normalizeIdpIssuer(params.idp.issuer); + const issuerUrl = parseHttpUrl(issuer, "EMA IdP issuer (Client Settings)"); + const idpMetadata = await discoverAuthorizationServerMetadata(issuerUrl, { + fetchFn: params.fetchFn, + }); + if (!idpMetadata?.token_endpoint) { + throw new Error("IdP metadata missing token_endpoint"); + } + + const clientInformation = { + client_id: params.idp.clientId, + client_secret: params.idp.clientSecret, + token_endpoint_auth_method: "client_secret_post", + } as OAuthClientInformation; + + const body = new URLSearchParams({ + grant_type: GRANT_TYPE_TOKEN_EXCHANGE, + requested_token_type: TOKEN_TYPE_ID_JAG, + subject_token: params.idToken, + subject_token_type: TOKEN_TYPE_ID_TOKEN, + audience: params.audience, + }); + if (params.resource) { + body.set("resource", params.resource); + } + if (params.scope) { + body.set("scope", params.scope); + } + + const response = await postOAuthTokenRequest( + parseHttpUrl( + idpMetadata.token_endpoint, + "IdP token_endpoint (from OIDC discovery)", + ), + body, + idpMetadata, + clientInformation, + params.fetchFn, + ); + if (!response.ok) { + throw await parseOAuthTokenErrorResponse( + response, + "EMA leg 2 (IdP token exchange for ID-JAG)", + ); + } + + const json = (await response.json()) as { + access_token?: string; + issued_token_type?: string; + }; + const idJag = json.access_token; + if (!idJag) { + throw new Error("IdP token exchange did not return an ID-JAG"); + } + return idJag; +} + +/** Leg 3 — redeem ID-JAG for MCP resource access token (RFC 7523). */ +export async function redeemIdJagForAccessToken(params: { + resourceAsUrl: URL; + idJag: string; + resourceClientId: string; + resourceClientSecret?: string; + resource?: string; + scope?: string; + fetchFn?: typeof fetch; +}): Promise { + const asMetadata = await discoverResourceAsMetadata( + params.resourceAsUrl, + params.fetchFn, + ); + if (!asMetadata.token_endpoint) { + throw new Error("Resource AS metadata missing token_endpoint"); + } + + const clientInformation = { + client_id: params.resourceClientId, + ...(params.resourceClientSecret && { + client_secret: params.resourceClientSecret, + }), + token_endpoint_auth_method: params.resourceClientSecret + ? "client_secret_post" + : "none", + } as OAuthClientInformation; + + const body = new URLSearchParams({ + grant_type: GRANT_TYPE_JWT_BEARER, + assertion: params.idJag, + }); + if (params.scope) { + body.set("scope", params.scope); + } + if (params.resource) { + body.set("resource", params.resource); + } + + const response = await postOAuthTokenRequest( + parseHttpUrl( + asMetadata.token_endpoint, + "resource authorization server token_endpoint (from AS discovery)", + ), + body, + asMetadata, + clientInformation, + params.fetchFn, + ); + if (!response.ok) { + throw await parseOAuthTokenErrorResponse( + response, + "EMA leg 3 (resource AS JWT bearer grant)", + ); + } + + return OAuthTokensSchema.parse(await response.json()); +} diff --git a/core/auth/index.ts b/core/auth/index.ts index 467b55a4..4caf088f 100644 --- a/core/auth/index.ts +++ b/core/auth/index.ts @@ -1,16 +1,28 @@ // Types export type { OAuthStep, - OAuthAuthType, + AuthExecution, + AuthProtocol, MessageType, StatusMessage, - AuthGuidedState, + OAuthFlowState, + OAuthConnectionState, CallbackParams, } from "./types.js"; -export { EMPTY_GUIDED_STATE } from "./types.js"; +export { + EMPTY_OAUTH_FLOW_STATE, + authProtocolFromEnterpriseManaged, +} from "./types.js"; + +export { + buildOAuthConnectionState, + isServerOAuthConfigured, + protocolFromOAuthConfig, +} from "./connection-state.js"; +export type { BuildOAuthConnectionStateParams } from "./connection-state.js"; // Storage -export type { OAuthStorage } from "./storage.js"; +export type { OAuthStorage, IdpSessionState } from "./storage.js"; export { getServerSpecificKey, OAUTH_STORAGE_KEYS } from "./storage.js"; // Providers @@ -29,13 +41,14 @@ export { // Utilities export { + parseHttpUrl, parseOAuthCallbackParams, generateOAuthState, + generateOAuthStateWithExecution, generateOAuthStateWithMode, parseOAuthState, generateOAuthErrorDescription, } from "./utils.js"; -export type { OAuthStateMode } from "./utils.js"; // Discovery export { discoverScopes } from "./discovery.js"; diff --git a/core/auth/node/oauth-callback-server.ts b/core/auth/node/oauth-callback-server.ts index 43ae74a4..43cf8c07 100644 --- a/core/auth/node/oauth-callback-server.ts +++ b/core/auth/node/oauth-callback-server.ts @@ -52,7 +52,7 @@ export interface OAuthCallbackServerStartResult { /** * Minimal HTTP server that receives OAuth 2.1 redirects at GET /oauth/callback. - * Used by TUI/CLI to complete the authorization code flow (both normal and guided). + * Used by TUI/CLI to complete the authorization code flow (both quick and guided). * Caller provides onCallback/onError; typically onCallback calls * InspectorClient.completeOAuthFlow(code) then stops the server. */ diff --git a/core/auth/node/secret-store.ts b/core/auth/node/secret-store.ts index 238543e7..4bc8c218 100644 --- a/core/auth/node/secret-store.ts +++ b/core/auth/node/secret-store.ts @@ -22,6 +22,7 @@ const SERVICE_NAME = "mcp-inspector"; export { SECRET_FIELD_OAUTH_CLIENT_SECRET, + SECRET_FIELD_IDP_CLIENT_SECRET, envSecretField, } from "../secret-fields.js"; diff --git a/core/auth/oauth-storage.ts b/core/auth/oauth-storage.ts index df3617b1..e148a28d 100644 --- a/core/auth/oauth-storage.ts +++ b/core/auth/oauth-storage.ts @@ -9,6 +9,7 @@ import { } from "@modelcontextprotocol/sdk/shared/auth.js"; import type { OAuthStorage } from "./storage.js"; import { type createOAuthStore, type ServerOAuthState } from "./store.js"; +import type { IdpSessionState, SaveTokensOptions } from "./storage.js"; /** * Concrete OAuthStorage implementation parameterized on a Zustand store. @@ -77,8 +78,15 @@ export class OAuthStorageBase implements OAuthStorage { return await OAuthTokensSchema.parseAsync(state.tokens); } - async saveTokens(serverUrl: string, tokens: OAuthTokens): Promise { - this.store.getState().setServerState(serverUrl, { tokens }); + async saveTokens( + serverUrl: string, + tokens: OAuthTokens, + options?: SaveTokensOptions, + ): Promise { + this.store.getState().setServerState(serverUrl, { + tokens, + ...(options?.enterpriseManaged === true && { enterpriseManaged: true }), + }); } clearTokens(serverUrl: string): void { @@ -139,4 +147,31 @@ export class OAuthStorageBase implements OAuthStorage { clear(serverUrl: string): void { this.store.getState().clearServerState(serverUrl); } + + async getIdpSession(issuer: string): Promise { + const session = this.store.getState().getIdpSession(issuer); + if ( + !session.idToken && + !session.refreshToken && + session.idTokenExpiresAt === undefined + ) { + return undefined; + } + return session; + } + + async saveIdpSession( + issuer: string, + session: Partial, + ): Promise { + this.store.getState().setIdpSession(issuer, session); + } + + clearIdpSession(issuer: string): void { + this.store.getState().clearIdpSession(issuer); + } + + clearEnterpriseManagedResourceServers(): void { + this.store.getState().clearEnterpriseManagedResourceServers(); + } } diff --git a/core/auth/providers.ts b/core/auth/providers.ts index f61dc470..abcdba35 100644 --- a/core/auth/providers.ts +++ b/core/auth/providers.ts @@ -6,19 +6,20 @@ import type { OAuthMetadata, } from "@modelcontextprotocol/sdk/shared/auth.js"; import type { OAuthStorage } from "./storage.js"; -import { generateOAuthStateWithMode } from "./utils.js"; +import type { AuthExecution } from "./types.js"; +import { generateOAuthStateWithExecution } from "./utils.js"; /** * Redirect URL provider. Returns the redirect URL for the requested mode. * Caller populates the URLs before authenticate() (e.g. from callback server). */ export interface RedirectUrlProvider { - getRedirectUrl(mode?: "normal" | "guided"): string; + getRedirectUrl(execution?: AuthExecution): string; } /** * Mutable redirect URL provider for TUI/CLI. Caller sets redirectUrl - * before authenticate(); same URL is used for both normal and guided flows. + * before authenticate(); same URL is used for both quick and guided flows. */ export class MutableRedirectUrlProvider implements RedirectUrlProvider { redirectUrl = ""; @@ -108,19 +109,19 @@ export class BaseOAuthClientProvider implements OAuthClientProvider { protected redirectUrlProvider: RedirectUrlProvider; protected navigation: OAuthNavigation; public clientMetadataUrl?: string; - protected mode: "normal" | "guided"; + protected execution: AuthExecution; constructor( serverUrl: string, oauthConfig: OAuthProviderConfig, - mode: "normal" | "guided" = "normal", + execution: AuthExecution = "quick", ) { this.serverUrl = serverUrl; this.storage = oauthConfig.storage; this.redirectUrlProvider = oauthConfig.redirectUrlProvider; this.navigation = oauthConfig.navigation; this.clientMetadataUrl = oauthConfig.clientMetadataUrl; - this.mode = mode; + this.execution = execution; } /** @@ -148,13 +149,13 @@ export class BaseOAuthClientProvider implements OAuthClientProvider { return this.storage.getScope(this.serverUrl); } - /** Redirect URL for the current flow (normal or guided). */ + /** Redirect URL for the current flow (quick or guided). */ get redirectUrl(): string { - return this.redirectUrlProvider.getRedirectUrl(this.mode); + return this.redirectUrlProvider.getRedirectUrl(this.execution); } get redirect_uris(): string[] { - return [this.redirectUrlProvider.getRedirectUrl("normal")]; + return [this.redirectUrlProvider.getRedirectUrl("quick")]; } get clientMetadata(): OAuthClientMetadata { @@ -175,7 +176,7 @@ export class BaseOAuthClientProvider implements OAuthClientProvider { } state(): string | Promise { - return generateOAuthStateWithMode(this.mode); + return generateOAuthStateWithExecution(this.execution); } async clientInformation(): Promise { diff --git a/core/auth/secret-fields.ts b/core/auth/secret-fields.ts index 341ef9eb..cf1c7a65 100644 --- a/core/auth/secret-fields.ts +++ b/core/auth/secret-fields.ts @@ -10,5 +10,8 @@ /** Field name for an entry's OAuth client secret. */ export const SECRET_FIELD_OAUTH_CLIENT_SECRET = "oauth-client-secret"; +/** Field name for the install-level enterprise IdP OIDC client secret (client.json). */ +export const SECRET_FIELD_IDP_CLIENT_SECRET = "idp-client-secret"; + /** Field name for one stdio env-variable value. */ export const envSecretField = (envKey: string): string => `env:${envKey}`; diff --git a/core/auth/state-machine.ts b/core/auth/state-machine.ts index dc5c6918..d98e18f1 100644 --- a/core/auth/state-machine.ts +++ b/core/auth/state-machine.ts @@ -1,4 +1,4 @@ -import type { OAuthStep, AuthGuidedState } from "./types.js"; +import type { OAuthStep, OAuthFlowState } from "./types.js"; import type { BaseOAuthClientProvider } from "./providers.js"; import { discoverScopes, getAuthorizationServerUrl } from "./discovery.js"; import { @@ -15,10 +15,10 @@ import { } from "@modelcontextprotocol/sdk/shared/auth.js"; export interface StateMachineContext { - state: AuthGuidedState; + state: OAuthFlowState; serverUrl: string; provider: BaseOAuthClientProvider; - updateState: (updates: Partial) => void; + updateState: (updates: Partial) => void; fetchFn?: typeof fetch; } @@ -258,13 +258,13 @@ export const oauthTransitions: Record = { export class OAuthStateMachine { private serverUrl: string; private provider: BaseOAuthClientProvider; - private updateState: (updates: Partial) => void; + private updateState: (updates: Partial) => void; private fetchFn?: typeof fetch; constructor( serverUrl: string, provider: BaseOAuthClientProvider, - updateState: (updates: Partial) => void, + updateState: (updates: Partial) => void, fetchFn?: typeof fetch, ) { this.serverUrl = serverUrl; @@ -273,7 +273,7 @@ export class OAuthStateMachine { this.fetchFn = fetchFn; } - async executeStep(state: AuthGuidedState): Promise { + async executeStep(state: OAuthFlowState): Promise { const context: StateMachineContext = { state, serverUrl: this.serverUrl, diff --git a/core/auth/storage.ts b/core/auth/storage.ts index 6cbe13b5..524bbfbf 100644 --- a/core/auth/storage.ts +++ b/core/auth/storage.ts @@ -8,6 +8,11 @@ import type { * Abstract storage interface for OAuth state * Supports both browser (sessionStorage) and Node.js (Zustand) environments */ +export interface SaveTokensOptions { + /** Marks resource tokens minted via EMA (legs 2–3) for sign-out cleanup. */ + enterpriseManaged?: boolean; +} + export interface OAuthStorage { /** * Get client information (preregistered or dynamically registered) @@ -46,7 +51,11 @@ export interface OAuthStorage { /** * Save OAuth tokens */ - saveTokens(serverUrl: string, tokens: OAuthTokens): Promise; + saveTokens( + serverUrl: string, + tokens: OAuthTokens, + options?: SaveTokensOptions, + ): Promise; /** * Clear OAuth tokens @@ -102,6 +111,39 @@ export interface OAuthStorage { * Clear all OAuth data for a server */ clear(serverUrl: string): void; + + /** + * Get cached IdP OIDC session for EMA (keyed by issuer). + */ + getIdpSession(issuer: string): Promise; + + /** + * Save IdP OIDC session fields for EMA. + */ + saveIdpSession( + issuer: string, + session: Partial, + ): Promise; + + /** + * Clear cached IdP session for an issuer. + */ + clearIdpSession(issuer: string): void; + + /** + * Remove per-server OAuth state for MCP servers whose tokens were minted via EMA. + */ + clearEnterpriseManagedResourceServers(): void; +} + +/** + * Cached IdP OIDC session for EMA leg 1. + */ +export interface IdpSessionState { + idToken?: string; + refreshToken?: string; + /** Epoch ms when the ID Token expires (when known). */ + idTokenExpiresAt?: number; } /** diff --git a/core/auth/store.ts b/core/auth/store.ts index c7c10516..afd49e1f 100644 --- a/core/auth/store.ts +++ b/core/auth/store.ts @@ -10,6 +10,7 @@ import type { OAuthTokens, OAuthMetadata, } from "@modelcontextprotocol/sdk/shared/auth.js"; +import type { IdpSessionState } from "./storage.js"; /** * OAuth state for a single server @@ -21,6 +22,8 @@ export interface ServerOAuthState { codeVerifier?: string; scope?: string; serverMetadata?: OAuthMetadata; + /** Set when resource tokens were minted via EMA (legs 2–3). */ + enterpriseManaged?: boolean; } /** @@ -28,9 +31,14 @@ export interface ServerOAuthState { */ export interface OAuthStoreState { servers: Record; + idpSessions: Record; getServerState: (serverUrl: string) => ServerOAuthState; setServerState: (serverUrl: string, state: Partial) => void; clearServerState: (serverUrl: string) => void; + getIdpSession: (issuer: string) => IdpSessionState; + setIdpSession: (issuer: string, updates: Partial) => void; + clearIdpSession: (issuer: string) => void; + clearEnterpriseManagedResourceServers: () => void; } /** @@ -47,6 +55,7 @@ export function createOAuthStore( persist( (set, get) => ({ servers: {}, + idpSessions: {}, getServerState: (serverUrl: string) => { return get().servers[serverUrl] || {}; }, @@ -71,6 +80,38 @@ export function createOAuthStore( return { servers: rest }; }); }, + getIdpSession: (issuer: string) => { + return get().idpSessions[issuer] || {}; + }, + setIdpSession: (issuer: string, updates: Partial) => { + set((state) => ({ + idpSessions: { + ...state.idpSessions, + [issuer]: { + ...state.idpSessions[issuer], + ...updates, + }, + }, + })); + }, + clearIdpSession: (issuer: string) => { + set((state) => { + const rest = { ...state.idpSessions }; + delete rest[issuer]; + return { idpSessions: rest }; + }); + }, + clearEnterpriseManagedResourceServers: () => { + set((state) => { + const rest = { ...state.servers }; + for (const [url, serverState] of Object.entries(state.servers)) { + if (serverState.enterpriseManaged === true) { + delete rest[url]; + } + } + return { servers: rest }; + }); + }, }), { name: "mcp-inspector-oauth", diff --git a/core/auth/types.ts b/core/auth/types.ts index 77f4a555..ed5a0192 100644 --- a/core/auth/types.ts +++ b/core/auth/types.ts @@ -23,13 +23,45 @@ export interface StatusMessage { message: string; } -// How the current auth flow was started (guided = state machine with step events; normal = SDK auth()) -export type OAuthAuthType = "guided" | "normal"; +/** How the auth flow is stepped through (orthogonal to {@link AuthProtocol}). */ +export type AuthExecution = "quick" | "guided"; -// Single state interface for OAuth state -export interface AuthGuidedState { - /** How this auth flow was started; determines which fields are populated. */ - authType: OAuthAuthType; +/** Which authorization protocol applies (orthogonal to {@link AuthExecution}). */ +export type AuthProtocol = "standard" | "ema"; + +/** Persisted OAuth authorization snapshot for an HTTP MCP server (storage + config). */ +export interface OAuthConnectionState { + authorized: boolean; + protocol: AuthProtocol; + serverUrl: string; + configuredScope?: string; + grantedScope?: string; + tokens?: OAuthTokens; + client?: { + source: "preregistered" | "dynamic"; + clientId: string; + hasClientSecret: boolean; + }; + authorizationServerMetadata?: OAuthMetadata; + enterpriseManaged?: boolean; + ema?: { + idpIssuer: string; + idpClientId: string; + idpSession: "none" | "logged_in" | "expired"; + idpMetadata?: OAuthMetadata; + }; +} + +export function authProtocolFromEnterpriseManaged( + enterpriseManaged?: boolean, +): AuthProtocol { + return enterpriseManaged ? "ema" : "standard"; +} + +/** In-memory snapshot while an OAuth flow is active or just completed (quick or guided). */ +export interface OAuthFlowState { + /** Quick (SDK / one-shot) vs guided (state machine with step events). */ + execution: AuthExecution; /** When auth reached step "complete" (ms since epoch), if applicable. */ completedAt: number | null; isInitiatingAuth: boolean; @@ -48,8 +80,8 @@ export interface AuthGuidedState { validationError: string | null; } -export const EMPTY_GUIDED_STATE: AuthGuidedState = { - authType: "guided", +export const EMPTY_OAUTH_FLOW_STATE: OAuthFlowState = { + execution: "guided", completedAt: null, isInitiatingAuth: false, oauthTokens: null, diff --git a/core/auth/utils.ts b/core/auth/utils.ts index 5b6799f9..6f18e5af 100644 --- a/core/auth/utils.ts +++ b/core/auth/utils.ts @@ -1,5 +1,20 @@ +import type { AuthExecution } from "./types.js"; import type { CallbackParams } from "./types.js"; +/** + * Parse a string as an absolute URL. On failure, throws with `label` and the + * offending value so callers (and UI toasts) can show what to fix. + */ +export function parseHttpUrl(value: string, label: string): URL { + const trimmed = value.trim(); + try { + return new URL(trimmed); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + throw new Error(`Invalid ${label}: "${trimmed}" (${detail})`); + } +} + /** * Parses OAuth 2.1 callback parameters from a URL search string * @param location The URL search string (e.g., "?code=abc123" or "?error=access_denied") @@ -55,36 +70,48 @@ export const generateOAuthState = (): string => { ); }; -export type OAuthStateMode = "normal" | "guided"; - /** - * Generate OAuth state with mode prefix for single-redirect-URL flow. - * Format: {mode}:{authId} (e.g. "guided:a1b2c3..."). - * The authId part is 64 hex chars for CSRF protection and serves as session identifier. + * Generate OAuth `state` with execution prefix for single-redirect-URL flows. + * Format: `{execution}:{authId}` (e.g. "guided:a1b2c3..."). + * Protocol (standard vs EMA) is not encoded here — it comes from server config. + * The authId is 64 hex chars for CSRF protection and serves as session identifier. */ -export const generateOAuthStateWithMode = (mode: OAuthStateMode): string => { +export const generateOAuthStateWithExecution = ( + execution: AuthExecution, +): string => { const authId = generateOAuthState(); - return `${mode}:${authId}`; + return `${execution}:${authId}`; }; +/** @deprecated Use {@link generateOAuthStateWithExecution}. */ +export const generateOAuthStateWithMode = generateOAuthStateWithExecution; + /** - * Parse OAuth state to extract mode and authId part. + * Parse OAuth `state` to extract execution and authId. * Returns null if invalid. - * Legacy state (plain 64-char hex, no prefix) is treated as mode "normal". + * Legacy prefixes `normal:` and `ema-idp:` map to `quick`. + * Plain 64-char hex (no prefix) is treated as quick. */ export const parseOAuthState = ( state: string, -): { mode: OAuthStateMode; authId: string } | null => { +): { execution: AuthExecution; authId: string } | null => { if (!state || typeof state !== "string") return null; - if (state.startsWith("normal:")) { - return { mode: "normal", authId: state.slice(7) }; + if (state.startsWith("quick:")) { + return { execution: "quick", authId: state.slice(6) }; } if (state.startsWith("guided:")) { - return { mode: "guided", authId: state.slice(7) }; + return { execution: "guided", authId: state.slice(7) }; + } + // Legacy execution prefixes + if (state.startsWith("normal:")) { + return { execution: "quick", authId: state.slice(7) }; + } + if (state.startsWith("ema-idp:")) { + return { execution: "quick", authId: state.slice(8) }; } // Legacy: plain 64-char hex if (/^[a-f0-9]{64}$/i.test(state)) { - return { mode: "normal", authId: state }; + return { execution: "quick", authId: state }; } return null; }; diff --git a/core/client/config-parse.ts b/core/client/config-parse.ts new file mode 100644 index 00000000..8ccfae11 --- /dev/null +++ b/core/client/config-parse.ts @@ -0,0 +1,61 @@ +/** + * Browser-safe client.json parse/validate (no Node file I/O). + */ + +import { z } from "zod"; +import type { ClientConfig } from "./types.js"; + +const HttpUrlStringSchema = z.string().min(1).superRefine((val, ctx) => { + const trimmed = val.trim(); + if (!URL.canParse(trimmed)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Invalid URL: "${trimmed}" — must be an absolute URL (e.g. https://idp.example.com)`, + }); + } +}); + +const EnterpriseManagedAuthIdpConfigSchema = z.object({ + issuer: HttpUrlStringSchema, + clientId: z.string().min(1), + clientSecret: z.string().optional(), +}); + +const ClientConfigSchema = z.object({ + enterpriseManagedAuth: z + .object({ + enabled: z.boolean().optional(), + idp: EnterpriseManagedAuthIdpConfigSchema, + }) + .optional(), +}); + +/** + * Parse and validate unknown JSON into {@link ClientConfig}. + * @throws {z.ZodError} when shape is invalid + */ +export function parseClientConfig(raw: unknown): ClientConfig { + return ClientConfigSchema.parse(raw); +} + +/** Canonical JSON serialization for client.json (matches backend store-io format). */ +export function serializeClientConfig(config: ClientConfig): string { + return JSON.stringify(config, null, 2); +} + +/** Human-readable message for a failed client.json load (parse or transport). */ +export function formatClientConfigLoadError(error: unknown): string { + if (error instanceof z.ZodError) { + const issue = error.issues[0]; + if (issue) { + const field = + issue.path.length > 0 ? issue.path.map(String).join(".") : "config"; + return `${field}: ${issue.message}`; + } + return "Invalid client.json shape"; + } + if (error instanceof Error) { + return error.message; + } + return String(error); +} diff --git a/core/client/config.ts b/core/client/config.ts new file mode 100644 index 00000000..c0d7ccb9 --- /dev/null +++ b/core/client/config.ts @@ -0,0 +1,67 @@ +/** + * Load/save/validate install-level client config (client.json). + */ + +import type { SecretStore } from "../auth/node/secret-store.js"; +import { + getDefaultStorageDir, + getStoreFilePath, + parseStore, + readStoreFile, + serializeStore, + writeStoreFile, +} from "../storage/store-io.js"; +import { parseClientConfig } from "./config-parse.js"; +import { + readClientConfigStore, + writeClientConfigStore, +} from "./node-persistence.js"; +import type { ClientConfig } from "./types.js"; + +export { parseClientConfig, serializeClientConfig } from "./config-parse.js"; + +/** Default path: ~/.mcp-inspector/storage/client.json */ +export function getClientConfigFilePath(customPath?: string): string { + return customPath ?? getStoreFilePath(getDefaultStorageDir(), "client"); +} + +/** + * Read client.json from disk. Returns `{}` when the file is absent. + * When `secretStore` is provided, IdP clientSecret is read from the keychain. + */ +export async function loadClientConfig(options?: { + filePath?: string; + secretStore?: SecretStore; +}): Promise { + const filePath = getClientConfigFilePath(options?.filePath); + if (options?.secretStore) { + return readClientConfigStore(filePath, options.secretStore); + } + + const raw = await readStoreFile(filePath); + if (raw === null) { + return {}; + } + return parseClientConfig(parseStore(raw)); +} + +/** Write client.json atomically (mode 0o600). */ +export async function saveClientConfig( + config: ClientConfig, + options?: { filePath?: string; secretStore?: SecretStore }, +): Promise { + const filePath = getClientConfigFilePath(options?.filePath); + if (options?.secretStore) { + await writeClientConfigStore(filePath, config, options.secretStore); + return; + } + + const validated = parseClientConfig(config); + await writeStoreFile(filePath, serializeStore(validated)); +} + +export { + deleteClientConfigStore, + readClientConfigStore, + writeClientConfigStore, +} from "./node-persistence.js"; diff --git a/core/client/index.ts b/core/client/index.ts new file mode 100644 index 00000000..34b6f891 --- /dev/null +++ b/core/client/index.ts @@ -0,0 +1,20 @@ +export type { + ClientConfig, + EnterpriseManagedAuthIdpConfig, +} from "./types.js"; +export { + getActiveEnterpriseManagedAuthIdp, + isEnterpriseManagedAuthEnabled, +} from "./types.js"; +export { + getClientConfigFilePath, + loadClientConfig, + parseClientConfig, + saveClientConfig, + serializeClientConfig, +} from "./config.js"; +export { + loadClientConfigRemote, + saveClientConfigRemote, + type RemoteClientConfigOptions, +} from "./remote.js"; diff --git a/core/client/node-persistence.ts b/core/client/node-persistence.ts new file mode 100644 index 00000000..fdb7c7af --- /dev/null +++ b/core/client/node-persistence.ts @@ -0,0 +1,118 @@ +/** + * Node-only client.json persistence with OS keychain for IdP clientSecret. + */ + +import { + KeychainUnavailableError, + type SecretStore, +} from "../auth/node/secret-store.js"; +import { SECRET_FIELD_IDP_CLIENT_SECRET } from "../auth/secret-fields.js"; +import { + deleteStoreFile, + parseStore, + readStoreFile, + serializeStore, + writeStoreFile, +} from "../storage/store-io.js"; +import { parseClientConfig } from "./config-parse.js"; +import type { ClientConfig } from "./types.js"; +import { + CLIENT_KEYCHAIN_ID, + extractSecretsFromClientConfig, + hasClientPlaintextSecret, + mergeSecretsIntoClientConfig, +} from "./secrets.js"; + +async function readIdpSecretFromKeychain( + secretStore: SecretStore, +): Promise> { + const secret = await secretStore.get( + CLIENT_KEYCHAIN_ID, + SECRET_FIELD_IDP_CLIENT_SECRET, + ); + if (!secret) return {}; + return { [SECRET_FIELD_IDP_CLIENT_SECRET]: secret }; +} + +async function migrateClientPlaintextSecret( + filePath: string, + config: ClientConfig, + secretStore: SecretStore, +): Promise { + const { stripped, secrets } = extractSecretsFromClientConfig(config); + const value = secrets[SECRET_FIELD_IDP_CLIENT_SECRET]; + if (!value) return config; + + try { + const existing = await secretStore.get( + CLIENT_KEYCHAIN_ID, + SECRET_FIELD_IDP_CLIENT_SECRET, + ); + if (existing === null) { + await secretStore.set( + CLIENT_KEYCHAIN_ID, + SECRET_FIELD_IDP_CLIENT_SECRET, + value, + ); + } + await writeStoreFile(filePath, serializeStore(stripped)); + return stripped; + } catch (err) { + if (err instanceof KeychainUnavailableError) { + return config; + } + throw err; + } +} + +/** Read client.json from disk and rehydrate IdP clientSecret from the keychain. */ +export async function readClientConfigStore( + filePath: string, + secretStore: SecretStore, +): Promise { + const raw = await readStoreFile(filePath); + if (raw === null) { + return {}; + } + + let config = parseClientConfig(parseStore(raw)); + if (hasClientPlaintextSecret(config)) { + config = await migrateClientPlaintextSecret(filePath, config, secretStore); + } + + const secrets = await readIdpSecretFromKeychain(secretStore); + return mergeSecretsIntoClientConfig(config, secrets); +} + +/** Validate, strip IdP clientSecret to keychain, and write client.json. */ +export async function writeClientConfigStore( + filePath: string, + body: unknown, + secretStore: SecretStore, +): Promise { + const validated = parseClientConfig(body); + const { stripped, secrets } = extractSecretsFromClientConfig(validated); + const idpSecret = secrets[SECRET_FIELD_IDP_CLIENT_SECRET]; + if (idpSecret) { + await secretStore.set( + CLIENT_KEYCHAIN_ID, + SECRET_FIELD_IDP_CLIENT_SECRET, + idpSecret, + ); + } else { + await secretStore.delete( + CLIENT_KEYCHAIN_ID, + SECRET_FIELD_IDP_CLIENT_SECRET, + ); + } + await writeStoreFile(filePath, serializeStore(stripped)); +} + +/** Remove client.json and the install-level IdP secret from the keychain. */ +export async function deleteClientConfigStore( + filePath: string, + secretStore: SecretStore, +): Promise { + await deleteStoreFile(filePath); + await secretStore.delete(CLIENT_KEYCHAIN_ID, SECRET_FIELD_IDP_CLIENT_SECRET); +} diff --git a/core/client/remote.ts b/core/client/remote.ts new file mode 100644 index 00000000..58915069 --- /dev/null +++ b/core/client/remote.ts @@ -0,0 +1,78 @@ +/** + * Remote HTTP load/save for client.json via /api/storage/client. + */ + +import { + parseClientConfig, + serializeClientConfig, +} from "./config-parse.js"; +import type { ClientConfig } from "./types.js"; + +export interface RemoteClientConfigOptions { + /** Base URL of the remote server (e.g. http://localhost:3000) */ + baseUrl: string; + /** Optional auth token for x-mcp-remote-auth header */ + authToken?: string; + fetchFn?: typeof fetch; +} + +/** + * GET /api/storage/client — returns `{}` when store is empty or 404. + */ +export async function loadClientConfigRemote( + options: RemoteClientConfigOptions, +): Promise { + const baseUrl = options.baseUrl.replace(/\/$/, ""); + const fetchFn = options.fetchFn ?? globalThis.fetch; + const headers: Record = {}; + if (options.authToken) { + headers["x-mcp-remote-auth"] = `Bearer ${options.authToken}`; + } + + const res = await fetchFn(`${baseUrl}/api/storage/client`, { + method: "GET", + headers, + }); + + if (!res.ok) { + if (res.status === 404) { + return {}; + } + throw new Error(`Failed to read client config: ${res.status}`); + } + + const store = await res.json(); + if (!store || typeof store !== "object" || Object.keys(store).length === 0) { + return {}; + } + + return parseClientConfig(store); +} + +/** + * POST /api/storage/client — replaces the stored client config blob. + */ +export async function saveClientConfigRemote( + config: ClientConfig, + options: RemoteClientConfigOptions, +): Promise { + const validated = parseClientConfig(config); + const baseUrl = options.baseUrl.replace(/\/$/, ""); + const fetchFn = options.fetchFn ?? globalThis.fetch; + const headers: Record = { + "Content-Type": "application/json", + }; + if (options.authToken) { + headers["x-mcp-remote-auth"] = `Bearer ${options.authToken}`; + } + + const res = await fetchFn(`${baseUrl}/api/storage/client`, { + method: "POST", + headers, + body: serializeClientConfig(validated), + }); + + if (!res.ok) { + throw new Error(`Failed to write client config: ${res.status}`); + } +} diff --git a/core/client/secrets.ts b/core/client/secrets.ts new file mode 100644 index 00000000..399360d7 --- /dev/null +++ b/core/client/secrets.ts @@ -0,0 +1,65 @@ +/** + * Strip/merge install-level IdP client secrets for client.json ↔ keychain. + * Browser-safe — no Node keychain imports. + */ + +import { SECRET_FIELD_IDP_CLIENT_SECRET } from "../auth/secret-fields.js"; +import type { ClientConfig } from "./types.js"; + +/** Keychain namespace for install-level client config (not a server catalog id). */ +export const CLIENT_KEYCHAIN_ID = "client"; + +export function hasClientPlaintextSecret(config: ClientConfig): boolean { + const secret = config.enterpriseManagedAuth?.idp?.clientSecret; + return typeof secret === "string" && secret.length > 0; +} + +/** + * Lift IdP `clientSecret` off the config blob. The stripped shape is what + * lands on disk; the secret map is written to the OS keychain. + */ +export function extractSecretsFromClientConfig(config: ClientConfig): { + stripped: ClientConfig; + secrets: Record; +} { + const secrets: Record = {}; + const ema = config.enterpriseManagedAuth; + if (!ema?.idp?.clientSecret) { + return { stripped: config, secrets }; + } + + const { clientSecret, ...idpRest } = ema.idp; + secrets[SECRET_FIELD_IDP_CLIENT_SECRET] = clientSecret; + return { + stripped: { + ...config, + enterpriseManagedAuth: { + ...ema, + idp: idpRest, + }, + }, + secrets, + }; +} + +/** Merge keychain secrets back into a stripped on-disk client config. */ +export function mergeSecretsIntoClientConfig( + config: ClientConfig, + secrets: Record, +): ClientConfig { + const idpSecret = secrets[SECRET_FIELD_IDP_CLIENT_SECRET]; + const ema = config.enterpriseManagedAuth; + if (!idpSecret || !ema?.idp) { + return config; + } + return { + ...config, + enterpriseManagedAuth: { + ...ema, + idp: { + ...ema.idp, + clientSecret: idpSecret, + }, + }, + }; +} diff --git a/core/client/types.ts b/core/client/types.ts new file mode 100644 index 00000000..1901f302 --- /dev/null +++ b/core/client/types.ts @@ -0,0 +1,36 @@ +/** + * Install-level client configuration (IdP / EMA settings, later client identity). + * Persisted in ~/.mcp-inspector/storage/client.json via /api/storage/client. + */ + +/** OIDC client credentials for the enterprise IdP (legs 1–2). */ +export interface EnterpriseManagedAuthIdpConfig { + issuer: string; + clientId: string; + /** Present after keychain merge; omitted from on-disk client.json. */ + clientSecret?: string; +} + +export interface ClientConfig { + enterpriseManagedAuth?: { + /** When false, IdP credentials are kept but EMA is inactive install-wide. */ + enabled?: boolean; + idp: EnterpriseManagedAuthIdpConfig; + }; +} + +/** True when install-level EMA IdP config is active (not just stored). */ +export function isEnterpriseManagedAuthEnabled( + config: ClientConfig, +): boolean { + const ema = config.enterpriseManagedAuth; + if (!ema?.idp) return false; + return ema.enabled !== false; +} + +export function getActiveEnterpriseManagedAuthIdp( + config: ClientConfig, +): EnterpriseManagedAuthIdpConfig | undefined { + if (!isEnterpriseManagedAuthEnabled(config)) return undefined; + return config.enterpriseManagedAuth!.idp; +} diff --git a/core/mcp/config.ts b/core/mcp/config.ts index 1d1e034b..c3b63428 100644 --- a/core/mcp/config.ts +++ b/core/mcp/config.ts @@ -22,3 +22,8 @@ export function getServerType(config: MCPServerConfig): ServerType { `Invalid server type: ${type}. Valid types are: stdio, sse, streamable-http`, ); } + +/** OAuth and enterprise-managed auth apply only to remote HTTP-based transports. */ +export function isOAuthCapableServerType(type: ServerType): boolean { + return type === "sse" || type === "streamable-http"; +} diff --git a/core/mcp/index.ts b/core/mcp/index.ts index d2afb8e7..cc65b7b7 100644 --- a/core/mcp/index.ts +++ b/core/mcp/index.ts @@ -11,7 +11,7 @@ export type { // Re-export type-safe event target types for consumers export type { InspectorClientEventMap } from "./inspectorClientEventTarget.js"; -export { getServerType } from "./config.js"; +export { getServerType, isOAuthCapableServerType } from "./config.js"; // Re-export types used by consumers export type { diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index f3c74d31..8cfc0f58 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -118,7 +118,7 @@ import { UrlElicitationLoopError, } from "./urlElicitation.js"; import { ToolCallCancelledError } from "./toolCallCancelledError.js"; -import type { AuthGuidedState, OAuthStep } from "../auth/types.js"; +import type { OAuthConnectionState, OAuthFlowState, OAuthStep } from "../auth/types.js"; import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"; import { silentLogger, type InspectorLogger } from "../logging/logger.js"; import { createFetchTracker } from "./fetchTracking.js"; @@ -306,6 +306,8 @@ export class InspectorClient extends InspectorClientEventTarget { return Promise.resolve(); }, initialConfig: oauthConfig, + enterpriseManagedAuth: options.enterpriseManagedAuth, + installEnterpriseManagedAuth: options.installEnterpriseManagedAuth, dispatchOAuthStepChange: (detail) => this.dispatchTypedEvent("oauthStepChange", detail), dispatchOAuthComplete: (detail) => @@ -362,6 +364,11 @@ export class InspectorClient extends InspectorClientEventTarget { }, }; } + if (options.oauth?.enterpriseManaged) { + capabilities.extensions = { + "io.modelcontextprotocol/enterprise-managed-authorization": {}, + }; + } if (Object.keys(capabilities).length > 0) { clientOptions.capabilities = capabilities; } @@ -702,7 +709,21 @@ export class InspectorClient extends InspectorClientEventTarget { }; const oauthManager = this.oauthManager; if (this.isHttpOAuthConfig() && oauthManager) { + if (oauthManager.isEnterpriseManaged()) { + await oauthManager.trySilentEnterpriseManagedAuth(); + } const provider = await oauthManager.createOAuthProviderForTransport(); + if (oauthManager.isEnterpriseManaged()) { + const tokens = await provider.tokens(); + if (!tokens?.access_token) { + const err = new Error( + "Unauthorized: EMA resource access token unavailable", + ) as Error & { status?: number; code?: number }; + err.status = 401; + err.code = 401; + throw err; + } + } transportOptions.authProvider = provider; } const { transport: baseTransport } = this.transportClientFactory( @@ -2655,7 +2676,7 @@ export class InspectorClient extends InspectorClientEventTarget { * Initiates OAuth flow using SDK's auth() function (normal mode) * Can be called directly by user or automatically triggered by 401 errors */ - async authenticate(): Promise { + async authenticate(): Promise { return this.ensureOAuthManager().authenticate(); } @@ -2733,17 +2754,30 @@ export class InspectorClient extends InspectorClientEventTarget { } /** - * Get current OAuth state machine state (for guided mode) + * In-memory OAuth flow snapshot (quick or guided). Undefined when no flow + * has run on this client instance; use {@link getOAuthState} for persisted + * authorization state. */ - getOAuthState(): AuthGuidedState | undefined { - return this.oauthManager?.getOAuthState(); + getOAuthFlowState(): OAuthFlowState | undefined { + return this.oauthManager?.getOAuthFlowState(); } /** - * Get current OAuth step (for guided mode) + * Current step when an OAuth flow is active (quick or guided). */ - getOAuthStep(): OAuthStep | undefined { - return this.oauthManager?.getOAuthStep(); + getOAuthFlowStep(): OAuthStep | undefined { + return this.oauthManager?.getOAuthFlowStep(); + } + + /** + * Persisted OAuth authorization snapshot for this HTTP server (storage + + * config). Undefined for stdio transports or when OAuth is not configured. + */ + async getOAuthState(): Promise { + if (!this.isHttpOAuthConfig() || !this.oauthManager) { + return undefined; + } + return this.oauthManager.getOAuthState(); } /** diff --git a/core/mcp/inspectorClientEventTarget.ts b/core/mcp/inspectorClientEventTarget.ts index 934bc1c1..e50ceb16 100644 --- a/core/mcp/inspectorClientEventTarget.ts +++ b/core/mcp/inspectorClientEventTarget.ts @@ -39,7 +39,7 @@ import type { SamplingCreateMessage } from "./samplingCreateMessage.js"; import type { ElicitationCreateMessage } from "./elicitationCreateMessage.js"; import type { JsonValue } from "../json/jsonUtils.js"; import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"; -import type { OAuthStep, AuthGuidedState } from "../auth/types.js"; +import type { OAuthStep, OAuthFlowState } from "../auth/types.js"; /** Task with createdAt optional so we can emit synthetic tasks (e.g. on result/error) that omit it. */ export type TaskWithOptionalCreatedAt = Omit & { @@ -149,7 +149,7 @@ export interface InspectorClientEventMap { oauthStepChange: { step: OAuthStep; previousStep: OAuthStep; - state: Partial; + state: Partial; }; oauthComplete: { tokens: OAuthTokens }; oauthAuthorizationRequired: { url: URL }; diff --git a/core/mcp/oauthManager.ts b/core/mcp/oauthManager.ts index 02cac4fa..96da7bf1 100644 --- a/core/mcp/oauthManager.ts +++ b/core/mcp/oauthManager.ts @@ -1,20 +1,37 @@ /** * Internal OAuth sub-manager for InspectorClient. - * Holds OAuth config, state machine, and guided state; orchestrates normal and guided flows. + * Holds OAuth config, state machine, and guided state; orchestrates quick and guided flows. * Not part of the public API; InspectorClient delegates to this module. */ import { BaseOAuthClientProvider } from "../auth/providers.js"; -import type { AuthGuidedState, OAuthStep } from "../auth/types.js"; -import { EMPTY_GUIDED_STATE } from "../auth/types.js"; +import type { AuthExecution, OAuthFlowState, OAuthStep } from "../auth/types.js"; +import { EMPTY_OAUTH_FLOW_STATE } from "../auth/types.js"; import { OAuthStateMachine } from "../auth/state-machine.js"; import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"; import { auth } from "@modelcontextprotocol/sdk/client/auth.js"; import type { OAuthClientInformation } from "@modelcontextprotocol/sdk/shared/auth.js"; import { parseOAuthState } from "../auth/utils.js"; +import type { EnterpriseManagedAuthIdpConfig } from "../client/types.js"; +import type { ClientConfig } from "../client/types.js"; +import { EmaClientNotConfiguredError } from "../auth/ema/clientConfigError.js"; +import { + completeEmaIdpAuthorizationAndMint, + refreshEmaResourceTokens, + startEmaIdpAuthorization, + trySilentEmaAuth, + type EmaFlowConfig, +} from "../auth/ema/emaFlow.js"; +import { + buildOAuthConnectionState, + isServerOAuthConfigured, + protocolFromOAuthConfig, +} from "../auth/connection-state.js"; +import type { OAuthConnectionState } from "../auth/types.js"; +import { EmaTransportOAuthProvider } from "../auth/ema/transportProvider.js"; import type { - InspectorClientOptions, InspectorClientEnvironment, + InspectorClientOptions, } from "./types.js"; export type OAuthManagerConfig = NonNullable & @@ -26,10 +43,13 @@ export interface OAuthManagerParams { getEventTarget: () => EventTarget; onBeforeOAuthRedirect?: (sessionId: string) => Promise; initialConfig: OAuthManagerConfig; + enterpriseManagedAuth?: { idp: EnterpriseManagedAuthIdpConfig }; + /** Install-level EMA block (including when disabled) for user-facing errors. */ + installEnterpriseManagedAuth?: ClientConfig["enterpriseManagedAuth"]; dispatchOAuthStepChange: (detail: { step: OAuthStep; previousStep: OAuthStep; - state: Partial; + state: Partial; }) => void; dispatchOAuthComplete: (detail: { tokens: OAuthTokens }) => void; dispatchOAuthAuthorizationRequired: (detail: { url: URL }) => void; @@ -44,7 +64,7 @@ export class OAuthManager { private params: OAuthManagerParams; private oauthConfig: OAuthManagerConfig; private oauthStateMachine: OAuthStateMachine | null = null; - private oauthState: AuthGuidedState | null = null; + private oauthFlowState: OAuthFlowState | null = null; constructor(params: OAuthManagerParams) { this.params = params; @@ -56,6 +76,7 @@ export class OAuthManager { clientSecret?: string; clientMetadataUrl?: string; scope?: string; + enterpriseManaged?: boolean; }): void { this.oauthConfig = { ...this.oauthConfig, @@ -68,7 +89,7 @@ export class OAuthManager { } private async createOAuthProvider( - mode: "normal" | "guided", + execution: AuthExecution, ): Promise { if ( !this.oauthConfig.storage || @@ -89,7 +110,7 @@ export class OAuthManager { navigation: this.oauthConfig.navigation, clientMetadataUrl: this.oauthConfig.clientMetadataUrl, }, - mode, + execution, ); provider.setEventTarget(this.params.getEventTarget()); @@ -111,8 +132,82 @@ export class OAuthManager { return provider; } - async authenticate(): Promise { - const provider = await this.createOAuthProvider("normal"); + /** Whether this connection uses enterprise-managed authorization (EMA). */ + isEnterpriseManaged(): boolean { + return this.oauthConfig.enterpriseManaged === true; + } + + private getEmaFlowConfig(): EmaFlowConfig { + if (!this.oauthConfig.storage || !this.oauthConfig.redirectUrlProvider) { + throw new Error( + "OAuth environment components (storage, redirectUrlProvider) are required.", + ); + } + const idp = this.params.enterpriseManagedAuth?.idp; + if (!idp) { + const install = this.params.installEnterpriseManagedAuth; + throw new EmaClientNotConfiguredError( + install?.enabled === false && install.idp + ? "disabled" + : "not_configured", + ); + } + return { + serverUrl: this.getServerUrl(), + idp, + resourceClientId: this.oauthConfig.clientId, + resourceClientSecret: this.oauthConfig.clientSecret, + scope: this.oauthConfig.scope, + redirectUrl: this.oauthConfig.redirectUrlProvider.getRedirectUrl("quick"), + storage: this.oauthConfig.storage, + fetchFn: this.params.effectiveAuthFetch, + }; + } + + /** Attempt silent EMA (cached IdP session + legs 2–3). */ + async trySilentEnterpriseManagedAuth(): Promise { + if (!this.isEnterpriseManaged()) return false; + const result = await trySilentEmaAuth(this.getEmaFlowConfig()); + if (result.status === "success") return true; + if (result.status === "mint_failed") throw result.error; + return false; + } + + private async authenticateEnterpriseManaged(): Promise { + const config = this.getEmaFlowConfig(); + const silent = await trySilentEmaAuth(config); + if (silent.status === "success") { + return undefined; + } + if (silent.status === "mint_failed") { + throw silent.error; + } + + const authorizationUrl = await startEmaIdpAuthorization(config); + const stateParam = authorizationUrl.searchParams.get("state"); + if (stateParam && this.params.onBeforeOAuthRedirect) { + const parsedState = parseOAuthState(stateParam); + if (parsedState?.authId) { + await this.params.onBeforeOAuthRedirect(parsedState.authId); + } + } + + this.oauthConfig.navigation!.navigateToAuthorization(authorizationUrl); + this.oauthFlowState = { + ...EMPTY_OAUTH_FLOW_STATE, + execution: "quick", + oauthStep: "authorization_code", + authorizationUrl, + }; + return authorizationUrl; + } + + async authenticate(): Promise { + if (this.isEnterpriseManaged()) { + return this.authenticateEnterpriseManaged(); + } + + const provider = await this.createOAuthProvider("quick"); const serverUrl = this.getServerUrl(); provider.clearCapturedAuthUrl(); @@ -143,9 +238,9 @@ export class OAuthManager { } const clientInfo = await provider.clientInformation(); - this.oauthState = { - ...EMPTY_GUIDED_STATE, - authType: "normal", + this.oauthFlowState = { + ...EMPTY_OAUTH_FLOW_STATE, + execution: "quick", oauthStep: "authorization_code", authorizationUrl: capturedUrl, oauthClientInfo: clientInfo ?? null, @@ -157,9 +252,9 @@ export class OAuthManager { const provider = await this.createOAuthProvider("guided"); const serverUrl = this.getServerUrl(); - this.oauthState = { ...EMPTY_GUIDED_STATE }; + this.oauthFlowState = { ...EMPTY_OAUTH_FLOW_STATE }; if (this.oauthConfig.clientId) { - this.oauthState.oauthClientInfo = { + this.oauthFlowState.oauthClientInfo = { client_id: this.oauthConfig.clientId, ...(this.oauthConfig.clientSecret && { client_secret: this.oauthConfig.clientSecret, @@ -170,12 +265,12 @@ export class OAuthManager { serverUrl, provider, (updates) => { - const state = this.oauthState; + const state = this.oauthFlowState; if (!state) throw new Error("OAuth state not initialized"); const previousStep = state.oauthStep; - this.oauthState = { ...state, ...updates }; + this.oauthFlowState = { ...state, ...updates }; if (updates.oauthStep === "complete") { - this.oauthState!.completedAt = Date.now(); + this.oauthFlowState!.completedAt = Date.now(); } const step = updates.oauthStep ?? previousStep; this.params.dispatchOAuthStepChange({ @@ -187,11 +282,11 @@ export class OAuthManager { this.params.effectiveAuthFetch, ); - await this.oauthStateMachine.executeStep(this.oauthState); + await this.oauthStateMachine.executeStep(this.oauthFlowState); } async runGuidedAuth(): Promise { - if (!this.oauthStateMachine || !this.oauthState) { + if (!this.oauthStateMachine || !this.oauthFlowState) { await this.beginGuidedAuth(); } @@ -201,7 +296,7 @@ export class OAuthManager { } while (true) { - const state = this.oauthState; + const state = this.oauthFlowState; if (!state) { throw new Error("Guided auth failed to initialize state"); } @@ -214,7 +309,7 @@ export class OAuthManager { await machine.executeStep(state); } - const state = this.oauthState; + const state = this.oauthFlowState; if (state?.oauthStep === "complete") { return undefined; } @@ -241,39 +336,39 @@ export class OAuthManager { authorizationCode: string, completeFlow: boolean = false, ): Promise { - if (!this.oauthStateMachine || !this.oauthState) { + if (!this.oauthStateMachine || !this.oauthFlowState) { throw new Error( "Not in guided OAuth flow. Call beginGuidedAuth() first.", ); } - const currentStep = this.oauthState.oauthStep; + const currentStep = this.oauthFlowState.oauthStep; if (currentStep !== "authorization_code") { throw new Error( `Cannot set authorization code at step ${currentStep}. Expected step: authorization_code`, ); } - this.oauthState.authorizationCode = authorizationCode; + this.oauthFlowState.authorizationCode = authorizationCode; if (completeFlow) { - await this.oauthStateMachine.executeStep(this.oauthState); - let step: OAuthStep = this.oauthState.oauthStep; + await this.oauthStateMachine.executeStep(this.oauthFlowState); + let step: OAuthStep = this.oauthFlowState.oauthStep; while (step !== "complete") { - await this.oauthStateMachine.executeStep(this.oauthState); - step = this.oauthState.oauthStep; + await this.oauthStateMachine.executeStep(this.oauthFlowState); + step = this.oauthFlowState.oauthStep; } - if (!this.oauthState.oauthTokens) { + if (!this.oauthFlowState.oauthTokens) { throw new Error("Failed to exchange authorization code for tokens"); } this.params.dispatchOAuthComplete({ - tokens: this.oauthState.oauthTokens, + tokens: this.oauthFlowState.oauthTokens, }); } else { this.params.dispatchOAuthStepChange({ - step: this.oauthState.oauthStep, - previousStep: this.oauthState.oauthStep, + step: this.oauthFlowState.oauthStep, + previousStep: this.oauthFlowState.oauthStep, state: { authorizationCode }, }); } @@ -281,10 +376,28 @@ export class OAuthManager { async completeOAuthFlow(authorizationCode: string): Promise { try { - if (this.oauthStateMachine && this.oauthState) { + if (this.isEnterpriseManaged()) { + const config = this.getEmaFlowConfig(); + const tokens = await completeEmaIdpAuthorizationAndMint( + config, + authorizationCode, + ); + const completedAt = Date.now(); + this.oauthFlowState = { + ...EMPTY_OAUTH_FLOW_STATE, + execution: "quick", + oauthStep: "complete", + oauthTokens: tokens, + completedAt, + }; + this.params.dispatchOAuthComplete({ tokens }); + return; + } + + if (this.oauthStateMachine && this.oauthFlowState) { await this.setGuidedAuthorizationCode(authorizationCode, true); } else { - const provider = await this.createOAuthProvider("normal"); + const provider = await this.createOAuthProvider("quick"); const serverUrl = this.getServerUrl(); const result = await auth(provider, { @@ -306,17 +419,17 @@ export class OAuthManager { const clientInfo = await provider.clientInformation(); const completedAt = Date.now(); - this.oauthState = this.oauthState + this.oauthFlowState = this.oauthFlowState ? { - ...this.oauthState, + ...this.oauthFlowState, oauthStep: "complete", oauthTokens: tokens, oauthClientInfo: clientInfo ?? null, completedAt, } : { - ...EMPTY_GUIDED_STATE, - authType: "normal", + ...EMPTY_OAUTH_FLOW_STATE, + execution: "quick", oauthStep: "complete", oauthTokens: tokens, oauthClientInfo: clientInfo ?? null, @@ -334,11 +447,11 @@ export class OAuthManager { } async getOAuthTokens(): Promise { - if (this.oauthState?.oauthTokens) { - return this.oauthState.oauthTokens; + if (this.oauthFlowState?.oauthTokens) { + return this.oauthFlowState.oauthTokens; } - const provider = await this.createOAuthProvider("normal"); + const provider = await this.createOAuthProvider("quick"); try { return await provider.tokens(); } catch { @@ -354,7 +467,7 @@ export class OAuthManager { const serverUrl = this.getServerUrl(); this.oauthConfig.storage.clear(serverUrl); - this.oauthState = null; + this.oauthFlowState = null; this.oauthStateMachine = null; } @@ -363,29 +476,63 @@ export class OAuthManager { return tokens !== undefined; } - getOAuthState(): AuthGuidedState | undefined { - return this.oauthState ? { ...this.oauthState } : undefined; + getOAuthFlowState(): OAuthFlowState | undefined { + return this.oauthFlowState ? { ...this.oauthFlowState } : undefined; } - getOAuthStep(): OAuthStep | undefined { - return this.oauthState?.oauthStep; + getOAuthFlowStep(): OAuthStep | undefined { + return this.oauthFlowState?.oauthStep; + } + + /** + * Persisted OAuth authorization snapshot for this server (storage + config). + * Returns undefined when OAuth is not configured for the server. + */ + async getOAuthState(): Promise { + if (!isServerOAuthConfigured(this.oauthConfig)) { + return undefined; + } + + return buildOAuthConnectionState({ + serverUrl: this.getServerUrl(), + protocol: protocolFromOAuthConfig(this.oauthConfig), + configuredScope: this.oauthConfig.scope, + enterpriseManagedAuth: this.params.enterpriseManagedAuth, + storage: this.oauthConfig.storage!, + flowState: this.oauthFlowState ?? undefined, + }); } async proceedOAuthStep(): Promise { - if (!this.oauthStateMachine || !this.oauthState) { + if (!this.oauthStateMachine || !this.oauthFlowState) { throw new Error( "Not in guided OAuth flow. Call authenticateGuided() first.", ); } - await this.oauthStateMachine.executeStep(this.oauthState); + await this.oauthStateMachine.executeStep(this.oauthFlowState); + } + + /** + * Re-run EMA legs 2–3 when resource tokens expire but IdP session remains valid. + */ + async refreshEnterpriseManagedTokens(): Promise { + if (!this.isEnterpriseManaged()) return false; + const tokens = await refreshEmaResourceTokens(this.getEmaFlowConfig()); + return tokens !== undefined; } /** * Create an OAuth provider for transport auth (connect()). * Used only when isHttpOAuthConfig() is true. */ - async createOAuthProviderForTransport(): Promise { - return this.createOAuthProvider("normal"); + async createOAuthProviderForTransport(): Promise< + BaseOAuthClientProvider | EmaTransportOAuthProvider + > { + const provider = await this.createOAuthProvider("quick"); + if (this.isEnterpriseManaged()) { + return new EmaTransportOAuthProvider(provider, this.getEmaFlowConfig()); + } + return provider; } } diff --git a/core/mcp/remote/node/server.ts b/core/mcp/remote/node/server.ts index 85a410e7..5ec0687a 100644 --- a/core/mcp/remote/node/server.ts +++ b/core/mcp/remote/node/server.ts @@ -59,7 +59,14 @@ import { KeyringSecretStore, type SecretStore, } from "../../../auth/node/secret-store.js"; +import { + deleteClientConfigStore, + readClientConfigStore, + writeClientConfigStore, +} from "../../../client/node-persistence.js"; +import { formatClientConfigLoadError } from "../../../client/config-parse.js"; import { envSecretField } from "../../../auth/secret-fields.js"; +import { ZodError } from "zod"; /** * Shape of the initial config returned by GET /api/config (defaults for client). @@ -808,6 +815,11 @@ export function createRemoteApp( const filePath = getStoreFilePath(storageDir, storeId); try { + if (storeId === "client") { + const config = await readClientConfigStore(filePath, secretStore); + return c.json(config); + } + const raw = await readStoreFile(filePath); if (raw === null) { return c.json({}, 200); @@ -836,10 +848,25 @@ export function createRemoteApp( const filePath = getStoreFilePath(storageDir, storeId); try { + if (storeId === "client") { + await writeClientConfigStore(filePath, body, secretStore); + return c.json({ ok: true }); + } + const jsonData = serializeStore(body); await writeStoreFile(filePath, jsonData); return c.json({ ok: true }); } catch (error) { + // A malformed client.json body fails `parseClientConfig` with a ZodError + // — that's a client error (bad request), not a server failure. Return 400 + // with the same human-readable formatting the load path uses, rather than + // letting it fall through to the generic 500 below. + if (error instanceof ZodError) { + return c.json({ error: formatClientConfigLoadError(error) }, 400); + } + const keychainResp = + storeId === "client" ? keychainErrorResponse(c, error) : undefined; + if (keychainResp) return keychainResp; const msg = error instanceof Error ? error.message : String(error); return c.json({ error: `Failed to write store: ${msg}` }, 500); } @@ -854,6 +881,11 @@ export function createRemoteApp( const filePath = getStoreFilePath(storageDir, storeId); try { + if (storeId === "client") { + await deleteClientConfigStore(filePath, secretStore); + return c.json({ ok: true }); + } + await deleteStoreFile(filePath); return c.json({ ok: true }); } catch (error) { @@ -910,12 +942,23 @@ export function createRemoteApp( }; const isOauthObject = ( v: unknown, - ): v is { clientId?: string; clientSecret?: string; scopes?: string } => { + ): v is { + clientId?: string; + clientSecret?: string; + scopes?: string; + enterpriseManaged?: boolean; + } => { if (v === null || typeof v !== "object" || Array.isArray(v)) return false; const o = v as Record; for (const k of ["clientId", "clientSecret", "scopes"] as const) { if (o[k] !== undefined && typeof o[k] !== "string") return false; } + if ( + o.enterpriseManaged !== undefined && + typeof o.enterpriseManaged !== "boolean" + ) { + return false; + } return true; }; // A roots array: each entry must have a string `uri` and, when present, a @@ -1021,7 +1064,7 @@ export function createRemoteApp( if ("oauth" in valObj && !isOauthObject(valObj.oauth)) { logWarn( { route: "/api/servers", id, droppedKey: "oauth" }, - "Dropping malformed `oauth` field — expected `{ clientId?, clientSecret?, scopes? }`.", + "Dropping malformed `oauth` field — expected `{ clientId?, clientSecret?, scopes?, enterpriseManaged? }`.", ); delete valObj.oauth; } @@ -1260,6 +1303,15 @@ export function createRemoteApp( return { ok: false, error: `settings.${optional} must be a string` }; } } + if ( + obj.enterpriseManaged !== undefined && + typeof obj.enterpriseManaged !== "boolean" + ) { + return { + ok: false, + error: "settings.enterpriseManaged must be a boolean", + }; + } // roots is optional on the wire (older clients won't send it); when // present it must be an array of `{ uri, name? }`. Reuses the same // module-scope `isRootArray` guard the read path applies in @@ -1319,6 +1371,9 @@ export function createRemoteApp( if (typeof obj.oauthScopes === "string" && obj.oauthScopes !== "") { value.oauthScopes = obj.oauthScopes; } + if (obj.enterpriseManaged === true) { + value.enterpriseManaged = true; + } // Empty cwd coerces to absent on the value (matching the read side); the // write-through distinguishes "sent cwd: '' " (clear) from "cwd omitted" // (preserve) via `cwdProvided` below rather than the value alone. @@ -1524,6 +1579,27 @@ export function createRemoteApp( return obsolete; }; + /** + * Merge keychain secrets for a server-id rename. PUT payload values win + * over the old id's keychain entries; keychain fills fields missing from + * the payload (e.g. config-modal rename that does not re-send stripped + * secrets). Filtered to the new on-disk entry's expected fields so env + * keys removed during the rename are not carried over. + */ + const mergeRenameKeychainSecrets = ( + stripped: StoredMCPServer, + keychainSecrets: Record, + nextSecrets: Record, + ): Record => { + const allowedFields = new Set(expectedSecretFields(stripped)); + const merged = { ...keychainSecrets, ...nextSecrets }; + const out: Record = {}; + for (const [field, value] of Object.entries(merged)) { + if (allowedFields.has(field)) out[field] = value; + } + return out; + }; + // Parallel for symmetry with `readKeychainEntriesFor` / // `writeKeychainEntriesFor`: distinct (id, field) deletes have no // ordering requirement, and `secretStore.delete` is already a silent @@ -1977,7 +2053,17 @@ export function createRemoteApp( // write leaves orphan keychain entries — recoverable on the // next reconcile or `deleteAllForServer` sweep. if (newId !== originalId) { - await writeKeychainEntriesFor(newId, secrets); + const previousFields = expectedSecretFields(existing); + const keychainSecrets = await readKeychainEntriesFor( + originalId, + previousFields, + ); + const secretsToWrite = mergeRenameKeychainSecrets( + stripped, + keychainSecrets, + secrets, + ); + await writeKeychainEntriesFor(newId, secretsToWrite); await writeMcpAndTrackMtime(serializeStore(next)); await secretStore.deleteAllForServer(originalId); } else { diff --git a/core/mcp/remote/node/tokenAuthProvider.ts b/core/mcp/remote/node/tokenAuthProvider.ts index aea91c24..ea51c4b6 100644 --- a/core/mcp/remote/node/tokenAuthProvider.ts +++ b/core/mcp/remote/node/tokenAuthProvider.ts @@ -1,13 +1,22 @@ import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; -import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"; +import type { + OAuthClientMetadata, + OAuthTokens, +} from "@modelcontextprotocol/sdk/shared/auth.js"; import type { RemoteConnectRequest } from "../types.js"; +const REMOTE_OAUTH_STUB_METADATA: OAuthClientMetadata = { + redirect_uris: [], + scope: "", +}; + /** * Simple OAuth client provider that just returns tokens. * Used by the remote server to inject Bearer tokens into transport requests. - * The other OAuthClientProvider methods are required by the interface but never - * exercised in the remote-server path (the SDK only calls `tokens()` for Bearer - * injection); they are kept as no-op stubs. + * + * The SDK may invoke {@link auth} on 401; stubs must satisfy the full provider + * surface so that path fails with a clear error instead of throwing on + * undefined `clientMetadata.scope`. */ export function createTokenAuthProvider( tokens: RemoteConnectRequest["oauthTokens"], @@ -15,6 +24,12 @@ export function createTokenAuthProvider( if (!tokens) return undefined; return { + get clientMetadata(): OAuthClientMetadata { + return REMOTE_OAUTH_STUB_METADATA; + }, + get redirectUrl(): string { + return ""; + }, async tokens(): Promise { return tokens as OAuthTokens; }, @@ -33,8 +48,10 @@ export function createTokenAuthProvider( clear() { // No-op }, - redirectToAuthorization() { - // No-op + async redirectToAuthorization() { + throw new Error( + "OAuth re-authorization must be performed in the Inspector web client (remote server cannot complete OAuth flows)", + ); }, state() { return ""; diff --git a/core/mcp/serverList.ts b/core/mcp/serverList.ts index 93b350f5..03b05936 100644 --- a/core/mcp/serverList.ts +++ b/core/mcp/serverList.ts @@ -199,6 +199,9 @@ export function storedFieldsToInspectorSettings( if (stored.oauth?.clientSecret) settings.oauthClientSecret = stored.oauth.clientSecret; if (stored.oauth?.scopes) settings.oauthScopes = stored.oauth.scopes; + if (stored.oauth?.enterpriseManaged === true) { + settings.enterpriseManaged = true; + } // Mirror the stdio working directory for the form. Like the OAuth fields, an // empty string coerces to absent so the form's "(inherit)" placeholder shows. if (stored.cwd) settings.cwd = stored.cwd; @@ -268,11 +271,15 @@ export function inspectorSettingsToStoredFields( clientId?: string; clientSecret?: string; scopes?: string; + enterpriseManaged?: boolean; } = {}; if (settings.oauthClientId) oauthFields.clientId = settings.oauthClientId; if (settings.oauthClientSecret) oauthFields.clientSecret = settings.oauthClientSecret; if (settings.oauthScopes) oauthFields.scopes = settings.oauthScopes; + if (settings.enterpriseManaged === true) { + oauthFields.enterpriseManaged = true; + } if (Object.keys(oauthFields).length > 0) { out.oauth = oauthFields; } @@ -467,11 +474,18 @@ export function extractSecretsFromStored( if (stored.oauth?.clientSecret) { secrets[SECRET_FIELD_OAUTH_CLIENT_SECRET] = stored.oauth.clientSecret; - const restOauth: { clientId?: string; scopes?: string } = {}; + const restOauth: { + clientId?: string; + scopes?: string; + enterpriseManaged?: boolean; + } = {}; if (stored.oauth.clientId !== undefined) restOauth.clientId = stored.oauth.clientId; if (stored.oauth.scopes !== undefined) restOauth.scopes = stored.oauth.scopes; + if (stored.oauth.enterpriseManaged === true) { + restOauth.enterpriseManaged = true; + } if (Object.keys(restOauth).length > 0) { stripped.oauth = restOauth; } else { diff --git a/core/mcp/types.ts b/core/mcp/types.ts index 473c5b9a..d9c8c480 100644 --- a/core/mcp/types.ts +++ b/core/mcp/types.ts @@ -23,6 +23,10 @@ import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth. import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import type { InspectorLogger } from "../logging/logger.js"; import type { JsonValue } from "../json/jsonUtils.js"; +import type { + ClientConfig, + EnterpriseManagedAuthIdpConfig, +} from "../client/types.js"; import type { OAuthNavigation, RedirectUrlProvider, @@ -128,6 +132,8 @@ export type StoredMCPServer = MCPServerConfig & { clientId?: string; clientSecret?: string; scopes?: string; + /** When true, connect via enterprise IdP (EMA) instead of standard resource OAuth. */ + enterpriseManaged?: boolean; }; /** * Filesystem/URI roots advertised to the server via the `roots` client @@ -397,6 +403,7 @@ export interface OAuthSettings { clientId: string; clientSecret: string; scopes: string; + enterpriseManaged?: boolean; } /** @@ -445,6 +452,12 @@ export interface InspectorServerSettings { oauthClientId?: string; oauthClientSecret?: string; oauthScopes?: string; + /** + * When true, connect via the configured enterprise IdP (EMA) instead of + * interactive OAuth to the MCP authorization server. Per-server OAuth + * fields below are resource AS credentials. (#1509) + */ + enterpriseManaged?: boolean; /** * When true, lists auto-refresh on `list_changed` notifications; when * false (default), the notification only lights the list-changed indicator @@ -715,8 +728,24 @@ export interface InspectorClientOptions { clientSecret?: string; clientMetadataUrl?: string; scope?: string; + /** Route to EMA flow when true (resource AS creds in clientId/clientSecret). */ + enterpriseManaged?: boolean; }; + /** + * Global enterprise IdP credentials (from client.json). Used for EMA legs 1–2 + * when {@link oauth.enterpriseManaged} is true on the server. + */ + enterpriseManagedAuth?: { + idp: EnterpriseManagedAuthIdpConfig; + }; + + /** + * Full install-level EMA config from client.json (including when disabled). + * Used to produce friendly errors when a server expects EMA but IdP is inactive. + */ + installEnterpriseManagedAuth?: ClientConfig["enterpriseManagedAuth"]; + /** * Optional session ID. If not provided, will be extracted from OAuth state * when OAuth flow starts. Passed in saveSession event for FetchRequestLogState. diff --git a/core/react/useClientSettingsDraft.ts b/core/react/useClientSettingsDraft.ts new file mode 100644 index 00000000..00ad25ac --- /dev/null +++ b/core/react/useClientSettingsDraft.ts @@ -0,0 +1,93 @@ +/** + * Draft state for the install-level client settings modal (client.json). + * + * Same controlled-modal pattern as {@link useSettingsDraft}: local draft, + * debounced persist, synchronous `flush` on close. Keyed on modal + * open/close (`opened`) rather than a server id because client config is + * install-level, not per-server. + */ +import { useCallback, useEffect, useRef, useState } from "react"; + +export interface UseClientSettingsDraftOptions { + opened: boolean; + resolveInitial: () => T; + onPersist: (value: T) => Promise; + onError: (err: unknown) => void; + debounceMs?: number; +} + +export interface UseClientSettingsDraftResult { + draft: T | null; + onChange: (next: T) => void; + flush: () => void; +} + +export function useClientSettingsDraft({ + opened, + resolveInitial, + onPersist, + onError, + debounceMs = 300, +}: UseClientSettingsDraftOptions): UseClientSettingsDraftResult { + const [draft, setDraft] = useState(null); + const timerRef = useRef | undefined>(undefined); + + const resolveInitialRef = useRef(resolveInitial); + const onPersistRef = useRef(onPersist); + const onErrorRef = useRef(onError); + const draftRef = useRef(null); + const openedRef = useRef(opened); + resolveInitialRef.current = resolveInitial; + onPersistRef.current = onPersist; + onErrorRef.current = onError; + draftRef.current = draft; + openedRef.current = opened; + + useEffect(() => { + if (!opened) { + setDraft(null); + return; + } + setDraft(resolveInitialRef.current()); + }, [opened]); + + useEffect(() => { + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = undefined; + } + }; + }, []); + + const persist = useCallback((value: T) => { + onPersistRef.current(value).catch((err) => { + onErrorRef.current(err); + }); + }, []); + + const onChange = useCallback( + (next: T) => { + if (!opened) return; + setDraft(next); + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => { + timerRef.current = undefined; + persist(next); + }, debounceMs); + }, + [opened, debounceMs, persist], + ); + + const flush = useCallback(() => { + if (!timerRef.current) return; + clearTimeout(timerRef.current); + timerRef.current = undefined; + const value = draftRef.current; + if (openedRef.current && value !== null) { + persist(value); + } + }, [persist]); + + return { draft, onChange, flush }; +} diff --git a/core/react/useEmaIdpLoginState.ts b/core/react/useEmaIdpLoginState.ts new file mode 100644 index 00000000..a9a65393 --- /dev/null +++ b/core/react/useEmaIdpLoginState.ts @@ -0,0 +1,48 @@ +import { useCallback, useEffect, useState } from "react"; +import type { OAuthStorage } from "../auth/storage.js"; +import { + clearEmaIdpSession, + getEmaIdpLoginState, + normalizeIdpIssuer, + type EmaIdpLoginState, +} from "../auth/ema/idpSession.js"; + +export interface UseEmaIdpLoginStateResult { + loginState: EmaIdpLoginState; + refresh: () => Promise; + logout: () => void; +} + +/** + * Read and clear EMA IdP session state for Client Settings UX. + * Pass `active: true` while the settings surface is open to refresh on open. + */ +export function useEmaIdpLoginState( + storage: OAuthStorage, + issuer: string | undefined, + active: boolean, +): UseEmaIdpLoginStateResult { + const normalizedIssuer = issuer ? normalizeIdpIssuer(issuer) : ""; + const [loginState, setLoginState] = useState("none"); + + const refresh = useCallback(async () => { + if (!normalizedIssuer) { + setLoginState("none"); + return; + } + setLoginState(await getEmaIdpLoginState(storage, normalizedIssuer)); + }, [storage, normalizedIssuer]); + + useEffect(() => { + if (!active) return; + void refresh(); + }, [active, refresh]); + + const logout = useCallback(() => { + if (!normalizedIssuer) return; + clearEmaIdpSession(storage, normalizedIssuer); + setLoginState("none"); + }, [storage, normalizedIssuer]); + + return { loginState, refresh, logout }; +} diff --git a/package-lock.json b/package-lock.json index b7b25054..fc02e30d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "pino": "^9.14.0", "react": "^19.2.4", "vite": "^8.0.0", + "yaml": "^2.9.0", "zod": "^4.3.6", "zustand": "^5.0.13" }, @@ -3218,6 +3219,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yoga-layout": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz", diff --git a/package.json b/package.json index 7f1ca18d..abb598aa 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "pino": "^9.14.0", "react": "^19.2.4", "vite": "^8.0.0", + "yaml": "^2.9.0", "zod": "^4.3.6", "zustand": "^5.0.13" }, diff --git a/specification/v2_enterprise_managed_auth.md b/specification/v2_enterprise_managed_auth.md new file mode 100644 index 00000000..ad4e8355 --- /dev/null +++ b/specification/v2_enterprise_managed_auth.md @@ -0,0 +1,885 @@ +# Inspector V2 — Enterprise-Managed Authorization (EMA / XAA) + +### [Brief](README.md) | [V2 Scope](v2_scope.md) | [Servers file](v2_servers_file.md) | EMA / XAA + +Tracks [#1509](https://github.com/modelcontextprotocol/inspector/issues/1509). + +## Summary + +Add support for [Enterprise-Managed Authorization](https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization) (EMA, also referred to as XAA / ID-JAG in client implementations). EMA extends the existing OAuth flow so an enterprise IdP (OIDC) can authenticate the client once; any MCP resource authorization server (AS) configured to trust that IdP can then be accessed with minimal or no user prompting. + +Inspector v2 already has OAuth infrastructure (`core/auth/`, `core/mcp/oauthManager.ts`, guided auth state machine, per-server OAuth fields in `~/.mcp-inspector/mcp.json` — see [Servers file](v2_servers_file.md)). Phases 1–2 (web **quick** connect) are **implemented** in `core/auth/ema/` and documented below. **Guided EMA is deferred** until the product has a guided OAuth UX (web v2 has connect/quick only today; TUI has guided via `AuthTab`). Phase 4 (CLI/TUI quick EMA) remains planned separately. VS Code reference material in the appendix informs remaining work. + +## Normative references + +- [EMA extension spec](https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization) +- [MCP blog: Enterprise-managed auth](https://blog.modelcontextprotocol.io/posts/enterprise-managed-auth/) (announced stable June 18, 2026) +- GitHub issue: [#1509](https://github.com/modelcontextprotocol/inspector/issues/1509) + +## Goals + +- Support EMA for HTTP MCP servers, aligned with the [MCP EMA extension spec](https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization). EMA wire and orchestration live in `core/auth/ema/` — the v1 TypeScript SDK does not expose EMA as a named API. +- **Preserve existing standard OAuth behavior** for non-EMA servers — no regressions to current connect, guided, or configured-credential flows. +- Reuse existing inspector OAuth configuration and secret storage where possible. +- **Guided EMA (deferred):** step-through EMA legs in the UI for debugging — **not in scope for the current #1509 slice.** Deferred until Inspector has a **guided OAuth option in the UX** (web: none today; TUI: `AuthTab` guided/quick/clear). Core APIs (`beginGuidedAuth`, `OAuthFlowState`, etc.) exist; web connect uses quick mode only (`authenticate()`). +- Work across web, CLI, and TUI clients via shared `core/` auth logic. + +## Non-goals + +- v1 or v1.5/main backport (v2 only; see issue label). +- **Guided EMA UI** on web (or any client) before that client exposes **guided standard OAuth** in the UX — not merely core/API support for guided flows. + +## EMA spec and SDK audit + +_Audited June 2026 against the [EMA extension spec](https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization), VS Code's shipped schema, and the TypeScript SDK docs._ + +### Protocol (EMA extension) + +- Extension id: `io.modelcontextprotocol/enterprise-managed-authorization` (stable as of June 2026). +- **Problem solved:** per-server interactive OAuth consent is replaced by enterprise IdP policy — user authenticates to the corporate IdP once; the client obtains an identity assertion and exchanges it for MCP resource tokens without redirecting to each MCP authorization server. +- **Client flow (three legs):** + 1. **IdP SSO** — obtain an OpenID ID Token for the user via OIDC at the enterprise IdP (the MCP protocol also allows SAML assertions; **out of scope for initial EMA implementation**). + 2. **ID-JAG mint** — RFC 8693 token exchange at the IdP: present the ID Token + IdP client credentials → receive an Identity Assertion JWT Authorization Grant (ID-JAG). + 3. **Resource token** — RFC 7523 JWT bearer grant at the MCP resource authorization server: present the ID-JAG + **resource AS** client credentials → receive the MCP access token. +- **No browser redirect to the MCP AS** on the happy path — the MCP AS trusts the IdP-issued assertion. +- Underlying standards: RFC 8693, RFC 7523, RFC 8707 (resource indicators), RFC 9728 (protected resource metadata). + +### TypeScript SDK (implemented) + +Inspector depends on **`@modelcontextprotocol/sdk` v1.x** only (`^1.29.0` in root `package.json`). Standard OAuth and EMA both build on that package — there is **no** `@modelcontextprotocol/client` v2 dependency in the tree today. + +| Concern | Package / module | +| ------- | ---------------- | +| Standard OAuth | v1 `@modelcontextprotocol/sdk/client/auth.js` via `BaseOAuthClientProvider` (`core/auth/providers.ts`) | +| EMA leg 1 (IdP OIDC) | v1 SDK: `startAuthorization`, `exchangeAuthorization`, `discoverAuthorizationServerMetadata` — `core/auth/ema/idpOidc.ts` | +| EMA legs 2–3 (ID-JAG + resource token) | **Local wire** in `core/auth/ema/wire.ts` (RFC 8693 token exchange + RFC 7523 JWT bearer); reuses v1 SDK for AS discovery, `selectClientAuthMethod`, `OAuthTokensSchema` | +| EMA orchestration | `core/auth/ema/emaFlow.ts` — `mintEmaResourceTokens`, silent connect, connect/callback completion | +| EMA 401 / transport | `core/auth/ema/transportProvider.ts` — `EmaTransportOAuthProvider` wraps v1 `BaseOAuthClientProvider`; re-runs legs 2–3 on expiry, IdP redirect on missing ID Token | +| JWT expiry helpers | `core/auth/ema/jwt.ts` — `jwtExpiresAtMs`, `isJwtExpired` (exp claim only; no signature verification) | +| Leg 1 in-flight PKCE key | `core/auth/ema/storage.ts` — `idpOAuthStorageKey(issuer)` → `ema-idp:{issuer}` in per-server `servers` map (storage key only; not an OAuth `state` param prefix) | +| Wire constants | `core/auth/ema/constants.ts` — grant/type URNs + `IDP_OIDC_SCOPES` for leg 1 | + +**EMA is not in the v1 SDK** as a named feature (`CrossAppAccess`, ID-JAG helpers, etc.). Inspector implements the protocol in `core/auth/ema/` instead. + +#### v2 `@modelcontextprotocol/client` (not used; future option) + +The v2 client package (`CrossAppAccessProvider`, `discoverAndRequestJwtAuthGrant`, `exchangeJwtAuthGrant`) implements the same legs 2–3 wire format internally. It is **not** imported anywhere today. Possible future paths: + +| Option | When | +| ------ | ---- | +| **Swap legs 2–3 to v2 Layer-2 helpers** | Replace `wire.ts` calls with `discoverAndRequestJwtAuthGrant` / `exchangeJwtAuthGrant`; keep v1 transport + `EmaTransportOAuthProvider` | +| **Full v2 transport (Path B)** | v2 `StreamableHTTPClientTransport` + `CrossAppAccessProvider` for EMA servers only — requires v2 client stack; does **not** drop into v1 transport today | + +**Why v1 + local wire for the first slice:** single SDK surface, custom `fetchFn` threading, inspector-owned scope/audience resolution (`resourceContext.ts`), and v1 `OAuthClientProvider` adapter for 401 re-auth — without pulling in alpha v2 client bundle surface. + +**Watch items (unchanged):** + +- **401 re-auth** — EMA connections re-run legs 2–3 (leg 1 only if ID Token missing/expired); do not fall back to standard resource OAuth redirect. +- **Future v2 adoption** — when migrating to v2 wholesale, prefer SDK Layer-2 helpers or `CrossAppAccessProvider` over maintaining duplicate wire in `wire.ts`. + +## Configuration + +Config shape matches VS Code (verified against [`mcpConfiguration.ts`](https://github.com/microsoft/vscode/blob/main/src/vs/workbench/contrib/mcp/common/mcpConfiguration.ts)). **Two credential planes** — do not merge them: + +| Plane | Scope | What it identifies | +| ----- | ----- | ------------------ | +| **IdP (client / tenant)** | Global — one per inspector install or session | Inspector's OIDC client at the enterprise IdP (legs 1–2) | +| **Resource AS (per-server)** | Per MCP server entry | OAuth client trusted by the protected resource (leg 3) | + +### 1. Client / tenant IdP credentials (global) + +One IdP registration shared across all EMA-enabled servers. VS Code setting key: `mcp.enterpriseManagedAuth.idp`. Shape: + +```ts +interface EnterpriseManagedAuthIdpConfig { + issuer: string; + clientId: string; + clientSecret: string; +} +``` + +All three fields are the **IdP OIDC client** credentials used in leg 2 (ID-JAG mint). `clientSecret` is sensitive — never persist it in `mcp.json` (server catalog). + +#### Near-term persistence: `client.json` (+ `/api/storage/client`) + +Until **client profiles** (see below) land, install-level client config — starting with `enterpriseManagedAuth` — is stored in **`~/.mcp-inspector/storage/client.json`** (same generic storage API as other install-level blobs): + +| Store | API (web) | On disk | Purpose | +| ----- | --------- | ------- | ------- | +| **`client`** | `GET/POST/DELETE /api/storage/client` | `~/.mcp-inspector/storage/client.json` | **Config** — IdP credentials, later client identity/caps | + +**Do not** put IdP credentials in the OAuth store or per-server `mcp.json`. **Do not** use environment variables for IdP config — all clients read/write the same `client.json` file. + +**Runtime auth state** (standard OAuth tokens, PKCE, guided metadata, **and** EMA runtime state: cached IdP ID Token / refresh token, leg-1 in-flight PKCE under `ema-idp:{issuer}`, and per-server resource tokens tagged `enterpriseManaged: true` when minted via EMA legs 2–3) uses the existing **`OAuthStorage`** interface (`core/auth/storage.ts`) — whatever adapter each client already passes to `InspectorClient`. ID-JAG is **not** cached — legs 2–3 re-mint on each connect or 401 refresh. EMA does **not** mandate a specific backing store; it extends the serialized auth state that adapter already persists. + +No EMA-specific migration of web OAuth persistence is required for the initial ship — sessionStorage-backed web sessions work for quick EMA today. **Follow-up:** shared file-backed OAuth via `RemoteOAuthStorage` → `/api/storage/oauth` so web matches CLI/TUI (see §Follow-up work). + +| Client | Config (`client.json`) | Runtime auth state (`OAuthStorage`) | +| ------ | ---------------------- | ----------------------------------- | +| **Web (today)** | `RemoteStorage` adapter → `/api/storage/client` | `BrowserOAuthStorage` → sessionStorage | +| **Web (target)** | same | `RemoteOAuthStorage` → `/api/storage/oauth` → `~/.mcp-inspector/storage/oauth.json` | +| **CLI / TUI** | `NodeClientStorage` (file adapter) | `NodeOAuthStorage` → `oauth.json` | + +On-disk shape (initial): + +```json +{ + "enterpriseManagedAuth": { + "idp": { + "issuer": "https://idp.example.com", + "clientId": "inspector-idp-client-id", + "clientSecret": "inspector-idp-client-secret" + } + } +} +``` + +When the user turns off **Enable** in Client Settings, credentials are retained but EMA is inactive install-wide: + +```json +{ + "enterpriseManagedAuth": { + "enabled": false, + "idp": { + "issuer": "https://idp.example.com", + "clientId": "inspector-idp-client-id", + "clientSecret": "inspector-idp-client-secret" + } + } +} +``` + +`isEnterpriseManagedAuthEnabled()` / `getActiveEnterpriseManagedAuthIdp()` in `core/client/types.ts` treat `enabled: false` as inactive while preserving the IdP fields for re-enable without re-entry. + +Later fields (client identity, capability toggles) extend this document until client profiles replace it. + +Core provides: + +- Types: `ClientConfig`, `EnterpriseManagedAuthIdpConfig` in `core/client/types.ts` (imported by `core/mcp/types.ts` for server/oauth shapes). +- Load/save helpers with JSON parse/validate — no `process.env` reads inside `InspectorClient`. +- File path: `getStoreFilePath(getDefaultStorageDir(), "client")` (reuse `store-io.ts` primitives). + +Tests write fixture `client.json` files directly or via `/api/storage/client` in integration tests (same as oauth test helpers). + +#### Wiring into `InspectorClient` + +Pass IdP config explicitly on the options surface — do **not** put it inside per-server `options.oauth` (that block already means resource OAuth client credentials). + +Near-term shape on `InspectorClientOptions`: + +```ts +/** Active IdP credentials — omitted when install-level EMA is disabled. */ +enterpriseManagedAuth?: { idp: EnterpriseManagedAuthIdpConfig }; + +/** + * Full install-level EMA config from client.json (including when disabled). + * Used to produce friendly errors when a server expects EMA but IdP is inactive. + */ +installEnterpriseManagedAuth?: ClientConfig["enterpriseManagedAuth"]; +``` + +Callers load `client.json` once at session startup, then pass into each `InspectorClient` construction: + +```ts +const clientConfig = await loadClientConfig(); // client.json +new InspectorClient(serverConfig, { + ...opts, + ...(getActiveEnterpriseManagedAuthIdp(clientConfig) && { + enterpriseManagedAuth: { idp: getActiveEnterpriseManagedAuthIdp(clientConfig)! }, + }), + ...(clientConfig.enterpriseManagedAuth && { + installEnterpriseManagedAuth: clientConfig.enterpriseManagedAuth, + }), +}); +``` + +**Issuer validation:** `parseHttpUrl()` in `core/auth/utils.ts` validates IdP issuer URLs (and other HTTP URLs in EMA paths) with clear, user-facing error labels — e.g. rejects `https;//idp.example.com` at connect time and during `client.json` parse. + +Future: nest under **`InspectorClientProfile`** loaded from the profile store; `client.json` migrates into that mechanism. + +**IdP session state** (cached ID Token from leg 1, optional refresh token, optional `idTokenExpiresAt`) lives in the **OAuth store** via `OAuthStorage`. Use a **store-root `idpSessions` map keyed by issuer** (global across servers — one IdP per install), separate from per-server `servers[url]` resource OAuth state. Leg-1 in-flight PKCE/metadata uses synthetic server key `ema-idp:{issuer}` in the `servers` map: + +```ts +interface ServerOAuthState { + // ... existing fields ... + enterpriseManaged?: boolean; // true when resource tokens came from EMA legs 2–3 +} + +interface IdpSessionState { + idToken?: string; + refreshToken?: string; + idTokenExpiresAt?: number; // epoch ms +} + +interface OAuthStoreState { + servers: Record; // per-server resource OAuth + leg-1 PKCE at ema-idp:{issuer} + idpSessions?: Record; // issuer → IdP session cache +} +``` + +Extend `OAuthStorage` / store methods accordingly. IdP **credentials** stay in `client.json`; only runtime tokens belong in `idpSessions`. Per-server `ServerOAuthState` may include `enterpriseManaged: true` when EMA legs 2–3 persist resource tokens (`saveTokens(..., { enterpriseManaged: true })`). **Sign out** clears `idpSessions` for the configured issuer and removes tagged EMA resource entries from the shared OAuth blob — standard OAuth server entries are not cleared. All adapters (`BrowserOAuthStorage`, `NodeOAuthStorage`, `RemoteOAuthStorage`) serialize the same extended shape through their existing backends. + +#### Later: client profiles + +Inspector is designing a **client profile** mechanism for install-level client configuration (identity, capabilities, IdP/EMA settings, etc.). That work is not settled yet. When it ships: + +- Migrate `client.json` content into the profile store (`clientSecret` in the OS keychain, not plaintext). +- `client.json` becomes legacy or an export format. + +Web **Client Settings** (see §UX) is the near-term editor for IdP credentials; client profiles may subsume or extend that dialog later. + +Near-term IdP config can be edited in the **web Client Settings** dialog (primary path), by hand in `client.json`, via **`POST /api/storage/client`** (tests/automation), or through the file adapters on CLI/TUI — no env-var path. + +### 2. Per-server MCP config (`~/.mcp-inspector/mcp.json`) + +Extend the existing per-server `oauth` block already used for standard OAuth ([Servers file](v2_servers_file.md)). When `enterpriseManaged` is true, the server-level OAuth fields mean **resource authorization server** credentials, not IdP credentials: + +```json +{ + "mcpServers": { + "my-enterprise-server": { + "url": "https://mcp.example.com/mcp", + "oauth": { + "enterpriseManaged": true, + "clientId": "resource-as-client-id", + "clientSecret": "resource-as-client-secret", + "scopes": "tools:read tools:execute" + } + } + } +} +``` + +| Field | When `enterpriseManaged: false` | When `enterpriseManaged: true` | +| ----- | ------------------------------- | ------------------------------ | +| `oauth.clientId` | OAuth client id (existing behavior) | **Resource AS** client id trusted by the protected resource | +| `oauth.clientSecret` | OAuth client secret (existing behavior) | **Resource AS** client secret (keychain-backed, same as today) | +| `oauth.scopes` | OAuth scopes (existing behavior) | Scopes for the resource token request | +| `oauth.enterpriseManaged` | omitted / `false` | `true` — route to EMA flow instead of standard authorization-code OAuth | + +Types to extend in `core/mcp/types.ts`: + +- `StoredMCPServer.oauth.enterpriseManaged?: boolean` +- `InspectorServerSettings` — lift `enterpriseManaged` alongside existing flat `oauthClientId` / `oauthClientSecret` / `oauthScopes` fields. + +Normalizers, server list read/write, and API wire shape follow the same patterns as existing OAuth fields ([Servers file](v2_servers_file.md)). + +### UX (near-term) + +#### Per-server: `enterpriseManaged` (in scope for #1509) + +Add **`enterpriseManaged`** to server settings data and surface it in the web UI: + +- **Where:** `ServerSettingsForm` OAuth section (`clients/web/src/components/groups/ServerSettingsForm/`) — same panel as existing OAuth client id, client secret, and scopes. +- **Control:** checkbox **"Enterprise-managed authorization"**, with help text: connect via the configured enterprise IdP instead of interactive OAuth to the MCP authorization server; OAuth fields below are resource authorization server credentials. +- **Behavior:** toggling updates `InspectorServerSettings.enterpriseManaged` and persists to `mcp.json` under `oauth.enterpriseManaged` on save. When unchecked, standard OAuth behavior is unchanged. +- **Visibility:** OAuth and EMA apply to remote HTTP transports (SSE / streamable-http) only. **Implemented:** the entire **OAuth Settings** accordion section is hidden for stdio servers (`isOAuthCapableServerType` in `core/mcp/config.ts`; `ServerSettingsForm` receives `serverType` from `ServerSettingsModal` / `App.tsx`). Stdio entries may still carry legacy `oauth` fields on disk; they are not used at connect time. + +CLI/TUI can read/write `enterpriseManaged` via catalog/config file without dedicated UX in the first slice; web form is the primary editor. + +#### Client / IdP settings (web — implemented) + +Install-level IdP credentials are edited in **Client Settings**, separate from per-server **Server Settings**. Same modal pattern as server settings: collapsible accordion sections, controlled draft, debounced persist on change, flush on close. + +- **Entry point:** gear icon in the top bar (`ViewHeader`), immediately left of the theme toggle. Available connected and disconnected. +- **Components:** `ClientSettingsModal` + `ClientSettingsForm` (`clients/web/src/components/groups/ClientSettingsModal/`, `ClientSettingsForm/`). Storybook: `Groups/ClientSettingsModal`. +- **Section (initial):** **Enterprise-Managed Authorization** + - **Enable** checkbox — when off, persisted config keeps `enterpriseManagedAuth.idp` but sets `enabled: false` so credentials are not lost; when on (or `enabled` omitted), EMA is active install-wide. + - When enabled: **Issuer**, **Client ID**, **Client secret** (Inspector's registration with the enterprise IdP). + - Shows the OAuth **redirect URI** to register with the IdP (`{origin}/oauth/callback`), derived from `window.location.origin`. +- **Persistence:** `POST /api/storage/client` via `saveClientConfigRemote` / `parseClientConfig` (`core/client/remote.ts`, `core/client/config-parse.ts`). Same on-disk shape as hand-edited `client.json` (`enterpriseManagedAuth.idp`, optional `enabled`). +- **Runtime wiring:** `App.tsx` loads config at startup (`loadClientConfigRemote`) and passes **active** IdP via `getActiveEnterpriseManagedAuthIdp()` plus full install config via `installEnterpriseManagedAuth` into each `InspectorClient`. After a successful save, in-memory `clientConfig` updates so the next connect uses new IdP values without a full page reload. +- **Draft hook:** `useClientSettingsDraft` (`core/react/`, alongside `useSettingsDraft`) — mirrors server settings draft behavior; skips debounced persist while EMA is enabled but issuer or client id is still empty (avoids validation errors mid-edit). +- **IdP sign-in state (implemented):** when an issuer is configured, **Client Settings** shows install-level IdP session status derived from `OAuthStorage.idpSessions[issuer]`: + - **Signed in** — cached ID Token is present and not expired (`core/auth/ema/idpSession.ts` + `isJwtExpired`). + - **Session expired** — cached ID Token exists but is expired (next EMA connect will prompt for IdP login). + - **Not signed in** — no cached IdP session (next EMA connect will open IdP login). + - State is refreshed when the modal opens (`useEmaIdpLoginState` in `core/react/`). +- **Sign out (implemented):** **Sign out** button when a cached IdP session exists (signed in or expired). Calls `clearEmaIdpSession(storage, issuer)` in **`core/auth/ema/idpSession.ts`** — a single core entry point; clients do **not** pass a server catalog. It: + 1. Clears `idpSessions[issuer]` + 2. Clears leg-1 in-flight state at `servers["ema-idp:{issuer}"]` + 3. Calls `OAuthStorage.clearEnterpriseManagedResourceServers()` — scans the shared OAuth blob and removes `servers[url]` entries where `enterpriseManaged === true` (set when EMA legs 2–3 saved tokens via `saveTokens(url, tokens, { enterpriseManaged: true })` in `emaFlow.ts` / `EmaTransportOAuthProvider`). Standard OAuth entries in the same blob are **not** cleared. Untagged legacy EMA tokens (saved before tagging landed) are not removed until the next EMA connect re-saves with the tag. + The next connect to a cleared EMA server has no cached access token, so a **401** triggers leg 1 (IdP login) via `authenticate()`. +- **Web OAuth store singleton:** web uses `getBrowserOAuthStorage()` (`core/auth/browser/storage.ts`) so Client Settings sign-out updates the same in-memory Zustand store as the active `InspectorClient` (not a separate `BrowserOAuthStorage` instance per consumer). + +**Future (not implemented):** explicit **Sign out** may additionally invoke the IdP's OIDC **end-session** / logout endpoint (RP-initiated logout) so the IdP SSO cookie is cleared — not just inspector-local IdP/resource token state. Today sign-out is **local-only** (clear `idpSessions`, tagged EMA resource entries, and leg-1 PKCE); the IdP may still treat the browser as signed in and skip the login prompt on the next authorize redirect until the IdP session expires or the user signs out at the IdP. + +**Copy / UX notes:** User-facing text explains enterprise-managed authorization in plain language (org IdP sign-in vs each server's OAuth login). It does **not** reference protocol jargon (`leg 1`, resource AS, etc.) or storage filenames in the form. Per-server EMA enablement and MCP-server OAuth credentials remain in **Server Settings** (see below). + +Alternative paths (CLI/TUI, automation, hand-edit): + +- Hand-edit `~/.mcp-inspector/storage/client.json` +- **`POST /api/storage/client`** from tests or scripts + +Do not store IdP credentials in the OAuth store — only **runtime OAuth/EMA state** (tokens, PKCE, IdP session cache, etc.) belongs there, via whichever `OAuthStorage` adapter the client uses. + +CLI/TUI do not yet have a dedicated Client Settings dialog; they read/write `client.json` via file adapters (Phase 4). + +#### Connect errors when IdP is missing or disabled (web — implemented) + +When the user connects to a server with `oauth.enterpriseManaged: true` but install-level IdP is not active, `OAuthManager.getEmaFlowConfig()` throws `EmaClientNotConfiguredError` (`core/auth/ema/clientConfigError.ts`) with a reason-specific message: + +| Reason | When | User-facing guidance | +| ------ | ---- | -------------------- | +| `not_configured` | No IdP block, or issuer/client id missing | Open Client Settings, enable Enterprise IdP, set issuer, client ID, and secret | +| `disabled` | `enterpriseManagedAuth.enabled === false` with IdP credentials retained | Enterprise IdP is turned off in Client Settings — re-enable and retry | + +`installEnterpriseManagedAuth` on `InspectorClientOptions` supplies the full install config so the error can distinguish disabled from never configured. Web surfaces this in connect, OAuth auth, and post-callback reconnect paths (`App.tsx`): toast title `Cannot connect to ""`, message from the error, `autoClose: false` (stays until dismissed). + +#### Connection Info — OAuth snapshot (web — implemented) + +`InspectorClient.getOAuthState()` (`core/auth/connection-state.ts`) assembles `OAuthConnectionState` from storage and config only — no network. Used by **Connection Info** (`ConnectionInfoContent`, `oauthDetailsFromConnectionState.ts`) for the active server: + +- **Protocol** — `standard` or `ema` +- **Authorized** — whether a usable access token exists in storage +- **Client ID** — preregistered or dynamic registration source +- **IdP session** (EMA only) — `none` / `logged_in` / `expired` from `idpSessions[issuer]` +- **Auth URL** — cached from an in-flight or completed quick OAuth flow (`OAuthFlowState`), when present +- **Scopes** — configured vs granted +- **Access token** — `OAuthAccessTokenField`: copy (raw), decode JWT in place (`core/auth/ema/jwt.ts` helpers); multi-line wrap with segment-aware breaking for JWTs + +In-flight guided/quick flow state uses `OAuthFlowState` / `getOAuthFlowState()` / `getOAuthFlowStep()` (renamed from the earlier `AuthGuidedState` names). `getOAuthState()` is the persisted connection snapshot; flow state is separate and ephemeral. + +### Leg 1 — IdP OIDC (settled) + +Leg 1 is **OIDC authorization-code login** against the enterprise IdP using credentials from `client.json` (`issuer`, `clientId`, `clientSecret`). Discover IdP endpoints from the issuer (OpenID Provider Metadata). Exchange the authorization code at the IdP token endpoint; persist the resulting **ID Token** (and refresh token if issued) in `OAuthStorage`. + +**Reuse existing OAuth client infrastructure** — leg 1 is not a new auth stack. It uses the same per-client mechanisms already wired for standard MCP resource OAuth (`OAuthManager`, redirect/callback, PKCE, token exchange). Only the target changes: IdP endpoints and `client.json` credentials instead of the resource authorization server and per-server `oauth.*` fields. + +| Client | Leg 1 mechanism (same stack as standard OAuth in that client) | +| ------ | ------------------------------------------------------------- | +| **Web** | Browser redirect via `navigation`; callback at `/oauth/callback`; `InspectorClient.completeOAuthFlow(code)`; `BrowserOAuthStorage` | +| **TUI / CLI** | `OAuthCallbackServer` opens local callback; system browser for authorize URL; `completeOAuthFlow(code)`; `NodeOAuthStorage` | + +**Silent connect:** if a valid cached ID Token exists for the configured issuer, skip the interactive redirect and proceed to leg 2. + +**SAML:** defined in the MCP EMA protocol but **out of scope** for initial EMA implementation. + +### Implementation notes (leg 1 + OAuthManager) + +Leg 1 reuses the existing OAuth redirect/callback/PKCE machinery but **targets the IdP**, not the resource authorization server. `OAuthManager` today discovers resource AS metadata and builds authorize URLs against the resource AS — the EMA branch must parameterize the same flow with IdP endpoints (from issuer OpenID Provider Metadata) and IdP credentials from `client.json`. + +**Web callback disambiguation:** web uses a single `/oauth/callback` path for both resource OAuth and IdP OIDC. Pending server id is stashed in sessionStorage before redirect; **protocol** (standard vs EMA) comes from `oauth.enterpriseManaged` on that server, not from the OAuth `state` query param. The `state` param carries **execution** (`quick` / `guided`) plus `authId` for CSRF and fetch-log restore (`{execution}:{authId}`). + +**Auth axes (orthogonal):** `AuthProtocol` (`standard` | `ema`) from server/client config; `AuthExecution` (`quick` | `guided`) for SDK one-shot vs state-machine stepping. Guided EMA would be `ema` + `guided` — **deferred** (see §Guided mode). + +**Scope for leg 2:** `resourceContext.resolveEmaScopes()` — prefer configured per-server `oauth.scopes`; else join `resourceMetadata.scopes_supported` when present; else omit. Do **not** fall back to IdP `scopes_supported`. + +**401 re-auth:** on EMA connections, re-run legs 2–3 (and leg 1 only if the cached ID Token is missing or expired). Do not fall back to standard resource authorization-code OAuth. + +**EMA resource token tagging + sign-out:** per-server `oauth.enterpriseManaged` in `mcp.json` is **config** (routing); the OAuth store does not read the catalog on sign-out. Instead, when EMA legs 2–3 persist a resource access token, the store tags that entry (`ServerOAuthState.enterpriseManaged: true` via `SaveTokensOptions`). Sign-out uses that tag to find and clear EMA resource state inside the same `OAuthStorage` blob without the client enumerating MCP servers. This avoids clearing standard OAuth servers and avoids clearing EMA catalog entries that were never connected. + +## Inspector mapping + +When connecting to an HTTP server with `oauth.enterpriseManaged: true`: + +1. Validate protected resource metadata (`resource`, `authorization_servers`). +2. Branch from standard OAuth (VS Code: `mainThreadMcp` — see appendix §A.3). +3. Load tenant IdP config from `client.json` (via `InspectorClientOptions.enterpriseManagedAuth`, loaded at session startup). +4. Obtain or reuse IdP ID Token (leg 1 — `idpOidc.ts`, v1 SDK OIDC redirect/callback). +5. Run leg 2 — `wire.exchangeIdJag()` (RFC 8693 at IdP) → ID-JAG (scopes from `resourceContext.ts`; see §Implementation notes). +6. Run leg 3 — `wire.redeemIdJagForAccessToken()` (RFC 7523 at resource AS) with per-server `oauth.clientId` / `oauth.clientSecret` → MCP access token. +7. Connect with the resource access token via the existing v1 transport; persist tokens via `OAuthStorage.saveTokens(serverUrl, tokens, { enterpriseManaged: true })`. + +When `enterpriseManaged` is absent or false, existing standard OAuth path is unchanged. + +**Client capability:** declare EMA support in the MCP `initialize` request when connecting EMA-enabled servers: + +```json +{ + "capabilities": { + "extensions": { + "io.modelcontextprotocol/enterprise-managed-authorization": {} + } + } +} +``` + +Inspector touchpoints to extend: + +- `core/client/types.ts` — `ClientConfig`, `EnterpriseManagedAuthIdpConfig`, `getActiveEnterpriseManagedAuthIdp`, `isEnterpriseManagedAuthEnabled` +- `core/mcp/types.ts` — `enterpriseManaged` on server oauth shape; `InspectorClientOptions.enterpriseManagedAuth`, `installEnterpriseManagedAuth` +- `core/client/` (or `core/storage/`) — `loadClientConfig` / `saveClientConfig`; `NodeClientStorage`; remote adapter for `/api/storage/client` +- `core/auth/utils.ts` — `parseHttpUrl` (issuer and EMA URL validation) +- `core/auth/connection-state.ts` — `buildOAuthConnectionState`, `OAuthConnectionState` +- `core/auth/storage.ts` + `core/auth/store.ts` — `OAuthStorage` / `OAuthStoreState`: store-root `idpSessions`; `ServerOAuthState.enterpriseManaged` tag; `SaveTokensOptions`; `clearEnterpriseManagedResourceServers()`; all adapters pick up the extended shape automatically +- `core/auth/ema/` — `idpOidc.ts` (leg 1), `wire.ts` (legs 2–3), `emaFlow.ts` (orchestration + tagged `saveTokens`), `transportProvider.ts` (401 re-auth + tagged `saveTokens`), `resourceContext.ts`, `idpSession.ts` (`getEmaIdpLoginState`, `clearEmaIdpSession`), `jwt.ts`, `storage.ts`, `constants.ts`, `clientConfigError.ts` (`EmaClientNotConfiguredError`) +- `core/mcp/oauthManager.ts` — branch on `enterpriseManaged`; EMA quick connect via `emaFlow`; `getOAuthState()`; `createOAuthProvider()` returns `EmaTransportOAuthProvider` for EMA servers (401 re-auth); standard quick/guided unchanged +- `core/mcp/inspectorClient.ts` — declare EMA extension in `initialize` capabilities when `enterpriseManaged`; `getOAuthState()` +- `core/react/useEmaIdpLoginState.ts` — Client Settings IdP session status; calls `clearEmaIdpSession` on sign-out (no catalog wiring) +- `core/auth/browser/storage.ts` — `getBrowserOAuthStorage()` singleton (web) +- `clients/web` — **Client Settings** modal; **Connection Info** OAuth snapshot; friendly EMA connect toasts; load client config at startup; `ServerSettingsForm` OAuth section (HTTP/SSE only); web callback flow tagging for IdP vs resource OAuth. **Guided EMA UI: deferred** until guided OAuth is a product option in the UX. +- `clients/cli`, `clients/tui` — load `client.json` at startup; pass into `InspectorClient`; leg 1 via same `OAuthCallbackServer` + `NodeOAuthStorage` stack as TUI standard OAuth today + +### Guided mode (deferred) + +**Status: deferred.** Guided EMA is out of scope for the current #1509 delivery. It resumes only after Inspector exposes **guided standard OAuth** as a user-facing option (step-through or run-to-completion), not merely as `InspectorClient` / `OAuthManager` APIs used in tests and TUI. + +**Why:** Web v2 has no guided OAuth UI. Connect uses **quick** mode only — `App.tsx` calls `InspectorClient.authenticate()` on 401, not `beginGuidedAuth()` / `runGuidedAuth()`. Guided OAuth exists in **core** and in the **TUI** (`AuthTab` — guided / quick / clear), but there is no web affordance to start or step through a guided flow. Building guided EMA on web without guided OAuth would add dead-end APIs with no entry point. + +**When unblocked**, EMA guided mode would extend the existing guided OAuth model (`OAuthFlowState` / `OAuthStep` in `core/auth/types.ts`). Leg 1 reuses the same redirect/callback/code-exchange steps as standard guided OAuth, pointed at the IdP. Legs 2–3 add EMA-specific steps: + +- **IdP OIDC login** (authorization redirect + code exchange — same machinery as guided resource OAuth; skippable when cached ID Token is valid) +- **ID-JAG exchange** (leg 2) +- **Resource metadata / token endpoint discovery** (if not already resolved) +- **Resource token redemption** (leg 3) + +On silent success, skip interactive IdP OIDC steps when a valid cached ID Token exists; still run legs 2–3. + +**Possible interim path:** implement guided EMA on **TUI first** (guided OAuth UX already exists there) without waiting for web guided OAuth — that would be an explicit product decision, not the default plan. + +### Clients + +| Client | Notes | +| ------ | ----- | +| Web | **First** — implement and test EMA here; **Client Settings** for IdP + per-server EMA checkbox (HTTP/SSE only); **quick connect only** (no guided OAuth UI yet — see §Guided mode); leg 1 via browser redirect + `/oauth/callback` (same path as standard OAuth; disambiguate pending flow) | +| TUI | Follow web; extend existing guided/quick OAuth (`clients/tui/src/App.tsx`) for EMA legs 2–3; leg 1 via existing `OAuthCallbackServer` flow | +| CLI | Follow web/TUI; same Node OAuth stack as TUI when interactive IdP login is required | + +### Implementation order (settled) + +**Web first** for development and testing. Core types, `OAuthManager` EMA branch, and `OAuthStorage` extensions live in `core/` and are client-agnostic; the shipped #1509 path is web **quick** connect plus Client Settings / Server Settings UX. Guided EMA is deferred until guided OAuth is a UX option (see §Guided mode). TUI and CLI quick EMA remain Phase 4. + +Design decisions for EMA are complete. Remaining work is the phased plan and checklist below (client profiles are a separate, later track — not a blocker for `#1509`). + +### Phased implementation plan + +#### Phase 1 — Foundation (core, no EMA connect yet) + +1. Add types: `ClientConfig`, `EnterpriseManagedAuthIdpConfig`, `enterpriseManaged`, `InspectorClientOptions.enterpriseManagedAuth`. +2. Add `enterpriseManaged` to server types, normalizers, and `mcp.json` read/write. +3. Implement `client.json` load/save (`NodeClientStorage` + remote adapter for `/api/storage/client`). +4. Extend `OAuthStorage` / store schema: store-root `idpSessions`; `ServerOAuthState.enterpriseManaged` tag; `clearEnterpriseManagedResourceServers()`. +5. Implement EMA wire + orchestration in `core/auth/ema/` (v1 SDK for discovery/OIDC; local `wire.ts` for legs 2–3). + +#### Phase 2 — Web EMA connect (first end-to-end path) + +6. Wire web to load `client.json` at session startup → active IdP + `installEnterpriseManagedAuth`. +7. Web UX: **Client Settings** dialog for IdP (`ClientSettingsModal` / `/api/storage/client`); IdP sign-in state + sign-out; `enterpriseManaged` checkbox in `ServerSettingsForm` OAuth section; friendly connect errors; Connection Info OAuth snapshot. +8. `OAuthManager` EMA branch: leg 1 (`idpOidc.ts`) → legs 2–3 (`wire.ts` / `emaFlow.ts`) → tagged `saveTokens(..., { enterpriseManaged: true })` → connect. +9. `InspectorClient`: declare EMA extension in `initialize` when `enterpriseManaged`. EMA 401 re-auth via `EmaTransportOAuthProvider` in `OAuthManager.createOAuthProvider()` (re-run legs 2–3, not resource OAuth redirect). +10. Web callback: disambiguate IdP OIDC vs resource OAuth pending flows at `/oauth/callback`. + +#### Phase 3 — Guided EMA + tests (deferred) + +**Deferred** until a client exposes guided OAuth in the UX (prerequisite for guided EMA). Not part of the current #1509 close-out. + +11. *(deferred)* Guided EMA: leg 1 reuses guided OAuth redirect/code-exchange steps; add legs 2–3 as new steps. +12. Tests: EMA wire/orchestration unit tests and mock IdP/AS integration. **Implemented:** `parseClientConfig`, `clientSettingsValues`, `idpSessions` storage, `enterpriseManaged` server-list round-trip, OAuth `state` parsing, `clientConfigError`, `connection-state`, OAuthManager EMA not-configured paths, keychain secret migration on server-id rename (`servers-route.test.ts`), **Phase 3b automated tests** (§Phase 3b test plan — Layers 1–3). **Manual staging:** live xaa.dev quick EMA verified (§Staging validation). **Optional follow-up:** `RemoteOAuthStorage` EMA E2E variant, 401 re-auth stretch case. + +#### Phase 4 — Other clients (after web works) + +13. Wire TUI/CLI to load `client.json` and pass `enterpriseManagedAuth` into `InspectorClient`. EMA sign-out (`clearEmaIdpSession`) is already client-agnostic in `core/` — Phase 4 clients can call it without catalog enumeration once Client Settings / logout UX lands. +14. TUI/CLI guided/quick OAuth extensions for EMA legs 2–3. + +## Implementation checklist + +### Design (complete) + +- [x] Audit EMA spec and TypeScript SDK surfaces +- [x] Finalize inspector config shape (server + tenant IdP) +- [x] Choose near-term implementation: v1 `@modelcontextprotocol/sdk` + local EMA wire (`core/auth/ema/`), v1 transport unchanged +- [x] Choose near-term persistence: `client.json` (IdP config) + extend existing `OAuthStorage` per client (OAuth/EMA runtime state) +- [x] Leg 1 mechanism: OIDC authorization-code flow via existing per-client OAuth stack (web redirect/callback; TUI/CLI `OAuthCallbackServer`) +- [x] Implementation order: web client first for EMA development and testing + +### Phase 1 — Foundation + +- [x] Implement EMA core: `core/auth/ema/` (leg 1 via v1 SDK OIDC; legs 2–3 via `wire.ts`; orchestration in `emaFlow.ts`) +- [x] Add types: `ClientConfig`, `EnterpriseManagedAuthIdpConfig`, `enterpriseManaged`, `InspectorClientOptions.enterpriseManagedAuth` +- [x] Add `enterpriseManaged` to server types, normalizers, and `mcp.json` read/write +- [x] Implement `client.json` load/save (`NodeClientStorage` + `/api/storage/client` remote adapter) +- [x] Extend `OAuthStorage` / OAuth store schema: `idpSessions`, `ServerOAuthState.enterpriseManaged`, `clearEnterpriseManagedResourceServers()` + +### Phase 2 — Web EMA connect + +- [x] Wire web to load client config at session startup → active IdP + `installEnterpriseManagedAuth` +- [x] Web UX: **Client Settings** dialog for IdP credentials (`ClientSettingsModal`, gear icon in header, `/api/storage/client`) +- [x] Web UX: IdP sign-in state + **Sign out** in Client Settings (`idpSession.ts`, `useEmaIdpLoginState`, `clearEmaIdpSession` + tagged resource cleanup) +- [x] Web UX: `enterpriseManaged` checkbox and OAuth settings section in `ServerSettingsForm` (hidden for stdio) +- [x] Web UX: friendly connect errors when EMA server but IdP missing/disabled (`EmaClientNotConfiguredError`, persistent toast) +- [x] Web UX: Connection Info OAuth snapshot (`getOAuthState`, access token copy/decode) +- [x] Integrate EMA routing in `OAuthManager` (branch on `enterpriseManaged`; leg 1 IdP OIDC; legs 2–3 via `wire.ts` / `emaFlow.ts`) +- [x] Declare EMA extension in `initialize` when connecting with `enterpriseManaged` +- [x] EMA 401 re-auth: re-run legs 2–3 (leg 1 only if ID Token expired/missing) via `EmaTransportOAuthProvider` +- [x] Web: disambiguate IdP vs resource OAuth at `/oauth/callback` (shared callback; `completeOAuthFlow` branches on `enterpriseManaged`; protocol from server config, execution in OAuth `state` prefix) + +### Phase 3 — Guided EMA (deferred) + +- [ ] **Blocked on product:** guided OAuth UX — user-facing step-through or run-to-completion (web: not implemented; TUI: `AuthTab`) +- [ ] *(deferred)* Guided EMA: legs 2–3 as new steps; leg 1 reuses guided OAuth redirect/code-exchange steps +- [ ] *(deferred)* Tests: guided EMA UI steps + +### Phase 3b — Integration tests + +- [x] **Manual staging validation** — full quick EMA connect against live xaa.dev (see §Staging validation). Confirms legs 1–3 and web UX outside CI. +- [x] **Automated integration tests** — mock IdP + mock resource AS + composable protected-resource server (see §Phase 3b test plan). Live xaa.dev is **not** required for CI. +- [ ] *(optional)* `RemoteOAuthStorage` EMA E2E variant — same happy path via `/api/storage/oauth` (shared-storage follow-up). +- [ ] *(optional)* 401 re-auth stretch — invalidate resource token, assert legs 2–3 re-run. + +### Phase 4 — Other clients + +- [ ] Wire TUI/CLI to load client config → `InspectorClientOptions.enterpriseManagedAuth` +- [ ] TUI/CLI guided/quick OAuth extensions for EMA + +### Later + +- [ ] **Guided EMA** (web or cross-client) — after guided OAuth is a UX option; see §Guided mode (deferred) +- [ ] **Remove Zustand from OAuth persistence** — direct read/write of OAuth blob; see §Follow-up work +- [ ] **Web shared OAuth store** — `RemoteOAuthStorage` / `oauth.json` for web + CLI + TUI parity; see §Follow-up work +- [ ] Client profile persistence (migrate from `client.json`; may extend or replace web Client Settings) +- [ ] Optional: adopt `@modelcontextprotocol/client` v2 Layer-2 helpers for legs 2–3 (replace `wire.ts`) or full v2 transport for EMA +- [ ] Optional: IdP **end-session** / RP-initiated logout on explicit **Sign out** (today sign-out clears inspector-local IdP session and tagged EMA resource tokens only; IdP browser SSO may remain active) + +--- + +## Staging validation (manual — verified) + +Full end-to-end **quick EMA** has been exercised manually against live **xaa.dev** (June 2026). This validates the #1509 web path outside CI. + +### Topology + +| Role | Service | Inspector config | +| ---- | ------- | ---------------- | +| **Enterprise IdP** (legs 1–2) | `https://idp.xaa.dev` | **Client Settings** — issuer, requesting-app client id/secret | +| **Resource authorization server** (leg 3) | `https://auth.resource.xaa.dev` (xaa.dev resource AS) | Per-server **Server Settings** — resource test client id/secret, EMA enabled, scopes | +| **Protected MCP resource** | Composable test server (`test-servers/configs/xaa-ema-http.json`) — `protected-resource` mode, local streamable-http | Server catalog entry URL must match registered resource identifier (`http://localhost:8080/` — use `localhost`, not `127.0.0.1`) | + +Registration on xaa.dev: composable test server registered as a **resource server**; its AS points at xaa.dev (`authorizationServers` / JWKS in `xaa-ema-http.json`). IdP and resource AS are distinct hosts on xaa.dev. + +### Flow exercised + +1. Client Settings — Enterprise IdP configured and enabled. +2. Server Settings — `enterpriseManaged` on; resource AS client credentials from xaa.dev resource registration. +3. Connect (quick) — IdP login when needed → ID-JAG mint → resource access token → MCP `initialize` with EMA extension capability. +4. Reconnect / 401 — silent or re-auth paths; Connection Info shows EMA OAuth snapshot. + +### Known-good fixture + +`test-servers/configs/xaa-ema-http.json` — protected-resource OAuth pointing at `https://auth.resource.xaa.dev`. Use with the composable test server CLI; align Inspector server URL and xaa.dev resource identifier. + +--- + +## Follow-up work (not blocking #1509 close-out) + +These items came out of EMA staging and apply beyond EMA. They are **not** required to ship quick EMA on web but should be tracked. + +### Remove Zustand from OAuth persistence + +Today `OAuthStorage` is backed by a **Zustand** `persist` store (`core/auth/store.ts`) with adapters for sessionStorage (`BrowserOAuthStorage`), file (`NodeOAuthStorage` via `file-storage.ts`), and HTTP (`RemoteOAuthStorage` via `remote-storage.ts`). The Zustand layer wraps a simple `{ servers, idpSessions }` blob and adds: + +- `{ state, version }` envelope on file/remote adapters (see [Servers file](v2_servers_file.md) — intentionally avoided for `mcp.json`) +- A second in-memory copy and async persist semantics that complicate sign-out, callback rebuild, and multi-consumer sharing (`getBrowserOAuthStorage()` singleton exists partly to paper over this) + +**Direction:** refactor `OAuthStorage` implementations to read/write the serialized shape **directly** (file I/O, `/api/storage/oauth`, or in-memory for tests) without Zustand. Keep the `OAuthStorage` interface stable for `InspectorClient` / `OAuthManager`. + +### Shared file-backed OAuth state (web + CLI + TUI) + +Today: + +| Client | OAuth runtime store | +| ------ | ------------------- | +| **Web** | `BrowserOAuthStorage` → **sessionStorage** (tab-local; lost on new tab / callback page load relies on same tab) | +| **CLI / TUI** | `NodeOAuthStorage` → `~/.mcp-inspector/storage/oauth.json` | + +`RemoteOAuthStorage` already exists (`core/auth/remote/storage-remote.ts`) and talks to **`GET/POST/DELETE /api/storage/oauth`** on the local Hono backend — the same generic storage API that persists to disk under `~/.mcp-inspector/storage/`. Integration tests use it (`inspectorClient-oauth-remote-storage-e2e.test.ts`). + +**Direction:** wire the **web app** through `RemoteOAuthStorage` (via `environmentFactory.ts` / `App.tsx`) instead of `BrowserOAuthStorage`, so web, CLI, and TUI share one **`oauth.json`** when using the default local backend. Benefits: IdP session and EMA resource tokens survive browser refresh; sign-out in Client Settings and connect use the same store without a sessionStorage singleton; EMA testing matches how CLI/TUI will behave in Phase 4. + +**Migration:** one-time import or “start fresh” for existing sessionStorage OAuth blobs; document that enabling shared storage is the default for local dev. + +--- + +## Phase 3b test plan (automated integration) + +Goal: CI tests that prove **quick EMA** legs 1–3 through `InspectorClient` / `OAuthManager` without calling live xaa.dev. Manual staging (§Staging validation) already covers live IdP/AS; automation encodes regressions. + +**Status (June 2026):** Layers 1–3 implemented and green in CI (`npm run test:integration`). Optional items (RemoteOAuthStorage variant, 401 re-auth) remain open. + +### Principles + +- **No live xaa.dev in default CI** — flaky, credential-bound, network-dependent. Optional `describe.skipIf` / manual job later. +- **Reuse existing patterns** — `TestServerHttp` + `createExternalResourceOAuthTestServerConfig`, `inspectorClient-oauth-e2e.test.ts`, `inspectorClient-oauth-remote-storage-e2e.test.ts`, `jose` for JWTs (`test-server-protected-resource.test.ts`). +- **Mock at HTTP boundary** — `fetchFn` injection on `InspectorClient` / `EmaFlowConfig` so `wire.ts` and `idpOidc.ts` run real code against local mock servers. +- **Guided execution not required** — quick path only (`authenticate()` / `trySilentEmaAuth` / `completeOAuthFlow`). + +### Test layers + +#### Layer 1 — `wire.ts` + `emaFlow.ts` (unit / near-unit) — **done** + +**Files:** `clients/web/src/test/core/auth/ema/wire.test.ts`, `emaFlow.test.ts` + +| Case | Status | +| ---- | ------ | +| `exchangeIdJag` happy path | ✅ Mock IdP token endpoint; assert RFC 8693 request shape | +| `exchangeIdJag` IdP error | ✅ Asserts leg-2 error message | +| `redeemIdJagForAccessToken` happy path | ✅ Mock resource AS token endpoint | +| `mintEmaResourceTokens` end-to-end | ✅ In-memory `OAuthStorage` + mocked fetch; `resourceContext` bypass | +| Missing ID Token | ✅ | +| Missing resource secret | ✅ | +| `trySilentEmaAuth` saves tagged tokens | ✅ `enterpriseManaged: true` on `saveTokens` | +| `trySilentEmaAuth` returns false without IdP session | ✅ | + +Uses `vi.fn()` fetch — no listening ports. Runs in `unit` project. + +#### Layer 2 — Mock IdP + mock resource AS (local HTTP) — **done** + +**File:** `clients/web/src/test/integration/mcp/ema-mock-servers.ts` + +Ephemeral `node:http` servers (port `0` on `127.0.0.1`): + +1. **Mock IdP** — `/.well-known/oauth-authorization-server` + `openid-configuration`, `/token` (RFC 8693 token exchange → ID-JAG). Fixture client id/secret: `EMA_MOCK_IDP_*` constants. +2. **Mock resource AS** — `/.well-known/oauth-authorization-server`, `/jwks`, `/token` (JWT bearer grant accepts ID-JAG, returns RS256 access token). Fixture client id/secret: `EMA_MOCK_RESOURCE_*` constants. + +Shared helper `minimalOAuthAsMetadata()` satisfies SDK OAuth metadata schema. `createMockIdToken()` seeds leg-1 IdP session in storage. Topology mirrors xaa.dev staging — see comment block in helper + `test-servers/configs/xaa-ema-http.json`. + +#### Layer 3 — `InspectorClient` EMA connect E2E (integration) — **done** + +**File:** `clients/web/src/test/integration/mcp/inspectorClient-ema-e2e.test.ts` + +| Step | Status | +| ---- | ------ | +| 1 | ✅ Protected-resource `TestServerHttp` + `createExternalResourceOAuthTestServerConfig` (mock AS JWKS/issuers) | +| 2 | ✅ Mock IdP + mock resource AS on ephemeral ports | +| 3 | ✅ `InspectorClient` with `enterpriseManaged: true`, fixture IdP + resource credentials | +| 4 | ✅ Cached ID Token seeded in `NodeOAuthStorage` (leg 1 shortcut) | +| 5 | ✅ `connect()` → `getOAuthState()` `protocol: "ema"`, `authorized: true`, `ema.idpSession: "logged_in"` | +| 6 | ✅ Second connect reuses silent path; persisted tokens tagged `enterpriseManaged: true` | + +Runs under `integration` project; `test-servers:build` prerequisite (existing). Transport: `streamable-http` only (EMA is HTTP-only). + +**Storage variant (optional — not implemented):** same flow with `RemoteOAuthStorage` + `createRemoteApp` tmp dir — proves EMA tokens persist via `/api/storage/oauth` (feeds shared-storage follow-up). + +#### Layer 4 — Protected-resource discovery — **partial** + +**File:** `clients/web/src/test/integration/mcp/test-server-protected-resource.test.ts` + +Existing coverage: `xaa-ema-http.json` config load, `ExternalAccessTokenValidator`, protected-resource metadata advertises external `authorization_servers`. Layer 3 E2E exercises `discoverEmaResourceContext` implicitly on connect. Dedicated `discoverEmaResourceContext` unit test not added (optional). + +### Out of scope for Phase 3b + +- Live xaa.dev (covered by §Staging validation) +- Guided EMA steps (deferred) +- Browser / Playwright UI tests +- TUI/CLI (Phase 4) + +### Suggested implementation order + +1. ~~Layer 1 (`wire` / `emaFlow` unit tests)~~ — **done** +2. ~~Layer 2 mock servers helper~~ — **done** +3. ~~Layer 3 single happy-path E2E (`streamable-http`)~~ — **done** +4. Layer 3 storage variant with `RemoteOAuthStorage` — optional, aligns with shared-storage refactor. +5. 401 re-auth case — connect, invalidate resource token, assert legs 2–3 re-run (stretch). + +### Done criteria for Phase 3b checklist + +- [x] Layer 1 tests merged +- [x] Layer 3 happy-path integration test green in CI +- [x] Document in test file how mock topology maps to xaa.dev staging (comment block + link to `xaa-ema-http.json`) +- [ ] *(optional)* `RemoteOAuthStorage` EMA E2E variant +- [ ] *(optional)* 401 re-auth stretch case + +--- + +## Appendix A — VS Code implementation reference + +The following is **reference material only**, adapted from a working document in `/Users/bob/Documents/Auth0/vscode-xaa-implementation-reference.md`. It describes Microsoft's VS Code TypeScript implementation of enterprise-managed MCP auth (XAA / ID-JAG). **Do not treat it as inspector design** — use it to locate patterns and source files when implementing [#1509](https://github.com/modelcontextprotocol/inspector/issues/1509). + +### A.1 Changelist that introduced this + +- PR: https://github.com/microsoft/vscode/pull/318067 +- Merge commit: https://github.com/microsoft/vscode/commit/2d95154af0f50db2a41ceee431acfc6d79638aab +- Release notes section (1.123): https://code.visualstudio.com/updates/v1_123#_enterprise-managed-mcp-authentication-preview + +### A.2 Where the feature is configured + +#### A.2.1 `mcp.json` server-level opt-in (`oauth.enterpriseManaged`) + +- MCP schema (`enterpriseManaged` under HTTP oauth): + - https://github.com/microsoft/vscode/blob/main/src/vs/workbench/contrib/mcp/common/mcpConfiguration.ts +- MCP types (`enterpriseManaged?: boolean`): + - https://github.com/microsoft/vscode/blob/main/src/vs/workbench/contrib/mcp/common/mcpTypes.ts + +#### A.2.2 Tenant-level IdP config (`mcp.enterpriseManagedAuth.idp`) + +- Setting key + TS interface (`issuer`, `clientId`, `clientSecret`): + - https://github.com/microsoft/vscode/blob/main/src/vs/workbench/contrib/mcp/common/mcpConfiguration.ts +- Registration of setting in configuration registry (policy-backed, app-scoped, hidden): + - https://github.com/microsoft/vscode/blob/main/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +- Policy metadata for enterprise deployment: + - https://github.com/microsoft/vscode/blob/main/build/lib/policies/policyData.jsonc + +### A.3 Routing logic: where VS Code chooses XAA vs normal OAuth + +- MCP HTTP launch data sends enterprise-managed flag to main thread: + - https://github.com/microsoft/vscode/blob/main/src/vs/workbench/api/common/extHostMcp.ts +- Main routing decision (`if (authDetails.enterpriseManaged) ...`): + - https://github.com/microsoft/vscode/blob/main/src/vs/workbench/api/browser/mainThreadMcp.ts + +In `mainThreadMcp`, enterprise-managed path does these implementation-specific checks: + +1. Validate resource metadata has `resource`. +2. Read resource AS from `authorization_servers[0]`. +3. Compute XAA scopes from challenge/resource metadata (not IdP scopes_supported fallback). +4. Resolve issuer from `mcp.enterpriseManagedAuth.idp.issuer`. +5. Request shared provider via `createOrGetXaaProvider(issuer)`. +6. Resolve resource client secret from secret store using resource + resource client id key. +7. Call session acquisition with `audience` and `resource` in options. + +All of this is implemented in: +- https://github.com/microsoft/vscode/blob/main/src/vs/workbench/api/browser/mainThreadMcp.ts + +### A.4 Provider lifecycle and host plumbing + +#### A.4.1 Authentication service API additions + +- New `createOrGetXaaProvider(issuer)` API contract: + - https://github.com/microsoft/vscode/blob/main/src/vs/workbench/services/authentication/common/authentication.ts +- Browser implementation (one provider per issuer id): + - https://github.com/microsoft/vscode/blob/main/src/vs/workbench/services/authentication/browser/authenticationService.ts + +#### A.4.2 Main thread registration path + +- Creates XAA provider from issuer metadata; prefers configured IdP credentials over cached: + - https://github.com/microsoft/vscode/blob/main/src/vs/workbench/api/browser/mainThreadAuthentication.ts + +#### A.4.3 Ext host registration path + +- New RPC methods in protocol (`$registerXaaAuthProvider`, enterpriseManaged metadata shape, prompt for resource secret): + - https://github.com/microsoft/vscode/blob/main/src/vs/workbench/api/common/extHost.protocol.ts +- Ext host registration of XAA provider (`$registerXaaAuthProvider`): + - https://github.com/microsoft/vscode/blob/main/src/vs/workbench/api/common/extHostAuthentication.ts +- Node ext host binds XAA mixin to node provider implementation: + - https://github.com/microsoft/vscode/blob/main/src/vs/workbench/api/node/extHostAuthentication.ts + +### A.5 Core XAA implementation class (the most important file) + +- Main implementation: + - https://github.com/microsoft/vscode/blob/main/src/vs/workbench/api/common/extHostXaaAuthProvider.ts + +Key implementation methods to mirror in your app: + +1. `getSessions(...)` + - Silent path first. + - Reuses persisted IdP session. + - Re-mints resource token silently (no prompts). + +2. `createSession(...)` + - Interactive path. + - Ensures IdP session, then mints resource token. + +3. `_mintResourceToken(...)` + - Runs leg 2 + leg 3 logic. + - Resolves resource `client_id` from explicit config first, then from JAG claim fallback. + - Handles per-resource `client_secret` retrieval/prompt/caching semantics. + +4. `_exchangeForIdJag(...)` + - IdP token exchange request construction and execution. + +5. `_discoverResourceTokenEndpoint(...)` + - Fetches metadata for audience/resource AS. + +6. `_redeemAtResource(...)` + - Resource-side JWT bearer redemption for access token. + +7. Caching helpers + - `cacheKey(...)`, `isExpired(...)`, in-memory resource token cache keyed by `(resource, scopes)`. + +### A.6 OAuth wire-format helpers (shared utilities) + +- OAuth constants + body builders used by XAA flow: + - https://github.com/microsoft/vscode/blob/main/src/vs/base/common/oauth.ts + +Functions to copy conceptually: + +1. `buildIdJagExchangeBody(...)` +2. `buildResourceRedemptionBody(...)` +3. JWT claims parsing helpers used to infer `client_id` from assertion + +Tests for body semantics: +- https://github.com/microsoft/vscode/blob/main/src/vs/base/test/common/oauth.test.ts + +### A.7 Secret storage model in this implementation + +#### A.7.1 Resource client secret prompt + persistence + +- Prompt and persistence (`$promptForResourceClientSecret`): + - https://github.com/microsoft/vscode/blob/main/src/vs/workbench/api/browser/mainThreadAuthentication.ts + +Behavior details implemented: + +1. `undefined` means user canceled (do not cache as "no secret"). +2. Empty string means explicit "no secret" (valid for public clients). +3. Non-empty values stored in OS secret storage. + +#### A.7.2 Secret keying strategy + +- Secret key utility is in MCP types (`mcpOAuthClientSecretStorageKey`): + - https://github.com/microsoft/vscode/blob/main/src/vs/workbench/contrib/mcp/common/mcpTypes.ts + +The feature intentionally keys by **resource indicator + resource client id**, not only client id. + +### A.8 Session option surface expansion (`audience`) + +The implementation introduces `audience` as part of auth session option shapes so XAA provider can receive resource AS context via standard session APIs. + +- Internal service types (`IAuthenticationCreateSessionOptions`, `IAuthenticationGetSessionsOptions`, provider session options): + - https://github.com/microsoft/vscode/blob/main/src/vs/workbench/services/authentication/common/authentication.ts +- Proposed VS Code API file: + - https://github.com/microsoft/vscode/blob/main/src/vscode-dts/vscode.proposed.authSessionAudience.d.ts +- Proposal registry inclusion: + - https://github.com/microsoft/vscode/blob/main/src/vs/platform/extensions/common/extensionsApiProposals.ts + +### A.9 Tests added in this changelist + +- XAA helper tests (`cacheKey`, `isExpired`, `IDP_SCOPES`): + - https://github.com/microsoft/vscode/blob/main/src/vs/workbench/api/test/browser/extHostXaaAuthProvider.test.ts +- OAuth wire-format tests for ID-JAG and redemption bodies: + - https://github.com/microsoft/vscode/blob/main/src/vs/base/test/common/oauth.test.ts +- Integration test shape update for new RPC method: + - https://github.com/microsoft/vscode/blob/main/src/vs/workbench/api/test/browser/mainThreadAuthentication.integrationTest.ts + +### A.10 Third-party libraries used for this feature + +No new third-party runtime dependency appears to be introduced by this changelist. + +The implementation relies on: + +1. Existing VS Code auth/mcp infrastructure. +2. Web/platform primitives (`fetch`, `URLSearchParams`). +3. Protocol standards (RFC 8693, RFC 7523, RFC 8707, ID-JAG draft semantics). + +### A.11 Practical architecture notes reusable in inspector + +If implementing this pattern in inspector, mirror these design choices: + +1. Split credentials into two planes: + - IdP client credentials (tenant/global) + - Resource AS client credentials (per resource/per client id) + +2. Cache provider by issuer: + - One XAA provider per issuer, shared across resources. + +3. Keep silent and interactive paths separate: + - `getSessions` style silent path must not prompt. + - `createSession` style path can prompt. + +4. Use `audience` explicitly: + - Do not overload `resource` for both meanings. + +5. Treat cancel as cancel: + - Distinguish `undefined` (cancel) from `""` (explicit no secret). + +6. Key secrets with resource context: + - Prevent cross-resource secret confusion. + +### A.12 Commit-pinned references (stable snapshot) + +If you need a frozen reference (won't drift as `main` changes), use commit-pinned paths under: + +- https://github.com/microsoft/vscode/tree/2d95154af0f50db2a41ceee431acfc6d79638aab + +For example: +- https://github.com/microsoft/vscode/blob/2d95154af0f50db2a41ceee431acfc6d79638aab/src/vs/workbench/api/common/extHostXaaAuthProvider.ts +- https://github.com/microsoft/vscode/blob/2d95154af0f50db2a41ceee431acfc6d79638aab/src/vs/workbench/api/browser/mainThreadMcp.ts +- https://github.com/microsoft/vscode/blob/2d95154af0f50db2a41ceee431acfc6d79638aab/src/vs/workbench/api/browser/mainThreadAuthentication.ts diff --git a/specification/v2_servers_file.md b/specification/v2_servers_file.md index 0511a4ff..8b391c93 100644 --- a/specification/v2_servers_file.md +++ b/specification/v2_servers_file.md @@ -127,7 +127,7 @@ Add granular endpoints (mirror of `/api/storage/:storeId`, but specialized so th |---|---|---|---| | `GET` | `/api/servers` | — | `{ mcpServers: {...} }` — creates the file with seeds if absent | | `POST` | `/api/servers` | `{ id: string, config: MCPServerConfig }` | `{ ok: true }`; 409 if `id` already exists | -| `PUT` | `/api/servers/:id` | `{ id?: string, config: MCPServerConfig }` | `{ ok: true }`; supports id rename (delete old key + write new) | +| `PUT` | `/api/servers/:id` | `{ id?: string, config: MCPServerConfig }` | `{ ok: true }`; supports id rename (rewrites `mcpServers` key; migrates keychain secrets — see §Secret storage) | | `DELETE` | `/api/servers/:id` | — | `{ ok: true }` (ignores missing) | `id` is validated with `validateStoreId` (same alphanum+hyphen+underscore rule as store IDs — prevents anyone slipping `..` into the key). All routes serialize through the same atomic `writeStoreFile`, so concurrent writes are well-defined (last writer wins per-write; granularity reduces blast radius). @@ -186,7 +186,7 @@ Place tests per `AGENTS.md`'s integration-folder convention. ### Integration (`integration` vitest project, node env, 30s) -- `clients/web/src/test/integration/mcp/remote/servers-route.test.ts` — spin up `createRemoteApp` with a tmp `mcpConfigPath`, exercise GET (file absent → seeds written; file present → returned), POST (success + 409 on dup), PUT (rename + payload update), DELETE (existing + missing). Mirrors the `adapters.test.ts` pattern already in `clients/web/src/test/integration/storage/adapters.test.ts`. +- `clients/web/src/test/integration/mcp/remote/servers-route.test.ts` — spin up `createRemoteApp` with a tmp `mcpConfigPath`, exercise GET (file absent → seeds written; file present → returned), POST (success + 409 on dup), PUT (rename + payload update + keychain secret migration on rename), DELETE (existing + missing). Mirrors the `adapters.test.ts` pattern already in `clients/web/src/test/integration/storage/adapters.test.ts`. - `clients/web/src/test/integration/react/useServers.test.tsx` — render the hook against a real `createRemoteApp` Hono instance (no mocking); assert load, add, update, remove flows reflect what the backend has on disk. ### Coverage @@ -248,6 +248,7 @@ Each server entry may carry these Inspector-extension fields at the top level: - **Linux without libsecret**: `KeyringSecretStore` is *tolerant* — only the `set` operation throws `KeychainUnavailableError` (translated to a `503` by the handlers); `get` returns `null` and the destructive operations silently no-op. The result is that no-secret flows (creating a stdio server with no env values, deleting an entry, reading the list, the defensive sweep on POST) all work normally on a minimal Linux box without libsecret. Only the moments where a secret would actually be lost — saving an OAuth client secret, saving a stdio env value, or migrating a plaintext value into the keychain — surface a clear error. macOS and Windows always have a working keychain so this only matters on minimal Linux installs. - **Migration tolerance**: when migration encounters `KeychainUnavailableError`, the GET handler logs a warning, leaves the on-disk plaintext untouched, and serves the (still-plaintext) response. Subsequent reads retry — installing libsecret later lifts the secrets on the next GET without any user action. - **Write ordering on POST/PUT**: keychain writes happen before the disk write, and obsolete-field deletions happen after. The intent is that a `set` failure (the only hard-fail path) leaves both stores in their pre-write state — no half-applied entry on disk that would trap a retry POST at `409`, and no premature deletion of an obsolete field whose disk write later fails. + - **Id rename (`PUT` with new `id`)**: secrets are keyed by **server id** (the `mcpServers` map key), not URL. On rename the handler reads existing keychain entries for the **original** id (`expectedSecretFields` + `readKeychainEntriesFor`), merges them with any secrets in the PUT body (body wins on conflict), filters to fields the **new** on-disk entry still expects (so removed stdio env keys are not carried over), writes under the **new** id, updates disk, then `deleteAllForServer(originalId)`. This matters for config-only renames (e.g. Server Config modal) that do not re-send OAuth client secrets or stdio env values — those live only in the keychain after #1356. Covered by `servers-route.test.ts` (`PUT rename moves … when it exists only in the keychain` for OAuth and stdio env). - **Out of scope for this PR**: the OAuth handshake itself still runs in the browser via the MCP SDK, so during the token exchange the secret transits the wire (browser → MCP SDK → OAuth provider's token endpoint). The on-disk win this PR delivers is that the secret is no longer in the shareable / symlinked `mcp.json` and is no longer the source-of-truth on the filesystem. Moving the token exchange to the Node side is tracked separately. - **Hard-cutover legacy behavior (per #1358 decision 4)**: files written by the one pre-#1358 build of v2/main have a nested `settings` block. `normalizeMcpServers` drops the node on read and logs a one-line warn including the server id; the persisted headers / metadata / timeouts / OAuth credentials are intentionally lost on first read. Users re-enter them via the settings form (or hand-edit the file into the flat shape). v2 has not shipped a stable release with the nested shape, so the blast radius is the small set of v2/main dogfooders who edited per-server settings between #1353 merging and this change. - **UI**: `ServerSettingsModal` is opened from the server card's settings affordance. Saving routes through `useServers.updateServerSettings(id, settings)` which issues a settings-only `PUT /api/servers/:id` with `{ id, settings }` — the route preserves the on-disk transport config inside its write lock. Conversely, `useServers.updateServer` (driven by the basic-config modal) issues a config-only PUT with `{ id, config }` and the route preserves the on-disk settings fields. Edits in either modal cannot silently wipe the other half. diff --git a/specification/v2_ux.md b/specification/v2_ux.md index 0630d110..adeb1ff0 100644 --- a/specification/v2_ux.md +++ b/specification/v2_ux.md @@ -276,9 +276,14 @@ Shown as a modal or dedicated screen after successful connection. │ │ │ OAuth Details (if applicable) │ │ ───────────── │ -│ Auth URL: https://auth.example.com │ -│ Scopes: read, write │ -│ Access Token: eyJhbG... [Copy] [Decode JWT ▼] │ +│ Protocol: Standard OAuth | Enterprise-managed (EMA) │ +│ Authorized: Yes | No │ +│ Client ID: my-client-id │ +│ IdP session: Signed in | Expired | Not signed in (EMA) │ +│ Auth URL: https://auth.example.com (when cached) │ +│ Scopes: configured → granted │ +│ Access Token: eyJhbG... [Copy] [Decode JWT] │ +│ (multi-line wrap; decode toggles in place)│ │ │ └─────────────────────────────────────────────────────────┘ ``` @@ -289,7 +294,7 @@ Shown as a modal or dedicated screen after successful connection. - Server capabilities: Tools, Resources, Prompts, Logging, Completions, Tasks, Experimental - Client capabilities: Sampling, Elicitation, Roots, Tasks, Experimental - Display server instructions if provided -- Show OAuth tokens with copy and JWT decode options +- Show OAuth connection snapshot when applicable (`InspectorClient.getOAuthState()` → `OAuthConnectionState`): protocol, authorization status, client id, EMA IdP session, cached auth URL, scopes, access token with copy and in-place JWT decode (`OAuthAccessTokenField`) ### Feature Screens diff --git a/test-servers/configs/xaa-ema-http.json b/test-servers/configs/xaa-ema-http.json new file mode 100644 index 00000000..ee5736d8 --- /dev/null +++ b/test-servers/configs/xaa-ema-http.json @@ -0,0 +1,22 @@ +{ + "serverInfo": { + "name": "xaa-ema-resource", + "version": "1.0.0" + }, + "tools": [{ "preset": "echo" }, { "preset": "get_temp" }], + "transport": { + "type": "streamable-http", + "port": 8080 + }, + "oauth": { + "enabled": true, + "mode": "protected-resource", + "authorizationServers": ["https://auth.resource.xaa.dev"], + "requireAuth": true, + "scopesSupported": ["mcp"], + "accessTokenIssuers": ["https://auth.resource.xaa.dev"], + "jwksUri": "https://auth.resource.xaa.dev/jwks", + "resource": "http://localhost:8080/", + "resourceAudience": "http://localhost:8080/" + } +} diff --git a/test-servers/src/composable-test-server.ts b/test-servers/src/composable-test-server.ts index 80dc5cab..44ca6057 100644 --- a/test-servers/src/composable-test-server.ts +++ b/test-servers/src/composable-test-server.ts @@ -239,32 +239,55 @@ export interface ServerConfig { */ taskMessageQueue?: TaskMessageQueue; /** - * OAuth 2.1 configuration for test server - * If enabled, server will act as an OAuth authorization server + * OAuth 2.1 configuration for test server. + * - **combined** (default): this server is both MCP resource and authorization server. + * - **protected-resource**: MCP resource only; metadata points at external authorization server(s). */ oauth?: { - /** - * Whether OAuth is enabled for this test server - */ enabled: boolean; /** - * OAuth authorization server issuer URL - * Used for metadata endpoints and token issuance - * If not provided, defaults to the test server's base URL + * combined — local AS + resource (existing behavior). + * protected-resource — external AS; JWT bearer validation against AS JWKS. + */ + mode?: "combined" | "protected-resource"; + + /** + * External authorization server URLs for protected-resource metadata + * (`authorization_servers` in RFC 9728). Required when mode is protected-resource. + */ + authorizationServers?: string[]; + + /** + * Protected resource identifier override for RFC 9728 metadata. + * Defaults to the MCP server base URL when omitted. + */ + resource?: string; + + /** + * OAuth authorization server issuer URL (combined mode AS metadata). + * If not provided, defaults to the test server's base URL. */ issuerUrl?: URL; /** - * List of scopes supported by this authorization server - * Defaults to ["mcp"] if not provided + * Allowed JWT `iss` values for access tokens (protected-resource mode). + * Defaults to authorizationServers plus issuer from AS metadata discovery. */ - scopesSupported?: string[]; + accessTokenIssuers?: string[]; /** - * If true, MCP endpoints require valid Bearer token - * Returns 401 Unauthorized if token is missing or invalid + * JWKS URI override for access-token signature verification. + * When omitted, discovered from authorization server metadata. */ + jwksUri?: string; + + /** + * When set, require JWT `aud` to match this resource identifier. + */ + resourceAudience?: string; + + scopesSupported?: string[]; requireAuth?: boolean; /** diff --git a/test-servers/src/index.ts b/test-servers/src/index.ts index 49074326..e63eab10 100644 --- a/test-servers/src/index.ts +++ b/test-servers/src/index.ts @@ -8,4 +8,7 @@ export * from "./test-server-stdio.js"; export * from "./test-server-http.js"; export * from "./test-server-control.js"; export * from "./test-server-oauth.js"; +export * from "./test-server-oauth-jwt.js"; export * from "./test-helpers.js"; +export * from "./load-config.js"; +export * from "./resolve-config.js"; diff --git a/test-servers/src/load-config.ts b/test-servers/src/load-config.ts index 1e97790d..34b90102 100644 --- a/test-servers/src/load-config.ts +++ b/test-servers/src/load-config.ts @@ -12,6 +12,28 @@ export interface PresetRef { params?: Record; } +export interface ConfigFileOAuth { + enabled: boolean; + mode?: "combined" | "protected-resource"; + authorizationServers?: string[]; + resource?: string; + issuerUrl?: string; + accessTokenIssuers?: string[]; + jwksUri?: string; + resourceAudience?: string; + scopesSupported?: string[]; + requireAuth?: boolean; + staticClients?: Array<{ + clientId: string; + clientSecret?: string; + redirectUris?: string[]; + }>; + supportDCR?: boolean; + supportCIMD?: boolean; + tokenExpirationSeconds?: number; + supportRefreshTokens?: boolean; +} + export interface ConfigFile { serverInfo: { name: string; @@ -38,6 +60,7 @@ export interface ConfigFile { resourceTemplates?: number; prompts?: number; }; + oauth?: ConfigFileOAuth; transport: { type: "stdio" | "streamable-http" | "sse"; port?: number; @@ -102,6 +125,48 @@ function validateConfig( `Invalid config in ${filePath}: transport.type must be stdio, streamable-http, or sse`, ); } + + if (o.oauth && typeof o.oauth === "object") { + const oauth = o.oauth as Record; + if (oauth.enabled !== true) { + throw new Error( + `Invalid config in ${filePath}: oauth.enabled must be true when oauth is present`, + ); + } + const mode = oauth.mode; + if ( + mode !== undefined && + mode !== "combined" && + mode !== "protected-resource" + ) { + throw new Error( + `Invalid config in ${filePath}: oauth.mode must be combined or protected-resource`, + ); + } + if (mode === "protected-resource") { + const servers = oauth.authorizationServers; + if (!Array.isArray(servers) || servers.length === 0) { + throw new Error( + `Invalid config in ${filePath}: oauth.authorizationServers is required when oauth.mode is protected-resource`, + ); + } + for (const url of servers) { + if (typeof url !== "string" || url.trim() === "") { + throw new Error( + `Invalid config in ${filePath}: oauth.authorizationServers must be non-empty URL strings`, + ); + } + } + } + if ( + (transportType === "stdio") && + oauth.enabled === true + ) { + throw new Error( + `Invalid config in ${filePath}: oauth requires streamable-http or sse transport`, + ); + } + } } /** diff --git a/test-servers/src/resolve-config.ts b/test-servers/src/resolve-config.ts index 2269fcfc..01513a6b 100644 --- a/test-servers/src/resolve-config.ts +++ b/test-servers/src/resolve-config.ts @@ -82,5 +82,13 @@ export function resolveConfig(config: ConfigFile): ServerConfig { port: isHttp ? transport.port : undefined, }; + if (config.oauth) { + const { issuerUrl, ...oauthRest } = config.oauth; + serverConfig.oauth = { + ...oauthRest, + ...(issuerUrl && { issuerUrl: new URL(issuerUrl) }), + }; + } + return serverConfig; } diff --git a/test-servers/src/test-server-fixtures.ts b/test-servers/src/test-server-fixtures.ts index 2762e0f0..95c8cbed 100644 --- a/test-servers/src/test-server-fixtures.ts +++ b/test-servers/src/test-server-fixtures.ts @@ -1922,6 +1922,7 @@ export function createOAuthTestServerConfig(options: { return { oauth: { enabled: true, + mode: "combined", requireAuth: options.requireAuth ?? false, scopesSupported: options.scopesSupported ?? ["mcp"], staticClients: options.staticClients, @@ -1932,3 +1933,31 @@ export function createOAuthTestServerConfig(options: { }, }; } + +/** + * MCP resource server that delegates authorization to an external AS (EMA / XAA testing). + * Access tokens must be JWTs from that AS (signature + iss validated via JWKS). + */ +export function createExternalResourceOAuthTestServerConfig(options: { + authorizationServers: string[]; + requireAuth?: boolean; + scopesSupported?: string[]; + resource?: string; + accessTokenIssuers?: string[]; + jwksUri?: string; + resourceAudience?: string; +}): Partial { + return { + oauth: { + enabled: true, + mode: "protected-resource", + authorizationServers: options.authorizationServers, + requireAuth: options.requireAuth ?? true, + scopesSupported: options.scopesSupported ?? ["mcp"], + resource: options.resource, + accessTokenIssuers: options.accessTokenIssuers, + jwksUri: options.jwksUri, + resourceAudience: options.resourceAudience, + }, + }; +} diff --git a/test-servers/src/test-server-oauth-jwt.ts b/test-servers/src/test-server-oauth-jwt.ts new file mode 100644 index 00000000..2df746d4 --- /dev/null +++ b/test-servers/src/test-server-oauth-jwt.ts @@ -0,0 +1,123 @@ +/** + * JWT access-token validation for test MCP servers using an external authorization server. + */ + +import { + createRemoteJWKSet, + customFetch, + jwtVerify, +} from "jose"; +import { discoverAuthorizationServerMetadata } from "@modelcontextprotocol/sdk/client/auth.js"; +import type { ServerConfig } from "./composable-test-server.js"; + +type ExternalValidatorOAuthConfig = Pick< + NonNullable, + | "authorizationServers" + | "accessTokenIssuers" + | "jwksUri" + | "resourceAudience" +>; + +function normalizeIssuer(issuer: string): string { + return issuer.replace(/\/$/, ""); +} + +function audienceMatches(aud: unknown, expected: string): boolean { + const normalized = expected.replace(/\/$/, ""); + if (typeof aud === "string") { + return normalizeIssuer(aud) === normalized; + } + if (Array.isArray(aud)) { + return aud.some((a) => typeof a === "string" && normalizeIssuer(a) === normalized); + } + return false; +} + +export class ExternalAccessTokenValidator { + private jwks: ReturnType | null = null; + private allowedIssuers = new Set(); + private initPromise: Promise | null = null; + private readonly config: ExternalValidatorOAuthConfig; + private readonly fetchFn: typeof fetch; + + constructor( + config: ExternalValidatorOAuthConfig, + fetchFn: typeof fetch = fetch, + ) { + this.config = config; + this.fetchFn = fetchFn; + } + + private async ensureReady(): Promise { + if (!this.initPromise) { + this.initPromise = this.init(); + } + await this.initPromise; + } + + private async init(): Promise { + const configuredIssuers = + this.config.accessTokenIssuers ?? + this.config.authorizationServers ?? + []; + if (configuredIssuers.length === 0) { + throw new Error( + "External access token validation requires authorizationServers or accessTokenIssuers", + ); + } + + for (const issuer of configuredIssuers) { + this.allowedIssuers.add(normalizeIssuer(issuer)); + } + + let jwksUri = this.config.jwksUri; + const primaryAs = this.config.authorizationServers?.[0]; + if (!jwksUri && primaryAs) { + const metadata = await discoverAuthorizationServerMetadata( + new URL(primaryAs), + { fetchFn: this.fetchFn }, + ); + if (metadata?.issuer) { + this.allowedIssuers.add(normalizeIssuer(metadata.issuer)); + } + const discoveredJwks = metadata?.jwks_uri; + if (typeof discoveredJwks === "string") { + jwksUri = discoveredJwks; + } + } + + if (!jwksUri) { + throw new Error( + "Could not resolve jwks_uri for external authorization server (set oauth.jwksUri in config)", + ); + } + + this.jwks = createRemoteJWKSet(new URL(jwksUri), { + [customFetch]: this.fetchFn, + }); + } + + async validateAccessToken(token: string): Promise { + if (!this.jwks) { + await this.ensureReady(); + } + if (!this.jwks) return false; + + try { + const { payload } = await jwtVerify(token, this.jwks, { + issuer: [...this.allowedIssuers], + }); + + if (this.config.resourceAudience) { + const aud = payload.aud; + if (!audienceMatches(aud, this.config.resourceAudience)) { + return false; + } + } + + return true; + } catch { + return false; + } + } +} diff --git a/test-servers/src/test-server-oauth.ts b/test-servers/src/test-server-oauth.ts index 0a86f84a..d15cb1cf 100644 --- a/test-servers/src/test-server-oauth.ts +++ b/test-servers/src/test-server-oauth.ts @@ -9,12 +9,19 @@ import crypto from "node:crypto"; import type { Request, Response } from "express"; import express from "express"; import type { ServerConfig } from "./composable-test-server.js"; +import { ExternalAccessTokenValidator } from "./test-server-oauth-jwt.js"; /** * OAuth configuration from ServerConfig */ export type OAuthConfig = NonNullable; +export function getOAuthMode( + config: OAuthConfig, +): "combined" | "protected-resource" { + return config.mode ?? "combined"; +} + /** * Set up OAuth routes on an Express application * This adds all OAuth endpoints (authorization, token, metadata, etc.) @@ -26,18 +33,14 @@ export function setupOAuthRoutes( app: express.Application, config: OAuthConfig, ): void { - // OAuth metadata endpoints (RFC 8414) setupMetadataEndpoints(app, config); - // OAuth authorization endpoint - setupAuthorizationEndpoint(app, config); - - // OAuth token endpoint - setupTokenEndpoint(app, config); - - // Dynamic Client Registration endpoint (if enabled) - if (config.supportDCR) { - setupDCREndpoint(app); + if (getOAuthMode(config) === "combined") { + setupAuthorizationEndpoint(app, config); + setupTokenEndpoint(app, config); + if (config.supportDCR) { + setupDCREndpoint(app); + } } } @@ -51,6 +54,12 @@ export function setupOAuthRoutes( export function createBearerTokenMiddleware( config: OAuthConfig, ): express.RequestHandler { + const mode = getOAuthMode(config); + const externalValidator = + mode === "protected-resource" + ? new ExternalAccessTokenValidator(config) + : undefined; + return async (req: Request, res: Response, next: express.NextFunction) => { if (!config.requireAuth) { return next(); @@ -77,8 +86,18 @@ export function createBearerTokenMiddleware( const token = authHeader.substring(7); // Remove "Bearer " prefix - // Verify token (simplified for test server - in production, use proper JWT verification) - if (!isValidToken(token)) { + let valid = false; + if (mode === "protected-resource") { + try { + valid = await externalValidator!.validateAccessToken(token); + } catch { + valid = false; + } + } else { + valid = isValidToken(token); + } + + if (!valid) { // Return 401 - the SDK's transport should detect this and throw an error res.status(401); res.setHeader("Content-Type", "application/json"); @@ -109,47 +128,59 @@ function setupMetadataEndpoints( config: OAuthConfig, ): void { const scopes = config.scopesSupported || ["mcp"]; + const mode = getOAuthMode(config); - // OAuth Authorization Server Metadata - app.get( - "/.well-known/oauth-authorization-server", - (req: Request, res: Response) => { - // Use request's host to get actual server URL (since port is assigned dynamically) - const requestBaseUrl = `${req.protocol}://${req.get("host")}`; - const actualIssuerUrl = new URL(requestBaseUrl); - const metadata = { - issuer: actualIssuerUrl.href, - authorization_endpoint: new URL("/oauth/authorize", actualIssuerUrl) - .href, - token_endpoint: new URL("/oauth/token", actualIssuerUrl).href, - scopes_supported: scopes, - response_types_supported: ["code"], - grant_types_supported: ["authorization_code", "refresh_token"], - code_challenge_methods_supported: ["S256"], // PKCE support - token_endpoint_auth_methods_supported: ["client_secret_basic", "none"], - ...(config.supportDCR && { - registration_endpoint: new URL("/oauth/register", actualIssuerUrl) + if (mode === "combined") { + // OAuth Authorization Server Metadata (local AS) + app.get( + "/.well-known/oauth-authorization-server", + (req: Request, res: Response) => { + const requestBaseUrl = `${req.protocol}://${req.get("host")}`; + const actualIssuerUrl = config.issuerUrl ?? new URL(requestBaseUrl); + const metadata = { + issuer: actualIssuerUrl.href.replace(/\/$/, "") + "/", + authorization_endpoint: new URL("/oauth/authorize", actualIssuerUrl) .href, - }), - ...(config.supportCIMD && { - client_id_metadata_document_supported: true, - }), - }; + token_endpoint: new URL("/oauth/token", actualIssuerUrl).href, + scopes_supported: scopes, + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token"], + code_challenge_methods_supported: ["S256"], + token_endpoint_auth_methods_supported: ["client_secret_basic", "none"], + ...(config.supportDCR && { + registration_endpoint: new URL("/oauth/register", actualIssuerUrl) + .href, + }), + ...(config.supportCIMD && { + client_id_metadata_document_supported: true, + }), + }; - res.json(metadata); - }, - ); + res.json(metadata); + }, + ); + } // OAuth Protected Resource Metadata app.get( "/.well-known/oauth-protected-resource", (req: Request, res: Response) => { - // Use request's host so resource matches actual server URL (port 0 → assigned port) const requestBaseUrl = `${req.protocol}://${req.get("host")}`; - const actualResourceUrl = new URL("/", requestBaseUrl).href; + const resourceUrl = + config.resource ?? new URL("/", requestBaseUrl).href; + const localAsUrl = (config.issuerUrl ?? new URL(requestBaseUrl)).href.replace( + /\/$/, + "", + ); + const authorizationServers = + mode === "protected-resource" + ? (config.authorizationServers ?? []) + : [localAsUrl]; const metadata = { - resource: actualResourceUrl, - authorization_servers: [actualResourceUrl], + resource: resourceUrl, + authorization_servers: authorizationServers.map((url) => + url.replace(/\/$/, ""), + ), scopes_supported: scopes, }; diff --git a/vitest.shared.mts b/vitest.shared.mts index b63fe436..1c7e6cd2 100644 --- a/vitest.shared.mts +++ b/vitest.shared.mts @@ -72,7 +72,7 @@ export function vitestSharedPaths(clientDir: string) { replacement: path.resolve(dirname, 'node_modules/@napi-rs/keyring'), }, { find: /^express$/, replacement: path.resolve(dirname, 'node_modules/express') }, - { find: /^yaml$/, replacement: path.resolve(dirname, 'node_modules/yaml') }, + { find: /^yaml$/, replacement: path.resolve(repoRoot, 'node_modules/yaml') }, { find: /^@modelcontextprotocol\/sdk\/client\/auth\.js$/, replacement: path.resolve(