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:
parent
066676a241
commit
c299b6e9d8
25 changed files with 786 additions and 135 deletions
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
64
clients/web/src/lib/remoteOAuthStorage.test.ts
Normal file
64
clients/web/src/lib/remoteOAuthStorage.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
45
clients/web/src/lib/remoteOAuthStorage.ts
Normal file
45
clients/web/src/lib/remoteOAuthStorage.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
224
clients/web/src/test/core/auth/oauth-storage.test.ts
Normal file
224
clients/web/src/test/core/auth/oauth-storage.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>>();
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue