inspector/clients/web/src/lib/remoteOAuthStorage.ts
cliffhall ba48e06ffd fix(auth): guard OAuth-storage async paths against unhandled rejections
Review follow-ups on the RemoteOAuthStorage web migration:

- useEmaIdpLoginState.logout(): add a .catch to the now-async
  clearEmaIdpSession call so a storage failure no longer surfaces as an
  unhandled rejection; leave loginState unchanged on failure (the session
  was not actually cleared). Add a test asserting no unhandled rejection.
- createOAuthStore: attach a no-op catch to the eager persistLoadPromise
  so a hydration read that fails before any load() awaits it does not
  become an unhandled rejection; real awaiters still observe the rejection
  via the shared reference.
- storage-remote.test: replace `as unknown as typeof fetch` double-casts
  with the typed vi.fn<typeof fetch>() pattern the same PR established.
- remoteOAuthStorage: correct the doc comment to describe the cache-of-one
  accurately instead of implying a keyed map.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 10:59:40 -04:00

59 lines
2 KiB
TypeScript

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