Removed Zustand storage, cleanup up auth storage IO interfaces, implementations, and usage.

This commit is contained in:
Bob Dickinson 2026-07-04 21:56:45 -07:00
commit 3c65bdb4c6
16 changed files with 863 additions and 1008 deletions

View file

@ -1,16 +1,14 @@
import { createJSONStorage } from "zustand/middleware";
import { OAuthStorageBase } from "../oauth-storage.js";
import { createOAuthStore } from "../store.js";
import { OAuthMemoryStore } from "../store.js";
import { createSessionOAuthPersistBackend } from "../oauth-persist.js";
/**
* Browser storage implementation using Zustand with sessionStorage.
* Browser storage implementation using sessionStorage.
* For web client (can be used by InspectorClient in browser).
*/
export class BrowserOAuthStorage extends OAuthStorageBase {
constructor() {
// Use Zustand's built-in sessionStorage adapter
// The `name` option in persist() ("mcp-inspector-oauth") becomes the sessionStorage key
super(createOAuthStore(createJSONStorage(() => sessionStorage)));
super(new OAuthMemoryStore(), createSessionOAuthPersistBackend());
}
}

View file

@ -88,13 +88,13 @@ export async function buildOAuthConnectionState(
const preregistered = await storage.getClientInformation(serverUrl, true);
const dynamic = await storage.getClientInformation(serverUrl);
const storageScope = storage.getScope(serverUrl);
const serverMetadata = storage.getServerMetadata(serverUrl);
const storageScope = await storage.getScope(serverUrl);
const serverMetadata = await storage.getServerMetadata(serverUrl);
const authorized = isAccessTokenUsable(tokens);
const grantedScope = resolveGrantedScope(tokens, storageScope);
const registrationKind = storage.getClientRegistrationKind(serverUrl);
const registrationKind = await storage.getClientRegistrationKind(serverUrl);
const dynamicRegistrationKind =
registrationKind === "dcr" || registrationKind === "cimd"
? registrationKind
@ -123,7 +123,7 @@ export async function buildOAuthConnectionState(
const idp = params.enterpriseManagedAuth.idp;
const issuer = normalizeIdpIssuer(idp.issuer);
const idpSession = await getEmaIdpLoginState(storage, issuer);
const idpMetadata = storage.getServerMetadata(idpOAuthStorageKey(issuer));
const idpMetadata = await storage.getServerMetadata(idpOAuthStorageKey(issuer));
state.ema = {
idpIssuer: issuer,
idpClientId: idp.clientId,

View file

@ -34,9 +34,8 @@ async function resolveIdpMetadata(
storage: OAuthStorage,
fetchFn?: typeof fetch,
): Promise<OAuthMetadata> {
await storage.load();
const storageKey = idpOAuthStorageKey(issuer);
const cached = storage.getServerMetadata(storageKey);
const cached = await storage.getServerMetadata(storageKey);
if (cached?.token_endpoint) {
return cached;
}
@ -102,8 +101,7 @@ export async function completeIdpOidcAuthorization(params: {
}> {
const issuer = normalizeIdpIssuer(params.idp.issuer);
const storageKey = idpOAuthStorageKey(issuer);
await params.storage.load();
const metadata = params.storage.getServerMetadata(storageKey);
const metadata = await params.storage.getServerMetadata(storageKey);
if (!metadata) {
throw new Error("IdP OAuth metadata not found — restart EMA IdP login");
}
@ -113,7 +111,7 @@ export async function completeIdpOidcAuthorization(params: {
if (!clientInformation) {
throw new Error("IdP client information not found — restart EMA IdP login");
}
const codeVerifier = params.storage.getCodeVerifier(storageKey);
const codeVerifier = await params.storage.getCodeVerifier(storageKey);
if (!codeVerifier) {
throw new Error("IdP PKCE verifier not found — restart EMA IdP login");
}

View file

@ -1,7 +1,5 @@
export {
NodeOAuthStorage,
getOAuthStore,
getStateFilePath,
clearAllOAuthClientState,
} from "./storage-node.js";
export {

View file

@ -1,6 +1,6 @@
import { OAuthStorageBase } from "../oauth-storage.js";
import { createOAuthStore } from "../store.js";
import { createFileStorageAdapter } from "../../storage/adapters/file-storage.js";
import { OAuthMemoryStore } from "../store.js";
import { createFileOAuthPersistBackend } from "../oauth-persist.js";
import {
getDefaultStorageDir,
getStoreFilePath,
@ -17,22 +17,27 @@ export function getStateFilePath(customPath?: string): string {
return customPath ?? DEFAULT_STATE_PATH;
}
const storeCache = new Map<string, ReturnType<typeof createOAuthStore>>();
const memoryCache = new Map<string, OAuthMemoryStore>();
const storageCache = new Map<string, NodeOAuthStorage>();
function getSharedMemory(stateFilePath?: string): OAuthMemoryStore {
const key = getStateFilePath(stateFilePath);
let memory = memoryCache.get(key);
if (!memory) {
memory = new OAuthMemoryStore();
memoryCache.set(key, memory);
}
return memory;
}
/**
* Get or create the OAuth store instance for the given path.
* @param stateFilePath - Optional custom path to state file. Default: ~/.mcp-inspector/storage/oauth.json
* Drop cached in-memory and {@link NodeOAuthStorage} instances for a path.
* @internal Test isolation only.
*/
export function getOAuthStore(stateFilePath?: string) {
export function resetNodeOAuthStorageCache(stateFilePath?: string): void {
const key = getStateFilePath(stateFilePath);
let store = storeCache.get(key);
if (!store) {
const filePath = getStateFilePath(stateFilePath);
const storage = createFileStorageAdapter({ filePath });
store = createOAuthStore(storage);
storeCache.set(key, store);
}
return store;
memoryCache.delete(key);
storageCache.delete(key);
}
/**
@ -40,24 +45,40 @@ export function getOAuthStore(stateFilePath?: string) {
* Useful for test isolation in E2E OAuth tests.
* Use a custom-path store and clear per serverUrl if you need to clear non-default storage.
*/
export function clearAllOAuthClientState(): void {
const store = getOAuthStore();
const state = store.getState();
const urls = Object.keys(state.servers ?? {});
export async function clearAllOAuthClientState(): Promise<void> {
const storage = getNodeOAuthStorage();
const filePath = getStateFilePath();
const snapshot = await createFileOAuthPersistBackend({ filePath }).read();
const urls = Object.keys(snapshot?.servers ?? {});
for (const url of urls) {
state.clearServerState(url);
await storage.clear(url);
}
}
/**
* Node.js storage implementation using Zustand with file-based persistence.
* Node.js storage implementation with file-based persistence.
* For InspectorClient, CLI, and TUI.
*/
export class NodeOAuthStorage extends OAuthStorageBase {
/**
* @param storagePath - Optional path to state file. Default: ~/.mcp-inspector/oauth/state.json
* @param storagePath - Optional path to state file. Default: ~/.mcp-inspector/storage/oauth.json
*/
constructor(storagePath?: string) {
super(getOAuthStore(storagePath));
const filePath = getStateFilePath(storagePath);
super(
getSharedMemory(storagePath),
createFileOAuthPersistBackend({ filePath }),
);
}
}
/** Cached NodeOAuthStorage instances keyed by resolved file path. */
function getNodeOAuthStorage(storagePath?: string): NodeOAuthStorage {
const key = getStateFilePath(storagePath);
let storage = storageCache.get(key);
if (!storage) {
storage = new NodeOAuthStorage(storagePath);
storageCache.set(key, storage);
}
return storage;
}

194
core/auth/oauth-persist.ts Normal file
View file

@ -0,0 +1,194 @@
/**
* OAuth persistence format and backends (file, remote HTTP, sessionStorage).
* Writes plain JSON `{ servers, idpSessions }`. On read, accepts legacy
* persist envelopes `{ state: { servers, idpSessions }, version }` and
* promotes the inner payload.
*/
import {
readStoreFile,
writeStoreFile,
deleteStoreFile,
serializeStore,
parseStore,
} from "../storage/store-io.js";
import type { IdpSessionState } from "./storage.js";
import type { ServerOAuthState } from "./store.js";
export const OAUTH_PERSIST_STORAGE_KEY = "mcp-inspector-oauth";
export interface OAuthPersistSnapshot {
servers: Record<string, ServerOAuthState>;
idpSessions: Record<string, IdpSessionState>;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function snapshotFromPayload(payload: Partial<OAuthPersistSnapshot>): OAuthPersistSnapshot {
return {
servers: payload.servers ?? {},
idpSessions: payload.idpSessions ?? {},
};
}
/**
* Parse OAuth store JSON from disk, remote API, or sessionStorage.
* Accepts plain `{ servers, idpSessions }` or legacy `{ state, version }`.
*/
export function parseOAuthPersistBlob(
raw: string | null,
): OAuthPersistSnapshot | null {
if (!raw) {
return null;
}
const parsed = parseStore(raw);
if (!isRecord(parsed)) {
return null;
}
if (isRecord(parsed.state) && "version" in parsed) {
return snapshotFromPayload(parsed.state as Partial<OAuthPersistSnapshot>);
}
if ("servers" in parsed || "idpSessions" in parsed) {
return snapshotFromPayload(parsed as Partial<OAuthPersistSnapshot>);
}
return null;
}
export function serializeOAuthPersistBlob(
snapshot: OAuthPersistSnapshot,
): string {
return serializeStore(snapshot);
}
export interface OAuthPersistBackend {
read(): Promise<OAuthPersistSnapshot | null>;
write(snapshot: OAuthPersistSnapshot): Promise<void>;
remove?(): Promise<void>;
}
export interface FileOAuthPersistBackendOptions {
filePath: string;
}
export function createFileOAuthPersistBackend(
options: FileOAuthPersistBackendOptions,
): OAuthPersistBackend {
return {
async read() {
const raw = await readStoreFile(options.filePath);
return parseOAuthPersistBlob(raw);
},
async write(snapshot) {
await writeStoreFile(
options.filePath,
serializeOAuthPersistBlob(snapshot),
);
},
async remove() {
await deleteStoreFile(options.filePath);
},
};
}
export interface RemoteOAuthPersistBackendOptions {
baseUrl: string;
storeId: string;
authToken?: string;
fetchFn?: typeof fetch;
}
export function createRemoteOAuthPersistBackend(
options: RemoteOAuthPersistBackendOptions,
): OAuthPersistBackend {
const baseUrl = options.baseUrl.replace(/\/$/, "");
const fetchFn = options.fetchFn ?? globalThis.fetch;
return {
async read() {
const headers: Record<string, string> = {};
if (options.authToken) {
headers["x-mcp-remote-auth"] = `Bearer ${options.authToken}`;
}
const res = await fetchFn(`${baseUrl}/api/storage/${options.storeId}`, {
method: "GET",
headers,
});
if (!res.ok) {
if (res.status === 404) {
return null;
}
throw new Error(`Failed to read store: ${res.status}`);
}
const store = await res.json();
if (Object.keys(store as object).length === 0) {
return null;
}
return parseOAuthPersistBlob(JSON.stringify(store));
},
async write(snapshot) {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (options.authToken) {
headers["x-mcp-remote-auth"] = `Bearer ${options.authToken}`;
}
const res = await fetchFn(`${baseUrl}/api/storage/${options.storeId}`, {
method: "POST",
headers,
body: serializeOAuthPersistBlob(snapshot),
});
if (!res.ok) {
throw new Error(`Failed to write store: ${res.status}`);
}
},
async remove() {
const headers: Record<string, string> = {};
if (options.authToken) {
headers["x-mcp-remote-auth"] = `Bearer ${options.authToken}`;
}
const res = await fetchFn(`${baseUrl}/api/storage/${options.storeId}`, {
method: "DELETE",
headers,
});
if (!res.ok && res.status !== 404) {
throw new Error(`Failed to delete store: ${res.status}`);
}
},
};
}
export interface SessionOAuthPersistBackendOptions {
storageKey?: string;
getStorage?: () => Storage;
}
export function createSessionOAuthPersistBackend(
options: SessionOAuthPersistBackendOptions = {},
): OAuthPersistBackend {
const storageKey = options.storageKey ?? OAUTH_PERSIST_STORAGE_KEY;
const getStorage = options.getStorage ?? (() => sessionStorage);
return {
async read() {
return parseOAuthPersistBlob(getStorage().getItem(storageKey));
},
async write(snapshot) {
getStorage().setItem(storageKey, serializeOAuthPersistBlob(snapshot));
},
async remove() {
getStorage().removeItem(storageKey);
},
};
}

View file

@ -8,11 +8,8 @@ import {
OAuthTokensSchema,
} from "@modelcontextprotocol/sdk/shared/auth.js";
import type { OAuthStorage } from "./storage.js";
import {
type OAuthStore,
type ServerOAuthState,
waitForOAuthStorePersistLoad,
} from "./store.js";
import { type OAuthMemoryStore, type ServerOAuthState } from "./store.js";
import type { OAuthPersistBackend } from "./oauth-persist.js";
import type {
IdpSessionState,
OAuthClientRegistrationKind,
@ -20,47 +17,53 @@ import type {
SaveTokensOptions,
} from "./storage.js";
type OAuthStoreWithPersist = OAuthStore & {
persist: {
hasHydrated: () => boolean;
};
};
/**
* Concrete OAuthStorage implementation parameterized on a Zustand store.
* The store carries the storage adapter (sessionStorage, file, remote HTTP, …),
* so the same body works for browser, Node, and remote environments.
* Concrete OAuthStorage implementation backed by in-memory state and an explicit
* persist backend (file, remote HTTP, sessionStorage, …).
*/
export class OAuthStorageBase implements OAuthStorage {
private readonly store: OAuthStoreWithPersist;
private loadedPromise: Promise<void> | undefined;
private loaded = false;
private loadPromise: Promise<void> | undefined;
private readonly memory: OAuthMemoryStore;
private readonly backend: OAuthPersistBackend;
constructor(store: OAuthStore) {
this.store = store as OAuthStoreWithPersist;
constructor(memory: OAuthMemoryStore, backend: OAuthPersistBackend) {
this.memory = memory;
this.backend = backend;
}
load(): Promise<void> {
if (!this.loadedPromise) {
this.loadedPromise = (async () => {
if (this.store.persist.hasHydrated()) {
return;
}
await waitForOAuthStorePersistLoad(this.store);
})();
if (!this.loadPromise) {
this.loadPromise = this.doLoad();
}
return this.loadedPromise;
return this.loadPromise;
}
private async doLoad(): Promise<void> {
if (this.loaded) {
return;
}
const snapshot = await this.backend.read();
if (snapshot) {
this.memory.replace(snapshot);
}
this.loaded = true;
}
private async ensureLoaded(): Promise<void> {
await this.load();
}
private async persist(): Promise<void> {
await this.backend.write(this.memory.snapshot());
}
async getClientInformation(
serverUrl: string,
isPreregistered?: boolean,
): Promise<OAuthClientInformation | undefined> {
await this.ensureLoaded();
const state = this.store.getState().getServerState(serverUrl);
const state = this.memory.getState().getServerState(serverUrl);
const clientInfo = isPreregistered
? state.preregisteredClientInformation
: state.clientInformation;
@ -72,10 +75,11 @@ export class OAuthStorageBase implements OAuthStorage {
return await OAuthClientInformationSchema.parseAsync(clientInfo);
}
getClientRegistrationKind(
async getClientRegistrationKind(
serverUrl: string,
): OAuthClientRegistrationKind | undefined {
return this.store.getState().getServerState(serverUrl)
): Promise<OAuthClientRegistrationKind | undefined> {
await this.ensureLoaded();
return this.memory.getState().getServerState(serverUrl)
.clientRegistrationKind;
}
@ -85,10 +89,11 @@ export class OAuthStorageBase implements OAuthStorage {
options: SaveClientInformationOptions,
): Promise<void> {
await this.ensureLoaded();
this.store.getState().setServerState(serverUrl, {
this.memory.getState().setServerState(serverUrl, {
clientInformation,
clientRegistrationKind: options.registrationKind,
});
await this.persist();
}
async savePreregisteredClientInformation(
@ -96,10 +101,11 @@ export class OAuthStorageBase implements OAuthStorage {
clientInformation: OAuthClientInformation,
): Promise<void> {
await this.ensureLoaded();
this.store.getState().setServerState(serverUrl, {
this.memory.getState().setServerState(serverUrl, {
preregisteredClientInformation: clientInformation,
clientRegistrationKind: "static",
});
await this.persist();
}
async clearClientInformation(
@ -116,12 +122,13 @@ export class OAuthStorageBase implements OAuthStorage {
updates.clientRegistrationKind = undefined;
}
this.store.getState().setServerState(serverUrl, updates);
this.memory.getState().setServerState(serverUrl, updates);
await this.persist();
}
async getTokens(serverUrl: string): Promise<OAuthTokens | undefined> {
await this.ensureLoaded();
const state = this.store.getState().getServerState(serverUrl);
const state = this.memory.getState().getServerState(serverUrl);
if (!state.tokens) {
return undefined;
}
@ -135,19 +142,22 @@ export class OAuthStorageBase implements OAuthStorage {
options?: SaveTokensOptions,
): Promise<void> {
await this.ensureLoaded();
this.store.getState().setServerState(serverUrl, {
this.memory.getState().setServerState(serverUrl, {
tokens,
...(options?.enterpriseManaged === true && { enterpriseManaged: true }),
});
await this.persist();
}
async clearTokens(serverUrl: string): Promise<void> {
await this.ensureLoaded();
this.store.getState().setServerState(serverUrl, { tokens: undefined });
this.memory.getState().setServerState(serverUrl, { tokens: undefined });
await this.persist();
}
getCodeVerifier(serverUrl: string): string | undefined {
const state = this.store.getState().getServerState(serverUrl);
async getCodeVerifier(serverUrl: string): Promise<string | undefined> {
await this.ensureLoaded();
const state = this.memory.getState().getServerState(serverUrl);
return state.codeVerifier;
}
@ -156,33 +166,37 @@ export class OAuthStorageBase implements OAuthStorage {
codeVerifier: string,
): Promise<void> {
await this.ensureLoaded();
this.store.getState().setServerState(serverUrl, { codeVerifier });
this.memory.getState().setServerState(serverUrl, { codeVerifier });
await this.persist();
}
async clearCodeVerifier(serverUrl: string): Promise<void> {
await this.ensureLoaded();
this.store
.getState()
.setServerState(serverUrl, { codeVerifier: undefined });
this.memory.getState().setServerState(serverUrl, { codeVerifier: undefined });
await this.persist();
}
getScope(serverUrl: string): string | undefined {
const state = this.store.getState().getServerState(serverUrl);
async getScope(serverUrl: string): Promise<string | undefined> {
await this.ensureLoaded();
const state = this.memory.getState().getServerState(serverUrl);
return state.scope;
}
async saveScope(serverUrl: string, scope: string | undefined): Promise<void> {
await this.ensureLoaded();
this.store.getState().setServerState(serverUrl, { scope });
this.memory.getState().setServerState(serverUrl, { scope });
await this.persist();
}
async clearScope(serverUrl: string): Promise<void> {
await this.ensureLoaded();
this.store.getState().setServerState(serverUrl, { scope: undefined });
this.memory.getState().setServerState(serverUrl, { scope: undefined });
await this.persist();
}
getServerMetadata(serverUrl: string): OAuthMetadata | null {
const state = this.store.getState().getServerState(serverUrl);
async getServerMetadata(serverUrl: string): Promise<OAuthMetadata | null> {
await this.ensureLoaded();
const state = this.memory.getState().getServerState(serverUrl);
return state.serverMetadata || null;
}
@ -191,26 +205,27 @@ export class OAuthStorageBase implements OAuthStorage {
metadata: OAuthMetadata,
): Promise<void> {
await this.ensureLoaded();
this.store
.getState()
.setServerState(serverUrl, { serverMetadata: metadata });
this.memory.getState().setServerState(serverUrl, { serverMetadata: metadata });
await this.persist();
}
async clearServerMetadata(serverUrl: string): Promise<void> {
await this.ensureLoaded();
this.store
this.memory
.getState()
.setServerState(serverUrl, { serverMetadata: undefined });
await this.persist();
}
async clear(serverUrl: string): Promise<void> {
await this.ensureLoaded();
this.store.getState().clearServerState(serverUrl);
this.memory.getState().clearServerState(serverUrl);
await this.persist();
}
async getIdpSession(issuer: string): Promise<IdpSessionState | undefined> {
await this.ensureLoaded();
const session = this.store.getState().getIdpSession(issuer);
const session = this.memory.getState().getIdpSession(issuer);
if (
!session.idToken &&
!session.refreshToken &&
@ -226,16 +241,19 @@ export class OAuthStorageBase implements OAuthStorage {
session: Partial<IdpSessionState>,
): Promise<void> {
await this.ensureLoaded();
this.store.getState().setIdpSession(issuer, session);
this.memory.getState().setIdpSession(issuer, session);
await this.persist();
}
async clearIdpSession(issuer: string): Promise<void> {
await this.ensureLoaded();
this.store.getState().clearIdpSession(issuer);
this.memory.getState().clearIdpSession(issuer);
await this.persist();
}
async clearEnterpriseManagedResourceServers(): Promise<void> {
await this.ensureLoaded();
this.store.getState().clearEnterpriseManagedResourceServers();
this.memory.getState().clearEnterpriseManagedResourceServers();
await this.persist();
}
}

View file

@ -103,6 +103,8 @@ export class BaseOAuthClientProvider implements OAuthClientProvider {
private capturedAuthUrl: URL | null = null;
private eventTarget: EventTarget | null = null;
private suppressAuthorizationNavigation = false;
/** Cached after {@link prepareForAuth} for sync SDK `clientMetadata.scope`. */
private cachedScope: string | undefined;
protected serverUrl: string;
protected storage: OAuthStorage;
@ -118,6 +120,14 @@ export class BaseOAuthClientProvider implements OAuthClientProvider {
this.clientMetadataUrl = oauthConfig.clientMetadataUrl;
}
/**
* Load persisted scope into {@link cachedScope} before SDK `auth()` (which
* reads {@link clientMetadata.scope} synchronously).
*/
async prepareForAuth(): Promise<void> {
this.cachedScope = await this.storage.getScope(this.serverUrl);
}
/**
* Set the event target for dispatching oauthAuthorizationRequired events
*/
@ -145,7 +155,7 @@ export class BaseOAuthClientProvider implements OAuthClientProvider {
}
get scope(): string | undefined {
return this.storage.getScope(this.serverUrl);
return this.cachedScope;
}
get redirectUrl(): string {
@ -200,6 +210,7 @@ export class BaseOAuthClientProvider implements OAuthClientProvider {
async saveScope(scope: string | undefined): Promise<void> {
await this.storage.saveScope(this.serverUrl, scope);
this.cachedScope = scope;
}
async savePreregisteredClientInformation(
@ -239,8 +250,8 @@ export class BaseOAuthClientProvider implements OAuthClientProvider {
await this.storage.saveCodeVerifier(this.serverUrl, codeVerifier);
}
codeVerifier(): string {
const verifier = this.storage.getCodeVerifier(this.serverUrl);
async codeVerifier(): Promise<string> {
const verifier = await this.storage.getCodeVerifier(this.serverUrl);
if (!verifier) {
throw new Error("No code verifier saved for session");
}
@ -251,7 +262,7 @@ export class BaseOAuthClientProvider implements OAuthClientProvider {
await this.storage.clear(this.serverUrl);
}
getServerMetadata(): OAuthMetadata | null {
async getServerMetadata(): Promise<OAuthMetadata | null> {
return this.storage.getServerMetadata(this.serverUrl);
}

View file

@ -1,12 +1,11 @@
/**
* Remote HTTP storage implementation for OAuth state.
* Uses Zustand with remote storage adapter (HTTP API).
* For web clients that need to share state with Node apps.
*/
import { OAuthStorageBase } from "../oauth-storage.js";
import { createOAuthStore } from "../store.js";
import { createRemoteStorageAdapter } from "../../storage/adapters/remote-storage.js";
import { OAuthMemoryStore } from "../store.js";
import { createRemoteOAuthPersistBackend } from "../oauth-persist.js";
export interface RemoteOAuthStorageOptions {
/** Base URL of the remote server (e.g. http://localhost:3000) */
@ -20,21 +19,20 @@ export interface RemoteOAuthStorageOptions {
}
/**
* Remote HTTP storage implementation using Zustand with remote storage adapter.
* Remote HTTP storage implementation.
* Stores OAuth state via HTTP API (GET/POST/DELETE /api/storage/:storeId).
* For web clients that need to share state with Node apps (TUI, CLI).
*/
export class RemoteOAuthStorage extends OAuthStorageBase {
constructor(options: RemoteOAuthStorageOptions) {
super(
createOAuthStore(
createRemoteStorageAdapter({
baseUrl: options.baseUrl,
storeId: options.storeId ?? "oauth",
authToken: options.authToken,
fetchFn: options.fetchFn,
}),
),
new OAuthMemoryStore(),
createRemoteOAuthPersistBackend({
baseUrl: options.baseUrl,
storeId: options.storeId ?? "oauth",
authToken: options.authToken,
fetchFn: options.fetchFn,
}),
);
}
}

View file

@ -7,7 +7,7 @@ import type { OAuthClientRegistrationKind } from "./types.js";
/**
* Abstract storage interface for OAuth state
* Supports both browser (sessionStorage) and Node.js (Zustand) environments
* Supports browser (sessionStorage), Node.js (file), and remote HTTP backends.
*/
export interface SaveTokensOptions {
/** Marks resource tokens minted via EMA (legs 2–3) for sign-out cleanup. */
@ -22,9 +22,9 @@ export interface SaveClientInformationOptions {
export interface OAuthStorage {
/**
* Load persisted OAuth state into memory. Resolves when the backing store
* (file, remote API, sessionStorage, …) has been read. Call before relying on
* sync reads, or await mutating methods which call this internally.
* Optional preload of persisted state into memory. Getters and setters load
* automatically when needed; use this only for fail-fast at known boundaries
* (e.g. OAuth callback resume after a full-page navigation).
*/
load(): Promise<void>;
@ -41,7 +41,7 @@ export interface OAuthStorage {
*/
getClientRegistrationKind(
serverUrl: string,
): OAuthClientRegistrationKind | undefined;
): Promise<OAuthClientRegistrationKind | undefined>;
/**
* Save client information (dynamically registered)
@ -90,7 +90,7 @@ export interface OAuthStorage {
/**
* Get code verifier (for PKCE)
*/
getCodeVerifier(serverUrl: string): string | undefined;
getCodeVerifier(serverUrl: string): Promise<string | undefined>;
/**
* Save code verifier (for PKCE)
@ -105,7 +105,7 @@ export interface OAuthStorage {
/**
* Get scope
*/
getScope(serverUrl: string): string | undefined;
getScope(serverUrl: string): Promise<string | undefined>;
/**
* Save scope
@ -120,7 +120,7 @@ export interface OAuthStorage {
/**
* Get server metadata discovered during OAuth
*/
getServerMetadata(serverUrl: string): OAuthMetadata | null;
getServerMetadata(serverUrl: string): Promise<OAuthMetadata | null>;
/**
* Save server metadata discovered during OAuth

View file

@ -1,16 +1,15 @@
/**
* OAuth store factory using Zustand.
* Creates a store with any storage adapter (file, remote, sessionStorage).
* In-memory OAuth state store (servers + IdP sessions).
* Persisted via `OAuthStorageBase` + `OAuthPersistBackend` backends.
*/
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";
import type { OAuthPersistSnapshot } from "./oauth-persist.js";
/**
* OAuth state for a single server
@ -29,7 +28,7 @@ export interface ServerOAuthState {
}
/**
* Zustand store state (all servers)
* OAuth store state (all servers) plus mutation helpers.
*/
export interface OAuthStoreState {
servers: Record<string, ServerOAuthState>;
@ -43,127 +42,81 @@ export interface OAuthStoreState {
clearEnterpriseManagedResourceServers: () => void;
}
export type OAuthStore = ReturnType<typeof createOAuthStore>;
const persistLoadByStore = new WeakMap<OAuthStore, Promise<void>>();
/**
* Wait until the store's initial persist read has finished (success or failure).
* Used by {@link OAuthStorageBase.load}; resolves on success, rejects when the
* storage adapter's getItem fails (Zustand does not fire onFinishHydration on error).
* Mutable in-memory OAuth state keyed by server URL and IdP issuer.
*/
export function waitForOAuthStorePersistLoad(store: OAuthStore): Promise<void> {
const promise = persistLoadByStore.get(store);
if (!promise) {
return Promise.resolve();
export class OAuthMemoryStore {
private servers: Record<string, ServerOAuthState> = {};
private idpSessions: Record<string, IdpSessionState> = {};
constructor(initial?: OAuthPersistSnapshot) {
if (initial) {
this.replace(initial);
}
}
return promise;
}
/**
* 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>,
) {
let resolvePersistLoad!: () => void;
let rejectPersistLoad!: (error: unknown) => void;
let persistLoadSettled = false;
const persistLoadPromise = new Promise<void>((resolve, reject) => {
resolvePersistLoad = resolve;
rejectPersistLoad = reject;
});
const settlePersistLoad = (error?: unknown) => {
if (persistLoadSettled) {
/* v8 ignore next -- idempotent if persist signals completion more than once */
return;
}
persistLoadSettled = true;
if (error) {
rejectPersistLoad(error);
return;
}
resolvePersistLoad();
};
const store = createStore<OAuthStoreState>()(
persist(
(set, get) => ({
servers: {},
idpSessions: {},
getServerState: (serverUrl: string) => {
return get().servers[serverUrl] || {};
},
setServerState: (
serverUrl: string,
updates: Partial<ServerOAuthState>,
) => {
set((state) => ({
servers: {
...state.servers,
[serverUrl]: {
...state.servers[serverUrl],
...updates,
},
},
}));
},
clearServerState: (serverUrl: string) => {
set((state) => {
const rest = { ...state.servers };
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,
onRehydrateStorage: () => (_state, error) => {
settlePersistLoad(error);
},
getState(): OAuthStoreState {
return {
servers: this.servers,
idpSessions: this.idpSessions,
getServerState: (serverUrl: string) => {
return this.servers[serverUrl] || {};
},
),
);
setServerState: (
serverUrl: string,
updates: Partial<ServerOAuthState>,
) => {
this.servers = {
...this.servers,
[serverUrl]: {
...this.servers[serverUrl],
...updates,
},
};
},
clearServerState: (serverUrl: string) => {
const rest = { ...this.servers };
delete rest[serverUrl];
this.servers = rest;
},
getIdpSession: (issuer: string) => {
return this.idpSessions[issuer] || {};
},
setIdpSession: (issuer: string, updates: Partial<IdpSessionState>) => {
this.idpSessions = {
...this.idpSessions,
[issuer]: {
...this.idpSessions[issuer],
...updates,
},
};
},
clearIdpSession: (issuer: string) => {
const rest = { ...this.idpSessions };
delete rest[issuer];
this.idpSessions = rest;
},
clearEnterpriseManagedResourceServers: () => {
const rest = { ...this.servers };
for (const [url, serverState] of Object.entries(this.servers)) {
if (serverState.enterpriseManaged === true) {
delete rest[url];
}
}
this.servers = rest;
},
};
}
persistLoadByStore.set(store, persistLoadPromise);
return store;
snapshot(): OAuthPersistSnapshot {
return {
servers: { ...this.servers },
idpSessions: { ...this.idpSessions },
};
}
replace(snapshot: OAuthPersistSnapshot): void {
this.servers = { ...(snapshot.servers ?? {}) };
this.idpSessions = { ...(snapshot.idpSessions ?? {}) };
}
}

View file

@ -139,7 +139,8 @@ export class OAuthManager {
provider.setEventTarget(this.params.getEventTarget());
const storedScope = this.oauthConfig.storage.getScope(serverUrl);
await provider.prepareForAuth();
const storedScope = provider.scope;
if (storedScope === undefined && this.oauthConfig.scope) {
await provider.saveScope(this.oauthConfig.scope);
}
@ -162,7 +163,7 @@ export class OAuthManager {
return this.oauthConfig.enterpriseManaged === true;
}
private getEmaFlowConfig(): EmaFlowConfig {
private async getEmaFlowConfig(): Promise<EmaFlowConfig> {
if (!this.oauthConfig.storage || !this.oauthConfig.redirectUrlProvider) {
throw new Error(
"OAuth environment components (storage, redirectUrlProvider) are required.",
@ -177,16 +178,17 @@ export class OAuthManager {
: "not_configured",
);
}
const storedScope = await this.oauthConfig.storage.getScope(
this.getServerUrl(),
);
return {
serverUrl: this.getServerUrl(),
idp,
resourceClientId: this.oauthConfig.clientId,
resourceClientSecret: this.oauthConfig.clientSecret,
scope:
computeScopeUnion(
this.oauthConfig.scope,
this.oauthConfig.storage.getScope(this.getServerUrl()),
) || this.oauthConfig.scope,
computeScopeUnion(this.oauthConfig.scope, storedScope) ||
this.oauthConfig.scope,
redirectUrl: this.oauthConfig.redirectUrlProvider.getRedirectUrl(),
storage: this.oauthConfig.storage,
fetchFn: this.params.effectiveAuthFetch,
@ -196,14 +198,14 @@ export class OAuthManager {
/** Attempt silent EMA (cached IdP session + legs 2–3). */
async trySilentEnterpriseManagedAuth(): Promise<boolean> {
if (!this.isEnterpriseManaged()) return false;
const result = await trySilentEmaAuth(this.getEmaFlowConfig());
const result = await trySilentEmaAuth(await this.getEmaFlowConfig());
if (result.status === "success") return true;
if (result.status === "mint_failed") throw result.error;
return false;
}
private async authenticateEnterpriseManaged(): Promise<URL | undefined> {
const config = this.getEmaFlowConfig();
const config = await this.getEmaFlowConfig();
const silent = await trySilentEmaAuth(config);
if (silent.status === "success") {
return undefined;
@ -282,14 +284,13 @@ export class OAuthManager {
iss?: string,
): Promise<void> {
try {
await this.requireStorage().load();
if (this.isEnterpriseManaged()) {
const emaConfig = await this.getEmaFlowConfig();
const scopeForMint =
this.pendingAuthorizationScope ?? this.getEmaFlowConfig().scope;
this.pendingAuthorizationScope ?? emaConfig.scope;
const config = scopeForMint
? { ...this.getEmaFlowConfig(), scope: scopeForMint }
: this.getEmaFlowConfig();
? { ...emaConfig, scope: scopeForMint }
: emaConfig;
const tokens = await completeEmaIdpAuthorizationAndMint(
config,
authorizationCode,
@ -448,7 +449,7 @@ export class OAuthManager {
*/
async refreshEnterpriseManagedTokens(): Promise<boolean> {
if (!this.isEnterpriseManaged()) return false;
const tokens = await refreshEmaResourceTokens(this.getEmaFlowConfig());
const tokens = await refreshEmaResourceTokens(await this.getEmaFlowConfig());
return tokens !== undefined;
}
@ -494,7 +495,7 @@ export class OAuthManager {
}
const effectiveScope = resolveEffectiveGrantedScope(
storage.getScope(serverUrl),
await storage.getScope(serverUrl),
tokens.scope,
);
return !isStrictScopeSuperset(scopeForAuth, effectiveScope);
@ -570,7 +571,7 @@ export class OAuthManager {
}
const previousScope = computeScopeUnion(
storage?.getScope(serverUrl),
storage ? await storage.getScope(serverUrl) : undefined,
grantedTokenScope,
);
const requiredFromChallenge =
@ -607,8 +608,10 @@ export class OAuthManager {
return this.oauthConfig.scope?.trim() || undefined;
}
private emaFlowConfigForChallenge(challenge: AuthChallenge): EmaFlowConfig {
const base = this.getEmaFlowConfig();
private async emaFlowConfigForChallenge(
challenge: AuthChallenge,
): Promise<EmaFlowConfig> {
const base = await this.getEmaFlowConfig();
const enriched = challenge.authorizationScopes;
if (enriched && enriched.length > 0) {
return { ...base, scope: enriched.join(" ") };
@ -628,7 +631,7 @@ export class OAuthManager {
return { kind: "step_up_confirm", challenge: enriched };
}
const config = this.emaFlowConfigForChallenge(enriched);
const config = await this.emaFlowConfigForChallenge(enriched);
if (enriched.reason === "insufficient_scope") {
const silent = await trySilentEmaAuth(config);
@ -863,7 +866,10 @@ export class OAuthManager {
> {
const provider = await this.createOAuthProvider();
if (this.isEnterpriseManaged()) {
return new EmaTransportOAuthProvider(provider, this.getEmaFlowConfig());
return new EmaTransportOAuthProvider(
provider,
await this.getEmaFlowConfig(),
);
}
return provider;
}

View file

@ -1,32 +0,0 @@
/**
* File-based storage adapter for Zustand persist middleware.
* Stores entire store state as JSON in a single file using atomic I/O.
*/
import { createJSONStorage } from "zustand/middleware";
import { readStoreFile, writeStoreFile, deleteStoreFile } from "../store-io.js";
export interface FileStorageAdapterOptions {
/** Full path to the storage file */
filePath: string;
}
/**
* Creates a Zustand storage adapter that reads/writes from a file.
* Conforms to Zustand's StateStorage interface.
*/
export function createFileStorageAdapter(
options: FileStorageAdapterOptions,
): ReturnType<typeof createJSONStorage> {
return createJSONStorage(() => ({
getItem: async () => readStoreFile(options.filePath),
// Do not introduce an `await` before writeStoreFile() here: it registers
// the write in pendingWrites synchronously, which is load-bearing for
// flushStoreFileWrites() (a microtask hop before registration would make a
// flush called right after setItem find an empty map and return early,
// silently regressing tests to non-deterministic).
setItem: async (_name: string, value: string) =>
writeStoreFile(options.filePath, value),
removeItem: async () => deleteStoreFile(options.filePath),
}));
}

View file

@ -1,10 +0,0 @@
/**
* Storage adapters for Zustand persist middleware.
* Provides adapters for file, remote HTTP, and browser storage.
*/
export { createFileStorageAdapter } from "./file-storage.js";
export type { FileStorageAdapterOptions } from "./file-storage.js";
export { createRemoteStorageAdapter } from "./remote-storage.js";
export type { RemoteStorageAdapterOptions } from "./remote-storage.js";

View file

@ -1,94 +0,0 @@
/**
* Remote HTTP storage adapter for Zustand persist middleware.
* Stores entire store state via HTTP API (GET/POST/DELETE /api/storage/:storeId).
*/
import { createJSONStorage } from "zustand/middleware";
export interface RemoteStorageAdapterOptions {
/** Base URL of the remote server (e.g. http://localhost:3000) */
baseUrl: string;
/** Store ID (e.g. "oauth", "inspector-settings") */
storeId: string;
/** Optional auth token for x-mcp-remote-auth header */
authToken?: string;
/** Fetch function to use (default: globalThis.fetch) */
fetchFn?: typeof fetch;
}
/**
* Creates a Zustand storage adapter that reads/writes via HTTP API.
* Conforms to Zustand's StateStorage interface.
*/
export function createRemoteStorageAdapter(
options: RemoteStorageAdapterOptions,
): ReturnType<typeof createJSONStorage> {
const baseUrl = options.baseUrl.replace(/\/$/, "");
const fetchFn = options.fetchFn ?? globalThis.fetch;
return createJSONStorage(() => ({
getItem: async (_name: string) => {
const headers: Record<string, string> = {};
if (options.authToken) {
headers["x-mcp-remote-auth"] = `Bearer ${options.authToken}`;
}
const res = await fetchFn(`${baseUrl}/api/storage/${options.storeId}`, {
method: "GET",
headers,
});
if (!res.ok) {
if (res.status === 404) {
return null;
}
throw new Error(`Failed to read store: ${res.status}`);
}
const store = await res.json();
// Zustand stores: { state: {...}, version: number }
// API returns the stored blob. If empty, Zustand hasn't initialized yet.
if (Object.keys(store).length === 0) {
return null; // Empty store means not initialized yet
}
// Return the stored Zustand format as string
return JSON.stringify(store);
},
setItem: async (_name: string, value: string) => {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (options.authToken) {
headers["x-mcp-remote-auth"] = `Bearer ${options.authToken}`;
}
// Zustand gives us the full persisted format as a string
// Store it as-is (the API treats it as an opaque blob)
const res = await fetchFn(`${baseUrl}/api/storage/${options.storeId}`, {
method: "POST",
headers,
body: value, // Already a JSON string from Zustand
});
if (!res.ok) {
throw new Error(`Failed to write store: ${res.status}`);
}
},
removeItem: async (_name: string) => {
const headers: Record<string, string> = {};
if (options.authToken) {
headers["x-mcp-remote-auth"] = `Bearer ${options.authToken}`;
}
const res = await fetchFn(`${baseUrl}/api/storage/${options.storeId}`, {
method: "DELETE",
headers,
});
// 404 is fine (already deleted), but other errors should propagate
if (!res.ok && res.status !== 404) {
throw new Error(`Failed to delete store: ${res.status}`);
}
},
}));
}

View file

@ -1,6 +1,6 @@
/**
* Shared storage path resolution, validation, and atomic file I/O.
* Used by the file storage adapter and the remote server's /api/storage routes.
* Used by OAuth file persistence and the remote server's /api/storage routes.
*/
import * as path from "node:path";
@ -57,15 +57,7 @@ export async function readStoreFile(filePath: string): Promise<string | null> {
/**
* In-flight writeStoreFile() promises, keyed by resolved path. Lets callers
* await persistence completion via flushStoreFileWrites() instead of polling
* the file — Zustand's persist middleware invokes writeStoreFile() fire-and-
* forget, so the in-memory store updates synchronously while the file write
* lags. Entries are removed once their write settles.
*
* Load-bearing: pendingWrites.set() below runs synchronously before the first
* await in writeStoreFile(), so a flushStoreFileWrites() called right after a
* persist sees the in-flight entry. Callers (e.g. the storage adapter's
* setItem) must not introduce an await before writeStoreFile() — doing so would
* let a flush run before registration and return early.
* the file. Entries are removed once their write settles.
*/
const pendingWrites = new Map<string, Promise<void>>();
@ -104,9 +96,8 @@ export async function writeStoreFile(
/**
* Await pending writeStoreFile() writes — those for `filePath` if given, else
* all of them. Use in tests after triggering persistence (Zustand persist
* writes fire-and-forget) instead of polling the file, and for graceful
* shutdown. Resolves immediately when nothing is in flight.
* all of them. Use in tests after triggering persistence instead of polling
* the file, and for graceful shutdown. Resolves immediately when nothing is in flight.
*/
export async function flushStoreFileWrites(filePath?: string): Promise<void> {
if (filePath !== undefined) {