refactor: migrate to RemoteOAuthStorage for web OAuth handling

This commit is contained in:
Bob Dickinson 2026-07-03 13:11:13 -07:00
commit 7bd26c938e
4 changed files with 317 additions and 44 deletions

View file

@ -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",
);
});
});

View file

@ -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,
},

View file

@ -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);
});
});

View file

@ -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;
}