Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion clients/tui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -896,7 +896,7 @@ function App({

const handleClearOAuth = useCallback(async () => {
if (!selectedInspectorClient) return;
selectedInspectorClient.clearOAuthTokens();
await selectedInspectorClient.clearOAuthTokens();
setOauthStatus("idle");
setOauthMessage(null);
setConnectError(null);
Expand Down
34 changes: 26 additions & 8 deletions clients/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -2183,6 +2191,15 @@ function App() {
}

void (async () => {
try {
await webOAuthStorage.load();
} catch (err) {
connectStartRef.current = undefined;
queueMicrotask(() => {
showReAuthBanner(server.id, err instanceof Error ? err : String(err));
});
return;
}
const client = setupClientForServer(server, sessionId);
setActiveServerId(server.id);
try {
Expand Down Expand Up @@ -2238,7 +2255,7 @@ function App() {
});
}
})();
}, [servers, setupClientForServer, showReAuthBanner]);
}, [servers, setupClientForServer, showReAuthBanner, webOAuthStorage]);

const onToggleConnection = useCallback(
async (id: string) => {
Expand Down Expand Up @@ -3239,10 +3256,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,
Expand All @@ -3266,10 +3282,11 @@ function App() {
const clearServerOAuthAndDisconnect = useCallback(
async (server: { id: string; name: string; config: MCPServerConfig }) => {
const isActive = server.id === activeServerId;
const cleared = clearServerOAuthState({
const cleared = await clearServerOAuthState({
config: server.config,
inspectorClient: isActive ? inspectorClient : null,
isActiveConnection: isActive,
oauthStorage: webOAuthStorage,
});
if (!cleared) return;

Expand All @@ -3295,6 +3312,7 @@ function App() {
[
activeServerId,
inspectorClient,
webOAuthStorage,
finalizeExplicitDisconnect,
clearOAuthResumeOnExplicitDisconnect,
],
Expand Down
124 changes: 124 additions & 0 deletions clients/web/src/lib/environmentFactory.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof fetch>().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",
);
});
});
19 changes: 9 additions & 10 deletions clients/web/src/lib/environmentFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -67,7 +66,7 @@ export function createWebEnvironment(
}),
logger,
oauth: {
storage: getBrowserOAuthStorage(),
storage: getWebRemoteOAuthStorage(authToken),
navigation: new BrowserNavigation(undefined, onBeforeOAuthRedirect),
redirectUrlProvider,
},
Expand Down
66 changes: 66 additions & 0 deletions clients/web/src/lib/remoteOAuthStorage.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
55 changes: 55 additions & 0 deletions clients/web/src/lib/remoteOAuthStorage.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading