fix(auth): reject load() when persist read fails

This commit is contained in:
Bob Dickinson 2026-07-03 14:50:04 -07:00
commit 512f45d357
2 changed files with 91 additions and 10 deletions

View file

@ -8,7 +8,11 @@ import {
OAuthTokensSchema,
} from "@modelcontextprotocol/sdk/shared/auth.js";
import type { OAuthStorage } from "./storage.js";
import { type createOAuthStore, type ServerOAuthState } from "./store.js";
import {
type OAuthStore,
type ServerOAuthState,
waitForOAuthStorePersistLoad,
} from "./store.js";
import type {
IdpSessionState,
OAuthClientRegistrationKind,
@ -16,10 +20,9 @@ import type {
SaveTokensOptions,
} from "./storage.js";
type OAuthStoreWithPersist = ReturnType<typeof createOAuthStore> & {
type OAuthStoreWithPersist = OAuthStore & {
persist: {
hasHydrated: () => boolean;
onFinishHydration: (fn: () => void) => void;
};
};
@ -32,19 +35,18 @@ export class OAuthStorageBase implements OAuthStorage {
private readonly store: OAuthStoreWithPersist;
private loadedPromise: Promise<void> | undefined;
constructor(store: ReturnType<typeof createOAuthStore>) {
constructor(store: OAuthStore) {
this.store = store as OAuthStoreWithPersist;
}
load(): Promise<void> {
if (!this.loadedPromise) {
this.loadedPromise = new Promise((resolve) => {
this.loadedPromise = (async () => {
if (this.store.persist.hasHydrated()) {
resolve();
return;
}
this.store.persist.onFinishHydration(() => resolve());
});
await waitForOAuthStorePersistLoad(this.store);
})();
}
return this.loadedPromise;
}

View file

@ -43,6 +43,23 @@ 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).
*/
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.).
@ -53,7 +70,29 @@ export interface OAuthStoreState {
export function createOAuthStore(
storage: ReturnType<typeof createJSONStorage>,
) {
return createStore<OAuthStoreState>()(
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: {},
@ -118,7 +157,13 @@ export function createOAuthStore(
{
name: "mcp-inspector-oauth",
storage,
onRehydrateStorage: () => (_state, error) => {
settlePersistLoad(error);
},
},
),
);
persistLoadByStore.set(store, persistLoadPromise);
return store;
}