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
198 lines
4.7 KiB
TypeScript
198 lines
4.7 KiB
TypeScript
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;
|