diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 1999cd1c..4ce5ffe2 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -86,7 +86,7 @@ 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 { getWebRemoteOAuthStorage } from "./lib/remoteOAuthStorage"; import { useManagedTools } from "@inspector/core/react/useManagedTools.js"; import { useManagedPrompts } from "@inspector/core/react/useManagedPrompts.js"; import { useManagedResources } from "@inspector/core/react/useManagedResources.js"; @@ -1428,6 +1428,13 @@ function App() { [messages], ); + // Shared OAuth runtime store (oauth.json via /api/storage/oauth). Memoized so + // connect, EMA IdP session, and per-server clear share one in-memory view. + const webOAuthStorage = useMemo( + () => getWebRemoteOAuthStorage(getAuthToken()), + [], + ); + // Backend-backed session storage used to carry the fetch (Network) log // across the OAuth full-page redirect. The auth handshake's first half — // protected-resource + auth-server discovery and Dynamic Client @@ -2098,9 +2105,10 @@ function App() { // `onToggleConnection` unloaded the previous one), so all React state is // reset and we recover the initiating server from sessionStorage. We wait for // `servers` to hydrate before acting; the ref guard keeps the exchange to a - // single run. The persisted PKCE verifier + DCR client info live in - // `BrowserOAuthStorage` and survive the redirect, so `completeOAuthFlow` - // exchanges the code without needing the original in-memory state machine. + // single run. The persisted PKCE verifier + DCR client info live in shared + // `RemoteOAuthStorage` (`oauth.json`) and survive the redirect, so + // `completeOAuthFlow` exchanges the code without needing the original + // in-memory state machine. useEffect(() => { if (typeof window === "undefined") return; if (window.location.pathname !== OAUTH_CALLBACK_PATH) return; @@ -3239,10 +3247,9 @@ function App() { const clientSettingsModalValue = clientSettingsDraft ?? EMPTY_CLIENT_SETTINGS; - const emaOAuthStorage = useMemo(() => getBrowserOAuthStorage(), []); const { loginState: emaIdpLoginState, logout: logoutEmaIdp } = useEmaIdpLoginState( - emaOAuthStorage, + webOAuthStorage, clientSettingsModalValue.emaEnabled ? clientSettingsModalValue.issuer : undefined, @@ -3270,6 +3277,7 @@ function App() { config: server.config, inspectorClient: isActive ? inspectorClient : null, isActiveConnection: isActive, + oauthStorage: webOAuthStorage, }); if (!cleared) return; @@ -3295,6 +3303,7 @@ function App() { [ activeServerId, inspectorClient, + webOAuthStorage, finalizeExplicitDisconnect, clearOAuthResumeOnExplicitDisconnect, ], diff --git a/clients/web/src/lib/environmentFactory.test.ts b/clients/web/src/lib/environmentFactory.test.ts new file mode 100644 index 00000000..c3c4e70e --- /dev/null +++ b/clients/web/src/lib/environmentFactory.test.ts @@ -0,0 +1,124 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { BrowserNavigation } from "@inspector/core/auth/browser/index.js"; +import { MutableRedirectUrlProvider } from "@inspector/core/auth/providers.js"; +import { createWebEnvironment } from "./environmentFactory"; +import { + getWebRemoteOAuthStorage, + resetWebRemoteOAuthStorageCacheForTests, +} from "./remoteOAuthStorage"; + +describe("createWebEnvironment", () => { + beforeEach(() => { + vi.stubGlobal("window", { + location: { + protocol: "http:", + host: "127.0.0.1:6299", + }, + }); + }); + + afterEach(() => { + resetWebRemoteOAuthStorageCacheForTests(); + vi.unstubAllGlobals(); + }); + + it("wires RemoteOAuthStorage shared with getWebRemoteOAuthStorage", () => { + const redirectUrlProvider = new MutableRedirectUrlProvider(); + const first = createWebEnvironment("unit-test-token", redirectUrlProvider); + const second = createWebEnvironment("unit-test-token", redirectUrlProvider); + + expect(first.environment.oauth).toBeDefined(); + expect(second.environment.oauth).toBeDefined(); + expect(second.environment.oauth!.storage).toBe( + first.environment.oauth!.storage, + ); + expect(first.environment.oauth!.storage).toBe( + getWebRemoteOAuthStorage("unit-test-token"), + ); + }); + + it("uses BrowserNavigation for oauth.navigation", () => { + const { environment } = createWebEnvironment( + undefined, + new MutableRedirectUrlProvider(), + ); + expect(environment.oauth).toBeDefined(); + expect(environment.oauth!.navigation).toBeInstanceOf(BrowserNavigation); + }); + + it("returns the same logger instance as environment.logger", () => { + const { environment, logger } = createWebEnvironment( + "tok", + new MutableRedirectUrlProvider(), + ); + expect(logger).toBe(environment.logger); + }); + + it("passes redirectUrlProvider into oauth config", () => { + const redirectUrlProvider = new MutableRedirectUrlProvider(); + redirectUrlProvider.redirectUrl = "http://127.0.0.1:6299/oauth/callback"; + const { environment } = createWebEnvironment("tok", redirectUrlProvider); + if (!environment.oauth) { + throw new Error("expected oauth config"); + } + const { redirectUrlProvider: oauthRedirect } = environment.oauth; + if (!oauthRedirect) { + throw new Error("expected redirectUrlProvider"); + } + expect(oauthRedirect.getRedirectUrl()).toBe( + "http://127.0.0.1:6299/oauth/callback", + ); + }); + + it("forwards onBeforeOAuthRedirect to BrowserNavigation", () => { + const onBeforeOAuthRedirect = vi.fn<(authorizationUrl: URL) => void>(); + const { environment } = createWebEnvironment( + "tok", + new MutableRedirectUrlProvider(), + onBeforeOAuthRedirect, + ); + if (!environment.oauth) { + throw new Error("expected oauth config"); + } + const { navigation } = environment.oauth; + if (!navigation) { + throw new Error("expected navigation"); + } + const authUrl = new URL("https://idp.example/authorize?state=abc"); + navigation.navigateToAuthorization(authUrl); + expect(onBeforeOAuthRedirect).toHaveBeenCalledWith(authUrl); + }); + + it("routes fetch through the wrapped global fetch at the window origin", async () => { + const remoteBody = { + ok: true, + status: 200, + statusText: "OK", + headers: { "content-type": "text/plain" }, + body: "echoed", + }; + const fetchMock = vi.fn().mockImplementation( + async () => + new Response(JSON.stringify(remoteBody), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const { environment } = createWebEnvironment( + "api-tok", + new MutableRedirectUrlProvider(), + ); + fetchMock.mockClear(); + + const res = await environment.fetch!("http://upstream.test/mcp"); + + expect(res.status).toBe(200); + expect(await res.text()).toBe("echoed"); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(String(fetchMock.mock.calls[0]?.[0])).toBe( + "http://127.0.0.1:6299/api/fetch", + ); + }); +}); diff --git a/clients/web/src/lib/environmentFactory.ts b/clients/web/src/lib/environmentFactory.ts index e64cadef..3b08460b 100644 --- a/clients/web/src/lib/environmentFactory.ts +++ b/clients/web/src/lib/environmentFactory.ts @@ -4,11 +4,9 @@ import { createRemoteFetch, createRemoteLogger, } from "@inspector/core/mcp/remote/index.js"; -import { - getBrowserOAuthStorage, - BrowserNavigation, -} from "@inspector/core/auth/browser/index.js"; +import { BrowserNavigation } from "@inspector/core/auth/browser/index.js"; import type { RedirectUrlProvider } from "@inspector/core/auth/index.js"; +import { getWebRemoteOAuthStorage } from "./remoteOAuthStorage.js"; export interface WebEnvironmentResult { environment: InspectorClientEnvironment; @@ -20,16 +18,17 @@ export interface WebEnvironmentResult { * - transport / fetch / logger all routed through the in-process Hono * backend at `window.location.origin` (the `clients/web/server` * dev-backend wires this in `/api/*`). - * - OAuth storage + navigation use the `BrowserOAuthStorage` (sessionStorage) - * and `BrowserNavigation` (full-page redirect) adapters. + * - OAuth storage uses `RemoteOAuthStorage` (shared `oauth.json` via + * `/api/storage/oauth`); navigation uses `BrowserNavigation`. * * Returns both the assembled environment and the logger so callers can share * the same pino instance for any direct logging they need to do, instead of * reaching back through the client. * - * `authToken` is read from a higher level (currently unused in this app since - * v2 has no auth-token UI yet, but kept in the signature so the wiring is - * ready when token plumbing lands). + * `authToken` is supplied by `App.tsx` (`getAuthToken()`) and forwarded to + * remote transport/fetch/logger and `getWebRemoteOAuthStorage` so every + * `/api/*` call (including `/api/storage/oauth`) carries `x-mcp-remote-auth` + * when the backend requires it. * * `onBeforeOAuthRedirect` runs synchronously immediately before the OAuth * full-page redirect (see `BrowserNavigation`). The app uses it to flush the @@ -67,7 +66,7 @@ export function createWebEnvironment( }), logger, oauth: { - storage: getBrowserOAuthStorage(), + storage: getWebRemoteOAuthStorage(authToken), navigation: new BrowserNavigation(undefined, onBeforeOAuthRedirect), redirectUrlProvider, }, diff --git a/clients/web/src/lib/remoteOAuthStorage.test.ts b/clients/web/src/lib/remoteOAuthStorage.test.ts new file mode 100644 index 00000000..0077480c --- /dev/null +++ b/clients/web/src/lib/remoteOAuthStorage.test.ts @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + getRemoteOAuthStorage, + getWebRemoteOAuthStorage, + resetWebRemoteOAuthStorageCacheForTests, +} from "./remoteOAuthStorage"; + +describe("remoteOAuthStorage", () => { + afterEach(() => { + resetWebRemoteOAuthStorageCacheForTests(); + }); + + it("returns one RemoteOAuthStorage instance per cache key", () => { + const a = getRemoteOAuthStorage({ + baseUrl: "http://127.0.0.1:6277", + authToken: "tok-a", + }); + const aAgain = getRemoteOAuthStorage({ + baseUrl: "http://127.0.0.1:6277", + authToken: "tok-a", + }); + const b = getRemoteOAuthStorage({ + baseUrl: "http://127.0.0.1:6277", + authToken: "tok-b", + }); + + expect(aAgain).toBe(a); + expect(b).not.toBe(a); + }); + + it("getWebRemoteOAuthStorage uses window.location origin", () => { + vi.stubGlobal("window", { + location: { + protocol: "http:", + host: "127.0.0.1:6299", + }, + }); + + const storage = getWebRemoteOAuthStorage("smoke-web-token"); + const again = getWebRemoteOAuthStorage("smoke-web-token"); + + expect(again).toBe(storage); + vi.unstubAllGlobals(); + }); + + it("throws when window is unavailable", () => { + vi.stubGlobal("window", undefined); + expect(() => getWebRemoteOAuthStorage()).toThrow( + "getWebRemoteOAuthStorage requires a browser environment", + ); + vi.unstubAllGlobals(); + }); + + it("creates a new instance after the test cache reset", () => { + const first = getRemoteOAuthStorage({ + baseUrl: "http://127.0.0.1:6277", + authToken: "reset-me", + }); + resetWebRemoteOAuthStorageCacheForTests(); + const second = getRemoteOAuthStorage({ + baseUrl: "http://127.0.0.1:6277", + authToken: "reset-me", + }); + expect(second).not.toBe(first); + }); +}); diff --git a/clients/web/src/lib/remoteOAuthStorage.ts b/clients/web/src/lib/remoteOAuthStorage.ts new file mode 100644 index 00000000..0d95806b --- /dev/null +++ b/clients/web/src/lib/remoteOAuthStorage.ts @@ -0,0 +1,55 @@ +import { RemoteOAuthStorage } from "@inspector/core/auth/remote/storage-remote.js"; + +export interface WebRemoteOAuthStorageOptions { + baseUrl: string; + authToken?: string; +} + +let cached: { cacheKey: string; storage: RemoteOAuthStorage } | undefined; + +function buildCacheKey(options: WebRemoteOAuthStorageOptions): string { + return `${options.baseUrl}\0${options.authToken ?? ""}`; +} + +const defaultFetch: typeof fetch = (...args) => globalThis.fetch(...args); + +/** + * Shared web OAuth store: `RemoteOAuthStorage` → `GET/POST /api/storage/oauth` + * → `~/.mcp-inspector/storage/oauth.json` (same file as CLI/TUI). + * + * Memoized by `{ baseUrl, authToken }` so connect, EMA IdP session, and + * per-server clear all mutate the same in-memory view. + */ +export function getRemoteOAuthStorage( + options: WebRemoteOAuthStorageOptions, +): RemoteOAuthStorage { + const cacheKey = buildCacheKey(options); + if (cached?.cacheKey === cacheKey) { + return cached.storage; + } + cached = { + cacheKey, + storage: new RemoteOAuthStorage({ + baseUrl: options.baseUrl, + authToken: options.authToken, + fetchFn: defaultFetch, + }), + }; + return cached.storage; +} + +/** Current origin + optional API token (see `getAuthToken()` in App.tsx). */ +export function getWebRemoteOAuthStorage( + authToken?: string, +): RemoteOAuthStorage { + if (typeof window === "undefined") { + throw new Error("getWebRemoteOAuthStorage requires a browser environment"); + } + const baseUrl = `${window.location.protocol}//${window.location.host}`; + return getRemoteOAuthStorage({ baseUrl, authToken }); +} + +/** @internal Vitest isolation */ +export function resetWebRemoteOAuthStorageCacheForTests(): void { + cached = undefined; +} 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 f009b4bd..6429811c 100644 --- a/clients/web/src/test/core/auth/storage-browser.test.ts +++ b/clients/web/src/test/core/auth/storage-browser.test.ts @@ -1,5 +1,8 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { BrowserOAuthStorage } from "@inspector/core/auth/browser/storage.js"; +import { + BrowserOAuthStorage, + getBrowserOAuthStorage, +} from "@inspector/core/auth/browser/storage.js"; import type { OAuthClientInformation, OAuthTokens, @@ -41,6 +44,14 @@ const mockSessionStorage = new MockSessionStorage(); (global as typeof globalThis & { sessionStorage?: Storage }).sessionStorage = mockSessionStorage; +describe("getBrowserOAuthStorage", () => { + it("returns the same singleton on repeated calls", () => { + const first = getBrowserOAuthStorage(); + const second = getBrowserOAuthStorage(); + expect(second).toBe(first); + }); +}); + describe("BrowserOAuthStorage", () => { let storage: BrowserOAuthStorage; const testServerUrl = "http://localhost:3000"; diff --git a/clients/web/src/utils/clearServerOAuthState.test.ts b/clients/web/src/utils/clearServerOAuthState.test.ts index 6cdbfeec..47f44e5d 100644 --- a/clients/web/src/utils/clearServerOAuthState.test.ts +++ b/clients/web/src/utils/clearServerOAuthState.test.ts @@ -1,14 +1,16 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { getBrowserOAuthStorage } from "@inspector/core/auth/browser/index.js"; +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { BrowserOAuthStorage } from "@inspector/core/auth/browser/storage.js"; import { clearServerOAuthState } from "./clearServerOAuthState"; describe("clearServerOAuthState", () => { + let storage: BrowserOAuthStorage; + beforeEach(() => { - getBrowserOAuthStorage().clear("https://mcp.example.com/mcp"); + storage = new BrowserOAuthStorage(); + storage.clear("https://mcp.example.com/mcp"); }); it("clears storage by server URL when not the active connection", async () => { - const storage = getBrowserOAuthStorage(); await storage.saveTokens("https://mcp.example.com/mcp", { access_token: "tok", token_type: "Bearer", @@ -17,6 +19,7 @@ describe("clearServerOAuthState", () => { const cleared = clearServerOAuthState({ config: { type: "streamable-http", url: "https://mcp.example.com/mcp" }, isActiveConnection: false, + oauthStorage: storage, }); expect(cleared).toBe(true); @@ -26,18 +29,18 @@ describe("clearServerOAuthState", () => { }); it("uses the live client when clearing the active connection", () => { - const inspectorClient = { - clearOAuthTokens: vi.fn(), - }; + const clearOAuthTokens = vi.fn<() => void>(); + const inspectorClient = { clearOAuthTokens }; const cleared = clearServerOAuthState({ config: { type: "streamable-http", url: "https://mcp.example.com/mcp" }, - inspectorClient: inspectorClient as never, + inspectorClient, isActiveConnection: true, + oauthStorage: storage, }); expect(cleared).toBe(true); - expect(inspectorClient.clearOAuthTokens).toHaveBeenCalledTimes(1); + expect(clearOAuthTokens).toHaveBeenCalledTimes(1); }); it("returns false for stdio servers", () => { @@ -45,6 +48,7 @@ describe("clearServerOAuthState", () => { clearServerOAuthState({ config: { type: "stdio", command: "node", args: [] }, isActiveConnection: false, + oauthStorage: storage, }), ).toBe(false); }); diff --git a/clients/web/src/utils/clearServerOAuthState.ts b/clients/web/src/utils/clearServerOAuthState.ts index a3fd9d7c..0e22dd5a 100644 --- a/clients/web/src/utils/clearServerOAuthState.ts +++ b/clients/web/src/utils/clearServerOAuthState.ts @@ -1,4 +1,4 @@ -import { getBrowserOAuthStorage } from "@inspector/core/auth/browser/index.js"; +import type { OAuthStorage } from "@inspector/core/auth/storage.js"; import { getOAuthServerUrl } from "@inspector/core/mcp/config.js"; import type { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; import type { MCPServerConfig } from "@inspector/core/mcp/types.js"; @@ -6,8 +6,10 @@ import type { MCPServerConfig } from "@inspector/core/mcp/types.js"; export interface ClearServerOAuthStateParams { config: MCPServerConfig; /** When set and this server is the active connection, clear via the live client. */ - inspectorClient?: InspectorClient | null; + inspectorClient?: Pick | null; isActiveConnection: boolean; + /** Shared web OAuth store; required so clear hits the same blob as connect. */ + oauthStorage: OAuthStorage; } /** @@ -26,7 +28,7 @@ export function clearServerOAuthState( if (params.isActiveConnection && params.inspectorClient) { params.inspectorClient.clearOAuthTokens(); } else { - getBrowserOAuthStorage().clear(serverUrl); + params.oauthStorage.clear(serverUrl); } return true; } diff --git a/clients/web/vite.config.ts b/clients/web/vite.config.ts index d3b99f93..e59e1f7d 100644 --- a/clients/web/vite.config.ts +++ b/clients/web/vite.config.ts @@ -100,6 +100,7 @@ export default defineConfig(({ command }) => { reporter: ['text', 'html', 'json-summary'], include: [ 'src/components/**/*.{ts,tsx}', + 'src/lib/**/*.{ts,tsx}', 'src/utils/**/*.{ts,tsx}', 'clients/web/server/**/*.{ts,tsx}', path.join(repoRoot, 'core/mcp/**/*.{ts,tsx}'), diff --git a/specification/v2_auth_ema.md b/specification/v2_auth_ema.md index 2c38693f..da4d8f02 100644 --- a/specification/v2_auth_ema.md +++ b/specification/v2_auth_ema.md @@ -111,12 +111,11 @@ Until **client profiles** (see below) land, install-level client config — star **Runtime auth state** (standard OAuth tokens, PKCE, **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 EMA today. **Follow-up:** shared file-backed OAuth via `RemoteOAuthStorage` → `/api/storage/oauth` so web matches CLI/TUI (see §Follow-up work). +Web uses shared file-backed OAuth via `RemoteOAuthStorage` → `/api/storage/oauth` (same `oauth.json` as CLI/TUI). Existing **sessionStorage** OAuth blobs from before that wiring are not migrated automatically — users start fresh or re-authorize once. | 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` | +| **Web** | `RemoteStorage` adapter → `/api/storage/client` | `RemoteOAuthStorage` → `/api/storage/oauth` → `~/.mcp-inspector/storage/oauth.json` (via `getWebRemoteOAuthStorage()` in `clients/web/src/lib/remoteOAuthStorage.ts`) | | **CLI / TUI** | `NodeClientStorage` (file adapter) | `NodeOAuthStorage` → `oauth.json` | On-disk shape (initial): @@ -299,7 +298,7 @@ Install-level IdP credentials are edited in **Client Settings**, separate from p 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). +- **Web OAuth store singleton:** web uses `getWebRemoteOAuthStorage()` (`clients/web/src/lib/remoteOAuthStorage.ts`) — memoized `RemoteOAuthStorage` backed by `/api/storage/oauth` — so Client Settings sign-out, EMA IdP session, connect, and per-server clear all mutate the same in-memory Zustand view as the active `InspectorClient`. **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. @@ -347,7 +346,7 @@ Leg 1 is **OIDC authorization-code login** against the enterprise IdP using cred | 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` | +| **Web** | Browser redirect via `navigation`; callback at `/oauth/callback`; `InspectorClient.completeOAuthFlow(code)`; `RemoteOAuthStorage` → shared `oauth.json` | | **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. @@ -422,7 +421,8 @@ Inspector touchpoints to extend: - `core/mcp/oauthManager.ts` — branch on `enterpriseManaged`; EMA connect via `emaFlow`; `getOAuthState()`; `createOAuthProvider()` returns `EmaTransportOAuthProvider` for EMA servers (401 re-auth); standard OAuth 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/src/lib/remoteOAuthStorage.ts` — `getWebRemoteOAuthStorage()` singleton (web OAuth runtime store) +- `core/auth/browser/storage.ts` — `BrowserOAuthStorage` / `getBrowserOAuthStorage()` (sessionStorage; reference provider only, not wired in v2 web app) - `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 - `clients/cli`, `clients/tui` — load `client.json` at startup; pass into `InspectorClient`; leg 1 via same `OAuthCallbackServer` + `NodeOAuthStorage` stack as TUI standard OAuth today @@ -515,7 +515,7 @@ Design decisions for EMA are complete. Remaining work is the phased plan and che ### Later - [ ] **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 +- [x] **Web shared OAuth store** — `RemoteOAuthStorage` / `oauth.json` for web + CLI + TUI parity (#1548); 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) @@ -558,24 +558,26 @@ These items came out of EMA staging and apply beyond EMA. They are **not** requi 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) +- A second in-memory copy and async persist semantics that complicate sign-out, callback rebuild, and multi-consumer sharing (web uses `getWebRemoteOAuthStorage()`; legacy `getBrowserOAuthStorage()` existed partly to paper over this for sessionStorage) **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: +**Status (July 2026):** Web is wired through `RemoteOAuthStorage` (`environmentFactory.ts`, `App.tsx`, `getWebRemoteOAuthStorage()`). CLI/TUI continue to use `NodeOAuthStorage` on the same on-disk file when using the default local backend. | Client | OAuth runtime store | | ------ | ------------------- | -| **Web** | `BrowserOAuthStorage` → **sessionStorage** (tab-local; lost on new tab / callback page load relies on same tab) | +| **Web** | `RemoteOAuthStorage` → `/api/storage/oauth` → `~/.mcp-inspector/storage/oauth.json` | | **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`). +`RemoteOAuthStorage` (`core/auth/remote/storage-remote.ts`) 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. +**Benefits:** IdP session and EMA resource tokens survive browser refresh; sign-out in Client Settings and connect use the same store; EMA testing matches CLI/TUI behavior. -**Migration:** one-time import or “start fresh” for existing sessionStorage OAuth blobs; document that enabling shared storage is the default for local dev. +**Migration:** no automatic import from old sessionStorage OAuth blobs; document that local dev uses shared `oauth.json` by default. + +**Optional follow-up:** `navigator.locks` for cross-tab single-flight on silent refresh (see [Mid-session auth](v2_auth_mid_session.md)). --- diff --git a/specification/v2_auth_mid_session.md b/specification/v2_auth_mid_session.md index 7b1cf3a9..0fdc5b56 100644 --- a/specification/v2_auth_mid_session.md +++ b/specification/v2_auth_mid_session.md @@ -338,7 +338,7 @@ Snapshots **per app tab UI**, not message logs, tool results, or network bodies. ### Multiple browser tabs -Each browser tab has its own `RemoteSession` and (today) `BrowserOAuthStorage` in `sessionStorage`. SSE `auth_challenge` is scoped to that session. +Each browser tab has its own `RemoteSession`. OAuth runtime state is shared file-backed (`RemoteOAuthStorage` → `oauth.json`) across tabs on the same backend; SSE `auth_challenge` is scoped to that tab's session. When a tab is **hidden**, **interactive** OAuth must not steal focus: @@ -413,7 +413,7 @@ Legacy **SSE** transport: **401 only** on mid-session `send()` (no 403 step-up). | ------- | --- | --- | --- | | Challenge detection | Inline send + SSE ambient | SDK transport + intercept | Same as TUI | | Auth execution | Browser `OAuthManager` | Node `OAuthManager` | Node `OAuthManager` | -| OAuth storage | `BrowserOAuthStorage` (sessionStorage) | `NodeOAuthStorage` (file) | Same file as TUI | +| OAuth storage | `RemoteOAuthStorage` → `oauth.json` (via `/api/storage/oauth`) | `NodeOAuthStorage` (file) | Same file as TUI | | Silent recovery | `auth-state` push + send retry | Reconnect / SDK retry (v2) | One-shot RPC retry | | Interactive recovery | Modal + full-page redirect + snapshot | Auth tab + callback | stderr **y/N** + callback | | Step-up confirm | `StepUpAuthModal` | Auth tab **A** / **C** | **y/N** |