fix(auth): await OAuth storage load() to fix remote OAuth races

This commit is contained in:
Bob Dickinson 2026-07-03 14:18:00 -07:00
commit a24091484c
25 changed files with 245 additions and 133 deletions

View file

@ -896,7 +896,7 @@ function App({
const handleClearOAuth = useCallback(async () => { const handleClearOAuth = useCallback(async () => {
if (!selectedInspectorClient) return; if (!selectedInspectorClient) return;
selectedInspectorClient.clearOAuthTokens(); await selectedInspectorClient.clearOAuthTokens();
setOauthStatus("idle"); setOauthStatus("idle");
setOauthMessage(null); setOauthMessage(null);
setConnectError(null); setConnectError(null);

View file

@ -2191,6 +2191,7 @@ function App() {
} }
void (async () => { void (async () => {
await webOAuthStorage.load();
const client = setupClientForServer(server, sessionId); const client = setupClientForServer(server, sessionId);
setActiveServerId(server.id); setActiveServerId(server.id);
try { try {
@ -2246,7 +2247,7 @@ function App() {
}); });
} }
})(); })();
}, [servers, setupClientForServer, showReAuthBanner]); }, [servers, setupClientForServer, showReAuthBanner, webOAuthStorage]);
const onToggleConnection = useCallback( const onToggleConnection = useCallback(
async (id: string) => { async (id: string) => {
@ -3273,7 +3274,7 @@ function App() {
const clearServerOAuthAndDisconnect = useCallback( const clearServerOAuthAndDisconnect = useCallback(
async (server: { id: string; name: string; config: MCPServerConfig }) => { async (server: { id: string; name: string; config: MCPServerConfig }) => {
const isActive = server.id === activeServerId; const isActive = server.id === activeServerId;
const cleared = clearServerOAuthState({ const cleared = await clearServerOAuthState({
config: server.config, config: server.config,
inspectorClient: isActive ? inspectorClient : null, inspectorClient: isActive ? inspectorClient : null,
isActiveConnection: isActive, isActiveConnection: isActive,

View file

@ -22,6 +22,7 @@ function createStorage(
}> = {}, }> = {},
): OAuthStorage { ): OAuthStorage {
return { return {
load: vi.fn().mockResolvedValue(undefined),
getTokens: vi.fn().mockResolvedValue(overrides.tokens), getTokens: vi.fn().mockResolvedValue(overrides.tokens),
getClientInformation: vi.fn(async (_url, isPreregistered) => getClientInformation: vi.fn(async (_url, isPreregistered) =>
isPreregistered ? overrides.preregistered : overrides.dynamic, isPreregistered ? overrides.preregistered : overrides.dynamic,

View file

@ -43,6 +43,7 @@ function createMemoryStorage(
const clientInfoByKey: Record<string, unknown> = {}; const clientInfoByKey: Record<string, unknown> = {};
const codeVerifierByKey: Record<string, string> = {}; const codeVerifierByKey: Record<string, string> = {};
return { return {
load: vi.fn().mockResolvedValue(undefined),
getIdpSession: vi.fn(async (issuer: string) => idpSessions[issuer]), getIdpSession: vi.fn(async (issuer: string) => idpSessions[issuer]),
saveIdpSession: vi.fn(async (issuer: string, updates) => { saveIdpSession: vi.fn(async (issuer: string, updates) => {
idpSessions[issuer] = { ...idpSessions[issuer], ...updates }; idpSessions[issuer] = { ...idpSessions[issuer], ...updates };

View file

@ -29,6 +29,7 @@ describe("idpOidc refresh", () => {
beforeEach(() => { beforeEach(() => {
Object.keys(idpSessions).forEach((k) => delete idpSessions[k]); Object.keys(idpSessions).forEach((k) => delete idpSessions[k]);
storage = { storage = {
load: vi.fn().mockResolvedValue(undefined),
getIdpSession: vi.fn(async (issuer: string) => idpSessions[issuer]), getIdpSession: vi.fn(async (issuer: string) => idpSessions[issuer]),
saveIdpSession: vi.fn(async (issuer: string, updates) => { saveIdpSession: vi.fn(async (issuer: string, updates) => {
idpSessions[issuer] = { ...idpSessions[issuer], ...updates }; idpSessions[issuer] = { ...idpSessions[issuer], ...updates };
@ -264,6 +265,8 @@ describe("startIdpOidcAuthorization", () => {
beforeEach(() => { beforeEach(() => {
Object.keys(saved).forEach((k) => delete saved[k]); Object.keys(saved).forEach((k) => delete saved[k]);
storage = { storage = {
load: vi.fn().mockResolvedValue(undefined),
getServerMetadata: vi.fn(() => null),
saveCodeVerifier: vi.fn(async (key: string, value: string) => { saveCodeVerifier: vi.fn(async (key: string, value: string) => {
saved[`cv:${key}`] = value; saved[`cv:${key}`] = value;
}), }),
@ -331,6 +334,7 @@ describe("completeIdpOidcAuthorization", () => {
function buildStorage(overrides: Partial<OAuthStorage> = {}): OAuthStorage { function buildStorage(overrides: Partial<OAuthStorage> = {}): OAuthStorage {
return { return {
load: vi.fn().mockResolvedValue(undefined),
getServerMetadata: vi.fn(() => minimalOAuthAsMetadata(IDP_ISSUER)), getServerMetadata: vi.fn(() => minimalOAuthAsMetadata(IDP_ISSUER)),
getClientInformation: vi.fn(async () => ({ getClientInformation: vi.fn(async () => ({
client_id: "idp-client", client_id: "idp-client",

View file

@ -19,6 +19,7 @@ describe("idpSession", () => {
beforeEach(() => { beforeEach(() => {
storage = { storage = {
load: vi.fn().mockResolvedValue(undefined),
getIdpSession: vi.fn(), getIdpSession: vi.fn(),
saveIdpSession: vi.fn(), saveIdpSession: vi.fn(),
clearIdpSession: vi.fn(), clearIdpSession: vi.fn(),
@ -73,15 +74,15 @@ describe("idpSession", () => {
); );
}); });
it("clearEmaIdpSession clears idp session, leg-1 key, and tagged resource servers", () => { it("clearEmaIdpSession clears idp session, leg-1 key, and tagged resource servers", async () => {
clearEmaIdpSession(storage, "https://idp.test/"); await clearEmaIdpSession(storage, "https://idp.test/");
expect(storage.clearIdpSession).toHaveBeenCalledWith("https://idp.test"); expect(storage.clearIdpSession).toHaveBeenCalledWith("https://idp.test");
expect(storage.clear).toHaveBeenCalledWith("ema-idp:https://idp.test"); expect(storage.clear).toHaveBeenCalledWith("ema-idp:https://idp.test");
expect(storage.clearEnterpriseManagedResourceServers).toHaveBeenCalled(); expect(storage.clearEnterpriseManagedResourceServers).toHaveBeenCalled();
}); });
it("clearEmaIdpSession no-ops when issuer normalizes to empty", () => { it("clearEmaIdpSession no-ops when issuer normalizes to empty", async () => {
clearEmaIdpSession(storage, ""); await clearEmaIdpSession(storage, "");
expect(storage.clearIdpSession).not.toHaveBeenCalled(); expect(storage.clearIdpSession).not.toHaveBeenCalled();
expect(storage.clear).not.toHaveBeenCalled(); expect(storage.clear).not.toHaveBeenCalled();
expect( expect(

View file

@ -213,6 +213,7 @@ describe("OAuthNavigation", () => {
function makeStorage(): OAuthStorage { function makeStorage(): OAuthStorage {
return { return {
load: vi.fn().mockResolvedValue(undefined),
getScope: vi.fn(() => undefined), getScope: vi.fn(() => undefined),
getClientInformation: vi.fn(async () => undefined), getClientInformation: vi.fn(async () => undefined),
saveClientInformation: vi.fn(async () => undefined), saveClientInformation: vi.fn(async () => undefined),
@ -240,11 +241,11 @@ describe("OAuthNavigation", () => {
return new BaseOAuthClientProvider(SERVER, config); return new BaseOAuthClientProvider(SERVER, config);
} }
it("clear() delegates to storage.clear with the server url", () => { it("clear() delegates to storage.clear with the server url", async () => {
const storage = makeStorage(); const storage = makeStorage();
const provider = makeProvider(storage); const provider = makeProvider(storage);
provider.clear(); await provider.clear();
expect(storage.clear).toHaveBeenCalledWith(SERVER); expect(storage.clear).toHaveBeenCalledWith(SERVER);
}); });

View file

@ -77,7 +77,7 @@ describe("BrowserOAuthStorage", () => {
client_secret: "test-secret", client_secret: "test-secret",
}; };
storage.saveClientInformation(testServerUrl, clientInfo, { await storage.saveClientInformation(testServerUrl, clientInfo, {
registrationKind: "dcr", registrationKind: "dcr",
}); });
const result = await storage.getClientInformation(testServerUrl); const result = await storage.getClientInformation(testServerUrl);
@ -93,7 +93,7 @@ describe("BrowserOAuthStorage", () => {
// Use the storage API instead of manually setting sessionStorage // Use the storage API instead of manually setting sessionStorage
// since BrowserOAuthStorage now uses Zustand with a different storage format // since BrowserOAuthStorage now uses Zustand with a different storage format
storage.savePreregisteredClientInformation( await storage.savePreregisteredClientInformation(
testServerUrl, testServerUrl,
preregisteredInfo, preregisteredInfo,
); );
@ -113,7 +113,7 @@ describe("BrowserOAuthStorage", () => {
client_id: "test-client-id", client_id: "test-client-id",
}; };
storage.saveClientInformation(testServerUrl, clientInfo, { await storage.saveClientInformation(testServerUrl, clientInfo, {
registrationKind: "dcr", registrationKind: "dcr",
}); });
const result = await storage.getClientInformation(testServerUrl); const result = await storage.getClientInformation(testServerUrl);
@ -130,10 +130,10 @@ describe("BrowserOAuthStorage", () => {
client_id: "second-id", client_id: "second-id",
}; };
storage.saveClientInformation(testServerUrl, firstInfo, { await storage.saveClientInformation(testServerUrl, firstInfo, {
registrationKind: "dcr", registrationKind: "dcr",
}); });
storage.saveClientInformation(testServerUrl, secondInfo, { await storage.saveClientInformation(testServerUrl, secondInfo, {
registrationKind: "cimd", registrationKind: "cimd",
}); });
const result = await storage.getClientInformation(testServerUrl); const result = await storage.getClientInformation(testServerUrl);
@ -163,7 +163,7 @@ describe("BrowserOAuthStorage", () => {
expires_in: 3600, expires_in: 3600,
}; };
storage.saveTokens(testServerUrl, tokens); await storage.saveTokens(testServerUrl, tokens);
const result = await storage.getTokens(testServerUrl); const result = await storage.getTokens(testServerUrl);
expect(result).toEqual(tokens); expect(result).toEqual(tokens);
@ -177,7 +177,7 @@ describe("BrowserOAuthStorage", () => {
token_type: "Bearer", token_type: "Bearer",
}; };
storage.saveTokens(testServerUrl, tokens); await storage.saveTokens(testServerUrl, tokens);
const result = await storage.getTokens(testServerUrl); const result = await storage.getTokens(testServerUrl);
expect(result).toEqual(tokens); expect(result).toEqual(tokens);
@ -194,7 +194,7 @@ describe("BrowserOAuthStorage", () => {
await storage.saveTokens(emaUrl, tokens, { enterpriseManaged: true }); await storage.saveTokens(emaUrl, tokens, { enterpriseManaged: true });
await storage.saveTokens(standardUrl, tokens); await storage.saveTokens(standardUrl, tokens);
storage.clearEnterpriseManagedResourceServers(); await storage.clearEnterpriseManagedResourceServers();
expect(await storage.getTokens(emaUrl)).toBeUndefined(); expect(await storage.getTokens(emaUrl)).toBeUndefined();
expect(await storage.getTokens(standardUrl)).toEqual(tokens); expect(await storage.getTokens(standardUrl)).toEqual(tokens);
@ -210,7 +210,7 @@ describe("BrowserOAuthStorage", () => {
it("should return stored code verifier", async () => { it("should return stored code verifier", async () => {
const codeVerifier = "test-code-verifier"; const codeVerifier = "test-code-verifier";
storage.saveCodeVerifier(testServerUrl, codeVerifier); await storage.saveCodeVerifier(testServerUrl, codeVerifier);
const result = await storage.getCodeVerifier(testServerUrl); const result = await storage.getCodeVerifier(testServerUrl);
expect(result).toBe(codeVerifier); expect(result).toBe(codeVerifier);
@ -221,7 +221,7 @@ describe("BrowserOAuthStorage", () => {
it("should save code verifier", async () => { it("should save code verifier", async () => {
const codeVerifier = "test-code-verifier"; const codeVerifier = "test-code-verifier";
storage.saveCodeVerifier(testServerUrl, codeVerifier); await storage.saveCodeVerifier(testServerUrl, codeVerifier);
const result = await storage.getCodeVerifier(testServerUrl); const result = await storage.getCodeVerifier(testServerUrl);
expect(result).toBe(codeVerifier); expect(result).toBe(codeVerifier);
@ -237,7 +237,7 @@ describe("BrowserOAuthStorage", () => {
it("should return stored scope", async () => { it("should return stored scope", async () => {
const scope = "read write"; const scope = "read write";
storage.saveScope(testServerUrl, scope); await storage.saveScope(testServerUrl, scope);
const result = await storage.getScope(testServerUrl); const result = await storage.getScope(testServerUrl);
expect(result).toBe(scope); expect(result).toBe(scope);
@ -248,7 +248,7 @@ describe("BrowserOAuthStorage", () => {
it("should save scope", async () => { it("should save scope", async () => {
const scope = "read write"; const scope = "read write";
storage.saveScope(testServerUrl, scope); await storage.saveScope(testServerUrl, scope);
const result = await storage.getScope(testServerUrl); const result = await storage.getScope(testServerUrl);
expect(result).toBe(scope); expect(result).toBe(scope);
@ -269,7 +269,7 @@ describe("BrowserOAuthStorage", () => {
response_types_supported: ["code"], response_types_supported: ["code"],
}; };
storage.saveServerMetadata(testServerUrl, metadata); await storage.saveServerMetadata(testServerUrl, metadata);
const result = await storage.getServerMetadata(testServerUrl); const result = await storage.getServerMetadata(testServerUrl);
expect(result).toEqual(metadata); expect(result).toEqual(metadata);
@ -285,7 +285,7 @@ describe("BrowserOAuthStorage", () => {
response_types_supported: ["code"], response_types_supported: ["code"],
}; };
storage.saveServerMetadata(testServerUrl, metadata); await storage.saveServerMetadata(testServerUrl, metadata);
const result = await storage.getServerMetadata(testServerUrl); const result = await storage.getServerMetadata(testServerUrl);
expect(result).toEqual(metadata); expect(result).toEqual(metadata);
@ -294,7 +294,7 @@ describe("BrowserOAuthStorage", () => {
describe("clearClientInformation", () => { describe("clearClientInformation", () => {
it("removes the dynamically-registered client info by default", async () => { it("removes the dynamically-registered client info by default", async () => {
storage.saveClientInformation( await storage.saveClientInformation(
testServerUrl, testServerUrl,
{ client_id: "dyn" }, { client_id: "dyn" },
{ registrationKind: "dcr" }, { registrationKind: "dcr" },
@ -302,18 +302,18 @@ describe("BrowserOAuthStorage", () => {
expect(await storage.getClientInformation(testServerUrl)).toEqual({ expect(await storage.getClientInformation(testServerUrl)).toEqual({
client_id: "dyn", client_id: "dyn",
}); });
storage.clearClientInformation(testServerUrl); await storage.clearClientInformation(testServerUrl);
expect(await storage.getClientInformation(testServerUrl)).toBeUndefined(); expect(await storage.getClientInformation(testServerUrl)).toBeUndefined();
}); });
it("removes the preregistered client info when isPreregistered=true", async () => { it("removes the preregistered client info when isPreregistered=true", async () => {
storage.savePreregisteredClientInformation(testServerUrl, { await storage.savePreregisteredClientInformation(testServerUrl, {
client_id: "pre", client_id: "pre",
}); });
expect(await storage.getClientInformation(testServerUrl, true)).toEqual({ expect(await storage.getClientInformation(testServerUrl, true)).toEqual({
client_id: "pre", client_id: "pre",
}); });
storage.clearClientInformation(testServerUrl, true); await storage.clearClientInformation(testServerUrl, true);
expect( expect(
await storage.getClientInformation(testServerUrl, true), await storage.getClientInformation(testServerUrl, true),
).toBeUndefined(); ).toBeUndefined();
@ -322,26 +322,26 @@ describe("BrowserOAuthStorage", () => {
describe("individual clear methods", () => { describe("individual clear methods", () => {
it("clearTokens removes only tokens", async () => { it("clearTokens removes only tokens", async () => {
storage.saveTokens(testServerUrl, { await storage.saveTokens(testServerUrl, {
access_token: "t", access_token: "t",
token_type: "Bearer", token_type: "Bearer",
}); });
expect(await storage.getTokens(testServerUrl)).toBeDefined(); expect(await storage.getTokens(testServerUrl)).toBeDefined();
storage.clearTokens(testServerUrl); await storage.clearTokens(testServerUrl);
expect(await storage.getTokens(testServerUrl)).toBeUndefined(); expect(await storage.getTokens(testServerUrl)).toBeUndefined();
}); });
it("clearCodeVerifier removes only the PKCE verifier", async () => { it("clearCodeVerifier removes only the PKCE verifier", async () => {
storage.saveCodeVerifier(testServerUrl, "verifier"); await storage.saveCodeVerifier(testServerUrl, "verifier");
expect(storage.getCodeVerifier(testServerUrl)).toBe("verifier"); expect(storage.getCodeVerifier(testServerUrl)).toBe("verifier");
storage.clearCodeVerifier(testServerUrl); await storage.clearCodeVerifier(testServerUrl);
expect(storage.getCodeVerifier(testServerUrl)).toBeUndefined(); expect(storage.getCodeVerifier(testServerUrl)).toBeUndefined();
}); });
it("clearScope removes only the scope", async () => { it("clearScope removes only the scope", async () => {
storage.saveScope(testServerUrl, "read"); await storage.saveScope(testServerUrl, "read");
expect(storage.getScope(testServerUrl)).toBe("read"); expect(storage.getScope(testServerUrl)).toBe("read");
storage.clearScope(testServerUrl); await storage.clearScope(testServerUrl);
expect(storage.getScope(testServerUrl)).toBeUndefined(); expect(storage.getScope(testServerUrl)).toBeUndefined();
}); });
@ -352,9 +352,9 @@ describe("BrowserOAuthStorage", () => {
token_endpoint: "http://localhost:3000/token", token_endpoint: "http://localhost:3000/token",
response_types_supported: ["code"], response_types_supported: ["code"],
}; };
storage.saveServerMetadata(testServerUrl, metadata); await storage.saveServerMetadata(testServerUrl, metadata);
expect(storage.getServerMetadata(testServerUrl)).toEqual(metadata); expect(storage.getServerMetadata(testServerUrl)).toEqual(metadata);
storage.clearServerMetadata(testServerUrl); await storage.clearServerMetadata(testServerUrl);
expect(storage.getServerMetadata(testServerUrl)).toBeNull(); expect(storage.getServerMetadata(testServerUrl)).toBeNull();
}); });
}); });
@ -369,12 +369,12 @@ describe("BrowserOAuthStorage", () => {
token_type: "Bearer", token_type: "Bearer",
}; };
storage.saveClientInformation(testServerUrl, clientInfo, { await storage.saveClientInformation(testServerUrl, clientInfo, {
registrationKind: "dcr", registrationKind: "dcr",
}); });
storage.saveTokens(testServerUrl, tokens); await storage.saveTokens(testServerUrl, tokens);
storage.clear(testServerUrl); await storage.clear(testServerUrl);
expect(await storage.getClientInformation(testServerUrl)).toBeUndefined(); expect(await storage.getClientInformation(testServerUrl)).toBeUndefined();
expect(await storage.getTokens(testServerUrl)).toBeUndefined(); expect(await storage.getTokens(testServerUrl)).toBeUndefined();
@ -386,14 +386,14 @@ describe("BrowserOAuthStorage", () => {
client_id: "test-client-id", client_id: "test-client-id",
}; };
storage.saveClientInformation(testServerUrl, clientInfo, { await storage.saveClientInformation(testServerUrl, clientInfo, {
registrationKind: "dcr", registrationKind: "dcr",
}); });
storage.saveClientInformation(otherServerUrl, clientInfo, { await storage.saveClientInformation(otherServerUrl, clientInfo, {
registrationKind: "dcr", registrationKind: "dcr",
}); });
storage.clear(testServerUrl); await storage.clear(testServerUrl);
expect(await storage.getClientInformation(testServerUrl)).toBeUndefined(); expect(await storage.getClientInformation(testServerUrl)).toBeUndefined();
expect(await storage.getClientInformation(otherServerUrl)).toEqual( expect(await storage.getClientInformation(otherServerUrl)).toEqual(
@ -415,10 +415,10 @@ describe("BrowserOAuthStorage", () => {
client_id: "client-2", client_id: "client-2",
}; };
storage.saveClientInformation(server1Url, clientInfo1, { await storage.saveClientInformation(server1Url, clientInfo1, {
registrationKind: "dcr", registrationKind: "dcr",
}); });
storage.saveClientInformation(server2Url, clientInfo2, { await storage.saveClientInformation(server2Url, clientInfo2, {
registrationKind: "dcr", registrationKind: "dcr",
}); });

View file

@ -50,7 +50,7 @@ describe("RemoteOAuthStorage (unit, mocked fetch)", () => {
{ client_id: "dyn" }, { client_id: "dyn" },
{ registrationKind: "dcr" }, { registrationKind: "dcr" },
); );
storage.clearClientInformation(serverUrl); await storage.clearClientInformation(serverUrl);
expect(await storage.getClientInformation(serverUrl)).toBeUndefined(); expect(await storage.getClientInformation(serverUrl)).toBeUndefined();
}); });
@ -58,7 +58,7 @@ describe("RemoteOAuthStorage (unit, mocked fetch)", () => {
await storage.savePreregisteredClientInformation(serverUrl, { await storage.savePreregisteredClientInformation(serverUrl, {
client_id: "pre", client_id: "pre",
}); });
storage.clearClientInformation(serverUrl, true); await storage.clearClientInformation(serverUrl, true);
expect(await storage.getClientInformation(serverUrl, true)).toBeUndefined(); expect(await storage.getClientInformation(serverUrl, true)).toBeUndefined();
}); });
@ -66,21 +66,21 @@ describe("RemoteOAuthStorage (unit, mocked fetch)", () => {
const tokens = { access_token: "t", token_type: "Bearer" }; const tokens = { access_token: "t", token_type: "Bearer" };
await storage.saveTokens(serverUrl, tokens); await storage.saveTokens(serverUrl, tokens);
expect(await storage.getTokens(serverUrl)).toEqual(tokens); expect(await storage.getTokens(serverUrl)).toEqual(tokens);
storage.clearTokens(serverUrl); await storage.clearTokens(serverUrl);
expect(await storage.getTokens(serverUrl)).toBeUndefined(); expect(await storage.getTokens(serverUrl)).toBeUndefined();
}); });
it("codeVerifier round-trip and clearCodeVerifier", async () => { it("codeVerifier round-trip and clearCodeVerifier", async () => {
await storage.saveCodeVerifier(serverUrl, "verifier"); await storage.saveCodeVerifier(serverUrl, "verifier");
expect(storage.getCodeVerifier(serverUrl)).toBe("verifier"); expect(storage.getCodeVerifier(serverUrl)).toBe("verifier");
storage.clearCodeVerifier(serverUrl); await storage.clearCodeVerifier(serverUrl);
expect(storage.getCodeVerifier(serverUrl)).toBeUndefined(); expect(storage.getCodeVerifier(serverUrl)).toBeUndefined();
}); });
it("scope round-trip and clearScope", async () => { it("scope round-trip and clearScope", async () => {
await storage.saveScope(serverUrl, "read write"); await storage.saveScope(serverUrl, "read write");
expect(storage.getScope(serverUrl)).toBe("read write"); expect(storage.getScope(serverUrl)).toBe("read write");
storage.clearScope(serverUrl); await storage.clearScope(serverUrl);
expect(storage.getScope(serverUrl)).toBeUndefined(); expect(storage.getScope(serverUrl)).toBeUndefined();
}); });
@ -93,7 +93,7 @@ describe("RemoteOAuthStorage (unit, mocked fetch)", () => {
}; };
await storage.saveServerMetadata(serverUrl, md); await storage.saveServerMetadata(serverUrl, md);
expect(storage.getServerMetadata(serverUrl)).toEqual(md); expect(storage.getServerMetadata(serverUrl)).toEqual(md);
storage.clearServerMetadata(serverUrl); await storage.clearServerMetadata(serverUrl);
expect(storage.getServerMetadata(serverUrl)).toBeNull(); expect(storage.getServerMetadata(serverUrl)).toBeNull();
}); });
@ -107,7 +107,7 @@ describe("RemoteOAuthStorage (unit, mocked fetch)", () => {
access_token: "t", access_token: "t",
token_type: "Bearer", token_type: "Bearer",
}); });
storage.clear(serverUrl); await storage.clear(serverUrl);
expect(await storage.getClientInformation(serverUrl)).toBeUndefined(); expect(await storage.getClientInformation(serverUrl)).toBeUndefined();
expect(await storage.getTokens(serverUrl)).toBeUndefined(); expect(await storage.getTokens(serverUrl)).toBeUndefined();
}); });
@ -120,4 +120,39 @@ describe("RemoteOAuthStorage (unit, mocked fetch)", () => {
// No public accessor; constructing without throwing covers the default-branch. // No public accessor; constructing without throwing covers the default-branch.
expect(s).toBeInstanceOf(RemoteOAuthStorage); expect(s).toBeInstanceOf(RemoteOAuthStorage);
}); });
it("load() waits for remote GET before sync reads see persisted state", async () => {
const persisted = {
state: {
servers: {
[serverUrl]: { codeVerifier: "persisted-verifier" },
},
idpSessions: {},
},
version: 0,
};
let resolveFetch!: (value: Response) => void;
const delayedFetch = vi.fn(
() =>
new Promise<Response>((resolve) => {
resolveFetch = resolve;
}),
) as unknown as typeof fetch;
const delayedStorage = new RemoteOAuthStorage({
baseUrl: "http://remote.example",
storeId: `delayed-${Math.random().toString(36).slice(2)}`,
fetchFn: delayedFetch,
});
const loadPromise = delayedStorage.load();
expect(delayedStorage.getCodeVerifier(serverUrl)).toBeUndefined();
resolveFetch(new Response(JSON.stringify(persisted), { status: 200 }));
await loadPromise;
expect(delayedStorage.getCodeVerifier(serverUrl)).toBe(
"persisted-verifier",
);
});
}); });

View file

@ -35,6 +35,7 @@ function createMockParams(
const dispatchOAuthError = vi.fn(); const dispatchOAuthError = vi.fn();
const storage = { const storage = {
load: vi.fn().mockResolvedValue(undefined),
getScope: vi.fn().mockReturnValue(undefined), getScope: vi.fn().mockReturnValue(undefined),
getClientInformation: vi.fn().mockResolvedValue(undefined), getClientInformation: vi.fn().mockResolvedValue(undefined),
getClientRegistrationKind: vi.fn().mockReturnValue(undefined), getClientRegistrationKind: vi.fn().mockReturnValue(undefined),
@ -45,7 +46,7 @@ function createMockParams(
saveTokens: vi.fn().mockResolvedValue(undefined), saveTokens: vi.fn().mockResolvedValue(undefined),
getCodeVerifier: vi.fn().mockReturnValue("verifier"), getCodeVerifier: vi.fn().mockReturnValue("verifier"),
saveCodeVerifier: vi.fn().mockResolvedValue(undefined), saveCodeVerifier: vi.fn().mockResolvedValue(undefined),
clear: vi.fn(), clear: vi.fn().mockResolvedValue(undefined),
clearClientInformation: vi.fn(), clearClientInformation: vi.fn(),
clearTokens: vi.fn(), clearTokens: vi.fn(),
clearCodeVerifier: vi.fn(), clearCodeVerifier: vi.fn(),
@ -126,10 +127,10 @@ describe("OAuthManager", () => {
}); });
describe("clearOAuthTokens", () => { describe("clearOAuthTokens", () => {
it("calls storage.clear(serverUrl) when storage is configured", () => { it("calls storage.clear(serverUrl) when storage is configured", async () => {
const params = createMockParams(); const params = createMockParams();
const manager = new OAuthManager(params); const manager = new OAuthManager(params);
manager.clearOAuthTokens(); await manager.clearOAuthTokens();
expect(params.initialConfig.storage!.clear).toHaveBeenCalledWith( expect(params.initialConfig.storage!.clear).toHaveBeenCalledWith(
SERVER_URL, SERVER_URL,
); );
@ -137,7 +138,7 @@ describe("OAuthManager", () => {
expect(manager.getOAuthFlowStep()).toBeUndefined(); expect(manager.getOAuthFlowStep()).toBeUndefined();
}); });
it("no-ops when storage is not configured", () => { it("no-ops when storage is not configured", async () => {
const params = createMockParams({ const params = createMockParams({
initialConfig: { initialConfig: {
redirectUrlProvider: { redirectUrlProvider: {
@ -147,7 +148,7 @@ describe("OAuthManager", () => {
} as OAuthManagerConfig, } as OAuthManagerConfig,
}); });
const manager = new OAuthManager(params); const manager = new OAuthManager(params);
manager.clearOAuthTokens(); await manager.clearOAuthTokens();
expect(params.getServerUrl).not.toHaveBeenCalled(); expect(params.getServerUrl).not.toHaveBeenCalled();
}); });
}); });

View file

@ -8,10 +8,13 @@ describe("useEmaIdpLoginState", () => {
beforeEach(() => { beforeEach(() => {
storage = { storage = {
load: vi.fn().mockResolvedValue(undefined),
getIdpSession: vi.fn().mockResolvedValue(undefined), getIdpSession: vi.fn().mockResolvedValue(undefined),
clearIdpSession: vi.fn(), clearIdpSession: vi.fn().mockResolvedValue(undefined),
clear: vi.fn(), clear: vi.fn().mockResolvedValue(undefined),
clearEnterpriseManagedResourceServers: vi.fn(), clearEnterpriseManagedResourceServers: vi
.fn()
.mockResolvedValue(undefined),
} as unknown as OAuthStorage; } as unknown as OAuthStorage;
}); });
@ -56,10 +59,12 @@ describe("useEmaIdpLoginState", () => {
result.current.logout(); result.current.logout();
}); });
expect(storage.clearIdpSession).toHaveBeenCalledWith("https://idp.test"); await waitFor(() => {
expect(storage.clear).toHaveBeenCalledWith("ema-idp:https://idp.test"); expect(storage.clearIdpSession).toHaveBeenCalledWith("https://idp.test");
expect(storage.clearEnterpriseManagedResourceServers).toHaveBeenCalled(); expect(storage.clear).toHaveBeenCalledWith("ema-idp:https://idp.test");
expect(result.current.loginState).toBe("none"); expect(storage.clearEnterpriseManagedResourceServers).toHaveBeenCalled();
expect(result.current.loginState).toBe("none");
});
}); });
it("reports 'expired' for an expired token with no refresh token", async () => { it("reports 'expired' for an expired token with no refresh token", async () => {

View file

@ -126,10 +126,10 @@ describe("NodeOAuthStorage", () => {
client_id: "second-id", client_id: "second-id",
}; };
storage.saveClientInformation(testServerUrl, firstInfo, { await storage.saveClientInformation(testServerUrl, firstInfo, {
registrationKind: "dcr", registrationKind: "dcr",
}); });
storage.saveClientInformation(testServerUrl, secondInfo, { await storage.saveClientInformation(testServerUrl, secondInfo, {
registrationKind: "dcr", registrationKind: "dcr",
}); });
const result = await storage.getClientInformation(testServerUrl); const result = await storage.getClientInformation(testServerUrl);
@ -310,7 +310,7 @@ describe("NodeOAuthStorage", () => {
expect(await storage.getClientInformation(testServerUrl)).toEqual({ expect(await storage.getClientInformation(testServerUrl)).toEqual({
client_id: "dyn", client_id: "dyn",
}); });
storage.clearClientInformation(testServerUrl); await storage.clearClientInformation(testServerUrl);
expect(await storage.getClientInformation(testServerUrl)).toBeUndefined(); expect(await storage.getClientInformation(testServerUrl)).toBeUndefined();
}); });
@ -321,7 +321,7 @@ describe("NodeOAuthStorage", () => {
expect(await storage.getClientInformation(testServerUrl, true)).toEqual({ expect(await storage.getClientInformation(testServerUrl, true)).toEqual({
client_id: "pre", client_id: "pre",
}); });
storage.clearClientInformation(testServerUrl, true); await storage.clearClientInformation(testServerUrl, true);
expect( expect(
await storage.getClientInformation(testServerUrl, true), await storage.getClientInformation(testServerUrl, true),
).toBeUndefined(); ).toBeUndefined();
@ -335,21 +335,21 @@ describe("NodeOAuthStorage", () => {
token_type: "Bearer", token_type: "Bearer",
}); });
expect(await storage.getTokens(testServerUrl)).toBeDefined(); expect(await storage.getTokens(testServerUrl)).toBeDefined();
storage.clearTokens(testServerUrl); await storage.clearTokens(testServerUrl);
expect(await storage.getTokens(testServerUrl)).toBeUndefined(); expect(await storage.getTokens(testServerUrl)).toBeUndefined();
}); });
it("clearCodeVerifier removes only the PKCE verifier", async () => { it("clearCodeVerifier removes only the PKCE verifier", async () => {
await storage.saveCodeVerifier(testServerUrl, "verifier"); await storage.saveCodeVerifier(testServerUrl, "verifier");
expect(storage.getCodeVerifier(testServerUrl)).toBe("verifier"); expect(storage.getCodeVerifier(testServerUrl)).toBe("verifier");
storage.clearCodeVerifier(testServerUrl); await storage.clearCodeVerifier(testServerUrl);
expect(storage.getCodeVerifier(testServerUrl)).toBeUndefined(); expect(storage.getCodeVerifier(testServerUrl)).toBeUndefined();
}); });
it("clearScope removes only the scope", async () => { it("clearScope removes only the scope", async () => {
await storage.saveScope(testServerUrl, "read write"); await storage.saveScope(testServerUrl, "read write");
expect(storage.getScope(testServerUrl)).toBe("read write"); expect(storage.getScope(testServerUrl)).toBe("read write");
storage.clearScope(testServerUrl); await storage.clearScope(testServerUrl);
expect(storage.getScope(testServerUrl)).toBeUndefined(); expect(storage.getScope(testServerUrl)).toBeUndefined();
}); });
@ -362,7 +362,7 @@ describe("NodeOAuthStorage", () => {
}; };
await storage.saveServerMetadata(testServerUrl, metadata); await storage.saveServerMetadata(testServerUrl, metadata);
expect(storage.getServerMetadata(testServerUrl)).toEqual(metadata); expect(storage.getServerMetadata(testServerUrl)).toEqual(metadata);
storage.clearServerMetadata(testServerUrl); await storage.clearServerMetadata(testServerUrl);
expect(storage.getServerMetadata(testServerUrl)).toBeNull(); expect(storage.getServerMetadata(testServerUrl)).toBeNull();
}); });
}); });
@ -382,7 +382,7 @@ describe("NodeOAuthStorage", () => {
}); });
await storage.saveTokens(testServerUrl, tokens); await storage.saveTokens(testServerUrl, tokens);
storage.clear(testServerUrl); await storage.clear(testServerUrl);
expect(await storage.getClientInformation(testServerUrl)).toBeUndefined(); expect(await storage.getClientInformation(testServerUrl)).toBeUndefined();
expect(await storage.getTokens(testServerUrl)).toBeUndefined(); expect(await storage.getTokens(testServerUrl)).toBeUndefined();
@ -401,7 +401,7 @@ describe("NodeOAuthStorage", () => {
registrationKind: "dcr", registrationKind: "dcr",
}); });
storage.clear(testServerUrl); await storage.clear(testServerUrl);
expect(await storage.getClientInformation(testServerUrl)).toBeUndefined(); expect(await storage.getClientInformation(testServerUrl)).toBeUndefined();
const otherResult = await storage.getClientInformation(otherServerUrl); const otherResult = await storage.getClientInformation(otherServerUrl);
@ -424,10 +424,10 @@ describe("NodeOAuthStorage", () => {
client_id: "client-2", client_id: "client-2",
}; };
storage.saveClientInformation(server1Url, clientInfo1, { await storage.saveClientInformation(server1Url, clientInfo1, {
registrationKind: "dcr", registrationKind: "dcr",
}); });
storage.saveClientInformation(server2Url, clientInfo2, { await storage.saveClientInformation(server2Url, clientInfo2, {
registrationKind: "dcr", registrationKind: "dcr",
}); });
@ -547,7 +547,7 @@ describe("NodeOAuthStorage idpSessions (EMA)", () => {
expect(session?.idToken).toBe("eyJ.id.token"); expect(session?.idToken).toBe("eyJ.id.token");
expect(session?.refreshToken).toBe("rt-1"); expect(session?.refreshToken).toBe("rt-1");
storage.clearIdpSession(issuer); await storage.clearIdpSession(issuer);
await flushStoreFileWrites(testStatePath); await flushStoreFileWrites(testStatePath);
expect(await storage.getIdpSession(issuer)).toBeUndefined(); expect(await storage.getIdpSession(issuer)).toBeUndefined();
}); });
@ -635,12 +635,10 @@ describe("NodeOAuthStorage with custom storagePath", () => {
); );
try { try {
const defaultStore = getOAuthStore(); const defaultStorage = new NodeOAuthStorage();
defaultStore.getState().setServerState(testServerUrl, { await defaultStorage.saveTokens(testServerUrl, {
tokens: { access_token: "default-token",
access_token: "default-token", token_type: "Bearer",
token_type: "Bearer",
},
}); });
const customStorage = new NodeOAuthStorage(customPath); const customStorage = new NodeOAuthStorage(customPath);
@ -652,11 +650,10 @@ describe("NodeOAuthStorage with custom storagePath", () => {
const fromCustom = await customStorage.getTokens(testServerUrl); const fromCustom = await customStorage.getTokens(testServerUrl);
expect(fromCustom?.access_token).toBe("custom-token"); expect(fromCustom?.access_token).toBe("custom-token");
const defaultStorage = new NodeOAuthStorage();
const fromDefault = await defaultStorage.getTokens(testServerUrl); const fromDefault = await defaultStorage.getTokens(testServerUrl);
expect(fromDefault?.access_token).toBe("default-token"); expect(fromDefault?.access_token).toBe("default-token");
defaultStore.getState().clearServerState(testServerUrl); getOAuthStore().getState().clearServerState(testServerUrl);
} finally { } finally {
try { try {
await fs.unlink(customPath); await fs.unlink(customPath);

View file

@ -630,7 +630,7 @@ describe("InspectorClient OAuth E2E", () => {
expect(client.getStatus()).toBe("connected"); expect(client.getStatus()).toBe("connected");
await client.disconnect(); await client.disconnect();
client.clearOAuthTokens(); await client.clearOAuthTokens();
const authUrlSecond = await client.authenticate(); const authUrlSecond = await client.authenticate();
if (!authUrlSecond) throw new Error("Expected authorization URL"); if (!authUrlSecond) throw new Error("Expected authorization URL");
@ -995,7 +995,7 @@ describe("InspectorClient OAuth E2E", () => {
expect(tokens?.access_token).toBeDefined(); expect(tokens?.access_token).toBeDefined();
expect(await client.isOAuthAuthorized()).toBe(true); expect(await client.isOAuthAuthorized()).toBe(true);
client.clearOAuthTokens(); await client.clearOAuthTokens();
expect(await client.isOAuthAuthorized()).toBe(false); expect(await client.isOAuthAuthorized()).toBe(false);
expect(await client.getOAuthTokens()).toBeUndefined(); expect(await client.getOAuthTokens()).toBeUndefined();
}); });

View file

@ -173,8 +173,8 @@ describe("InspectorClient OAuth", () => {
expect(tokens).toBeUndefined(); expect(tokens).toBeUndefined();
}); });
it("should clear OAuth tokens", () => { it("should clear OAuth tokens", async () => {
client.clearOAuthTokens(); await client.clearOAuthTokens();
// Should not throw // Should not throw
expect(client).toBeDefined(); expect(client).toBeDefined();
}); });

View file

@ -4949,7 +4949,7 @@ describe("InspectorClient", () => {
expect(c.getOAuthFlowState()).toBeUndefined(); expect(c.getOAuthFlowState()).toBeUndefined();
await expect(c.getOAuthState()).resolves.toBeUndefined(); await expect(c.getOAuthState()).resolves.toBeUndefined();
// clearOAuthTokens is a no-op when there is no manager // clearOAuthTokens is a no-op when there is no manager
expect(() => c.clearOAuthTokens()).not.toThrow(); await expect(c.clearOAuthTokens()).resolves.toBeUndefined();
}); });
it("setOAuthConfig throws when oauthManager is unset", () => { it("setOAuthConfig throws when oauthManager is unset", () => {

View file

@ -1,13 +1,14 @@
import { describe, it, expect, beforeEach, vi } from "vitest"; import { describe, it, expect, beforeEach, vi } from "vitest";
import { BrowserOAuthStorage } from "@inspector/core/auth/browser/storage.js"; import { BrowserOAuthStorage } from "@inspector/core/auth/browser/storage.js";
import type { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
import { clearServerOAuthState } from "./clearServerOAuthState"; import { clearServerOAuthState } from "./clearServerOAuthState";
describe("clearServerOAuthState", () => { describe("clearServerOAuthState", () => {
let storage: BrowserOAuthStorage; let storage: BrowserOAuthStorage;
beforeEach(() => { beforeEach(async () => {
storage = new BrowserOAuthStorage(); storage = new BrowserOAuthStorage();
storage.clear("https://mcp.example.com/mcp"); await storage.clear("https://mcp.example.com/mcp");
}); });
it("clears storage by server URL when not the active connection", async () => { it("clears storage by server URL when not the active connection", async () => {
@ -16,7 +17,7 @@ describe("clearServerOAuthState", () => {
token_type: "Bearer", token_type: "Bearer",
}); });
const cleared = clearServerOAuthState({ const cleared = await clearServerOAuthState({
config: { type: "streamable-http", url: "https://mcp.example.com/mcp" }, config: { type: "streamable-http", url: "https://mcp.example.com/mcp" },
isActiveConnection: false, isActiveConnection: false,
oauthStorage: storage, oauthStorage: storage,
@ -28,11 +29,11 @@ describe("clearServerOAuthState", () => {
).toBeUndefined(); ).toBeUndefined();
}); });
it("uses the live client when clearing the active connection", () => { it("uses the live client when clearing the active connection", async () => {
const clearOAuthTokens = vi.fn<() => void>(); const clearOAuthTokens = vi.fn<InspectorClient["clearOAuthTokens"]>();
const inspectorClient = { clearOAuthTokens }; const inspectorClient = { clearOAuthTokens };
const cleared = clearServerOAuthState({ const cleared = await clearServerOAuthState({
config: { type: "streamable-http", url: "https://mcp.example.com/mcp" }, config: { type: "streamable-http", url: "https://mcp.example.com/mcp" },
inspectorClient, inspectorClient,
isActiveConnection: true, isActiveConnection: true,
@ -43,13 +44,13 @@ describe("clearServerOAuthState", () => {
expect(clearOAuthTokens).toHaveBeenCalledTimes(1); expect(clearOAuthTokens).toHaveBeenCalledTimes(1);
}); });
it("returns false for stdio servers", () => { it("returns false for stdio servers", async () => {
expect( await expect(
clearServerOAuthState({ clearServerOAuthState({
config: { type: "stdio", command: "node", args: [] }, config: { type: "stdio", command: "node", args: [] },
isActiveConnection: false, isActiveConnection: false,
oauthStorage: storage, oauthStorage: storage,
}), }),
).toBe(false); ).resolves.toBe(false);
}); });
}); });

View file

@ -17,18 +17,18 @@ export interface ClearServerOAuthStateParams {
* HTTP MCP server. When clearing the active connection, uses the live client so * HTTP MCP server. When clearing the active connection, uses the live client so
* in-memory flow state is reset too. * in-memory flow state is reset too.
*/ */
export function clearServerOAuthState( export async function clearServerOAuthState(
params: ClearServerOAuthStateParams, params: ClearServerOAuthStateParams,
): boolean { ): Promise<boolean> {
const serverUrl = getOAuthServerUrl(params.config); const serverUrl = getOAuthServerUrl(params.config);
if (!serverUrl) { if (!serverUrl) {
return false; return false;
} }
if (params.isActiveConnection && params.inspectorClient) { if (params.isActiveConnection && params.inspectorClient) {
params.inspectorClient.clearOAuthTokens(); await params.inspectorClient.clearOAuthTokens();
} else { } else {
params.oauthStorage.clear(serverUrl); await params.oauthStorage.clear(serverUrl);
} }
return true; return true;
} }

View file

@ -34,6 +34,7 @@ async function resolveIdpMetadata(
storage: OAuthStorage, storage: OAuthStorage,
fetchFn?: typeof fetch, fetchFn?: typeof fetch,
): Promise<OAuthMetadata> { ): Promise<OAuthMetadata> {
await storage.load();
const storageKey = idpOAuthStorageKey(issuer); const storageKey = idpOAuthStorageKey(issuer);
const cached = storage.getServerMetadata(storageKey); const cached = storage.getServerMetadata(storageKey);
if (cached?.token_endpoint) { if (cached?.token_endpoint) {
@ -63,7 +64,7 @@ export async function startIdpOidcAuthorization(params: {
fetchFn?: typeof fetch; fetchFn?: typeof fetch;
}): Promise<{ authorizationUrl: URL }> { }): Promise<{ authorizationUrl: URL }> {
const issuer = normalizeIdpIssuer(params.idp.issuer); const issuer = normalizeIdpIssuer(params.idp.issuer);
const metadata = await discoverIdpMetadata(issuer, params.fetchFn); const metadata = await resolveIdpMetadata(issuer, params.storage, params.fetchFn);
const clientInformation = idpClientInformation(params.idp); const clientInformation = idpClientInformation(params.idp);
const storageKey = idpOAuthStorageKey(issuer); const storageKey = idpOAuthStorageKey(issuer);
const state = generateOAuthState(); const state = generateOAuthState();
@ -101,6 +102,7 @@ export async function completeIdpOidcAuthorization(params: {
}> { }> {
const issuer = normalizeIdpIssuer(params.idp.issuer); const issuer = normalizeIdpIssuer(params.idp.issuer);
const storageKey = idpOAuthStorageKey(issuer); const storageKey = idpOAuthStorageKey(issuer);
await params.storage.load();
const metadata = params.storage.getServerMetadata(storageKey); const metadata = params.storage.getServerMetadata(storageKey);
if (!metadata) { if (!metadata) {
throw new Error("IdP OAuth metadata not found — restart EMA IdP login"); throw new Error("IdP OAuth metadata not found — restart EMA IdP login");
@ -131,7 +133,7 @@ export async function completeIdpOidcAuthorization(params: {
throw new Error("IdP token response did not include an ID Token"); throw new Error("IdP token response did not include an ID Token");
} }
params.storage.clearCodeVerifier(storageKey); await params.storage.clearCodeVerifier(storageKey);
const idTokenExpiresAt = jwtExpiresAtMs(idToken); const idTokenExpiresAt = jwtExpiresAtMs(idToken);
await params.storage.saveIdpSession(issuer, { await params.storage.saveIdpSession(issuer, {
idToken, idToken,

View file

@ -21,10 +21,13 @@ export async function getEmaIdpLoginState(
return "expired"; return "expired";
} }
export function clearEmaIdpSession(storage: OAuthStorage, issuer: string): void { export async function clearEmaIdpSession(
storage: OAuthStorage,
issuer: string,
): Promise<void> {
const normalized = normalizeIdpIssuer(issuer); const normalized = normalizeIdpIssuer(issuer);
if (!normalized) return; if (!normalized) return;
storage.clearIdpSession(normalized); await storage.clearIdpSession(normalized);
storage.clear(idpOAuthStorageKey(normalized)); await storage.clear(idpOAuthStorageKey(normalized));
storage.clearEnterpriseManagedResourceServers(); await storage.clearEnterpriseManagedResourceServers();
} }

View file

@ -16,22 +16,48 @@ import type {
SaveTokensOptions, SaveTokensOptions,
} from "./storage.js"; } from "./storage.js";
type OAuthStoreWithPersist = ReturnType<typeof createOAuthStore> & {
persist: {
hasHydrated: () => boolean;
onFinishHydration: (fn: () => void) => void;
};
};
/** /**
* Concrete OAuthStorage implementation parameterized on a Zustand store. * Concrete OAuthStorage implementation parameterized on a Zustand store.
* The store carries the storage adapter (sessionStorage, file, remote HTTP, …), * The store carries the storage adapter (sessionStorage, file, remote HTTP, …),
* so the same body works for browser, Node, and remote environments. * so the same body works for browser, Node, and remote environments.
*/ */
export class OAuthStorageBase implements OAuthStorage { export class OAuthStorageBase implements OAuthStorage {
private readonly store: ReturnType<typeof createOAuthStore>; private readonly store: OAuthStoreWithPersist;
private loadedPromise: Promise<void> | undefined;
constructor(store: ReturnType<typeof createOAuthStore>) { constructor(store: ReturnType<typeof createOAuthStore>) {
this.store = store; this.store = store as OAuthStoreWithPersist;
}
load(): Promise<void> {
if (!this.loadedPromise) {
this.loadedPromise = new Promise((resolve) => {
if (this.store.persist.hasHydrated()) {
resolve();
return;
}
this.store.persist.onFinishHydration(() => resolve());
});
}
return this.loadedPromise;
}
private async ensureLoaded(): Promise<void> {
await this.load();
} }
async getClientInformation( async getClientInformation(
serverUrl: string, serverUrl: string,
isPreregistered?: boolean, isPreregistered?: boolean,
): Promise<OAuthClientInformation | undefined> { ): Promise<OAuthClientInformation | undefined> {
await this.ensureLoaded();
const state = this.store.getState().getServerState(serverUrl); const state = this.store.getState().getServerState(serverUrl);
const clientInfo = isPreregistered const clientInfo = isPreregistered
? state.preregisteredClientInformation ? state.preregisteredClientInformation
@ -56,6 +82,7 @@ export class OAuthStorageBase implements OAuthStorage {
clientInformation: OAuthClientInformation, clientInformation: OAuthClientInformation,
options: SaveClientInformationOptions, options: SaveClientInformationOptions,
): Promise<void> { ): Promise<void> {
await this.ensureLoaded();
this.store.getState().setServerState(serverUrl, { this.store.getState().setServerState(serverUrl, {
clientInformation, clientInformation,
clientRegistrationKind: options.registrationKind, clientRegistrationKind: options.registrationKind,
@ -66,13 +93,18 @@ export class OAuthStorageBase implements OAuthStorage {
serverUrl: string, serverUrl: string,
clientInformation: OAuthClientInformation, clientInformation: OAuthClientInformation,
): Promise<void> { ): Promise<void> {
await this.ensureLoaded();
this.store.getState().setServerState(serverUrl, { this.store.getState().setServerState(serverUrl, {
preregisteredClientInformation: clientInformation, preregisteredClientInformation: clientInformation,
clientRegistrationKind: "static", clientRegistrationKind: "static",
}); });
} }
clearClientInformation(serverUrl: string, isPreregistered?: boolean): void { async clearClientInformation(
serverUrl: string,
isPreregistered?: boolean,
): Promise<void> {
await this.ensureLoaded();
const updates: Partial<ServerOAuthState> = {}; const updates: Partial<ServerOAuthState> = {};
if (isPreregistered) { if (isPreregistered) {
@ -86,6 +118,7 @@ export class OAuthStorageBase implements OAuthStorage {
} }
async getTokens(serverUrl: string): Promise<OAuthTokens | undefined> { async getTokens(serverUrl: string): Promise<OAuthTokens | undefined> {
await this.ensureLoaded();
const state = this.store.getState().getServerState(serverUrl); const state = this.store.getState().getServerState(serverUrl);
if (!state.tokens) { if (!state.tokens) {
return undefined; return undefined;
@ -99,13 +132,15 @@ export class OAuthStorageBase implements OAuthStorage {
tokens: OAuthTokens, tokens: OAuthTokens,
options?: SaveTokensOptions, options?: SaveTokensOptions,
): Promise<void> { ): Promise<void> {
await this.ensureLoaded();
this.store.getState().setServerState(serverUrl, { this.store.getState().setServerState(serverUrl, {
tokens, tokens,
...(options?.enterpriseManaged === true && { enterpriseManaged: true }), ...(options?.enterpriseManaged === true && { enterpriseManaged: true }),
}); });
} }
clearTokens(serverUrl: string): void { async clearTokens(serverUrl: string): Promise<void> {
await this.ensureLoaded();
this.store.getState().setServerState(serverUrl, { tokens: undefined }); this.store.getState().setServerState(serverUrl, { tokens: undefined });
} }
@ -118,10 +153,12 @@ export class OAuthStorageBase implements OAuthStorage {
serverUrl: string, serverUrl: string,
codeVerifier: string, codeVerifier: string,
): Promise<void> { ): Promise<void> {
await this.ensureLoaded();
this.store.getState().setServerState(serverUrl, { codeVerifier }); this.store.getState().setServerState(serverUrl, { codeVerifier });
} }
clearCodeVerifier(serverUrl: string): void { async clearCodeVerifier(serverUrl: string): Promise<void> {
await this.ensureLoaded();
this.store this.store
.getState() .getState()
.setServerState(serverUrl, { codeVerifier: undefined }); .setServerState(serverUrl, { codeVerifier: undefined });
@ -133,10 +170,12 @@ export class OAuthStorageBase implements OAuthStorage {
} }
async saveScope(serverUrl: string, scope: string | undefined): Promise<void> { async saveScope(serverUrl: string, scope: string | undefined): Promise<void> {
await this.ensureLoaded();
this.store.getState().setServerState(serverUrl, { scope }); this.store.getState().setServerState(serverUrl, { scope });
} }
clearScope(serverUrl: string): void { async clearScope(serverUrl: string): Promise<void> {
await this.ensureLoaded();
this.store.getState().setServerState(serverUrl, { scope: undefined }); this.store.getState().setServerState(serverUrl, { scope: undefined });
} }
@ -149,22 +188,26 @@ export class OAuthStorageBase implements OAuthStorage {
serverUrl: string, serverUrl: string,
metadata: OAuthMetadata, metadata: OAuthMetadata,
): Promise<void> { ): Promise<void> {
await this.ensureLoaded();
this.store this.store
.getState() .getState()
.setServerState(serverUrl, { serverMetadata: metadata }); .setServerState(serverUrl, { serverMetadata: metadata });
} }
clearServerMetadata(serverUrl: string): void { async clearServerMetadata(serverUrl: string): Promise<void> {
await this.ensureLoaded();
this.store this.store
.getState() .getState()
.setServerState(serverUrl, { serverMetadata: undefined }); .setServerState(serverUrl, { serverMetadata: undefined });
} }
clear(serverUrl: string): void { async clear(serverUrl: string): Promise<void> {
await this.ensureLoaded();
this.store.getState().clearServerState(serverUrl); this.store.getState().clearServerState(serverUrl);
} }
async getIdpSession(issuer: string): Promise<IdpSessionState | undefined> { async getIdpSession(issuer: string): Promise<IdpSessionState | undefined> {
await this.ensureLoaded();
const session = this.store.getState().getIdpSession(issuer); const session = this.store.getState().getIdpSession(issuer);
if ( if (
!session.idToken && !session.idToken &&
@ -180,14 +223,17 @@ export class OAuthStorageBase implements OAuthStorage {
issuer: string, issuer: string,
session: Partial<IdpSessionState>, session: Partial<IdpSessionState>,
): Promise<void> { ): Promise<void> {
await this.ensureLoaded();
this.store.getState().setIdpSession(issuer, session); this.store.getState().setIdpSession(issuer, session);
} }
clearIdpSession(issuer: string): void { async clearIdpSession(issuer: string): Promise<void> {
await this.ensureLoaded();
this.store.getState().clearIdpSession(issuer); this.store.getState().clearIdpSession(issuer);
} }
clearEnterpriseManagedResourceServers(): void { async clearEnterpriseManagedResourceServers(): Promise<void> {
await this.ensureLoaded();
this.store.getState().clearEnterpriseManagedResourceServers(); this.store.getState().clearEnterpriseManagedResourceServers();
} }
} }

Some files were not shown because too many files have changed in this diff Show more