inspector/core/auth/storage.ts
cliffhall c299b6e9d8 feat(web): migrate OAuth store to shared /store API (RemoteOAuthStorage parity) (closes #1548)
Migrate the web client's OAuth storage from BrowserOAuthStorage
(sessionStorage) to RemoteOAuthStorage, which POSTs through the backend's
/api/storage/oauth route to ~/.mcp-inspector/storage/oauth.json (mode 0600).
A credential obtained in the browser is now the same blob the TUI/CLI read
on the same host, giving web ⇄ TUI ⇄ CLI parity (Wave 1 of #1579).

Core (shared with TUI/CLI):
- OAuthStorageBase now drives a single async hydration (`ready()`,
  `getHydrationError()`); every post-redirect read (getClientInformation,
  getTokens, getCodeVerifier, getServerMetadata, getIdpSession) and every
  save awaits it so a late hydration merge cannot clobber a fresh write.
  getCodeVerifier/getServerMetadata become async; the store is created with
  skipHydration:true so there is no auto-hydration to race the explicit one.
- store.ts adds normalizeServerUrl so a token saved under
  https://Example.com/mcp is found when the CLI asks for
  https://example.com/mcp/; getServerState falls back to the raw key.
- remote-storage adapter POSTs with keepalive so the write survives the
  OAuth authorize redirect, surfaces swallowed persist failures, and gives
  richer read/write error messages.
- generateOAuthState throws instead of silently degrading to Math.random.
- NodeOAuthStorage honors MCP_INSPECTOR_OAUTH_STATE_PATH for isolated
  fixtures. Provider codeVerifier()/getServerMetadata() + the EMA idpOidc and
  connection-state readers await the now-async storage.

Web:
- environmentFactory + a new shared lib/remoteOAuthStorage accessor
  (memoized per {baseUrl, authToken}) wire RemoteOAuthStorage into the
  connection path, the EMA IdP hook, and the per-server "clear OAuth" action
  so all three share one in-memory view of oauth.json.

Tests: async-hydration + normalizeServerUrl suite, keepalive/error-path
coverage, and the sync→async migration across existing storage/provider/EMA
tests. Web validate + coverage gate + integration suite green; CLI/TUI/
launcher validate green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S3fTN8H3R8YV4yUGvZjYnX
2026-07-01 18:39:17 -04:00

198 lines
4.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type {
OAuthClientInformation,
OAuthTokens,
OAuthMetadata,
} from "@modelcontextprotocol/sdk/shared/auth.js";
import type { OAuthClientRegistrationKind } from "./types.js";
/**
* Abstract storage interface for OAuth state
* Supports both browser (sessionStorage) and Node.js (Zustand) environments
*/
export interface SaveTokensOptions {
/** Marks resource tokens minted via EMA (legs 2–3) for sign-out cleanup. */
enterpriseManaged?: boolean;
}
export type { OAuthClientRegistrationKind };
export interface SaveClientInformationOptions {
registrationKind: "dcr" | "cimd";
}
export interface OAuthStorage {
/**
* Resolves once any async hydration of the underlying store has completed.
* With a synchronous storage adapter this resolves immediately.
*/
ready(): Promise<void>;
/**
* Get client information (preregistered or dynamically registered)
*/
getClientInformation(
serverUrl: string,
isPreregistered?: boolean,
): Promise<OAuthClientInformation | undefined>;
/**
* Get how the dynamic client registration slot was established.
*/
getClientRegistrationKind(
serverUrl: string,
): OAuthClientRegistrationKind | undefined;
/**
* Save client information (dynamically registered)
*/
saveClientInformation(
serverUrl: string,
clientInformation: OAuthClientInformation,
options: SaveClientInformationOptions,
): Promise<void>;
/**
* Save preregistered client information (static client from config)
*/
savePreregisteredClientInformation(
serverUrl: string,
clientInformation: OAuthClientInformation,
): Promise<void>;
/**
* Clear client information
*/
clearClientInformation(serverUrl: string, isPreregistered?: boolean): void;
/**
* Get OAuth tokens
*/
getTokens(serverUrl: string): Promise<OAuthTokens | undefined>;
/**
* Save OAuth tokens
*/
saveTokens(
serverUrl: string,
tokens: OAuthTokens,
options?: SaveTokensOptions,
): Promise<void>;
/**
* Clear OAuth tokens
*/
clearTokens(serverUrl: string): void;
/**
* Get code verifier (for PKCE).
*
* Async because the backend-backed store may still be hydrating when this is
* read on the post-redirect OAuth callback path; the implementation awaits
* hydration before reading.
*/
getCodeVerifier(serverUrl: string): Promise<string | undefined>;
/**
* Save code verifier (for PKCE)
*/
saveCodeVerifier(serverUrl: string, codeVerifier: string): Promise<void>;
/**
* Clear code verifier
*/
clearCodeVerifier(serverUrl: string): void;
/**
* Get scope
*/
getScope(serverUrl: string): string | undefined;
/**
* Save scope
*/
saveScope(serverUrl: string, scope: string | undefined): Promise<void>;
/**
* Clear scope
*/
clearScope(serverUrl: string): void;
/**
* Get server metadata discovered during OAuth.
*
* Async because the backend-backed store may still be hydrating when this is
* read on the post-redirect OAuth callback path; the implementation awaits
* hydration before reading.
*/
getServerMetadata(serverUrl: string): Promise<OAuthMetadata | null>;
/**
* Save server metadata discovered during OAuth
*/
saveServerMetadata(serverUrl: string, metadata: OAuthMetadata): Promise<void>;
/**
* Clear server metadata
*/
clearServerMetadata(serverUrl: string): void;
/**
* Clear all OAuth data for a server
*/
clear(serverUrl: string): void;
/**
* Get cached IdP OIDC session for EMA (keyed by issuer).
*/
getIdpSession(issuer: string): Promise<IdpSessionState | undefined>;
/**
* Save IdP OIDC session fields for EMA.
*/
saveIdpSession(
issuer: string,
session: Partial<IdpSessionState>,
): Promise<void>;
/**
* Clear cached IdP session for an issuer.
*/
clearIdpSession(issuer: string): void;
/**
* Remove per-server OAuth state for MCP servers whose tokens were minted via EMA.
*/
clearEnterpriseManagedResourceServers(): void;
}
/**
* Cached IdP OIDC session for EMA leg 1.
*/
export interface IdpSessionState {
idToken?: string;
refreshToken?: string;
/** Epoch ms when the ID Token expires (when known). */
idTokenExpiresAt?: number;
}
/**
* Generate server-specific storage key
*/
export function getServerSpecificKey(
baseKey: string,
serverUrl: string,
): string {
return `[${serverUrl}] ${baseKey}`;
}
/**
* Base storage keys for OAuth data
*/
export const OAUTH_STORAGE_KEYS = {
CODE_VERIFIER: "mcp_code_verifier",
TOKENS: "mcp_tokens",
CLIENT_INFORMATION: "mcp_client_information",
PREREGISTERED_CLIENT_INFORMATION: "mcp_preregistered_client_information",
SERVER_METADATA: "mcp_server_metadata",
SCOPE: "mcp_scope",
} as const;