inspector/core/auth/store.ts
cliffhall 8c10b8eb8e fix(auth): make clear* hydration-safe, migrate raw-key blobs, doc keepalive/memo caveats
Address round-1 @claude review of #1592:

- OAuthStorageBase.clear* no longer mutate the pre-hydration (empty) store —
  a clear issued before hydration is deferred until it lands, so it merges
  onto the real persisted state instead of (a) being resurrected by the late
  rehydrate merge or (b) persisting a near-empty blob that clobbers every
  other server's on-disk credential. Already-hydrated clears stay synchronous.
- store.setServerState migrates a pre-normalization raw-key blob onto the
  canonical key on first partial write (was: shadowed it with a fresh
  canonical entry, orphaning the raw blob's other fields). clearServerState
  now also drops the raw-key orphan.
- Document the keepalive 64KB combined-body ceiling on the remote-storage
  write, the getRemoteOAuthStorage memo key intentionally omitting fetchFn,
  and the real ordering invariant behind the sync getScope.
- Tests for the clear-before-hydration and raw-key-migration paths.

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

175 lines
6.4 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.

/**
* OAuth store factory using Zustand.
* Creates a store with any storage adapter (file, remote, sessionStorage).
*/
import { createStore } from "zustand/vanilla";
import { persist, createJSONStorage } from "zustand/middleware";
import type {
OAuthClientInformation,
OAuthTokens,
OAuthMetadata,
} from "@modelcontextprotocol/sdk/shared/auth.js";
import type {
IdpSessionState,
OAuthClientRegistrationKind,
} from "./storage.js";
/**
* OAuth state for a single server
*/
export interface ServerOAuthState {
clientInformation?: OAuthClientInformation;
/** Set when {@link clientInformation} is saved — DCR vs CIMD. */
clientRegistrationKind?: OAuthClientRegistrationKind;
preregisteredClientInformation?: OAuthClientInformation;
tokens?: OAuthTokens;
codeVerifier?: string;
scope?: string;
serverMetadata?: OAuthMetadata;
/** Set when resource tokens were minted via EMA (legs 2–3). */
enterpriseManaged?: boolean;
}
/**
* Normalize a server URL to its canonical form for use as a store key.
*
* Different callers (web deep-link parser, CLI `--server-url`, the SDK's own
* client constructor) can present the same endpoint with cosmetic differences
* — host case, a trailing slash on a bare-origin URL, default-port omission —
* that `new URL().href` collapses. Keying by the canonical form means a token
* the web inspector saved under `https://Example.com/mcp` is found when the
* CLI later asks for `https://example.com/mcp/`. Non-URL strings (e.g. a
* stdio server name) are returned trimmed so the store still works for them.
*/
export function normalizeServerUrl(serverUrl: string): string {
const trimmed = serverUrl.trim();
try {
return new URL(trimmed).href;
} catch {
return trimmed;
}
}
/**
* Zustand store state (all servers)
*/
export interface OAuthStoreState {
servers: Record<string, ServerOAuthState>;
idpSessions: Record<string, IdpSessionState>;
getServerState: (serverUrl: string) => ServerOAuthState;
setServerState: (serverUrl: string, state: Partial<ServerOAuthState>) => void;
clearServerState: (serverUrl: string) => void;
getIdpSession: (issuer: string) => IdpSessionState;
setIdpSession: (issuer: string, updates: Partial<IdpSessionState>) => void;
clearIdpSession: (issuer: string) => void;
clearEnterpriseManagedResourceServers: () => void;
}
/**
* Creates a Zustand store for OAuth state with the given storage adapter.
* The storage adapter handles persistence (file, remote HTTP, sessionStorage, etc.).
*
* @param storage - Zustand storage adapter (from createJSONStorage)
* @returns Zustand store instance
*/
export function createOAuthStore(
storage: ReturnType<typeof createJSONStorage>,
) {
return createStore<OAuthStoreState>()(
persist(
(set, get) => ({
servers: {},
idpSessions: {},
getServerState: (serverUrl: string) => {
const servers = get().servers;
// Look up by canonical key, falling back to the raw key so an
// already-persisted blob written before normalization existed (or by
// another writer) is still found.
return (
servers[normalizeServerUrl(serverUrl)] ?? servers[serverUrl] ?? {}
);
},
setServerState: (
serverUrl: string,
updates: Partial<ServerOAuthState>,
) => {
const key = normalizeServerUrl(serverUrl);
set((state) => {
const servers = { ...state.servers };
// Mirror getServerState's canonical→raw fallback for writes: if no
// canonical entry exists yet but a pre-normalization blob lives
// under the raw key, migrate it onto the canonical key so this
// partial write MERGES onto the existing credential instead of
// shadowing it with a fresh canonical entry (which would orphan the
// raw-key blob's other fields — e.g. a token — behind the new
// canonical one that getServerState now prefers).
let base = servers[key];
if (base === undefined && servers[serverUrl] !== undefined) {
// Reaching here implies `serverUrl !== key`: if they were equal,
// `servers[serverUrl]` would equal the `servers[key]` we just
// found undefined, so the guard above could not have passed.
base = servers[serverUrl];
delete servers[serverUrl];
}
servers[key] = { ...base, ...updates };
return { servers };
});
},
clearServerState: (serverUrl: string) => {
const key = normalizeServerUrl(serverUrl);
set((state) => {
const rest = { ...state.servers };
delete rest[key];
// Also drop a pre-normalization blob under the raw key so a clear
// fully removes the credential rather than leaving an orphan that
// getServerState's raw-key fallback would keep surfacing.
if (serverUrl !== key) delete rest[serverUrl];
return { servers: rest };
});
},
getIdpSession: (issuer: string) => {
return get().idpSessions[issuer] || {};
},
setIdpSession: (issuer: string, updates: Partial<IdpSessionState>) => {
set((state) => ({
idpSessions: {
...state.idpSessions,
[issuer]: {
...state.idpSessions[issuer],
...updates,
},
},
}));
},
clearIdpSession: (issuer: string) => {
set((state) => {
const rest = { ...state.idpSessions };
delete rest[issuer];
return { idpSessions: rest };
});
},
clearEnterpriseManagedResourceServers: () => {
set((state) => {
const rest = { ...state.servers };
for (const [url, serverState] of Object.entries(state.servers)) {
if (serverState.enterpriseManaged === true) {
delete rest[url];
}
}
return { servers: rest };
});
},
}),
{
name: "mcp-inspector-oauth",
storage,
// OAuthStorageBase drives `persist.rehydrate()` itself so it can await
// (and catch errors from) the single hydration. Auto-hydration here
// would race the explicit one and could clobber a save that landed
// between them.
skipHydration: true,
},
),
);
}