Merge pull request #1609 from modelcontextprotocol/v2/1548-web-remote-oauth-storage
refactor: migrate to RemoteOAuthStorage for web OAuth handling
This commit is contained in:
commit
87133bd42d
33 changed files with 714 additions and 177 deletions
|
|
@ -896,7 +896,7 @@ function App({
|
|||
|
||||
const handleClearOAuth = useCallback(async () => {
|
||||
if (!selectedInspectorClient) return;
|
||||
selectedInspectorClient.clearOAuthTokens();
|
||||
await selectedInspectorClient.clearOAuthTokens();
|
||||
setOauthStatus("idle");
|
||||
setOauthMessage(null);
|
||||
setConnectError(null);
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ 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 { getWebRemoteOAuthStorage } 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";
|
||||
|
|
@ -1428,6 +1428,13 @@ function App() {
|
|||
[messages],
|
||||
);
|
||||
|
||||
// Shared OAuth runtime store (oauth.json via /api/storage/oauth). Memoized so
|
||||
// connect, EMA IdP session, and per-server clear share one in-memory view.
|
||||
const webOAuthStorage = useMemo(
|
||||
() => getWebRemoteOAuthStorage(getAuthToken()),
|
||||
[],
|
||||
);
|
||||
|
||||
// Backend-backed session storage used to carry the fetch (Network) log
|
||||
// across the OAuth full-page redirect. The auth handshake's first half —
|
||||
// protected-resource + auth-server discovery and Dynamic Client
|
||||
|
|
@ -2098,9 +2105,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 shared
|
||||
// `RemoteOAuthStorage` (`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;
|
||||
|
|
@ -2183,6 +2191,15 @@ function App() {
|
|||
}
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
await webOAuthStorage.load();
|
||||
} catch (err) {
|
||||
connectStartRef.current = undefined;
|
||||
queueMicrotask(() => {
|
||||
showReAuthBanner(server.id, err instanceof Error ? err : String(err));
|
||||
});
|
||||
return;
|
||||
}
|
||||
const client = setupClientForServer(server, sessionId);
|
||||
setActiveServerId(server.id);
|
||||
try {
|
||||
|
|
@ -2238,7 +2255,7 @@ function App() {
|
|||
});
|
||||
}
|
||||
})();
|
||||
}, [servers, setupClientForServer, showReAuthBanner]);
|
||||
}, [servers, setupClientForServer, showReAuthBanner, webOAuthStorage]);
|
||||
|
||||
const onToggleConnection = useCallback(
|
||||
async (id: string) => {
|
||||
|
|
@ -3239,10 +3256,9 @@ function App() {
|
|||
|
||||
const clientSettingsModalValue = clientSettingsDraft ?? EMPTY_CLIENT_SETTINGS;
|
||||
|
||||
const emaOAuthStorage = useMemo(() => getBrowserOAuthStorage(), []);
|
||||
const { loginState: emaIdpLoginState, logout: logoutEmaIdp } =
|
||||
useEmaIdpLoginState(
|
||||
emaOAuthStorage,
|
||||
webOAuthStorage,
|
||||
clientSettingsModalValue.emaEnabled
|
||||
? clientSettingsModalValue.issuer
|
||||
: undefined,
|
||||
|
|
@ -3266,10 +3282,11 @@ function App() {
|
|||
const clearServerOAuthAndDisconnect = useCallback(
|
||||
async (server: { id: string; name: string; config: MCPServerConfig }) => {
|
||||
const isActive = server.id === activeServerId;
|
||||
const cleared = clearServerOAuthState({
|
||||
const cleared = await clearServerOAuthState({
|
||||
config: server.config,
|
||||
inspectorClient: isActive ? inspectorClient : null,
|
||||
isActiveConnection: isActive,
|
||||
oauthStorage: webOAuthStorage,
|
||||
});
|
||||
if (!cleared) return;
|
||||
|
||||
|
|
@ -3295,6 +3312,7 @@ function App() {
|
|||
[
|
||||
activeServerId,
|
||||
inspectorClient,
|
||||
webOAuthStorage,
|
||||
finalizeExplicitDisconnect,
|
||||
clearOAuthResumeOnExplicitDisconnect,
|
||||
],
|
||||
|
|
|
|||
124
clients/web/src/lib/environmentFactory.test.ts
Normal file
124
clients/web/src/lib/environmentFactory.test.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { BrowserNavigation } from "@inspector/core/auth/browser/index.js";
|
||||
import { MutableRedirectUrlProvider } from "@inspector/core/auth/providers.js";
|
||||
import { createWebEnvironment } from "./environmentFactory";
|
||||
import {
|
||||
getWebRemoteOAuthStorage,
|
||||
resetWebRemoteOAuthStorageCacheForTests,
|
||||
} from "./remoteOAuthStorage";
|
||||
|
||||
describe("createWebEnvironment", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("window", {
|
||||
location: {
|
||||
protocol: "http:",
|
||||
host: "127.0.0.1:6299",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetWebRemoteOAuthStorageCacheForTests();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("wires RemoteOAuthStorage shared with getWebRemoteOAuthStorage", () => {
|
||||
const redirectUrlProvider = new MutableRedirectUrlProvider();
|
||||
const first = createWebEnvironment("unit-test-token", redirectUrlProvider);
|
||||
const second = createWebEnvironment("unit-test-token", redirectUrlProvider);
|
||||
|
||||
expect(first.environment.oauth).toBeDefined();
|
||||
expect(second.environment.oauth).toBeDefined();
|
||||
expect(second.environment.oauth!.storage).toBe(
|
||||
first.environment.oauth!.storage,
|
||||
);
|
||||
expect(first.environment.oauth!.storage).toBe(
|
||||
getWebRemoteOAuthStorage("unit-test-token"),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses BrowserNavigation for oauth.navigation", () => {
|
||||
const { environment } = createWebEnvironment(
|
||||
undefined,
|
||||
new MutableRedirectUrlProvider(),
|
||||
);
|
||||
expect(environment.oauth).toBeDefined();
|
||||
expect(environment.oauth!.navigation).toBeInstanceOf(BrowserNavigation);
|
||||
});
|
||||
|
||||
it("returns the same logger instance as environment.logger", () => {
|
||||
const { environment, logger } = createWebEnvironment(
|
||||
"tok",
|
||||
new MutableRedirectUrlProvider(),
|
||||
);
|
||||
expect(logger).toBe(environment.logger);
|
||||
});
|
||||
|
||||
it("passes redirectUrlProvider into oauth config", () => {
|
||||
const redirectUrlProvider = new MutableRedirectUrlProvider();
|
||||
redirectUrlProvider.redirectUrl = "http://127.0.0.1:6299/oauth/callback";
|
||||
const { environment } = createWebEnvironment("tok", redirectUrlProvider);
|
||||
if (!environment.oauth) {
|
||||
throw new Error("expected oauth config");
|
||||
}
|
||||
const { redirectUrlProvider: oauthRedirect } = environment.oauth;
|
||||
if (!oauthRedirect) {
|
||||
throw new Error("expected redirectUrlProvider");
|
||||
}
|
||||
expect(oauthRedirect.getRedirectUrl()).toBe(
|
||||
"http://127.0.0.1:6299/oauth/callback",
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards onBeforeOAuthRedirect to BrowserNavigation", () => {
|
||||
const onBeforeOAuthRedirect = vi.fn<(authorizationUrl: URL) => void>();
|
||||
const { environment } = createWebEnvironment(
|
||||
"tok",
|
||||
new MutableRedirectUrlProvider(),
|
||||
onBeforeOAuthRedirect,
|
||||
);
|
||||
if (!environment.oauth) {
|
||||
throw new Error("expected oauth config");
|
||||
}
|
||||
const { navigation } = environment.oauth;
|
||||
if (!navigation) {
|
||||
throw new Error("expected navigation");
|
||||
}
|
||||
const authUrl = new URL("https://idp.example/authorize?state=abc");
|
||||
navigation.navigateToAuthorization(authUrl);
|
||||
expect(onBeforeOAuthRedirect).toHaveBeenCalledWith(authUrl);
|
||||
});
|
||||
|
||||
it("routes fetch through the wrapped global fetch at the window origin", async () => {
|
||||
const remoteBody = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: { "content-type": "text/plain" },
|
||||
body: "echoed",
|
||||
};
|
||||
const fetchMock = vi.fn<typeof fetch>().mockImplementation(
|
||||
async () =>
|
||||
new Response(JSON.stringify(remoteBody), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const { environment } = createWebEnvironment(
|
||||
"api-tok",
|
||||
new MutableRedirectUrlProvider(),
|
||||
);
|
||||
fetchMock.mockClear();
|
||||
|
||||
const res = await environment.fetch!("http://upstream.test/mcp");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.text()).toBe("echoed");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(String(fetchMock.mock.calls[0]?.[0])).toBe(
|
||||
"http://127.0.0.1:6299/api/fetch",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 { getWebRemoteOAuthStorage } from "./remoteOAuthStorage.js";
|
||||
|
||||
export interface WebEnvironmentResult {
|
||||
environment: InspectorClientEnvironment;
|
||||
|
|
@ -20,16 +18,17 @@ 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` (shared `oauth.json` via
|
||||
* `/api/storage/oauth`); navigation uses `BrowserNavigation`.
|
||||
*
|
||||
* 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
|
||||
* reaching back through the client.
|
||||
*
|
||||
* `authToken` is read from a higher level (currently unused in this app since
|
||||
* v2 has no auth-token UI yet, but kept in the signature so the wiring is
|
||||
* ready when token plumbing lands).
|
||||
* `authToken` is supplied by `App.tsx` (`getAuthToken()`) and forwarded to
|
||||
* remote transport/fetch/logger and `getWebRemoteOAuthStorage` so every
|
||||
* `/api/*` call (including `/api/storage/oauth`) carries `x-mcp-remote-auth`
|
||||
* when the backend requires it.
|
||||
*
|
||||
* `onBeforeOAuthRedirect` runs synchronously immediately before the OAuth
|
||||
* full-page redirect (see `BrowserNavigation`). The app uses it to flush the
|
||||
|
|
@ -67,7 +66,7 @@ export function createWebEnvironment(
|
|||
}),
|
||||
logger,
|
||||
oauth: {
|
||||
storage: getBrowserOAuthStorage(),
|
||||
storage: getWebRemoteOAuthStorage(authToken),
|
||||
navigation: new BrowserNavigation(undefined, onBeforeOAuthRedirect),
|
||||
redirectUrlProvider,
|
||||
},
|
||||
|
|
|
|||
66
clients/web/src/lib/remoteOAuthStorage.test.ts
Normal file
66
clients/web/src/lib/remoteOAuthStorage.test.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
getRemoteOAuthStorage,
|
||||
getWebRemoteOAuthStorage,
|
||||
resetWebRemoteOAuthStorageCacheForTests,
|
||||
} from "./remoteOAuthStorage";
|
||||
|
||||
describe("remoteOAuthStorage", () => {
|
||||
afterEach(() => {
|
||||
resetWebRemoteOAuthStorageCacheForTests();
|
||||
});
|
||||
|
||||
it("returns one RemoteOAuthStorage instance per cache key", () => {
|
||||
const a = getRemoteOAuthStorage({
|
||||
baseUrl: "http://127.0.0.1:6277",
|
||||
authToken: "tok-a",
|
||||
});
|
||||
const aAgain = getRemoteOAuthStorage({
|
||||
baseUrl: "http://127.0.0.1:6277",
|
||||
authToken: "tok-a",
|
||||
});
|
||||
const b = getRemoteOAuthStorage({
|
||||
baseUrl: "http://127.0.0.1:6277",
|
||||
authToken: "tok-b",
|
||||
});
|
||||
|
||||
expect(aAgain).toBe(a);
|
||||
expect(b).not.toBe(a);
|
||||
});
|
||||
|
||||
it("getWebRemoteOAuthStorage uses window.location origin", () => {
|
||||
vi.stubGlobal("window", {
|
||||
location: {
|
||||
protocol: "http:",
|
||||
host: "127.0.0.1:6299",
|
||||
},
|
||||
});
|
||||
|
||||
const storage = getWebRemoteOAuthStorage("smoke-web-token");
|
||||
const again = getWebRemoteOAuthStorage("smoke-web-token");
|
||||
|
||||
expect(again).toBe(storage);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("throws when window is unavailable", () => {
|
||||
vi.stubGlobal("window", undefined);
|
||||
expect(() => getWebRemoteOAuthStorage()).toThrow(
|
||||
"getWebRemoteOAuthStorage requires a browser environment",
|
||||
);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("creates a new instance after the test cache reset", () => {
|
||||
const first = getRemoteOAuthStorage({
|
||||
baseUrl: "http://127.0.0.1:6277",
|
||||
authToken: "reset-me",
|
||||
});
|
||||
resetWebRemoteOAuthStorageCacheForTests();
|
||||
const second = getRemoteOAuthStorage({
|
||||
baseUrl: "http://127.0.0.1:6277",
|
||||
authToken: "reset-me",
|
||||
});
|
||||
expect(second).not.toBe(first);
|
||||
});
|
||||
});
|
||||
59
clients/web/src/lib/remoteOAuthStorage.ts
Normal file
59
clients/web/src/lib/remoteOAuthStorage.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { RemoteOAuthStorage } from "@inspector/core/auth/remote/storage-remote.js";
|
||||
|
||||
export interface WebRemoteOAuthStorageOptions {
|
||||
baseUrl: string;
|
||||
authToken?: string;
|
||||
}
|
||||
|
||||
let cached: { cacheKey: string; storage: RemoteOAuthStorage } | undefined;
|
||||
|
||||
function buildCacheKey(options: WebRemoteOAuthStorageOptions): string {
|
||||
return `${options.baseUrl}\0${options.authToken ?? ""}`;
|
||||
}
|
||||
|
||||
const defaultFetch: typeof fetch = (...args) => globalThis.fetch(...args);
|
||||
|
||||
/**
|
||||
* Shared web OAuth store: `RemoteOAuthStorage` → `GET/POST /api/storage/oauth`
|
||||
* → `~/.mcp-inspector/storage/oauth.json` (same file as CLI/TUI).
|
||||
*
|
||||
* Caches a single instance keyed by `{ baseUrl, authToken }`: repeat calls with
|
||||
* the same key return that instance, so connect, EMA IdP session, and per-server
|
||||
* clear all mutate the same in-memory view. A call with a different key replaces
|
||||
* the cached instance (cache of one). That is sufficient because the web app
|
||||
* uses a stable origin + page-lifetime API token, so the key never changes
|
||||
* within a session.
|
||||
*/
|
||||
export function getRemoteOAuthStorage(
|
||||
options: WebRemoteOAuthStorageOptions,
|
||||
): RemoteOAuthStorage {
|
||||
const cacheKey = buildCacheKey(options);
|
||||
if (cached?.cacheKey === cacheKey) {
|
||||
return cached.storage;
|
||||
}
|
||||
cached = {
|
||||
cacheKey,
|
||||
storage: new RemoteOAuthStorage({
|
||||
baseUrl: options.baseUrl,
|
||||
authToken: options.authToken,
|
||||
fetchFn: defaultFetch,
|
||||
}),
|
||||
};
|
||||
return cached.storage;
|
||||
}
|
||||
|
||||
/** Current origin + optional API token (see `getAuthToken()` in App.tsx). */
|
||||
export function getWebRemoteOAuthStorage(
|
||||
authToken?: string,
|
||||
): RemoteOAuthStorage {
|
||||
if (typeof window === "undefined") {
|
||||
throw new Error("getWebRemoteOAuthStorage requires a browser environment");
|
||||
}
|
||||
const baseUrl = `${window.location.protocol}//${window.location.host}`;
|
||||
return getRemoteOAuthStorage({ baseUrl, authToken });
|
||||
}
|
||||
|
||||
/** @internal Vitest isolation */
|
||||
export function resetWebRemoteOAuthStorageCacheForTests(): void {
|
||||
cached = undefined;
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@ function createStorage(
|
|||
}> = {},
|
||||
): OAuthStorage {
|
||||
return {
|
||||
load: vi.fn().mockResolvedValue(undefined),
|
||||
getTokens: vi.fn().mockResolvedValue(overrides.tokens),
|
||||
getClientInformation: vi.fn(async (_url, isPreregistered) =>
|
||||
isPreregistered ? overrides.preregistered : overrides.dynamic,
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ function createMemoryStorage(
|
|||
const clientInfoByKey: Record<string, unknown> = {};
|
||||
const codeVerifierByKey: Record<string, string> = {};
|
||||
return {
|
||||
load: vi.fn().mockResolvedValue(undefined),
|
||||
getIdpSession: vi.fn(async (issuer: string) => idpSessions[issuer]),
|
||||
saveIdpSession: vi.fn(async (issuer: string, updates) => {
|
||||
idpSessions[issuer] = { ...idpSessions[issuer], ...updates };
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ describe("idpOidc refresh", () => {
|
|||
beforeEach(() => {
|
||||
Object.keys(idpSessions).forEach((k) => delete idpSessions[k]);
|
||||
storage = {
|
||||
load: vi.fn().mockResolvedValue(undefined),
|
||||
getIdpSession: vi.fn(async (issuer: string) => idpSessions[issuer]),
|
||||
saveIdpSession: vi.fn(async (issuer: string, updates) => {
|
||||
idpSessions[issuer] = { ...idpSessions[issuer], ...updates };
|
||||
|
|
@ -264,6 +265,8 @@ describe("startIdpOidcAuthorization", () => {
|
|||
beforeEach(() => {
|
||||
Object.keys(saved).forEach((k) => delete saved[k]);
|
||||
storage = {
|
||||
load: vi.fn().mockResolvedValue(undefined),
|
||||
getServerMetadata: vi.fn(() => null),
|
||||
saveCodeVerifier: vi.fn(async (key: string, value: string) => {
|
||||
saved[`cv:${key}`] = value;
|
||||
}),
|
||||
|
|
@ -331,6 +334,7 @@ describe("completeIdpOidcAuthorization", () => {
|
|||
|
||||
function buildStorage(overrides: Partial<OAuthStorage> = {}): OAuthStorage {
|
||||
return {
|
||||
load: vi.fn().mockResolvedValue(undefined),
|
||||
getServerMetadata: vi.fn(() => minimalOAuthAsMetadata(IDP_ISSUER)),
|
||||
getClientInformation: vi.fn(async () => ({
|
||||
client_id: "idp-client",
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ describe("idpSession", () => {
|
|||
|
||||
beforeEach(() => {
|
||||
storage = {
|
||||
load: vi.fn().mockResolvedValue(undefined),
|
||||
getIdpSession: vi.fn(),
|
||||
saveIdpSession: vi.fn(),
|
||||
clearIdpSession: vi.fn(),
|
||||
|
|
@ -73,15 +74,15 @@ describe("idpSession", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("clearEmaIdpSession clears idp session, leg-1 key, and tagged resource servers", () => {
|
||||
clearEmaIdpSession(storage, "https://idp.test/");
|
||||
it("clearEmaIdpSession clears idp session, leg-1 key, and tagged resource servers", async () => {
|
||||
await clearEmaIdpSession(storage, "https://idp.test/");
|
||||
expect(storage.clearIdpSession).toHaveBeenCalledWith("https://idp.test");
|
||||
expect(storage.clear).toHaveBeenCalledWith("ema-idp:https://idp.test");
|
||||
expect(storage.clearEnterpriseManagedResourceServers).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clearEmaIdpSession no-ops when issuer normalizes to empty", () => {
|
||||
clearEmaIdpSession(storage, "");
|
||||
it("clearEmaIdpSession no-ops when issuer normalizes to empty", async () => {
|
||||
await clearEmaIdpSession(storage, "");
|
||||
expect(storage.clearIdpSession).not.toHaveBeenCalled();
|
||||
expect(storage.clear).not.toHaveBeenCalled();
|
||||
expect(
|
||||
|
|
|
|||
|
|
@ -213,6 +213,7 @@ describe("OAuthNavigation", () => {
|
|||
|
||||
function makeStorage(): OAuthStorage {
|
||||
return {
|
||||
load: vi.fn().mockResolvedValue(undefined),
|
||||
getScope: vi.fn(() => undefined),
|
||||
getClientInformation: vi.fn(async () => undefined),
|
||||
saveClientInformation: vi.fn(async () => undefined),
|
||||
|
|
@ -240,11 +241,11 @@ describe("OAuthNavigation", () => {
|
|||
return new BaseOAuthClientProvider(SERVER, config);
|
||||
}
|
||||
|
||||
it("clear() delegates to storage.clear with the server url", () => {
|
||||
it("clear() delegates to storage.clear with the server url", async () => {
|
||||
const storage = makeStorage();
|
||||
const provider = makeProvider(storage);
|
||||
|
||||
provider.clear();
|
||||
await provider.clear();
|
||||
|
||||
expect(storage.clear).toHaveBeenCalledWith(SERVER);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,14 @@ const mockSessionStorage = new MockSessionStorage();
|
|||
(global as typeof globalThis & { sessionStorage?: Storage }).sessionStorage =
|
||||
mockSessionStorage;
|
||||
|
||||
describe("getBrowserOAuthStorage", () => {
|
||||
it("returns the same singleton on repeated calls", () => {
|
||||
const first = getBrowserOAuthStorage();
|
||||
const second = getBrowserOAuthStorage();
|
||||
expect(second).toBe(first);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BrowserOAuthStorage", () => {
|
||||
let storage: BrowserOAuthStorage;
|
||||
const testServerUrl = "http://localhost:3000";
|
||||
|
|
@ -66,7 +77,7 @@ describe("BrowserOAuthStorage", () => {
|
|||
client_secret: "test-secret",
|
||||
};
|
||||
|
||||
storage.saveClientInformation(testServerUrl, clientInfo, {
|
||||
await storage.saveClientInformation(testServerUrl, clientInfo, {
|
||||
registrationKind: "dcr",
|
||||
});
|
||||
const result = await storage.getClientInformation(testServerUrl);
|
||||
|
|
@ -82,7 +93,7 @@ describe("BrowserOAuthStorage", () => {
|
|||
|
||||
// Use the storage API instead of manually setting sessionStorage
|
||||
// since BrowserOAuthStorage now uses Zustand with a different storage format
|
||||
storage.savePreregisteredClientInformation(
|
||||
await storage.savePreregisteredClientInformation(
|
||||
testServerUrl,
|
||||
preregisteredInfo,
|
||||
);
|
||||
|
|
@ -102,7 +113,7 @@ describe("BrowserOAuthStorage", () => {
|
|||
client_id: "test-client-id",
|
||||
};
|
||||
|
||||
storage.saveClientInformation(testServerUrl, clientInfo, {
|
||||
await storage.saveClientInformation(testServerUrl, clientInfo, {
|
||||
registrationKind: "dcr",
|
||||
});
|
||||
const result = await storage.getClientInformation(testServerUrl);
|
||||
|
|
@ -119,10 +130,10 @@ describe("BrowserOAuthStorage", () => {
|
|||
client_id: "second-id",
|
||||
};
|
||||
|
||||
storage.saveClientInformation(testServerUrl, firstInfo, {
|
||||
await storage.saveClientInformation(testServerUrl, firstInfo, {
|
||||
registrationKind: "dcr",
|
||||
});
|
||||
storage.saveClientInformation(testServerUrl, secondInfo, {
|
||||
await storage.saveClientInformation(testServerUrl, secondInfo, {
|
||||
registrationKind: "cimd",
|
||||
});
|
||||
const result = await storage.getClientInformation(testServerUrl);
|
||||
|
|
@ -152,7 +163,7 @@ describe("BrowserOAuthStorage", () => {
|
|||
expires_in: 3600,
|
||||
};
|
||||
|
||||
storage.saveTokens(testServerUrl, tokens);
|
||||
await storage.saveTokens(testServerUrl, tokens);
|
||||
const result = await storage.getTokens(testServerUrl);
|
||||
|
||||
expect(result).toEqual(tokens);
|
||||
|
|
@ -166,7 +177,7 @@ describe("BrowserOAuthStorage", () => {
|
|||
token_type: "Bearer",
|
||||
};
|
||||
|
||||
storage.saveTokens(testServerUrl, tokens);
|
||||
await storage.saveTokens(testServerUrl, tokens);
|
||||
const result = await storage.getTokens(testServerUrl);
|
||||
|
||||
expect(result).toEqual(tokens);
|
||||
|
|
@ -183,7 +194,7 @@ describe("BrowserOAuthStorage", () => {
|
|||
await storage.saveTokens(emaUrl, tokens, { enterpriseManaged: true });
|
||||
await storage.saveTokens(standardUrl, tokens);
|
||||
|
||||
storage.clearEnterpriseManagedResourceServers();
|
||||
await storage.clearEnterpriseManagedResourceServers();
|
||||
|
||||
expect(await storage.getTokens(emaUrl)).toBeUndefined();
|
||||
expect(await storage.getTokens(standardUrl)).toEqual(tokens);
|
||||
|
|
@ -199,7 +210,7 @@ describe("BrowserOAuthStorage", () => {
|
|||
it("should return stored code verifier", async () => {
|
||||
const codeVerifier = "test-code-verifier";
|
||||
|
||||
storage.saveCodeVerifier(testServerUrl, codeVerifier);
|
||||
await storage.saveCodeVerifier(testServerUrl, codeVerifier);
|
||||
const result = await storage.getCodeVerifier(testServerUrl);
|
||||
|
||||
expect(result).toBe(codeVerifier);
|
||||
|
|
@ -210,7 +221,7 @@ describe("BrowserOAuthStorage", () => {
|
|||
it("should save code verifier", async () => {
|
||||
const codeVerifier = "test-code-verifier";
|
||||
|
||||
storage.saveCodeVerifier(testServerUrl, codeVerifier);
|
||||
await storage.saveCodeVerifier(testServerUrl, codeVerifier);
|
||||
const result = await storage.getCodeVerifier(testServerUrl);
|
||||
|
||||
expect(result).toBe(codeVerifier);
|
||||
|
|
@ -226,7 +237,7 @@ describe("BrowserOAuthStorage", () => {
|
|||
it("should return stored scope", async () => {
|
||||
const scope = "read write";
|
||||
|
||||
storage.saveScope(testServerUrl, scope);
|
||||
await storage.saveScope(testServerUrl, scope);
|
||||
const result = await storage.getScope(testServerUrl);
|
||||
|
||||
expect(result).toBe(scope);
|
||||
|
|
@ -237,7 +248,7 @@ describe("BrowserOAuthStorage", () => {
|
|||
it("should save scope", async () => {
|
||||
const scope = "read write";
|
||||
|
||||
storage.saveScope(testServerUrl, scope);
|
||||
await storage.saveScope(testServerUrl, scope);
|
||||
const result = await storage.getScope(testServerUrl);
|
||||
|
||||
expect(result).toBe(scope);
|
||||
|
|
@ -258,7 +269,7 @@ describe("BrowserOAuthStorage", () => {
|
|||
response_types_supported: ["code"],
|
||||
};
|
||||
|
||||
storage.saveServerMetadata(testServerUrl, metadata);
|
||||
await storage.saveServerMetadata(testServerUrl, metadata);
|
||||
const result = await storage.getServerMetadata(testServerUrl);
|
||||
|
||||
expect(result).toEqual(metadata);
|
||||
|
|
@ -274,7 +285,7 @@ describe("BrowserOAuthStorage", () => {
|
|||
response_types_supported: ["code"],
|
||||
};
|
||||
|
||||
storage.saveServerMetadata(testServerUrl, metadata);
|
||||
await storage.saveServerMetadata(testServerUrl, metadata);
|
||||
const result = await storage.getServerMetadata(testServerUrl);
|
||||
|
||||
expect(result).toEqual(metadata);
|
||||
|
|
@ -283,7 +294,7 @@ describe("BrowserOAuthStorage", () => {
|
|||
|
||||
describe("clearClientInformation", () => {
|
||||
it("removes the dynamically-registered client info by default", async () => {
|
||||
storage.saveClientInformation(
|
||||
await storage.saveClientInformation(
|
||||
testServerUrl,
|
||||
{ client_id: "dyn" },
|
||||
{ registrationKind: "dcr" },
|
||||
|
|
@ -291,18 +302,18 @@ describe("BrowserOAuthStorage", () => {
|
|||
expect(await storage.getClientInformation(testServerUrl)).toEqual({
|
||||
client_id: "dyn",
|
||||
});
|
||||
storage.clearClientInformation(testServerUrl);
|
||||
await storage.clearClientInformation(testServerUrl);
|
||||
expect(await storage.getClientInformation(testServerUrl)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("removes the preregistered client info when isPreregistered=true", async () => {
|
||||
storage.savePreregisteredClientInformation(testServerUrl, {
|
||||
await storage.savePreregisteredClientInformation(testServerUrl, {
|
||||
client_id: "pre",
|
||||
});
|
||||
expect(await storage.getClientInformation(testServerUrl, true)).toEqual({
|
||||
client_id: "pre",
|
||||
});
|
||||
storage.clearClientInformation(testServerUrl, true);
|
||||
await storage.clearClientInformation(testServerUrl, true);
|
||||
expect(
|
||||
await storage.getClientInformation(testServerUrl, true),
|
||||
).toBeUndefined();
|
||||
|
|
@ -311,26 +322,26 @@ describe("BrowserOAuthStorage", () => {
|
|||
|
||||
describe("individual clear methods", () => {
|
||||
it("clearTokens removes only tokens", async () => {
|
||||
storage.saveTokens(testServerUrl, {
|
||||
await storage.saveTokens(testServerUrl, {
|
||||
access_token: "t",
|
||||
token_type: "Bearer",
|
||||
});
|
||||
expect(await storage.getTokens(testServerUrl)).toBeDefined();
|
||||
storage.clearTokens(testServerUrl);
|
||||
await storage.clearTokens(testServerUrl);
|
||||
expect(await storage.getTokens(testServerUrl)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clearCodeVerifier removes only the PKCE verifier", async () => {
|
||||
storage.saveCodeVerifier(testServerUrl, "verifier");
|
||||
await storage.saveCodeVerifier(testServerUrl, "verifier");
|
||||
expect(storage.getCodeVerifier(testServerUrl)).toBe("verifier");
|
||||
storage.clearCodeVerifier(testServerUrl);
|
||||
await storage.clearCodeVerifier(testServerUrl);
|
||||
expect(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);
|
||||
await storage.clearScope(testServerUrl);
|
||||
expect(storage.getScope(testServerUrl)).toBeUndefined();
|
||||
});
|
||||
|
||||
|
|
@ -341,9 +352,9 @@ describe("BrowserOAuthStorage", () => {
|
|||
token_endpoint: "http://localhost:3000/token",
|
||||
response_types_supported: ["code"],
|
||||
};
|
||||
storage.saveServerMetadata(testServerUrl, metadata);
|
||||
await storage.saveServerMetadata(testServerUrl, metadata);
|
||||
expect(storage.getServerMetadata(testServerUrl)).toEqual(metadata);
|
||||
storage.clearServerMetadata(testServerUrl);
|
||||
await storage.clearServerMetadata(testServerUrl);
|
||||
expect(storage.getServerMetadata(testServerUrl)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -358,12 +369,12 @@ 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);
|
||||
await storage.clear(testServerUrl);
|
||||
|
||||
expect(await storage.getClientInformation(testServerUrl)).toBeUndefined();
|
||||
expect(await storage.getTokens(testServerUrl)).toBeUndefined();
|
||||
|
|
@ -375,14 +386,14 @@ 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",
|
||||
});
|
||||
|
||||
storage.clear(testServerUrl);
|
||||
await storage.clear(testServerUrl);
|
||||
|
||||
expect(await storage.getClientInformation(testServerUrl)).toBeUndefined();
|
||||
expect(await storage.getClientInformation(otherServerUrl)).toEqual(
|
||||
|
|
@ -404,10 +415,10 @@ describe("BrowserOAuthStorage", () => {
|
|||
client_id: "client-2",
|
||||
};
|
||||
|
||||
storage.saveClientInformation(server1Url, clientInfo1, {
|
||||
await storage.saveClientInformation(server1Url, clientInfo1, {
|
||||
registrationKind: "dcr",
|
||||
});
|
||||
storage.saveClientInformation(server2Url, clientInfo2, {
|
||||
await storage.saveClientInformation(server2Url, clientInfo2, {
|
||||
registrationKind: "dcr",
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { RemoteOAuthStorage } from "@inspector/core/auth/remote/storage-remote.js";
|
||||
import {
|
||||
type OAuthStore,
|
||||
waitForOAuthStorePersistLoad,
|
||||
} from "@inspector/core/auth/store.js";
|
||||
|
||||
const NOOP_FETCH = vi.fn(
|
||||
async () =>
|
||||
|
|
@ -50,7 +54,7 @@ describe("RemoteOAuthStorage (unit, mocked fetch)", () => {
|
|||
{ client_id: "dyn" },
|
||||
{ registrationKind: "dcr" },
|
||||
);
|
||||
storage.clearClientInformation(serverUrl);
|
||||
await storage.clearClientInformation(serverUrl);
|
||||
expect(await storage.getClientInformation(serverUrl)).toBeUndefined();
|
||||
});
|
||||
|
||||
|
|
@ -58,7 +62,7 @@ describe("RemoteOAuthStorage (unit, mocked fetch)", () => {
|
|||
await storage.savePreregisteredClientInformation(serverUrl, {
|
||||
client_id: "pre",
|
||||
});
|
||||
storage.clearClientInformation(serverUrl, true);
|
||||
await storage.clearClientInformation(serverUrl, true);
|
||||
expect(await storage.getClientInformation(serverUrl, true)).toBeUndefined();
|
||||
});
|
||||
|
||||
|
|
@ -66,21 +70,21 @@ describe("RemoteOAuthStorage (unit, mocked fetch)", () => {
|
|||
const tokens = { access_token: "t", token_type: "Bearer" };
|
||||
await storage.saveTokens(serverUrl, tokens);
|
||||
expect(await storage.getTokens(serverUrl)).toEqual(tokens);
|
||||
storage.clearTokens(serverUrl);
|
||||
await storage.clearTokens(serverUrl);
|
||||
expect(await storage.getTokens(serverUrl)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("codeVerifier round-trip and clearCodeVerifier", async () => {
|
||||
await storage.saveCodeVerifier(serverUrl, "verifier");
|
||||
expect(storage.getCodeVerifier(serverUrl)).toBe("verifier");
|
||||
storage.clearCodeVerifier(serverUrl);
|
||||
await storage.clearCodeVerifier(serverUrl);
|
||||
expect(storage.getCodeVerifier(serverUrl)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("scope round-trip and clearScope", async () => {
|
||||
await storage.saveScope(serverUrl, "read write");
|
||||
expect(storage.getScope(serverUrl)).toBe("read write");
|
||||
storage.clearScope(serverUrl);
|
||||
await storage.clearScope(serverUrl);
|
||||
expect(storage.getScope(serverUrl)).toBeUndefined();
|
||||
});
|
||||
|
||||
|
|
@ -93,7 +97,7 @@ describe("RemoteOAuthStorage (unit, mocked fetch)", () => {
|
|||
};
|
||||
await storage.saveServerMetadata(serverUrl, md);
|
||||
expect(storage.getServerMetadata(serverUrl)).toEqual(md);
|
||||
storage.clearServerMetadata(serverUrl);
|
||||
await storage.clearServerMetadata(serverUrl);
|
||||
expect(storage.getServerMetadata(serverUrl)).toBeNull();
|
||||
});
|
||||
|
||||
|
|
@ -107,7 +111,7 @@ describe("RemoteOAuthStorage (unit, mocked fetch)", () => {
|
|||
access_token: "t",
|
||||
token_type: "Bearer",
|
||||
});
|
||||
storage.clear(serverUrl);
|
||||
await storage.clear(serverUrl);
|
||||
expect(await storage.getClientInformation(serverUrl)).toBeUndefined();
|
||||
expect(await storage.getTokens(serverUrl)).toBeUndefined();
|
||||
});
|
||||
|
|
@ -120,4 +124,61 @@ describe("RemoteOAuthStorage (unit, mocked fetch)", () => {
|
|||
// No public accessor; constructing without throwing covers the default-branch.
|
||||
expect(s).toBeInstanceOf(RemoteOAuthStorage);
|
||||
});
|
||||
|
||||
it("waitForOAuthStorePersistLoad resolves for an unregistered store handle", async () => {
|
||||
await expect(
|
||||
waitForOAuthStorePersistLoad({} as OAuthStore),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("load() waits for remote GET before sync reads see persisted state", async () => {
|
||||
const persisted = {
|
||||
state: {
|
||||
servers: {
|
||||
[serverUrl]: { codeVerifier: "persisted-verifier" },
|
||||
},
|
||||
idpSessions: {},
|
||||
},
|
||||
version: 0,
|
||||
};
|
||||
let resolveFetch!: (value: Response) => void;
|
||||
const delayedFetch = vi.fn<typeof fetch>(
|
||||
() =>
|
||||
new Promise<Response>((resolve) => {
|
||||
resolveFetch = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const delayedStorage = new RemoteOAuthStorage({
|
||||
baseUrl: "http://remote.example",
|
||||
storeId: `delayed-${Math.random().toString(36).slice(2)}`,
|
||||
fetchFn: delayedFetch,
|
||||
});
|
||||
|
||||
const loadPromise = delayedStorage.load();
|
||||
expect(delayedStorage.getCodeVerifier(serverUrl)).toBeUndefined();
|
||||
|
||||
resolveFetch(new Response(JSON.stringify(persisted), { status: 200 }));
|
||||
await loadPromise;
|
||||
|
||||
expect(delayedStorage.getCodeVerifier(serverUrl)).toBe(
|
||||
"persisted-verifier",
|
||||
);
|
||||
});
|
||||
|
||||
it("load() rejects when remote GET fails instead of hanging", async () => {
|
||||
const failingFetch = vi.fn<typeof fetch>(
|
||||
async () => new Response("server error", { status: 500 }),
|
||||
);
|
||||
|
||||
const failingStorage = new RemoteOAuthStorage({
|
||||
baseUrl: "http://remote.example",
|
||||
storeId: `fail-${Math.random().toString(36).slice(2)}`,
|
||||
fetchFn: failingFetch,
|
||||
});
|
||||
|
||||
await expect(failingStorage.load()).rejects.toThrow(
|
||||
"Failed to read store: 500",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ function createMockParams(
|
|||
const dispatchOAuthError = vi.fn();
|
||||
|
||||
const storage = {
|
||||
load: vi.fn().mockResolvedValue(undefined),
|
||||
getScope: vi.fn().mockReturnValue(undefined),
|
||||
getClientInformation: vi.fn().mockResolvedValue(undefined),
|
||||
getClientRegistrationKind: vi.fn().mockReturnValue(undefined),
|
||||
|
|
@ -45,7 +46,7 @@ function createMockParams(
|
|||
saveTokens: vi.fn().mockResolvedValue(undefined),
|
||||
getCodeVerifier: vi.fn().mockReturnValue("verifier"),
|
||||
saveCodeVerifier: vi.fn().mockResolvedValue(undefined),
|
||||
clear: vi.fn(),
|
||||
clear: vi.fn().mockResolvedValue(undefined),
|
||||
clearClientInformation: vi.fn(),
|
||||
clearTokens: vi.fn(),
|
||||
clearCodeVerifier: vi.fn(),
|
||||
|
|
@ -126,10 +127,10 @@ describe("OAuthManager", () => {
|
|||
});
|
||||
|
||||
describe("clearOAuthTokens", () => {
|
||||
it("calls storage.clear(serverUrl) when storage is configured", () => {
|
||||
it("calls storage.clear(serverUrl) when storage is configured", async () => {
|
||||
const params = createMockParams();
|
||||
const manager = new OAuthManager(params);
|
||||
manager.clearOAuthTokens();
|
||||
await manager.clearOAuthTokens();
|
||||
expect(params.initialConfig.storage!.clear).toHaveBeenCalledWith(
|
||||
SERVER_URL,
|
||||
);
|
||||
|
|
@ -137,7 +138,7 @@ describe("OAuthManager", () => {
|
|||
expect(manager.getOAuthFlowStep()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("no-ops when storage is not configured", () => {
|
||||
it("no-ops when storage is not configured", async () => {
|
||||
const params = createMockParams({
|
||||
initialConfig: {
|
||||
redirectUrlProvider: {
|
||||
|
|
@ -147,7 +148,7 @@ describe("OAuthManager", () => {
|
|||
} as OAuthManagerConfig,
|
||||
});
|
||||
const manager = new OAuthManager(params);
|
||||
manager.clearOAuthTokens();
|
||||
await manager.clearOAuthTokens();
|
||||
expect(params.getServerUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,10 +8,13 @@ describe("useEmaIdpLoginState", () => {
|
|||
|
||||
beforeEach(() => {
|
||||
storage = {
|
||||
load: vi.fn().mockResolvedValue(undefined),
|
||||
getIdpSession: vi.fn().mockResolvedValue(undefined),
|
||||
clearIdpSession: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
clearEnterpriseManagedResourceServers: vi.fn(),
|
||||
clearIdpSession: vi.fn().mockResolvedValue(undefined),
|
||||
clear: vi.fn().mockResolvedValue(undefined),
|
||||
clearEnterpriseManagedResourceServers: vi
|
||||
.fn()
|
||||
.mockResolvedValue(undefined),
|
||||
} as unknown as OAuthStorage;
|
||||
});
|
||||
|
||||
|
|
@ -56,10 +59,64 @@ describe("useEmaIdpLoginState", () => {
|
|||
result.current.logout();
|
||||
});
|
||||
|
||||
expect(storage.clearIdpSession).toHaveBeenCalledWith("https://idp.test");
|
||||
expect(storage.clear).toHaveBeenCalledWith("ema-idp:https://idp.test");
|
||||
expect(storage.clearEnterpriseManagedResourceServers).toHaveBeenCalled();
|
||||
expect(result.current.loginState).toBe("none");
|
||||
await waitFor(() => {
|
||||
expect(storage.clearIdpSession).toHaveBeenCalledWith("https://idp.test");
|
||||
expect(storage.clear).toHaveBeenCalledWith("ema-idp:https://idp.test");
|
||||
expect(storage.clearEnterpriseManagedResourceServers).toHaveBeenCalled();
|
||||
expect(result.current.loginState).toBe("none");
|
||||
});
|
||||
});
|
||||
|
||||
it("logout swallows a clear failure without an unhandled rejection and keeps state", async () => {
|
||||
const exp = Math.floor(Date.now() / 1000) + 3600;
|
||||
const payload = btoa(JSON.stringify({ exp }))
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
vi.mocked(storage.getIdpSession).mockResolvedValue({
|
||||
idToken: `h.${payload}.s`,
|
||||
});
|
||||
vi.mocked(storage.clearIdpSession).mockRejectedValue(
|
||||
new Error("storage backend unreachable"),
|
||||
);
|
||||
const unhandled = vi.fn();
|
||||
const onUnhandled = (event: PromiseRejectionEvent) => {
|
||||
event.preventDefault();
|
||||
unhandled();
|
||||
};
|
||||
window.addEventListener("unhandledrejection", onUnhandled);
|
||||
|
||||
try {
|
||||
const { result } = renderHook(() =>
|
||||
useEmaIdpLoginState(storage, "https://idp.test", true),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loginState).toBe("logged_in");
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.logout();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(storage.clearIdpSession).toHaveBeenCalledWith(
|
||||
"https://idp.test",
|
||||
);
|
||||
});
|
||||
// Give the rejected promise a turn to settle so any unhandled rejection
|
||||
// would have fired.
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// Clear failed, so the session is still present: state stays "logged_in"
|
||||
// and no unhandled rejection escaped.
|
||||
expect(result.current.loginState).toBe("logged_in");
|
||||
expect(unhandled).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
window.removeEventListener("unhandledrejection", onUnhandled);
|
||||
}
|
||||
});
|
||||
|
||||
it("reports 'expired' for an expired token with no refresh token", async () => {
|
||||
|
|
|
|||
|
|
@ -126,10 +126,10 @@ describe("NodeOAuthStorage", () => {
|
|||
client_id: "second-id",
|
||||
};
|
||||
|
||||
storage.saveClientInformation(testServerUrl, firstInfo, {
|
||||
await storage.saveClientInformation(testServerUrl, firstInfo, {
|
||||
registrationKind: "dcr",
|
||||
});
|
||||
storage.saveClientInformation(testServerUrl, secondInfo, {
|
||||
await storage.saveClientInformation(testServerUrl, secondInfo, {
|
||||
registrationKind: "dcr",
|
||||
});
|
||||
const result = await storage.getClientInformation(testServerUrl);
|
||||
|
|
@ -310,7 +310,7 @@ describe("NodeOAuthStorage", () => {
|
|||
expect(await storage.getClientInformation(testServerUrl)).toEqual({
|
||||
client_id: "dyn",
|
||||
});
|
||||
storage.clearClientInformation(testServerUrl);
|
||||
await storage.clearClientInformation(testServerUrl);
|
||||
expect(await storage.getClientInformation(testServerUrl)).toBeUndefined();
|
||||
});
|
||||
|
||||
|
|
@ -321,7 +321,7 @@ describe("NodeOAuthStorage", () => {
|
|||
expect(await storage.getClientInformation(testServerUrl, true)).toEqual({
|
||||
client_id: "pre",
|
||||
});
|
||||
storage.clearClientInformation(testServerUrl, true);
|
||||
await storage.clearClientInformation(testServerUrl, true);
|
||||
expect(
|
||||
await storage.getClientInformation(testServerUrl, true),
|
||||
).toBeUndefined();
|
||||
|
|
@ -335,21 +335,21 @@ describe("NodeOAuthStorage", () => {
|
|||
token_type: "Bearer",
|
||||
});
|
||||
expect(await storage.getTokens(testServerUrl)).toBeDefined();
|
||||
storage.clearTokens(testServerUrl);
|
||||
await storage.clearTokens(testServerUrl);
|
||||
expect(await storage.getTokens(testServerUrl)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clearCodeVerifier removes only the PKCE verifier", async () => {
|
||||
await storage.saveCodeVerifier(testServerUrl, "verifier");
|
||||
expect(storage.getCodeVerifier(testServerUrl)).toBe("verifier");
|
||||
storage.clearCodeVerifier(testServerUrl);
|
||||
await storage.clearCodeVerifier(testServerUrl);
|
||||
expect(storage.getCodeVerifier(testServerUrl)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clearScope removes only the scope", async () => {
|
||||
await storage.saveScope(testServerUrl, "read write");
|
||||
expect(storage.getScope(testServerUrl)).toBe("read write");
|
||||
storage.clearScope(testServerUrl);
|
||||
await storage.clearScope(testServerUrl);
|
||||
expect(storage.getScope(testServerUrl)).toBeUndefined();
|
||||
});
|
||||
|
||||
|
|
@ -362,7 +362,7 @@ describe("NodeOAuthStorage", () => {
|
|||
};
|
||||
await storage.saveServerMetadata(testServerUrl, metadata);
|
||||
expect(storage.getServerMetadata(testServerUrl)).toEqual(metadata);
|
||||
storage.clearServerMetadata(testServerUrl);
|
||||
await storage.clearServerMetadata(testServerUrl);
|
||||
expect(storage.getServerMetadata(testServerUrl)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -382,7 +382,7 @@ describe("NodeOAuthStorage", () => {
|
|||
});
|
||||
await storage.saveTokens(testServerUrl, tokens);
|
||||
|
||||
storage.clear(testServerUrl);
|
||||
await storage.clear(testServerUrl);
|
||||
|
||||
expect(await storage.getClientInformation(testServerUrl)).toBeUndefined();
|
||||
expect(await storage.getTokens(testServerUrl)).toBeUndefined();
|
||||
|
|
@ -401,7 +401,7 @@ describe("NodeOAuthStorage", () => {
|
|||
registrationKind: "dcr",
|
||||
});
|
||||
|
||||
storage.clear(testServerUrl);
|
||||
await storage.clear(testServerUrl);
|
||||
|
||||
expect(await storage.getClientInformation(testServerUrl)).toBeUndefined();
|
||||
const otherResult = await storage.getClientInformation(otherServerUrl);
|
||||
|
|
@ -424,10 +424,10 @@ describe("NodeOAuthStorage", () => {
|
|||
client_id: "client-2",
|
||||
};
|
||||
|
||||
storage.saveClientInformation(server1Url, clientInfo1, {
|
||||
await storage.saveClientInformation(server1Url, clientInfo1, {
|
||||
registrationKind: "dcr",
|
||||
});
|
||||
storage.saveClientInformation(server2Url, clientInfo2, {
|
||||
await storage.saveClientInformation(server2Url, clientInfo2, {
|
||||
registrationKind: "dcr",
|
||||
});
|
||||
|
||||
|
|
@ -547,7 +547,7 @@ describe("NodeOAuthStorage idpSessions (EMA)", () => {
|
|||
expect(session?.idToken).toBe("eyJ.id.token");
|
||||
expect(session?.refreshToken).toBe("rt-1");
|
||||
|
||||
storage.clearIdpSession(issuer);
|
||||
await storage.clearIdpSession(issuer);
|
||||
await flushStoreFileWrites(testStatePath);
|
||||
expect(await storage.getIdpSession(issuer)).toBeUndefined();
|
||||
});
|
||||
|
|
@ -635,12 +635,10 @@ describe("NodeOAuthStorage with custom storagePath", () => {
|
|||
);
|
||||
|
||||
try {
|
||||
const defaultStore = getOAuthStore();
|
||||
defaultStore.getState().setServerState(testServerUrl, {
|
||||
tokens: {
|
||||
access_token: "default-token",
|
||||
token_type: "Bearer",
|
||||
},
|
||||
const defaultStorage = new NodeOAuthStorage();
|
||||
await defaultStorage.saveTokens(testServerUrl, {
|
||||
access_token: "default-token",
|
||||
token_type: "Bearer",
|
||||
});
|
||||
|
||||
const customStorage = new NodeOAuthStorage(customPath);
|
||||
|
|
@ -652,11 +650,10 @@ describe("NodeOAuthStorage with custom storagePath", () => {
|
|||
const fromCustom = await customStorage.getTokens(testServerUrl);
|
||||
expect(fromCustom?.access_token).toBe("custom-token");
|
||||
|
||||
const defaultStorage = new NodeOAuthStorage();
|
||||
const fromDefault = await defaultStorage.getTokens(testServerUrl);
|
||||
expect(fromDefault?.access_token).toBe("default-token");
|
||||
|
||||
defaultStore.getState().clearServerState(testServerUrl);
|
||||
getOAuthStore().getState().clearServerState(testServerUrl);
|
||||
} finally {
|
||||
try {
|
||||
await fs.unlink(customPath);
|
||||
|
|
|
|||
|
|
@ -630,7 +630,7 @@ describe("InspectorClient OAuth E2E", () => {
|
|||
expect(client.getStatus()).toBe("connected");
|
||||
|
||||
await client.disconnect();
|
||||
client.clearOAuthTokens();
|
||||
await client.clearOAuthTokens();
|
||||
|
||||
const authUrlSecond = await client.authenticate();
|
||||
if (!authUrlSecond) throw new Error("Expected authorization URL");
|
||||
|
|
@ -995,7 +995,7 @@ describe("InspectorClient OAuth E2E", () => {
|
|||
expect(tokens?.access_token).toBeDefined();
|
||||
expect(await client.isOAuthAuthorized()).toBe(true);
|
||||
|
||||
client.clearOAuthTokens();
|
||||
await client.clearOAuthTokens();
|
||||
expect(await client.isOAuthAuthorized()).toBe(false);
|
||||
expect(await client.getOAuthTokens()).toBeUndefined();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -173,8 +173,8 @@ describe("InspectorClient OAuth", () => {
|
|||
expect(tokens).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should clear OAuth tokens", () => {
|
||||
client.clearOAuthTokens();
|
||||
it("should clear OAuth tokens", async () => {
|
||||
await client.clearOAuthTokens();
|
||||
// Should not throw
|
||||
expect(client).toBeDefined();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4949,7 +4949,7 @@ describe("InspectorClient", () => {
|
|||
expect(c.getOAuthFlowState()).toBeUndefined();
|
||||
await expect(c.getOAuthState()).resolves.toBeUndefined();
|
||||
// clearOAuthTokens is a no-op when there is no manager
|
||||
expect(() => c.clearOAuthTokens()).not.toThrow();
|
||||
await expect(c.clearOAuthTokens()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("setOAuthConfig throws when oauthManager is unset", () => {
|
||||
|
|
|
|||
|
|
@ -1,22 +1,26 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { getBrowserOAuthStorage } from "@inspector/core/auth/browser/index.js";
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { BrowserOAuthStorage } from "@inspector/core/auth/browser/storage.js";
|
||||
import type { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
|
||||
import { clearServerOAuthState } from "./clearServerOAuthState";
|
||||
|
||||
describe("clearServerOAuthState", () => {
|
||||
beforeEach(() => {
|
||||
getBrowserOAuthStorage().clear("https://mcp.example.com/mcp");
|
||||
let storage: BrowserOAuthStorage;
|
||||
|
||||
beforeEach(async () => {
|
||||
storage = new BrowserOAuthStorage();
|
||||
await storage.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",
|
||||
});
|
||||
|
||||
const cleared = clearServerOAuthState({
|
||||
const cleared = await clearServerOAuthState({
|
||||
config: { type: "streamable-http", url: "https://mcp.example.com/mcp" },
|
||||
isActiveConnection: false,
|
||||
oauthStorage: storage,
|
||||
});
|
||||
|
||||
expect(cleared).toBe(true);
|
||||
|
|
@ -25,27 +29,28 @@ describe("clearServerOAuthState", () => {
|
|||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses the live client when clearing the active connection", () => {
|
||||
const inspectorClient = {
|
||||
clearOAuthTokens: vi.fn(),
|
||||
};
|
||||
it("uses the live client when clearing the active connection", async () => {
|
||||
const clearOAuthTokens = vi.fn<InspectorClient["clearOAuthTokens"]>();
|
||||
const inspectorClient = { clearOAuthTokens };
|
||||
|
||||
const cleared = clearServerOAuthState({
|
||||
const cleared = await clearServerOAuthState({
|
||||
config: { type: "streamable-http", url: "https://mcp.example.com/mcp" },
|
||||
inspectorClient: inspectorClient as never,
|
||||
inspectorClient,
|
||||
isActiveConnection: true,
|
||||
oauthStorage: storage,
|
||||
});
|
||||
|
||||
expect(cleared).toBe(true);
|
||||
expect(inspectorClient.clearOAuthTokens).toHaveBeenCalledTimes(1);
|
||||
expect(clearOAuthTokens).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns false for stdio servers", () => {
|
||||
expect(
|
||||
it("returns false for stdio servers", async () => {
|
||||
await expect(
|
||||
clearServerOAuthState({
|
||||
config: { type: "stdio", command: "node", args: [] },
|
||||
isActiveConnection: false,
|
||||
oauthStorage: storage,
|
||||
}),
|
||||
).toBe(false);
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue