Merge pull request #1609 from modelcontextprotocol/v2/1548-web-remote-oauth-storage

refactor: migrate to RemoteOAuthStorage for web OAuth handling
This commit is contained in:
Cliff Hall 2026-07-08 13:38:34 -04:00 • committed by GitHub
commit 87133bd42d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 714 additions and 177 deletions

View file

@ -896,7 +896,7 @@ function App({
const handleClearOAuth = useCallback(async () => {
if (!selectedInspectorClient) return;
selectedInspectorClient.clearOAuthTokens();
await selectedInspectorClient.clearOAuthTokens();
setOauthStatus("idle");
setOauthMessage(null);
setConnectError(null);

View file

@ -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;
@ -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 {
@ -2238,7 +2255,7 @@ function App() {
});
}
})();
}, [servers, setupClientForServer, showReAuthBanner]);
}, [servers, setupClientForServer, showReAuthBanner, webOAuthStorage]);
const onToggleConnection = useCallback(
async (id: string) => {
@ -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,
@ -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;
@ -3295,6 +3312,7 @@ function App() {
[
activeServerId,
inspectorClient,
webOAuthStorage,
finalizeExplicitDisconnect,
clearOAuthResumeOnExplicitDisconnect,
],

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,59 @@
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).
*
* Caches a single instance keyed by `{ baseUrl, authToken }`: repeat calls with
* the same key return that instance, so connect, EMA IdP session, and per-server
* clear all mutate the same in-memory view. A call with a different key replaces
* the cached instance (cache of one). That is sufficient because the web app
* uses a stable origin + page-lifetime API token, so the key never changes
* within a session.
*/
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;
}

View file

@ -22,6 +22,7 @@ function createStorage(
}> = {},
): OAuthStorage {
return {
load: vi.fn().mockResolvedValue(undefined),
getTokens: vi.fn().mockResolvedValue(overrides.tokens),
getClientInformation: vi.fn(async (_url, isPreregistered) =>
isPreregistered ? overrides.preregistered : overrides.dynamic,

View file

@ -43,6 +43,7 @@ function createMemoryStorage(
const clientInfoByKey: Record<string, unknown> = {};
const codeVerifierByKey: Record<string, string> = {};
return {
load: vi.fn().mockResolvedValue(undefined),
getIdpSession: vi.fn(async (issuer: string) => idpSessions[issuer]),
saveIdpSession: vi.fn(async (issuer: string, updates) => {
idpSessions[issuer] = { ...idpSessions[issuer], ...updates };

View file

@ -29,6 +29,7 @@ describe("idpOidc refresh", () => {
beforeEach(() => {
Object.keys(idpSessions).forEach((k) => delete idpSessions[k]);
storage = {
load: vi.fn().mockResolvedValue(undefined),
getIdpSession: vi.fn(async (issuer: string) => idpSessions[issuer]),
saveIdpSession: vi.fn(async (issuer: string, updates) => {
idpSessions[issuer] = { ...idpSessions[issuer], ...updates };
@ -264,6 +265,8 @@ describe("startIdpOidcAuthorization", () => {
beforeEach(() => {
Object.keys(saved).forEach((k) => delete saved[k]);
storage = {
load: vi.fn().mockResolvedValue(undefined),
getServerMetadata: vi.fn(() => null),
saveCodeVerifier: vi.fn(async (key: string, value: string) => {
saved[`cv:${key}`] = value;
}),
@ -331,6 +334,7 @@ describe("completeIdpOidcAuthorization", () => {
function buildStorage(overrides: Partial<OAuthStorage> = {}): OAuthStorage {
return {
load: vi.fn().mockResolvedValue(undefined),
getServerMetadata: vi.fn(() => minimalOAuthAsMetadata(IDP_ISSUER)),
getClientInformation: vi.fn(async () => ({
client_id: "idp-client",

View file

@ -19,6 +19,7 @@ describe("idpSession", () => {
beforeEach(() => {
storage = {
load: vi.fn().mockResolvedValue(undefined),
getIdpSession: vi.fn(),
saveIdpSession: vi.fn(),
clearIdpSession: vi.fn(),
@ -73,15 +74,15 @@ describe("idpSession", () => {
);
});
it("clearEmaIdpSession clears idp session, leg-1 key, and tagged resource servers", () => {
clearEmaIdpSession(storage, "https://idp.test/");
it("clearEmaIdpSession clears idp session, leg-1 key, and tagged resource servers", async () => {
await 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();
});
it("clearEmaIdpSession no-ops when issuer normalizes to empty", () => {
clearEmaIdpSession(storage, "");
it("clearEmaIdpSession no-ops when issuer normalizes to empty", async () => {
await clearEmaIdpSession(storage, "");
expect(storage.clearIdpSession).not.toHaveBeenCalled();
expect(storage.clear).not.toHaveBeenCalled();
expect(

View file

@ -213,6 +213,7 @@ describe("OAuthNavigation", () => {
function makeStorage(): OAuthStorage {
return {
load: vi.fn().mockResolvedValue(undefined),
getScope: vi.fn(() => undefined),
getClientInformation: vi.fn(async () => undefined),
saveClientInformation: vi.fn(async () => undefined),
@ -240,11 +241,11 @@ describe("OAuthNavigation", () => {
return new BaseOAuthClientProvider(SERVER, config);
}
it("clear() delegates to storage.clear with the server url", () => {
it("clear() delegates to storage.clear with the server url", async () => {
const storage = makeStorage();
const provider = makeProvider(storage);
provider.clear();
await provider.clear();
expect(storage.clear).toHaveBeenCalledWith(SERVER);
});

View file

@ -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";
@ -66,7 +77,7 @@ describe("BrowserOAuthStorage", () => {
client_secret: "test-secret",
};
storage.saveClientInformation(testServerUrl, clientInfo, {
await storage.saveClientInformation(testServerUrl, clientInfo, {
registrationKind: "dcr",
});
const result = await storage.getClientInformation(testServerUrl);
@ -82,7 +93,7 @@ describe("BrowserOAuthStorage", () => {
// Use the storage API instead of manually setting sessionStorage
// since BrowserOAuthStorage now uses Zustand with a different storage format
storage.savePreregisteredClientInformation(
await storage.savePreregisteredClientInformation(
testServerUrl,
preregisteredInfo,
);
@ -102,7 +113,7 @@ describe("BrowserOAuthStorage", () => {
client_id: "test-client-id",
};
storage.saveClientInformation(testServerUrl, clientInfo, {
await storage.saveClientInformation(testServerUrl, clientInfo, {
registrationKind: "dcr",
});
const result = await storage.getClientInformation(testServerUrl);
@ -119,10 +130,10 @@ describe("BrowserOAuthStorage", () => {
client_id: "second-id",
};
storage.saveClientInformation(testServerUrl, firstInfo, {
await storage.saveClientInformation(testServerUrl, firstInfo, {
registrationKind: "dcr",
});
storage.saveClientInformation(testServerUrl, secondInfo, {
await storage.saveClientInformation(testServerUrl, secondInfo, {
registrationKind: "cimd",
});
const result = await storage.getClientInformation(testServerUrl);
@ -152,7 +163,7 @@ describe("BrowserOAuthStorage", () => {
expires_in: 3600,
};
storage.saveTokens(testServerUrl, tokens);
await storage.saveTokens(testServerUrl, tokens);
const result = await storage.getTokens(testServerUrl);
expect(result).toEqual(tokens);
@ -166,7 +177,7 @@ describe("BrowserOAuthStorage", () => {
token_type: "Bearer",
};
storage.saveTokens(testServerUrl, tokens);
await storage.saveTokens(testServerUrl, tokens);
const result = await storage.getTokens(testServerUrl);
expect(result).toEqual(tokens);
@ -183,7 +194,7 @@ describe("BrowserOAuthStorage", () => {
await storage.saveTokens(emaUrl, tokens, { enterpriseManaged: true });
await storage.saveTokens(standardUrl, tokens);
storage.clearEnterpriseManagedResourceServers();
await storage.clearEnterpriseManagedResourceServers();
expect(await storage.getTokens(emaUrl)).toBeUndefined();
expect(await storage.getTokens(standardUrl)).toEqual(tokens);
@ -199,7 +210,7 @@ describe("BrowserOAuthStorage", () => {
it("should return stored code verifier", async () => {
const codeVerifier = "test-code-verifier";
storage.saveCodeVerifier(testServerUrl, codeVerifier);
await storage.saveCodeVerifier(testServerUrl, codeVerifier);
const result = await storage.getCodeVerifier(testServerUrl);
expect(result).toBe(codeVerifier);
@ -210,7 +221,7 @@ describe("BrowserOAuthStorage", () => {
it("should save code verifier", async () => {
const codeVerifier = "test-code-verifier";
storage.saveCodeVerifier(testServerUrl, codeVerifier);
await storage.saveCodeVerifier(testServerUrl, codeVerifier);
const result = await storage.getCodeVerifier(testServerUrl);
expect(result).toBe(codeVerifier);
@ -226,7 +237,7 @@ describe("BrowserOAuthStorage", () => {
it("should return stored scope", async () => {
const scope = "read write";
storage.saveScope(testServerUrl, scope);
await storage.saveScope(testServerUrl, scope);
const result = await storage.getScope(testServerUrl);
expect(result).toBe(scope);
@ -237,7 +248,7 @@ describe("BrowserOAuthStorage", () => {
it("should save scope", async () => {
const scope = "read write";
storage.saveScope(testServerUrl, scope);
await storage.saveScope(testServerUrl, scope);
const result = await storage.getScope(testServerUrl);
expect(result).toBe(scope);
@ -258,7 +269,7 @@ describe("BrowserOAuthStorage", () => {
response_types_supported: ["code"],
};
storage.saveServerMetadata(testServerUrl, metadata);
await storage.saveServerMetadata(testServerUrl, metadata);
const result = await storage.getServerMetadata(testServerUrl);
expect(result).toEqual(metadata);
@ -274,7 +285,7 @@ describe("BrowserOAuthStorage", () => {
response_types_supported: ["code"],
};
storage.saveServerMetadata(testServerUrl, metadata);
await storage.saveServerMetadata(testServerUrl, metadata);
const result = await storage.getServerMetadata(testServerUrl);
expect(result).toEqual(metadata);
@ -283,7 +294,7 @@ describe("BrowserOAuthStorage", () => {
describe("clearClientInformation", () => {
it("removes the dynamically-registered client info by default", async () => {
storage.saveClientInformation(
await storage.saveClientInformation(
testServerUrl,
{ client_id: "dyn" },
{ registrationKind: "dcr" },
@ -291,18 +302,18 @@ describe("BrowserOAuthStorage", () => {
expect(await storage.getClientInformation(testServerUrl)).toEqual({
client_id: "dyn",
});
storage.clearClientInformation(testServerUrl);
await storage.clearClientInformation(testServerUrl);
expect(await storage.getClientInformation(testServerUrl)).toBeUndefined();
});
it("removes the preregistered client info when isPreregistered=true", async () => {
storage.savePreregisteredClientInformation(testServerUrl, {
await storage.savePreregisteredClientInformation(testServerUrl, {
client_id: "pre",
});
expect(await storage.getClientInformation(testServerUrl, true)).toEqual({
client_id: "pre",
});
storage.clearClientInformation(testServerUrl, true);
await storage.clearClientInformation(testServerUrl, true);
expect(
await storage.getClientInformation(testServerUrl, true),
).toBeUndefined();
@ -311,26 +322,26 @@ describe("BrowserOAuthStorage", () => {
describe("individual clear methods", () => {
it("clearTokens removes only tokens", async () => {
storage.saveTokens(testServerUrl, {
await storage.saveTokens(testServerUrl, {
access_token: "t",
token_type: "Bearer",
});
expect(await storage.getTokens(testServerUrl)).toBeDefined();
storage.clearTokens(testServerUrl);
await storage.clearTokens(testServerUrl);
expect(await storage.getTokens(testServerUrl)).toBeUndefined();
});
it("clearCodeVerifier removes only the PKCE verifier", async () => {
storage.saveCodeVerifier(testServerUrl, "verifier");
await storage.saveCodeVerifier(testServerUrl, "verifier");
expect(storage.getCodeVerifier(testServerUrl)).toBe("verifier");
storage.clearCodeVerifier(testServerUrl);
await storage.clearCodeVerifier(testServerUrl);
expect(storage.getCodeVerifier(testServerUrl)).toBeUndefined();
});
it("clearScope removes only the scope", async () => {
storage.saveScope(testServerUrl, "read");
await storage.saveScope(testServerUrl, "read");
expect(storage.getScope(testServerUrl)).toBe("read");
storage.clearScope(testServerUrl);
await storage.clearScope(testServerUrl);
expect(storage.getScope(testServerUrl)).toBeUndefined();
});
@ -341,9 +352,9 @@ describe("BrowserOAuthStorage", () => {
token_endpoint: "http://localhost:3000/token",
response_types_supported: ["code"],
};
storage.saveServerMetadata(testServerUrl, metadata);
await storage.saveServerMetadata(testServerUrl, metadata);
expect(storage.getServerMetadata(testServerUrl)).toEqual(metadata);
storage.clearServerMetadata(testServerUrl);
await storage.clearServerMetadata(testServerUrl);
expect(storage.getServerMetadata(testServerUrl)).toBeNull();
});
});
@ -358,12 +369,12 @@ describe("BrowserOAuthStorage", () => {
token_type: "Bearer",
};
storage.saveClientInformation(testServerUrl, clientInfo, {
await storage.saveClientInformation(testServerUrl, clientInfo, {
registrationKind: "dcr",
});
storage.saveTokens(testServerUrl, tokens);
await storage.saveTokens(testServerUrl, tokens);
storage.clear(testServerUrl);
await storage.clear(testServerUrl);
expect(await storage.getClientInformation(testServerUrl)).toBeUndefined();
expect(await storage.getTokens(testServerUrl)).toBeUndefined();
@ -375,14 +386,14 @@ describe("BrowserOAuthStorage", () => {
client_id: "test-client-id",
};
storage.saveClientInformation(testServerUrl, clientInfo, {
await storage.saveClientInformation(testServerUrl, clientInfo, {
registrationKind: "dcr",
});
storage.saveClientInformation(otherServerUrl, clientInfo, {
await storage.saveClientInformation(otherServerUrl, clientInfo, {
registrationKind: "dcr",
});
storage.clear(testServerUrl);
await storage.clear(testServerUrl);
expect(await storage.getClientInformation(testServerUrl)).toBeUndefined();
expect(await storage.getClientInformation(otherServerUrl)).toEqual(
@ -404,10 +415,10 @@ describe("BrowserOAuthStorage", () => {
client_id: "client-2",
};
storage.saveClientInformation(server1Url, clientInfo1, {
await storage.saveClientInformation(server1Url, clientInfo1, {
registrationKind: "dcr",
});
storage.saveClientInformation(server2Url, clientInfo2, {
await storage.saveClientInformation(server2Url, clientInfo2, {
registrationKind: "dcr",
});

View file

@ -1,5 +1,9 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { RemoteOAuthStorage } from "@inspector/core/auth/remote/storage-remote.js";
import {
type OAuthStore,
waitForOAuthStorePersistLoad,
} from "@inspector/core/auth/store.js";
const NOOP_FETCH = vi.fn(
async () =>
@ -50,7 +54,7 @@ describe("RemoteOAuthStorage (unit, mocked fetch)", () => {
{ client_id: "dyn" },
{ registrationKind: "dcr" },
);
storage.clearClientInformation(serverUrl);
await storage.clearClientInformation(serverUrl);
expect(await storage.getClientInformation(serverUrl)).toBeUndefined();
});
@ -58,7 +62,7 @@ describe("RemoteOAuthStorage (unit, mocked fetch)", () => {
await storage.savePreregisteredClientInformation(serverUrl, {
client_id: "pre",
});
storage.clearClientInformation(serverUrl, true);
await storage.clearClientInformation(serverUrl, true);
expect(await storage.getClientInformation(serverUrl, true)).toBeUndefined();
});
@ -66,21 +70,21 @@ describe("RemoteOAuthStorage (unit, mocked fetch)", () => {
const tokens = { access_token: "t", token_type: "Bearer" };
await storage.saveTokens(serverUrl, tokens);
expect(await storage.getTokens(serverUrl)).toEqual(tokens);
storage.clearTokens(serverUrl);
await storage.clearTokens(serverUrl);
expect(await storage.getTokens(serverUrl)).toBeUndefined();
});
it("codeVerifier round-trip and clearCodeVerifier", async () => {
await storage.saveCodeVerifier(serverUrl, "verifier");
expect(storage.getCodeVerifier(serverUrl)).toBe("verifier");
storage.clearCodeVerifier(serverUrl);
await storage.clearCodeVerifier(serverUrl);
expect(storage.getCodeVerifier(serverUrl)).toBeUndefined();
});
it("scope round-trip and clearScope", async () => {
await storage.saveScope(serverUrl, "read write");
expect(storage.getScope(serverUrl)).toBe("read write");
storage.clearScope(serverUrl);
await storage.clearScope(serverUrl);
expect(storage.getScope(serverUrl)).toBeUndefined();
});
@ -93,7 +97,7 @@ describe("RemoteOAuthStorage (unit, mocked fetch)", () => {
};
await storage.saveServerMetadata(serverUrl, md);
expect(storage.getServerMetadata(serverUrl)).toEqual(md);
storage.clearServerMetadata(serverUrl);
await storage.clearServerMetadata(serverUrl);
expect(storage.getServerMetadata(serverUrl)).toBeNull();
});
@ -107,7 +111,7 @@ describe("RemoteOAuthStorage (unit, mocked fetch)", () => {
access_token: "t",
token_type: "Bearer",
});
storage.clear(serverUrl);
await storage.clear(serverUrl);
expect(await storage.getClientInformation(serverUrl)).toBeUndefined();
expect(await storage.getTokens(serverUrl)).toBeUndefined();
});
@ -120,4 +124,61 @@ describe("RemoteOAuthStorage (unit, mocked fetch)", () => {
// No public accessor; constructing without throwing covers the default-branch.
expect(s).toBeInstanceOf(RemoteOAuthStorage);
});
it("waitForOAuthStorePersistLoad resolves for an unregistered store handle", async () => {
await expect(
waitForOAuthStorePersistLoad({} as OAuthStore),
).resolves.toBeUndefined();
});
it("load() waits for remote GET before sync reads see persisted state", async () => {
const persisted = {
state: {
servers: {
[serverUrl]: { codeVerifier: "persisted-verifier" },
},
idpSessions: {},
},
version: 0,
};
let resolveFetch!: (value: Response) => void;
const delayedFetch = vi.fn<typeof fetch>(
() =>
new Promise<Response>((resolve) => {
resolveFetch = resolve;
}),
);
const delayedStorage = new RemoteOAuthStorage({
baseUrl: "http://remote.example",
storeId: `delayed-${Math.random().toString(36).slice(2)}`,
fetchFn: delayedFetch,
});
const loadPromise = delayedStorage.load();
expect(delayedStorage.getCodeVerifier(serverUrl)).toBeUndefined();
resolveFetch(new Response(JSON.stringify(persisted), { status: 200 }));
await loadPromise;
expect(delayedStorage.getCodeVerifier(serverUrl)).toBe(
"persisted-verifier",
);
});
it("load() rejects when remote GET fails instead of hanging", async () => {
const failingFetch = vi.fn<typeof fetch>(
async () => new Response("server error", { status: 500 }),
);
const failingStorage = new RemoteOAuthStorage({
baseUrl: "http://remote.example",
storeId: `fail-${Math.random().toString(36).slice(2)}`,
fetchFn: failingFetch,
});
await expect(failingStorage.load()).rejects.toThrow(
"Failed to read store: 500",
);
});
});

View file

@ -35,6 +35,7 @@ function createMockParams(
const dispatchOAuthError = vi.fn();
const storage = {
load: vi.fn().mockResolvedValue(undefined),
getScope: vi.fn().mockReturnValue(undefined),
getClientInformation: vi.fn().mockResolvedValue(undefined),
getClientRegistrationKind: vi.fn().mockReturnValue(undefined),
@ -45,7 +46,7 @@ function createMockParams(
saveTokens: vi.fn().mockResolvedValue(undefined),
getCodeVerifier: vi.fn().mockReturnValue("verifier"),
saveCodeVerifier: vi.fn().mockResolvedValue(undefined),
clear: vi.fn(),
clear: vi.fn().mockResolvedValue(undefined),
clearClientInformation: vi.fn(),
clearTokens: vi.fn(),
clearCodeVerifier: vi.fn(),
@ -126,10 +127,10 @@ describe("OAuthManager", () => {
});
describe("clearOAuthTokens", () => {
it("calls storage.clear(serverUrl) when storage is configured", () => {
it("calls storage.clear(serverUrl) when storage is configured", async () => {
const params = createMockParams();
const manager = new OAuthManager(params);
manager.clearOAuthTokens();
await manager.clearOAuthTokens();
expect(params.initialConfig.storage!.clear).toHaveBeenCalledWith(
SERVER_URL,
);
@ -137,7 +138,7 @@ describe("OAuthManager", () => {
expect(manager.getOAuthFlowStep()).toBeUndefined();
});
it("no-ops when storage is not configured", () => {
it("no-ops when storage is not configured", async () => {
const params = createMockParams({
initialConfig: {
redirectUrlProvider: {
@ -147,7 +148,7 @@ describe("OAuthManager", () => {
} as OAuthManagerConfig,
});
const manager = new OAuthManager(params);
manager.clearOAuthTokens();
await manager.clearOAuthTokens();
expect(params.getServerUrl).not.toHaveBeenCalled();
});
});

View file

@ -8,10 +8,13 @@ describe("useEmaIdpLoginState", () => {
beforeEach(() => {
storage = {
load: vi.fn().mockResolvedValue(undefined),
getIdpSession: vi.fn().mockResolvedValue(undefined),
clearIdpSession: vi.fn(),
clear: vi.fn(),
clearEnterpriseManagedResourceServers: vi.fn(),
clearIdpSession: vi.fn().mockResolvedValue(undefined),
clear: vi.fn().mockResolvedValue(undefined),
clearEnterpriseManagedResourceServers: vi
.fn()
.mockResolvedValue(undefined),
} as unknown as OAuthStorage;
});
@ -56,10 +59,64 @@ describe("useEmaIdpLoginState", () => {
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");
await waitFor(() => {
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");
});
});
it("logout swallows a clear failure without an unhandled rejection and keeps 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`,
});
vi.mocked(storage.clearIdpSession).mockRejectedValue(
new Error("storage backend unreachable"),
);
const unhandled = vi.fn();
const onUnhandled = (event: PromiseRejectionEvent) => {
event.preventDefault();
unhandled();
};
window.addEventListener("unhandledrejection", onUnhandled);
try {
const { result } = renderHook(() =>
useEmaIdpLoginState(storage, "https://idp.test", true),
);
await waitFor(() => {
expect(result.current.loginState).toBe("logged_in");
});
act(() => {
result.current.logout();
});
await waitFor(() => {
expect(storage.clearIdpSession).toHaveBeenCalledWith(
"https://idp.test",
);
});
// Give the rejected promise a turn to settle so any unhandled rejection
// would have fired.
await act(async () => {
await Promise.resolve();
});
// Clear failed, so the session is still present: state stays "logged_in"
// and no unhandled rejection escaped.
expect(result.current.loginState).toBe("logged_in");
expect(unhandled).not.toHaveBeenCalled();
} finally {
window.removeEventListener("unhandledrejection", onUnhandled);
}
});
it("reports 'expired' for an expired token with no refresh token", async () => {

View file

@ -126,10 +126,10 @@ describe("NodeOAuthStorage", () => {
client_id: "second-id",
};
storage.saveClientInformation(testServerUrl, firstInfo, {
await storage.saveClientInformation(testServerUrl, firstInfo, {
registrationKind: "dcr",
});
storage.saveClientInformation(testServerUrl, secondInfo, {
await storage.saveClientInformation(testServerUrl, secondInfo, {
registrationKind: "dcr",
});
const result = await storage.getClientInformation(testServerUrl);
@ -310,7 +310,7 @@ describe("NodeOAuthStorage", () => {
expect(await storage.getClientInformation(testServerUrl)).toEqual({
client_id: "dyn",
});
storage.clearClientInformation(testServerUrl);
await storage.clearClientInformation(testServerUrl);
expect(await storage.getClientInformation(testServerUrl)).toBeUndefined();
});
@ -321,7 +321,7 @@ describe("NodeOAuthStorage", () => {
expect(await storage.getClientInformation(testServerUrl, true)).toEqual({
client_id: "pre",
});
storage.clearClientInformation(testServerUrl, true);
await storage.clearClientInformation(testServerUrl, true);
expect(
await storage.getClientInformation(testServerUrl, true),
).toBeUndefined();
@ -335,21 +335,21 @@ describe("NodeOAuthStorage", () => {
token_type: "Bearer",
});
expect(await storage.getTokens(testServerUrl)).toBeDefined();
storage.clearTokens(testServerUrl);
await storage.clearTokens(testServerUrl);
expect(await storage.getTokens(testServerUrl)).toBeUndefined();
});
it("clearCodeVerifier removes only the PKCE verifier", async () => {
await storage.saveCodeVerifier(testServerUrl, "verifier");
expect(storage.getCodeVerifier(testServerUrl)).toBe("verifier");
storage.clearCodeVerifier(testServerUrl);
await storage.clearCodeVerifier(testServerUrl);
expect(storage.getCodeVerifier(testServerUrl)).toBeUndefined();
});
it("clearScope removes only the scope", async () => {
await storage.saveScope(testServerUrl, "read write");
expect(storage.getScope(testServerUrl)).toBe("read write");
storage.clearScope(testServerUrl);
await storage.clearScope(testServerUrl);
expect(storage.getScope(testServerUrl)).toBeUndefined();
});
@ -362,7 +362,7 @@ describe("NodeOAuthStorage", () => {
};
await storage.saveServerMetadata(testServerUrl, metadata);
expect(storage.getServerMetadata(testServerUrl)).toEqual(metadata);
storage.clearServerMetadata(testServerUrl);
await storage.clearServerMetadata(testServerUrl);
expect(storage.getServerMetadata(testServerUrl)).toBeNull();
});
});
@ -382,7 +382,7 @@ describe("NodeOAuthStorage", () => {
});
await storage.saveTokens(testServerUrl, tokens);
storage.clear(testServerUrl);
await storage.clear(testServerUrl);
expect(await storage.getClientInformation(testServerUrl)).toBeUndefined();
expect(await storage.getTokens(testServerUrl)).toBeUndefined();
@ -401,7 +401,7 @@ describe("NodeOAuthStorage", () => {
registrationKind: "dcr",
});
storage.clear(testServerUrl);
await storage.clear(testServerUrl);
expect(await storage.getClientInformation(testServerUrl)).toBeUndefined();
const otherResult = await storage.getClientInformation(otherServerUrl);
@ -424,10 +424,10 @@ describe("NodeOAuthStorage", () => {
client_id: "client-2",
};
storage.saveClientInformation(server1Url, clientInfo1, {
await storage.saveClientInformation(server1Url, clientInfo1, {
registrationKind: "dcr",
});
storage.saveClientInformation(server2Url, clientInfo2, {
await storage.saveClientInformation(server2Url, clientInfo2, {
registrationKind: "dcr",
});
@ -547,7 +547,7 @@ describe("NodeOAuthStorage idpSessions (EMA)", () => {
expect(session?.idToken).toBe("eyJ.id.token");
expect(session?.refreshToken).toBe("rt-1");
storage.clearIdpSession(issuer);
await storage.clearIdpSession(issuer);
await flushStoreFileWrites(testStatePath);
expect(await storage.getIdpSession(issuer)).toBeUndefined();
});
@ -635,12 +635,10 @@ describe("NodeOAuthStorage with custom storagePath", () => {
);
try {
const defaultStore = getOAuthStore();
defaultStore.getState().setServerState(testServerUrl, {
tokens: {
access_token: "default-token",
token_type: "Bearer",
},
const defaultStorage = new NodeOAuthStorage();
await defaultStorage.saveTokens(testServerUrl, {
access_token: "default-token",
token_type: "Bearer",
});
const customStorage = new NodeOAuthStorage(customPath);
@ -652,11 +650,10 @@ describe("NodeOAuthStorage with custom storagePath", () => {
const fromCustom = await customStorage.getTokens(testServerUrl);
expect(fromCustom?.access_token).toBe("custom-token");
const defaultStorage = new NodeOAuthStorage();
const fromDefault = await defaultStorage.getTokens(testServerUrl);
expect(fromDefault?.access_token).toBe("default-token");
defaultStore.getState().clearServerState(testServerUrl);
getOAuthStore().getState().clearServerState(testServerUrl);
} finally {
try {
await fs.unlink(customPath);

View file

@ -630,7 +630,7 @@ describe("InspectorClient OAuth E2E", () => {
expect(client.getStatus()).toBe("connected");
await client.disconnect();
client.clearOAuthTokens();
await client.clearOAuthTokens();
const authUrlSecond = await client.authenticate();
if (!authUrlSecond) throw new Error("Expected authorization URL");
@ -995,7 +995,7 @@ describe("InspectorClient OAuth E2E", () => {
expect(tokens?.access_token).toBeDefined();
expect(await client.isOAuthAuthorized()).toBe(true);
client.clearOAuthTokens();
await client.clearOAuthTokens();
expect(await client.isOAuthAuthorized()).toBe(false);
expect(await client.getOAuthTokens()).toBeUndefined();
});

View file

@ -173,8 +173,8 @@ describe("InspectorClient OAuth", () => {
expect(tokens).toBeUndefined();
});
it("should clear OAuth tokens", () => {
client.clearOAuthTokens();
it("should clear OAuth tokens", async () => {
await client.clearOAuthTokens();
// Should not throw
expect(client).toBeDefined();
});

View file

@ -4949,7 +4949,7 @@ describe("InspectorClient", () => {
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();
await expect(c.clearOAuthTokens()).resolves.toBeUndefined();
});
it("setOAuthConfig throws when oauthManager is unset", () => {

View file

@ -1,22 +1,26 @@
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 type { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
import { clearServerOAuthState } from "./clearServerOAuthState";
describe("clearServerOAuthState", () => {
beforeEach(() => {
getBrowserOAuthStorage().clear("https://mcp.example.com/mcp");
let storage: BrowserOAuthStorage;
beforeEach(async () => {
storage = new BrowserOAuthStorage();
await 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",
});
const cleared = clearServerOAuthState({
const cleared = await clearServerOAuthState({
config: { type: "streamable-http", url: "https://mcp.example.com/mcp" },
isActiveConnection: false,
oauthStorage: storage,
});
expect(cleared).toBe(true);
@ -25,27 +29,28 @@ describe("clearServerOAuthState", () => {
).toBeUndefined();
});
it("uses the live client when clearing the active connection", () => {
const inspectorClient = {
clearOAuthTokens: vi.fn(),
};
it("uses the live client when clearing the active connection", async () => {
const clearOAuthTokens = vi.fn<InspectorClient["clearOAuthTokens"]>();
const inspectorClient = { clearOAuthTokens };
const cleared = clearServerOAuthState({
const cleared = await 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", () => {
expect(
it("returns false for stdio servers", async () => {
await expect(
clearServerOAuthState({
config: { type: "stdio", command: "node", args: [] },
isActiveConnection: false,
oauthStorage: storage,
}),
).toBe(false);
).resolves.toBe(false);
});
});

Some files were not shown because too many files have changed in this diff Show more