feat(web): migrate OAuth store to shared /store API (RemoteOAuthStorage parity) (closes #1548)

Migrate the web client's OAuth storage from BrowserOAuthStorage
(sessionStorage) to RemoteOAuthStorage, which POSTs through the backend's
/api/storage/oauth route to ~/.mcp-inspector/storage/oauth.json (mode 0600).
A credential obtained in the browser is now the same blob the TUI/CLI read
on the same host, giving web ⇄ TUI ⇄ CLI parity (Wave 1 of #1579).

Core (shared with TUI/CLI):
- OAuthStorageBase now drives a single async hydration (`ready()`,
  `getHydrationError()`); every post-redirect read (getClientInformation,
  getTokens, getCodeVerifier, getServerMetadata, getIdpSession) and every
  save awaits it so a late hydration merge cannot clobber a fresh write.
  getCodeVerifier/getServerMetadata become async; the store is created with
  skipHydration:true so there is no auto-hydration to race the explicit one.
- store.ts adds normalizeServerUrl so a token saved under
  https://Example.com/mcp is found when the CLI asks for
  https://example.com/mcp/; getServerState falls back to the raw key.
- remote-storage adapter POSTs with keepalive so the write survives the
  OAuth authorize redirect, surfaces swallowed persist failures, and gives
  richer read/write error messages.
- generateOAuthState throws instead of silently degrading to Math.random.
- NodeOAuthStorage honors MCP_INSPECTOR_OAUTH_STATE_PATH for isolated
  fixtures. Provider codeVerifier()/getServerMetadata() + the EMA idpOidc and
  connection-state readers await the now-async storage.

Web:
- environmentFactory + a new shared lib/remoteOAuthStorage accessor
  (memoized per {baseUrl, authToken}) wire RemoteOAuthStorage into the
  connection path, the EMA IdP hook, and the per-server "clear OAuth" action
  so all three share one in-memory view of oauth.json.

Tests: async-hydration + normalizeServerUrl suite, keepalive/error-path
coverage, and the sync→async migration across existing storage/provider/EMA
tests. Web validate + coverage gate + integration suite green; CLI/TUI/
launcher validate green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S3fTN8H3R8YV4yUGvZjYnX
This commit is contained in:
cliffhall 2026-07-01 18:39:17 -04:00
commit c299b6e9d8
25 changed files with 786 additions and 135 deletions

View file

@ -83,7 +83,10 @@ import { useServers } from "@inspector/core/react/useServers.js";
import { useSettingsDraft } from "@inspector/core/react/useSettingsDraft.js";
import { useClientSettingsDraft } from "@inspector/core/react/useClientSettingsDraft.js";
import { useEmaIdpLoginState } from "@inspector/core/react/useEmaIdpLoginState.js";
import { getBrowserOAuthStorage } from "@inspector/core/auth/browser/index.js";
import {
getRemoteOAuthStorage,
getWebOAuthBaseUrl,
} from "./lib/remoteOAuthStorage";
import { useManagedTools } from "@inspector/core/react/useManagedTools.js";
import { useManagedPrompts } from "@inspector/core/react/useManagedPrompts.js";
import { useManagedResources } from "@inspector/core/react/useManagedResources.js";
@ -1455,9 +1458,10 @@ function App() {
// `onToggleConnection` unloaded the previous one), so all React state is
// reset and we recover the initiating server from sessionStorage. We wait for
// `servers` to hydrate before acting; the ref guard keeps the exchange to a
// single run. The persisted PKCE verifier + DCR client info live in
// `BrowserOAuthStorage` and survive the redirect, so `completeOAuthFlow`
// exchanges the code without needing the original in-memory state machine.
// single run. The persisted PKCE verifier + DCR client info live in the
// backend-backed `RemoteOAuthStorage` (`~/.mcp-inspector/storage/oauth.json`)
// and survive the redirect, so `completeOAuthFlow` exchanges the code without
// needing the original in-memory state machine.
useEffect(() => {
if (typeof window === "undefined") return;
if (window.location.pathname !== OAUTH_CALLBACK_PATH) return;
@ -2384,7 +2388,10 @@ function App() {
const clientSettingsModalValue = clientSettingsDraft ?? EMPTY_CLIENT_SETTINGS;
const emaOAuthStorage = useMemo(() => getBrowserOAuthStorage(), []);
const emaOAuthStorage = useMemo(
() => getRemoteOAuthStorage(getWebOAuthBaseUrl(), getAuthToken()),
[],
);
const { loginState: emaIdpLoginState, logout: logoutEmaIdp } =
useEmaIdpLoginState(
emaOAuthStorage,
@ -2415,6 +2422,10 @@ function App() {
config: server.config,
inspectorClient: isActive ? inspectorClient : null,
isActiveConnection: isActive,
oauthStorage: getRemoteOAuthStorage(
getWebOAuthBaseUrl(),
getAuthToken(),
),
});
if (!cleared) return;

View file

@ -4,11 +4,9 @@ import {
createRemoteFetch,
createRemoteLogger,
} from "@inspector/core/mcp/remote/index.js";
import {
getBrowserOAuthStorage,
BrowserNavigation,
} from "@inspector/core/auth/browser/index.js";
import { BrowserNavigation } from "@inspector/core/auth/browser/index.js";
import type { RedirectUrlProvider } from "@inspector/core/auth/index.js";
import { getRemoteOAuthStorage } from "./remoteOAuthStorage";
export interface WebEnvironmentResult {
environment: InspectorClientEnvironment;
@ -20,8 +18,11 @@ export interface WebEnvironmentResult {
* - transport / fetch / logger all routed through the in-process Hono
* backend at `window.location.origin` (the `clients/web/server`
* dev-backend wires this in `/api/*`).
* - OAuth storage + navigation use the `BrowserOAuthStorage` (sessionStorage)
* and `BrowserNavigation` (full-page redirect) adapters.
* - OAuth storage uses `RemoteOAuthStorage` (POSTs to the same backend's
* `/api/storage/oauth`, which writes `~/.mcp-inspector/storage/oauth.json`
* mode 0600), so a token obtained in this browser session is also
* available to the CLI/TUI on the same host. Navigation still uses
* `BrowserNavigation` (full-page redirect).
*
* Returns both the assembled environment and the logger so callers can share
* the same pino instance for any direct logging they need to do, instead of
@ -67,7 +68,7 @@ export function createWebEnvironment(
}),
logger,
oauth: {
storage: getBrowserOAuthStorage(),
storage: getRemoteOAuthStorage(baseUrl, authToken, fetchFn),
navigation: new BrowserNavigation(undefined, onBeforeOAuthRedirect),
redirectUrlProvider,
},

View file

@ -0,0 +1,64 @@
import { describe, it, expect, vi } from "vitest";
import { RemoteOAuthStorage } from "@inspector/core/auth/remote/index.js";
import {
getRemoteOAuthStorage,
getWebOAuthBaseUrl,
webOAuthFetch,
} from "./remoteOAuthStorage";
const NOOP_FETCH = vi.fn(
async () =>
new Response(JSON.stringify({ state: { servers: {} }, version: 0 }), {
status: 200,
}),
) as unknown as typeof fetch;
describe("remoteOAuthStorage", () => {
it("getWebOAuthBaseUrl derives the backend origin from window.location", () => {
expect(getWebOAuthBaseUrl()).toBe(
`${window.location.protocol}//${window.location.host}`,
);
});
it("returns a RemoteOAuthStorage instance", () => {
const storage = getRemoteOAuthStorage(
"http://a.example",
undefined,
NOOP_FETCH,
);
expect(storage).toBeInstanceOf(RemoteOAuthStorage);
});
it("memoizes one instance per {baseUrl, authToken}", () => {
const a1 = getRemoteOAuthStorage("http://memo.example", "tok", NOOP_FETCH);
const a2 = getRemoteOAuthStorage("http://memo.example", "tok", NOOP_FETCH);
expect(a1).toBe(a2);
// A different auth token is a distinct key → distinct instance.
const b = getRemoteOAuthStorage("http://memo.example", "other", NOOP_FETCH);
expect(b).not.toBe(a1);
// A different base URL is a distinct key → distinct instance.
const c = getRemoteOAuthStorage("http://memo2.example", "tok", NOOP_FETCH);
expect(c).not.toBe(a1);
});
it("treats an undefined authToken as its own stable key", () => {
const a = getRemoteOAuthStorage("http://undef.example", undefined);
const b = getRemoteOAuthStorage("http://undef.example", undefined);
expect(a).toBe(b);
});
it("webOAuthFetch delegates to globalThis.fetch preserving the receiver", async () => {
const spy = vi
.spyOn(globalThis, "fetch")
.mockResolvedValue(new Response("ok", { status: 200 }));
try {
const res = await webOAuthFetch("http://x.example");
expect(spy).toHaveBeenCalledWith("http://x.example");
expect(res.status).toBe(200);
} finally {
spy.mockRestore();
}
});
});

View file

@ -0,0 +1,45 @@
import { RemoteOAuthStorage } from "@inspector/core/auth/remote/index.js";
/**
* Shared `RemoteOAuthStorage` accessor for the web client.
*
* The browser's OAuth state is persisted through the in-process Hono backend
* (`POST /api/storage/oauth` → `~/.mcp-inspector/storage/oauth.json`, mode
* 0600) rather than `sessionStorage`, so a credential obtained in the browser
* is the same blob the TUI/CLI read on the same host (web ⇄ TUI ⇄ CLI parity).
*
* One instance per `{baseUrl, authToken}`. The environment factory is called
* per-connect (a fresh `InspectorClient` is built for each server switch), but
* the OAuth blob on disk is a single shared resource: a stale instance's
* whole-blob POST could clobber a write made by a newer one. Memoizing also
* avoids re-hydrating the same file on every connect, and lets the connection
* path, the EMA IdP hook, and the per-server "clear OAuth" action all share
* one in-memory view of the store.
*/
const oauthStorageCache = new Map<string, RemoteOAuthStorage>();
/** Backend origin the web client talks to (same origin that serves the SPA). */
export function getWebOAuthBaseUrl(): string {
return `${window.location.protocol}//${window.location.host}`;
}
/**
* `window.fetch` loses its `this` binding when extracted, raising "Illegal
* invocation"; wrap so the call site preserves the global receiver.
*/
export const webOAuthFetch: typeof fetch = (...args) =>
globalThis.fetch(...args);
/** Memoized `RemoteOAuthStorage` for the given backend + auth token. */
export function getRemoteOAuthStorage(
baseUrl: string,
authToken: string | undefined,
fetchFn: typeof fetch = webOAuthFetch,
): RemoteOAuthStorage {
const key = `${baseUrl} ${authToken ?? ""}`;
const cached = oauthStorageCache.get(key);
if (cached) return cached;
const storage = new RemoteOAuthStorage({ baseUrl, authToken, fetchFn });
oauthStorageCache.set(key, storage);
return storage;
}

View file

@ -16,18 +16,19 @@ function createStorage(
dynamic: Awaited<ReturnType<OAuthStorage["getClientInformation"]>>;
registrationKind: ReturnType<OAuthStorage["getClientRegistrationKind"]>;
scope: string | undefined;
serverMetadata: ReturnType<OAuthStorage["getServerMetadata"]>;
serverMetadata: Awaited<ReturnType<OAuthStorage["getServerMetadata"]>>;
idpSession: Awaited<ReturnType<OAuthStorage["getIdpSession"]>>;
idpMetadata: ReturnType<OAuthStorage["getServerMetadata"]>;
idpMetadata: Awaited<ReturnType<OAuthStorage["getServerMetadata"]>>;
}> = {},
): OAuthStorage {
return {
ready: vi.fn().mockResolvedValue(undefined),
getTokens: vi.fn().mockResolvedValue(overrides.tokens),
getClientInformation: vi.fn(async (_url, isPreregistered) =>
isPreregistered ? overrides.preregistered : overrides.dynamic,
),
getScope: vi.fn().mockReturnValue(overrides.scope),
getServerMetadata: vi.fn((url: string) => {
getServerMetadata: vi.fn(async (url: string) => {
if (url.startsWith("ema-idp:")) {
return overrides.idpMetadata ?? null;
}

View file

@ -470,7 +470,7 @@ describe("completeIdpOidcAuthorization", () => {
});
it("throws when the PKCE verifier is missing", async () => {
storage = buildStorage({ getCodeVerifier: vi.fn(() => undefined) });
storage = buildStorage({ getCodeVerifier: vi.fn(async () => undefined) });
await expect(
completeIdpOidcAuthorization({
idp,

View file

@ -0,0 +1,224 @@
import { describe, it, expect, vi } from "vitest";
import { createJSONStorage } from "zustand/middleware";
import { OAuthStorageBase } from "@inspector/core/auth/oauth-storage.js";
import {
createOAuthStore,
normalizeServerUrl,
} from "@inspector/core/auth/store.js";
/**
* Builds an OAuthStorageBase backed by an *async* in-memory storage adapter
* pre-seeded with the given persisted blob. Hydration of the store completes
* only after the returned `release()` is called, so tests can assert that
* getters wait for it.
*/
function makeAsyncBackedStorage(
persistedServers: Record<string, unknown> = {},
) {
let mem: string | null = JSON.stringify({
state: { servers: persistedServers },
version: 0,
});
let releaseGetItem!: () => void;
const gate = new Promise<void>((resolve) => {
releaseGetItem = resolve;
});
const adapter = createJSONStorage(() => ({
getItem: async () => {
await gate;
return mem;
},
setItem: async (name: string, value: string) => {
void name;
mem = value;
},
removeItem: async () => {
mem = null;
},
}));
const store = createOAuthStore(adapter);
return { storage: new OAuthStorageBase(store), release: releaseGetItem };
}
const SERVER = "https://example.com/mcp";
const METADATA = {
issuer: SERVER,
authorization_endpoint: `${SERVER}/authorize`,
token_endpoint: `${SERVER}/token`,
response_types_supported: ["code"],
};
describe("OAuthStorageBase — async hydration", () => {
it("getCodeVerifier awaits hydration before reading state", async () => {
const { storage, release } = makeAsyncBackedStorage({
[SERVER]: { codeVerifier: "persisted-verifier" },
});
// The store is empty until release() lets the async getItem resolve.
let resolvedWith: string | undefined | "pending" = "pending";
const p = storage.getCodeVerifier(SERVER).then((v) => {
resolvedWith = v;
});
// Give the microtask queue a chance — the getter should NOT have resolved
// because hydration is still gated.
await Promise.resolve();
expect(resolvedWith).toBe("pending");
release();
await p;
expect(resolvedWith).toBe("persisted-verifier");
});
it("getServerMetadata awaits hydration before reading state", async () => {
const { storage, release } = makeAsyncBackedStorage({
[SERVER]: { serverMetadata: METADATA },
});
const p = storage.getServerMetadata(SERVER);
release();
expect(await p).toEqual(METADATA);
});
it("getTokens and getClientInformation await hydration", async () => {
const { storage, release } = makeAsyncBackedStorage({
[SERVER]: {
tokens: { access_token: "t", token_type: "Bearer" },
clientInformation: { client_id: "abc" },
},
});
const tokens = storage.getTokens(SERVER);
const client = storage.getClientInformation(SERVER);
release();
expect(await tokens).toEqual({ access_token: "t", token_type: "Bearer" });
expect(await client).toEqual({ client_id: "abc" });
});
it("ready() resolves once hydration completes", async () => {
const { storage, release } = makeAsyncBackedStorage();
let ready = false;
void storage.ready().then(() => {
ready = true;
});
await Promise.resolve();
expect(ready).toBe(false);
release();
await storage.ready();
expect(ready).toBe(true);
});
it("ready() resolves promptly when the adapter is synchronous", async () => {
let mem: string | null = null;
const syncAdapter = createJSONStorage(() => ({
getItem: () => mem,
setItem: (name: string, value: string) => {
void name;
mem = value;
},
removeItem: () => {
mem = null;
},
}));
const storage = new OAuthStorageBase(createOAuthStore(syncAdapter));
// With a sync adapter the constructor's rehydrate() resolves on the next
// microtask, so ready() and the getters settle without any release step.
await storage.ready();
expect(await storage.getCodeVerifier(SERVER)).toBeUndefined();
});
it("save* awaits hydration so a late merge cannot clobber the write", async () => {
const { storage, release } = makeAsyncBackedStorage({
[SERVER]: { codeVerifier: "persisted-old" },
});
// Kick off a save while hydration is still gated. It must not write until
// hydration completes, otherwise the persist merge would overwrite "new"
// with "persisted-old".
const save = storage.saveCodeVerifier(SERVER, "new");
let saved = false;
void save.then(() => {
saved = true;
});
await Promise.resolve();
expect(saved).toBe(false);
release();
await save;
expect(await storage.getCodeVerifier(SERVER)).toBe("new");
});
it("normalizeServerUrl canonicalizes host case, bare-origin trailing slash, default port, and whitespace", () => {
expect(normalizeServerUrl("https://Example.COM/mcp")).toBe(
"https://example.com/mcp",
);
expect(normalizeServerUrl("https://example.com")).toBe(
"https://example.com/",
);
expect(normalizeServerUrl("https://example.com:443/mcp")).toBe(
"https://example.com/mcp",
);
expect(normalizeServerUrl(" https://example.com/mcp ")).toBe(
"https://example.com/mcp",
);
// Non-URL strings (e.g. a stdio server name) are passed through trimmed.
expect(normalizeServerUrl(" my-stdio-server ")).toBe("my-stdio-server");
});
it("store keys are normalized so save and get tolerate cosmetic URL differences", async () => {
const { storage, release } = makeAsyncBackedStorage();
release();
await storage.saveCodeVerifier("https://Example.COM/mcp", "v");
expect(await storage.getCodeVerifier("https://example.com/mcp")).toBe("v");
// Bare-origin: the web inspector stores `new URL().href` (trailing slash);
// a CLI caller passing the slash-less form must still hit it.
await storage.saveTokens("https://api.partner.dev/", {
access_token: "t",
token_type: "Bearer",
});
expect(await storage.getTokens("https://api.partner.dev")).toEqual({
access_token: "t",
token_type: "Bearer",
});
});
it("getServerState falls back to a pre-normalization persisted key", async () => {
// A blob persisted before normalization existed may carry a raw key that
// the canonical form differs from (here: bare-origin without slash).
const { storage, release } = makeAsyncBackedStorage({
"https://legacy.example": { codeVerifier: "legacy" },
});
release();
expect(await storage.getCodeVerifier("https://legacy.example")).toBe(
"legacy",
);
});
it("ready()/getters resolve (empty) when the adapter throws instead of hanging", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
const throwingAdapter = createJSONStorage(() => ({
getItem: async () => {
throw new Error("Failed to read store: 500");
},
setItem: async () => {},
removeItem: async () => {},
}));
const storage = new OAuthStorageBase(createOAuthStore(throwingAdapter));
// Race ready() against a short timeout — if the catch path doesn't
// resolve `hydrated`, this would hit "TIMEOUT" instead of "ok".
const result = await Promise.race([
storage.ready().then(() => "ok"),
new Promise((r) => setTimeout(() => r("TIMEOUT"), 200)),
]);
expect(result).toBe("ok");
expect(await storage.getTokens(SERVER)).toBeUndefined();
expect(warn).toHaveBeenCalled();
// The failure is recorded so callers can distinguish "no token" from
// "store unreadable".
expect(storage.getHydrationError()).toBeInstanceOf(Error);
} finally {
warn.mockRestore();
}
});
it("getHydrationError() is undefined after a successful hydration", async () => {
const { storage, release } = makeAsyncBackedStorage();
release();
await storage.ready();
expect(storage.getHydrationError()).toBeUndefined();
});
});

View file

@ -221,9 +221,9 @@ describe("OAuthNavigation", () => {
getTokens: vi.fn(async () => undefined),
saveTokens: vi.fn(async () => undefined),
saveCodeVerifier: vi.fn(async () => undefined),
getCodeVerifier: vi.fn(() => undefined),
getCodeVerifier: vi.fn(async () => undefined),
clear: vi.fn(),
getServerMetadata: vi.fn(() => null),
getServerMetadata: vi.fn(async () => null),
saveServerMetadata: vi.fn(async () => undefined),
} as unknown as OAuthStorage;
}
@ -271,21 +271,21 @@ describe("OAuthNavigation", () => {
expect(await provider.clientInformation()).toEqual({ client_id: "dyn" });
});
it("codeVerifier() throws when none is saved", () => {
it("codeVerifier() rejects when none is saved", async () => {
const storage = makeStorage();
const provider = makeProvider(storage);
expect(() => provider.codeVerifier()).toThrow(
await expect(provider.codeVerifier()).rejects.toThrow(
/No code verifier saved for session/,
);
});
it("codeVerifier() returns the saved verifier", () => {
it("codeVerifier() returns the saved verifier", async () => {
const storage = makeStorage();
vi.mocked(storage.getCodeVerifier).mockReturnValue("cv-1");
vi.mocked(storage.getCodeVerifier).mockResolvedValue("cv-1");
const provider = makeProvider(storage);
expect(provider.codeVerifier()).toBe("cv-1");
expect(await provider.codeVerifier()).toBe("cv-1");
});
it("clientMetadata reflects the stored scope when present", () => {
@ -362,7 +362,7 @@ describe("OAuthNavigation", () => {
expect(storage.saveCodeVerifier).toHaveBeenCalledWith(SERVER, "cv");
expect(storage.saveServerMetadata).toHaveBeenCalled();
expect(await provider.tokens()).toBeUndefined();
expect(provider.getServerMetadata()).toBeNull();
expect(await provider.getServerMetadata()).toBeNull();
const state = await provider.state();
expect(typeof state).toBe("string");
expect(state.length).toBeGreaterThan(0);

View file

@ -1,5 +1,8 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { BrowserOAuthStorage } from "@inspector/core/auth/browser/storage.js";
import {
BrowserOAuthStorage,
getBrowserOAuthStorage,
} from "@inspector/core/auth/browser/storage.js";
import type {
OAuthClientInformation,
OAuthTokens,
@ -41,6 +44,16 @@ const mockSessionStorage = new MockSessionStorage();
(global as typeof globalThis & { sessionStorage?: Storage }).sessionStorage =
mockSessionStorage;
describe("getBrowserOAuthStorage", () => {
it("returns a memoized singleton across calls", () => {
const first = getBrowserOAuthStorage();
const second = getBrowserOAuthStorage();
expect(first).toBeInstanceOf(BrowserOAuthStorage);
// Second call hits the cached-instance branch rather than constructing anew.
expect(second).toBe(first);
});
});
describe("BrowserOAuthStorage", () => {
let storage: BrowserOAuthStorage;
const testServerUrl = "http://localhost:3000";
@ -226,8 +239,10 @@ describe("BrowserOAuthStorage", () => {
it("should return stored scope", async () => {
const scope = "read write";
storage.saveScope(testServerUrl, scope);
const result = await storage.getScope(testServerUrl);
// getScope is intentionally synchronous; await the save (which waits for
// hydration) so the value has landed before the sync read.
await storage.saveScope(testServerUrl, scope);
const result = storage.getScope(testServerUrl);
expect(result).toBe(scope);
});
@ -237,8 +252,8 @@ describe("BrowserOAuthStorage", () => {
it("should save scope", async () => {
const scope = "read write";
storage.saveScope(testServerUrl, scope);
const result = await storage.getScope(testServerUrl);
await storage.saveScope(testServerUrl, scope);
const result = storage.getScope(testServerUrl);
expect(result).toBe(scope);
});
@ -322,13 +337,13 @@ describe("BrowserOAuthStorage", () => {
it("clearCodeVerifier removes only the PKCE verifier", async () => {
storage.saveCodeVerifier(testServerUrl, "verifier");
expect(storage.getCodeVerifier(testServerUrl)).toBe("verifier");
expect(await storage.getCodeVerifier(testServerUrl)).toBe("verifier");
storage.clearCodeVerifier(testServerUrl);
expect(storage.getCodeVerifier(testServerUrl)).toBeUndefined();
expect(await storage.getCodeVerifier(testServerUrl)).toBeUndefined();
});
it("clearScope removes only the scope", async () => {
storage.saveScope(testServerUrl, "read");
await storage.saveScope(testServerUrl, "read");
expect(storage.getScope(testServerUrl)).toBe("read");
storage.clearScope(testServerUrl);
expect(storage.getScope(testServerUrl)).toBeUndefined();
@ -342,9 +357,9 @@ describe("BrowserOAuthStorage", () => {
response_types_supported: ["code"],
};
storage.saveServerMetadata(testServerUrl, metadata);
expect(storage.getServerMetadata(testServerUrl)).toEqual(metadata);
expect(await storage.getServerMetadata(testServerUrl)).toEqual(metadata);
storage.clearServerMetadata(testServerUrl);
expect(storage.getServerMetadata(testServerUrl)).toBeNull();
expect(await storage.getServerMetadata(testServerUrl)).toBeNull();
});
});
@ -358,10 +373,10 @@ describe("BrowserOAuthStorage", () => {
token_type: "Bearer",
};
storage.saveClientInformation(testServerUrl, clientInfo, {
await storage.saveClientInformation(testServerUrl, clientInfo, {
registrationKind: "dcr",
});
storage.saveTokens(testServerUrl, tokens);
await storage.saveTokens(testServerUrl, tokens);
storage.clear(testServerUrl);
@ -375,10 +390,10 @@ describe("BrowserOAuthStorage", () => {
client_id: "test-client-id",
};
storage.saveClientInformation(testServerUrl, clientInfo, {
await storage.saveClientInformation(testServerUrl, clientInfo, {
registrationKind: "dcr",
});
storage.saveClientInformation(otherServerUrl, clientInfo, {
await storage.saveClientInformation(otherServerUrl, clientInfo, {
registrationKind: "dcr",
});

View file

@ -72,9 +72,9 @@ describe("RemoteOAuthStorage (unit, mocked fetch)", () => {
it("codeVerifier round-trip and clearCodeVerifier", async () => {
await storage.saveCodeVerifier(serverUrl, "verifier");
expect(storage.getCodeVerifier(serverUrl)).toBe("verifier");
expect(await storage.getCodeVerifier(serverUrl)).toBe("verifier");
storage.clearCodeVerifier(serverUrl);
expect(storage.getCodeVerifier(serverUrl)).toBeUndefined();
expect(await storage.getCodeVerifier(serverUrl)).toBeUndefined();
});
it("scope round-trip and clearScope", async () => {
@ -92,9 +92,9 @@ describe("RemoteOAuthStorage (unit, mocked fetch)", () => {
response_types_supported: ["code"],
};
await storage.saveServerMetadata(serverUrl, md);
expect(storage.getServerMetadata(serverUrl)).toEqual(md);
expect(await storage.getServerMetadata(serverUrl)).toEqual(md);
storage.clearServerMetadata(serverUrl);
expect(storage.getServerMetadata(serverUrl)).toBeNull();
expect(await storage.getServerMetadata(serverUrl)).toBeNull();
});
it("clear() wipes all state for a server", async () => {
@ -120,4 +120,65 @@ describe("RemoteOAuthStorage (unit, mocked fetch)", () => {
// No public accessor; constructing without throwing covers the default-branch.
expect(s).toBeInstanceOf(RemoteOAuthStorage);
});
it("getCodeVerifier waits for the async hydration GET before reading", async () => {
// Unlike the other tests in this file (which write-then-read in the same
// session), this asserts that a value PERSISTED on the backend before the
// store was constructed is still returned — i.e. the getter awaits the
// remote storage adapter's hydration GET.
let releaseGet!: () => void;
const getGate = new Promise<void>((resolve) => {
releaseGet = resolve;
});
const fetchFn = vi.fn(
async (url: RequestInfo | URL, init?: RequestInit) => {
if ((init?.method ?? "GET") === "GET") {
await getGate;
return new Response(
JSON.stringify({
state: {
servers: { [serverUrl]: { codeVerifier: "from-disk" } },
},
version: 0,
}),
{ status: 200 },
);
}
void url;
return new Response("{}", { status: 200 });
},
);
const s = new RemoteOAuthStorage({
baseUrl: "http://remote.example",
storeId: "hydration-test",
fetchFn: fetchFn as unknown as typeof fetch,
});
const p = s.getCodeVerifier(serverUrl);
releaseGet();
expect(await p).toBe("from-disk");
});
it("persist POST is sent with keepalive so it survives an immediate redirect", async () => {
const fetchFn = vi.fn(
async (url: RequestInfo | URL, init?: RequestInit) => {
void url;
void init;
return new Response("{}", { status: 200 });
},
);
const s = new RemoteOAuthStorage({
baseUrl: "http://remote.example",
storeId: "keepalive-test",
fetchFn: fetchFn as unknown as typeof fetch,
});
await s.ready();
await s.saveCodeVerifier(serverUrl, "v");
// Zustand persist fires setItem on the next microtask after set(); flush
// a couple of ticks so the POST has been issued.
await Promise.resolve();
await Promise.resolve();
const post = fetchFn.mock.calls.find((c) => c[1]?.method === "POST");
expect(post).toBeDefined();
expect(post?.[1]?.keepalive).toBe(true);
});
});

View file

@ -61,6 +61,26 @@ describe("generateOAuthState", () => {
const s2 = generateOAuthState();
expect(s1).not.toBe(s2);
});
it("throws instead of silently degrading when crypto.getRandomValues is unavailable", () => {
const original = globalThis.crypto;
// Simulate a runtime without Web Crypto — the CSRF token must not fall back
// to a predictable Math.random source.
Object.defineProperty(globalThis, "crypto", {
value: undefined,
configurable: true,
});
try {
expect(() => generateOAuthState()).toThrow(
/crypto\.getRandomValues is not available/,
);
} finally {
Object.defineProperty(globalThis, "crypto", {
value: original,
configurable: true,
});
}
});
});
describe("parseOAuthState", () => {

View file

@ -38,6 +38,7 @@ function createMockParams(
const dispatchOAuthError = vi.fn();
const storage = {
ready: vi.fn().mockResolvedValue(undefined),
getScope: vi.fn().mockReturnValue(undefined),
getClientInformation: vi.fn().mockResolvedValue(undefined),
getClientRegistrationKind: vi.fn().mockReturnValue(undefined),

View file

@ -4,6 +4,7 @@ import {
getOAuthStore,
clearAllOAuthClientState,
} from "@inspector/core/auth/node/storage-node.js";
import { normalizeServerUrl } from "@inspector/core/auth/store.js";
import type {
OAuthClientInformation,
OAuthTokens,
@ -341,9 +342,9 @@ describe("NodeOAuthStorage", () => {
it("clearCodeVerifier removes only the PKCE verifier", async () => {
await storage.saveCodeVerifier(testServerUrl, "verifier");
expect(storage.getCodeVerifier(testServerUrl)).toBe("verifier");
expect(await storage.getCodeVerifier(testServerUrl)).toBe("verifier");
storage.clearCodeVerifier(testServerUrl);
expect(storage.getCodeVerifier(testServerUrl)).toBeUndefined();
expect(await storage.getCodeVerifier(testServerUrl)).toBeUndefined();
});
it("clearScope removes only the scope", async () => {
@ -361,9 +362,9 @@ describe("NodeOAuthStorage", () => {
response_types_supported: ["code"],
};
await storage.saveServerMetadata(testServerUrl, metadata);
expect(storage.getServerMetadata(testServerUrl)).toEqual(metadata);
expect(await storage.getServerMetadata(testServerUrl)).toEqual(metadata);
storage.clearServerMetadata(testServerUrl);
expect(storage.getServerMetadata(testServerUrl)).toBeNull();
expect(await storage.getServerMetadata(testServerUrl)).toBeNull();
});
});
@ -505,9 +506,9 @@ describe("OAuth Store (Zustand)", () => {
const parsed = JSON.parse(
await fs.readFile(persistTestPath, "utf-8"),
) as StateShape;
expect(parsed.state.servers[serverUrl]?.clientInformation).toEqual(
clientInfo,
);
expect(
parsed.state.servers[normalizeServerUrl(serverUrl)]?.clientInformation,
).toEqual(clientInfo);
} finally {
try {
await fs.unlink(persistTestPath);
@ -582,9 +583,10 @@ describe("NodeOAuthStorage with custom storagePath", () => {
await fs.readFile(customPath, "utf-8"),
) as StateShape;
expect(parsed.state.servers[testServerUrl]?.tokens?.access_token).toBe(
tokens.access_token,
);
expect(
parsed.state.servers[normalizeServerUrl(testServerUrl)]?.tokens
?.access_token,
).toBe(tokens.access_token);
const stored = await storage.getTokens(testServerUrl);
expect(stored?.access_token).toBe(tokens.access_token);
@ -635,7 +637,13 @@ describe("NodeOAuthStorage with custom storagePath", () => {
);
try {
// The store is created with skipHydration: true; OAuthStorageBase
// normally drives rehydrate(), but this test writes via the raw store
// so hydrate it explicitly first (otherwise the later `new
// NodeOAuthStorage()` wrapper would drive the first rehydrate and clobber
// these unpersisted in-memory writes).
const defaultStore = getOAuthStore();
await defaultStore.persist.rehydrate();
defaultStore.getState().setServerState(testServerUrl, {
tokens: {
access_token: "default-token",

View file

@ -11,7 +11,10 @@ import { serve } from "@hono/node-server";
import type { ServerType } from "@hono/node-server";
import { createFileStorageAdapter } from "@inspector/core/storage/adapters/file-storage.js";
import { createRemoteStorageAdapter } from "@inspector/core/storage/adapters/remote-storage.js";
import { createOAuthStore } from "@inspector/core/auth/store.js";
import {
createOAuthStore,
normalizeServerUrl,
} from "@inspector/core/auth/store.js";
import { createRemoteApp } from "@inspector/core/mcp/remote/node/server.js";
import {
writeStoreFile,
@ -90,13 +93,46 @@ describe("Storage adapters", () => {
});
it("setItem throws when the POST fails", async () => {
// The adapter logs the swallowed-persist failure via console.error before
// rethrowing; suppress the expected noise.
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
const fetchFn = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(new Response("nope", { status: 500 }));
const adapter = makeAdapter(fetchFn as typeof fetch);
await expect(
adapter.setItem("name", { state: { a: 1 }, version: 0 }),
).rejects.toThrow(/500/);
expect(errorSpy).toHaveBeenCalled();
} finally {
errorSpy.mockRestore();
}
});
it("setItem sends keepalive so the POST survives an immediate redirect", async () => {
const fetchFn = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(new Response("nope", { status: 500 }));
.mockResolvedValueOnce(new Response("{}"));
const adapter = makeAdapter(fetchFn as typeof fetch);
await expect(
adapter.setItem("name", { state: { a: 1 }, version: 0 }),
).rejects.toThrow(/500/);
await adapter.setItem("name", { state: { a: 1 }, version: 0 });
expect(fetchFn.mock.calls[0]?.[1]?.keepalive).toBe(true);
});
it("setItem rethrows a network-level fetch rejection after logging it", async () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
const fetchFn = vi
.fn<typeof fetch>()
.mockRejectedValueOnce(new Error("network down"));
const adapter = makeAdapter(fetchFn as typeof fetch);
await expect(
adapter.setItem("name", { state: { a: 1 }, version: 0 }),
).rejects.toThrow(/network down/);
expect(errorSpy).toHaveBeenCalled();
} finally {
errorSpy.mockRestore();
}
});
it("removeItem tolerates 404 but rethrows on other failures", async () => {
@ -155,7 +191,9 @@ describe("Storage adapters", () => {
await flushStoreFileWrites(filePath);
const fileContent = readFileSync(filePath, "utf-8");
const parsed = JSON.parse(fileContent);
expect(parsed.state.servers["https://example.com"].tokens).toEqual({
expect(
parsed.state.servers[normalizeServerUrl("https://example.com")].tokens,
).toEqual({
access_token: "test-token",
token_type: "Bearer",
});
@ -173,10 +211,12 @@ describe("Storage adapters", () => {
});
await flushStoreFileWrites(filePath);
// Create new store instance (should load persisted state)
// Create new store instance and load persisted state. The store is
// created with skipHydration: true, so drive the single rehydrate
// explicitly (OAuthStorageBase does this in production).
const storage2 = createFileStorageAdapter({ filePath });
const store2 = createOAuthStore(storage2);
await new Promise((resolve) => setTimeout(resolve, 100));
await store2.persist.rehydrate();
const state = store2.getState().getServerState("https://example.com");
expect(state.tokens).toEqual({
@ -332,6 +372,7 @@ describe("Storage adapters", () => {
tokens: { access_token: "test-token", token_type: "Bearer" },
});
const exampleKey = normalizeServerUrl("https://example.com");
await waitForRemoteStore(baseUrl, "test-store", authToken, (body) => {
const d = body as {
state?: {
@ -339,8 +380,7 @@ describe("Storage adapters", () => {
};
};
return (
d?.state?.servers?.["https://example.com"]?.tokens?.access_token ===
"test-token"
d?.state?.servers?.[exampleKey]?.tokens?.access_token === "test-token"
);
});
@ -353,7 +393,7 @@ describe("Storage adapters", () => {
});
expect(res.status).toBe(200);
const storeData = await res.json();
expect(storeData.state.servers["https://example.com"].tokens).toEqual({
expect(storeData.state.servers[exampleKey].tokens).toEqual({
access_token: "test-token",
token_type: "Bearer",
});
@ -376,6 +416,7 @@ describe("Storage adapters", () => {
store1.getState().setServerState("https://example.com", {
tokens: { access_token: "initial-token", token_type: "Bearer" },
});
const exampleKey = normalizeServerUrl("https://example.com");
await waitForRemoteStore(baseUrl, "test-store", authToken, (body) => {
const d = body as {
state?: {
@ -383,26 +424,21 @@ describe("Storage adapters", () => {
};
};
return (
d?.state?.servers?.["https://example.com"]?.tokens?.access_token ===
d?.state?.servers?.[exampleKey]?.tokens?.access_token ===
"initial-token"
);
});
// Create new store instance (should load persisted state)
// Create new store instance and load persisted state. The store is
// created with skipHydration: true, so drive the single rehydrate
// explicitly (OAuthStorageBase does this in production).
const storage2 = createRemoteStorageAdapter({
baseUrl,
storeId: "test-store",
authToken,
});
const store2 = createOAuthStore(storage2);
await vi.waitFor(
() => {
const state = store2.getState().getServerState("https://example.com");
if (!state.tokens) throw new Error("Store not yet hydrated");
return state;
},
{ timeout: 2000, interval: 50 },
);
await store2.persist.rehydrate();
const state = store2.getState().getServerState("https://example.com");
expect(state.tokens).toEqual({

View file

@ -1,50 +1,49 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { getBrowserOAuthStorage } from "@inspector/core/auth/browser/index.js";
import { describe, it, expect, vi } from "vitest";
import { clearServerOAuthState } from "./clearServerOAuthState";
describe("clearServerOAuthState", () => {
beforeEach(() => {
getBrowserOAuthStorage().clear("https://mcp.example.com/mcp");
});
it("clears storage by server URL when not the active connection", async () => {
const storage = getBrowserOAuthStorage();
await storage.saveTokens("https://mcp.example.com/mcp", {
access_token: "tok",
token_type: "Bearer",
});
it("clears storage by server URL when not the active connection", () => {
const oauthStorage = { clear: vi.fn() };
const cleared = clearServerOAuthState({
config: { type: "streamable-http", url: "https://mcp.example.com/mcp" },
isActiveConnection: false,
oauthStorage,
});
expect(cleared).toBe(true);
expect(
await storage.getTokens("https://mcp.example.com/mcp"),
).toBeUndefined();
expect(oauthStorage.clear).toHaveBeenCalledWith(
"https://mcp.example.com/mcp",
);
});
it("uses the live client when clearing the active connection", () => {
const inspectorClient = {
clearOAuthTokens: vi.fn(),
};
const oauthStorage = { clear: vi.fn() };
const cleared = clearServerOAuthState({
config: { type: "streamable-http", url: "https://mcp.example.com/mcp" },
inspectorClient: inspectorClient as never,
isActiveConnection: true,
oauthStorage,
});
expect(cleared).toBe(true);
expect(inspectorClient.clearOAuthTokens).toHaveBeenCalledTimes(1);
// The backend-backed store is not touched directly when a live client is
// present — the client owns resetting both persisted and in-memory state.
expect(oauthStorage.clear).not.toHaveBeenCalled();
});
it("returns false for stdio servers", () => {
const oauthStorage = { clear: vi.fn() };
expect(
clearServerOAuthState({
config: { type: "stdio", command: "node", args: [] },
isActiveConnection: false,
oauthStorage,
}),
).toBe(false);
});

View file

@ -1,4 +1,4 @@
import { getBrowserOAuthStorage } from "@inspector/core/auth/browser/index.js";
import type { OAuthStorage } from "@inspector/core/auth/storage.js";
import { getOAuthServerUrl } from "@inspector/core/mcp/config.js";
import type { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
import type { MCPServerConfig } from "@inspector/core/mcp/types.js";
@ -8,6 +8,13 @@ export interface ClearServerOAuthStateParams {
/** When set and this server is the active connection, clear via the live client. */
inspectorClient?: InspectorClient | null;
isActiveConnection: boolean;
/**
* Backend-backed OAuth storage (`RemoteOAuthStorage`) used to clear a
* non-active server's persisted state. Must be the same shared instance the
* connection path writes through so the clear targets `oauth.json`, not a
* divergent sessionStorage copy.
*/
oauthStorage: Pick<OAuthStorage, "clear">;
}
/**
@ -26,7 +33,7 @@ export function clearServerOAuthState(
if (params.isActiveConnection && params.inspectorClient) {
params.inspectorClient.clearOAuthTokens();
} else {
getBrowserOAuthStorage().clear(serverUrl);
params.oauthStorage.clear(serverUrl);
}
return true;
}

View file

@ -86,7 +86,7 @@ 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 serverMetadata = await storage.getServerMetadata(serverUrl);
const authorized = isAccessTokenUsable(tokens);
const grantedScope = resolveGrantedScope(tokens, storageScope);
@ -120,7 +120,9 @@ 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

@ -35,7 +35,7 @@ async function resolveIdpMetadata(
fetchFn?: typeof fetch,
): Promise<OAuthMetadata> {
const storageKey = idpOAuthStorageKey(issuer);
const cached = storage.getServerMetadata(storageKey);
const cached = await storage.getServerMetadata(storageKey);
if (cached?.token_endpoint) {
return cached;
}
@ -101,7 +101,7 @@ export async function completeIdpOidcAuthorization(params: {
}> {
const issuer = normalizeIdpIssuer(params.idp.issuer);
const storageKey = idpOAuthStorageKey(issuer);
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");
}
@ -111,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

@ -10,11 +10,17 @@ import {
const DEFAULT_STATE_PATH = getStoreFilePath(getDefaultStorageDir(), "oauth");
/**
* Get path to OAuth state file.
* @param customPath - Optional custom path (full path to state file). Default: ~/.mcp-inspector/storage/oauth.json
* Get path to OAuth state file. Resolution order: explicit `customPath`, then
* the `MCP_INSPECTOR_OAUTH_STATE_PATH` environment variable (so tests and
* scripted runs can point at an isolated fixture without touching
* `~/.mcp-inspector`), then the default `~/.mcp-inspector/storage/oauth.json`.
*/
export function getStateFilePath(customPath?: string): string {
return customPath ?? DEFAULT_STATE_PATH;
return (
customPath ??
process.env.MCP_INSPECTOR_OAUTH_STATE_PATH ??
DEFAULT_STATE_PATH
);
}
const storeCache = new Map<string, ReturnType<typeof createOAuthStore>>();

View file

@ -20,18 +20,87 @@ import type {
* 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.
*
* With an async storage adapter (file, remote HTTP) the store is empty until
* hydration completes. Every read used on the post-OAuth-redirect callback path
* (`getCodeVerifier`, `getServerMetadata`, `getClientInformation`, `getTokens`)
* — and every save — therefore awaits {@link ready} first; saves wait so a
* late hydration merge cannot clobber a value written before it landed. The
* underlying store is created with `skipHydration: true`
* ({@link createOAuthStore}), so the constructor's `rehydrate()` is the single
* hydration and there is no auto-hydration to race it. With a synchronous
* adapter (sessionStorage) hydration resolves on the next microtask and the
* behaviour is effectively unchanged.
*/
export class OAuthStorageBase implements OAuthStorage {
private readonly store: ReturnType<typeof createOAuthStore>;
private readonly hydrated: Promise<void>;
private hydrationError?: Error;
constructor(store: ReturnType<typeof createOAuthStore>) {
this.store = store;
// The store is created with `skipHydration: true` so this is the SOLE
// hydration. Driving it explicitly (rather than relying on persist's
// auto-hydration + onFinishHydration) lets us catch a rejecting adapter —
// onFinishHydration only fires on success, so a throwing getItem would
// otherwise leave `hydrated` pending forever and hang every getter.
// A failed hydration resolves to "empty store", which is the correct
// fallback (reads return undefined; writes proceed) — but the failure is
// recorded so callers can distinguish "no token stored" from "could not
// read the store" (e.g. corrupt file, EACCES, backend 500).
const recordFailure = (err: unknown) => {
this.hydrationError =
err instanceof Error
? err
: new Error(
typeof err === "string"
? err
: "OAuth storage hydration failed (adapter getItem threw or returned invalid data)",
);
console.warn(
"[OAuthStorage] hydration failed; continuing with empty state:",
this.hydrationError,
);
};
this.hydrated = this.store.persist.hasHydrated()
? Promise.resolve()
: Promise.resolve(this.store.persist.rehydrate()).then(
() => {
// Zustand swallows getItem errors internally (routing them to
// onRehydrateStorage) and resolves rehydrate() regardless;
// hasHydrated() only flips true on success, so use it to detect a
// silently-failed hydration. The underlying error is lost in this
// path — record a generic one so getHydrationError() is non-null.
if (!this.store.persist.hasHydrated()) recordFailure(undefined);
},
(err: unknown) => recordFailure(err),
);
}
/**
* If hydration failed (adapter threw, file unreadable/corrupt, backend
* non-2xx), the captured error. Undefined when hydration succeeded or has
* not yet completed — call after `await ready()`. Lets callers report
* "oauth.json is unreadable" instead of a misleading "no token stored".
*/
getHydrationError(): Error | undefined {
return this.hydrationError;
}
/**
* Resolves once the underlying persist adapter has hydrated the store.
* Callers that need to read state outside the typed getters can await this
* directly (e.g. before reading the store via `getState()` for diagnostics).
*/
ready(): Promise<void> {
return this.hydrated;
}
async getClientInformation(
serverUrl: string,
isPreregistered?: boolean,
): Promise<OAuthClientInformation | undefined> {
await this.hydrated;
const state = this.store.getState().getServerState(serverUrl);
const clientInfo = isPreregistered
? state.preregisteredClientInformation
@ -56,6 +125,7 @@ export class OAuthStorageBase implements OAuthStorage {
clientInformation: OAuthClientInformation,
options: SaveClientInformationOptions,
): Promise<void> {
await this.hydrated;
this.store.getState().setServerState(serverUrl, {
clientInformation,
clientRegistrationKind: options.registrationKind,
@ -66,6 +136,7 @@ export class OAuthStorageBase implements OAuthStorage {
serverUrl: string,
clientInformation: OAuthClientInformation,
): Promise<void> {
await this.hydrated;
this.store.getState().setServerState(serverUrl, {
preregisteredClientInformation: clientInformation,
clientRegistrationKind: "static",
@ -86,6 +157,7 @@ export class OAuthStorageBase implements OAuthStorage {
}
async getTokens(serverUrl: string): Promise<OAuthTokens | undefined> {
await this.hydrated;
const state = this.store.getState().getServerState(serverUrl);
if (!state.tokens) {
return undefined;
@ -99,6 +171,7 @@ export class OAuthStorageBase implements OAuthStorage {
tokens: OAuthTokens,
options?: SaveTokensOptions,
): Promise<void> {
await this.hydrated;
this.store.getState().setServerState(serverUrl, {
tokens,
...(options?.enterpriseManaged === true && { enterpriseManaged: true }),
@ -109,7 +182,8 @@ export class OAuthStorageBase implements OAuthStorage {
this.store.getState().setServerState(serverUrl, { tokens: undefined });
}
getCodeVerifier(serverUrl: string): string | undefined {
async getCodeVerifier(serverUrl: string): Promise<string | undefined> {
await this.hydrated;
const state = this.store.getState().getServerState(serverUrl);
return state.codeVerifier;
}
@ -118,6 +192,7 @@ export class OAuthStorageBase implements OAuthStorage {
serverUrl: string,
codeVerifier: string,
): Promise<void> {
await this.hydrated;
this.store.getState().setServerState(serverUrl, { codeVerifier });
}
@ -127,12 +202,21 @@ export class OAuthStorageBase implements OAuthStorage {
.setServerState(serverUrl, { codeVerifier: undefined });
}
/**
* Intentionally synchronous. The only caller is
* {@link BaseOAuthClientProvider.scope}, a sync getter the SDK requires
* (it feeds the sync `clientMetadata` getter). Scope is always set via
* {@link saveScope} in the same session before it's read (during the
* pre-redirect half of the flow), so the in-memory store has the value
* regardless of async hydration.
*/
getScope(serverUrl: string): string | undefined {
const state = this.store.getState().getServerState(serverUrl);
return state.scope;
}
async saveScope(serverUrl: string, scope: string | undefined): Promise<void> {
await this.hydrated;
this.store.getState().setServerState(serverUrl, { scope });
}
@ -140,7 +224,8 @@ export class OAuthStorageBase implements OAuthStorage {
this.store.getState().setServerState(serverUrl, { scope: undefined });
}
getServerMetadata(serverUrl: string): OAuthMetadata | null {
async getServerMetadata(serverUrl: string): Promise<OAuthMetadata | null> {
await this.hydrated;
const state = this.store.getState().getServerState(serverUrl);
return state.serverMetadata || null;
}
@ -149,6 +234,7 @@ export class OAuthStorageBase implements OAuthStorage {
serverUrl: string,
metadata: OAuthMetadata,
): Promise<void> {
await this.hydrated;
this.store
.getState()
.setServerState(serverUrl, { serverMetadata: metadata });
@ -165,6 +251,7 @@ export class OAuthStorageBase implements OAuthStorage {
}
async getIdpSession(issuer: string): Promise<IdpSessionState | undefined> {
await this.hydrated;
const session = this.store.getState().getIdpSession(issuer);
if (
!session.idToken &&
@ -180,6 +267,7 @@ export class OAuthStorageBase implements OAuthStorage {
issuer: string,
session: Partial<IdpSessionState>,
): Promise<void> {
await this.hydrated;
this.store.getState().setIdpSession(issuer, session);
}

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