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>
176 lines
5.6 KiB
TypeScript
176 lines
5.6 KiB
TypeScript
/**
|
||
* 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;
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
}
|
||
|
||
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).
|
||
*/
|
||
export function waitForOAuthStorePersistLoad(store: OAuthStore): Promise<void> {
|
||
const promise = persistLoadByStore.get(store);
|
||
if (!promise) {
|
||
return Promise.resolve();
|
||
}
|
||
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;
|
||
});
|
||
// Zustand kicks off async hydration as soon as the store is created, before
|
||
// any consumer calls `OAuthStorageBase.load()`. If that initial read fails
|
||
// and no one is awaiting yet, `persistLoadPromise` would reject with no
|
||
// handler and surface as an unhandled rejection. Attach a no-op handler so
|
||
// the promise is always "handled"; real awaiters still observe the rejection
|
||
// through the reference returned by `waitForOAuthStorePersistLoad`.
|
||
void persistLoadPromise.catch(() => {});
|
||
|
||
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);
|
||
},
|
||
},
|
||
),
|
||
);
|
||
|
||
persistLoadByStore.set(store, persistLoadPromise);
|
||
return store;
|
||
}
|