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 () => {
|
const handleClearOAuth = useCallback(async () => {
|
||||||
if (!selectedInspectorClient) return;
|
if (!selectedInspectorClient) return;
|
||||||
selectedInspectorClient.clearOAuthTokens();
|
await selectedInspectorClient.clearOAuthTokens();
|
||||||
setOauthStatus("idle");
|
setOauthStatus("idle");
|
||||||
setOauthMessage(null);
|
setOauthMessage(null);
|
||||||
setConnectError(null);
|
setConnectError(null);
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,7 @@ import { useServers } from "@inspector/core/react/useServers.js";
|
||||||
import { useSettingsDraft } from "@inspector/core/react/useSettingsDraft.js";
|
import { useSettingsDraft } from "@inspector/core/react/useSettingsDraft.js";
|
||||||
import { useClientSettingsDraft } from "@inspector/core/react/useClientSettingsDraft.js";
|
import { useClientSettingsDraft } from "@inspector/core/react/useClientSettingsDraft.js";
|
||||||
import { useEmaIdpLoginState } from "@inspector/core/react/useEmaIdpLoginState.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 { useManagedTools } from "@inspector/core/react/useManagedTools.js";
|
||||||
import { useManagedPrompts } from "@inspector/core/react/useManagedPrompts.js";
|
import { useManagedPrompts } from "@inspector/core/react/useManagedPrompts.js";
|
||||||
import { useManagedResources } from "@inspector/core/react/useManagedResources.js";
|
import { useManagedResources } from "@inspector/core/react/useManagedResources.js";
|
||||||
|
|
@ -1428,6 +1428,13 @@ function App() {
|
||||||
[messages],
|
[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
|
// Backend-backed session storage used to carry the fetch (Network) log
|
||||||
// across the OAuth full-page redirect. The auth handshake's first half —
|
// across the OAuth full-page redirect. The auth handshake's first half —
|
||||||
// protected-resource + auth-server discovery and Dynamic Client
|
// protected-resource + auth-server discovery and Dynamic Client
|
||||||
|
|
@ -2098,9 +2105,10 @@ function App() {
|
||||||
// `onToggleConnection` unloaded the previous one), so all React state is
|
// `onToggleConnection` unloaded the previous one), so all React state is
|
||||||
// reset and we recover the initiating server from sessionStorage. We wait for
|
// 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
|
// `servers` to hydrate before acting; the ref guard keeps the exchange to a
|
||||||
// single run. The persisted PKCE verifier + DCR client info live in
|
// single run. The persisted PKCE verifier + DCR client info live in shared
|
||||||
// `BrowserOAuthStorage` and survive the redirect, so `completeOAuthFlow`
|
// `RemoteOAuthStorage` (`oauth.json`) and survive the redirect, so
|
||||||
// exchanges the code without needing the original in-memory state machine.
|
// `completeOAuthFlow` exchanges the code without needing the original
|
||||||
|
// in-memory state machine.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
if (window.location.pathname !== OAUTH_CALLBACK_PATH) return;
|
if (window.location.pathname !== OAUTH_CALLBACK_PATH) return;
|
||||||
|
|
@ -2183,6 +2191,15 @@ function App() {
|
||||||
}
|
}
|
||||||
|
|
||||||
void (async () => {
|
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);
|
const client = setupClientForServer(server, sessionId);
|
||||||
setActiveServerId(server.id);
|
setActiveServerId(server.id);
|
||||||
try {
|
try {
|
||||||
|
|
@ -2238,7 +2255,7 @@ function App() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [servers, setupClientForServer, showReAuthBanner]);
|
}, [servers, setupClientForServer, showReAuthBanner, webOAuthStorage]);
|
||||||
|
|
||||||
const onToggleConnection = useCallback(
|
const onToggleConnection = useCallback(
|
||||||
async (id: string) => {
|
async (id: string) => {
|
||||||
|
|
@ -3239,10 +3256,9 @@ function App() {
|
||||||
|
|
||||||
const clientSettingsModalValue = clientSettingsDraft ?? EMPTY_CLIENT_SETTINGS;
|
const clientSettingsModalValue = clientSettingsDraft ?? EMPTY_CLIENT_SETTINGS;
|
||||||
|
|
||||||
const emaOAuthStorage = useMemo(() => getBrowserOAuthStorage(), []);
|
|
||||||
const { loginState: emaIdpLoginState, logout: logoutEmaIdp } =
|
const { loginState: emaIdpLoginState, logout: logoutEmaIdp } =
|
||||||
useEmaIdpLoginState(
|
useEmaIdpLoginState(
|
||||||
emaOAuthStorage,
|
webOAuthStorage,
|
||||||
clientSettingsModalValue.emaEnabled
|
clientSettingsModalValue.emaEnabled
|
||||||
? clientSettingsModalValue.issuer
|
? clientSettingsModalValue.issuer
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|
@ -3266,10 +3282,11 @@ function App() {
|
||||||
const clearServerOAuthAndDisconnect = useCallback(
|
const clearServerOAuthAndDisconnect = useCallback(
|
||||||
async (server: { id: string; name: string; config: MCPServerConfig }) => {
|
async (server: { id: string; name: string; config: MCPServerConfig }) => {
|
||||||
const isActive = server.id === activeServerId;
|
const isActive = server.id === activeServerId;
|
||||||
const cleared = clearServerOAuthState({
|
const cleared = await clearServerOAuthState({
|
||||||
config: server.config,
|
config: server.config,
|
||||||
inspectorClient: isActive ? inspectorClient : null,
|
inspectorClient: isActive ? inspectorClient : null,
|
||||||
isActiveConnection: isActive,
|
isActiveConnection: isActive,
|
||||||
|
oauthStorage: webOAuthStorage,
|
||||||
});
|
});
|
||||||
if (!cleared) return;
|
if (!cleared) return;
|
||||||
|
|
||||||
|
|
@ -3295,6 +3312,7 @@ function App() {
|
||||||
[
|
[
|
||||||
activeServerId,
|
activeServerId,
|
||||||
inspectorClient,
|
inspectorClient,
|
||||||
|
webOAuthStorage,
|
||||||
finalizeExplicitDisconnect,
|
finalizeExplicitDisconnect,
|
||||||
clearOAuthResumeOnExplicitDisconnect,
|
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,
|
createRemoteFetch,
|
||||||
createRemoteLogger,
|
createRemoteLogger,
|
||||||
} from "@inspector/core/mcp/remote/index.js";
|
} from "@inspector/core/mcp/remote/index.js";
|
||||||
import {
|
import { BrowserNavigation } from "@inspector/core/auth/browser/index.js";
|
||||||
getBrowserOAuthStorage,
|
|
||||||
BrowserNavigation,
|
|
||||||
} from "@inspector/core/auth/browser/index.js";
|
|
||||||
import type { RedirectUrlProvider } from "@inspector/core/auth/index.js";
|
import type { RedirectUrlProvider } from "@inspector/core/auth/index.js";
|
||||||
|
import { getWebRemoteOAuthStorage } from "./remoteOAuthStorage.js";
|
||||||
|
|
||||||
export interface WebEnvironmentResult {
|
export interface WebEnvironmentResult {
|
||||||
environment: InspectorClientEnvironment;
|
environment: InspectorClientEnvironment;
|
||||||
|
|
@ -20,16 +18,17 @@ export interface WebEnvironmentResult {
|
||||||
* - transport / fetch / logger all routed through the in-process Hono
|
* - transport / fetch / logger all routed through the in-process Hono
|
||||||
* backend at `window.location.origin` (the `clients/web/server`
|
* backend at `window.location.origin` (the `clients/web/server`
|
||||||
* dev-backend wires this in `/api/*`).
|
* dev-backend wires this in `/api/*`).
|
||||||
* - OAuth storage + navigation use the `BrowserOAuthStorage` (sessionStorage)
|
* - OAuth storage uses `RemoteOAuthStorage` (shared `oauth.json` via
|
||||||
* and `BrowserNavigation` (full-page redirect) adapters.
|
* `/api/storage/oauth`); navigation uses `BrowserNavigation`.
|
||||||
*
|
*
|
||||||
* Returns both the assembled environment and the logger so callers can share
|
* 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
|
* the same pino instance for any direct logging they need to do, instead of
|
||||||
* reaching back through the client.
|
* reaching back through the client.
|
||||||
*
|
*
|
||||||
* `authToken` is read from a higher level (currently unused in this app since
|
* `authToken` is supplied by `App.tsx` (`getAuthToken()`) and forwarded to
|
||||||
* v2 has no auth-token UI yet, but kept in the signature so the wiring is
|
* remote transport/fetch/logger and `getWebRemoteOAuthStorage` so every
|
||||||
* ready when token plumbing lands).
|
* `/api/*` call (including `/api/storage/oauth`) carries `x-mcp-remote-auth`
|
||||||
|
* when the backend requires it.
|
||||||
*
|
*
|
||||||
* `onBeforeOAuthRedirect` runs synchronously immediately before the OAuth
|
* `onBeforeOAuthRedirect` runs synchronously immediately before the OAuth
|
||||||
* full-page redirect (see `BrowserNavigation`). The app uses it to flush the
|
* full-page redirect (see `BrowserNavigation`). The app uses it to flush the
|
||||||
|
|
@ -67,7 +66,7 @@ export function createWebEnvironment(
|
||||||
}),
|
}),
|
||||||
logger,
|
logger,
|
||||||
oauth: {
|
oauth: {
|
||||||
storage: getBrowserOAuthStorage(),
|
storage: getWebRemoteOAuthStorage(authToken),
|
||||||
navigation: new BrowserNavigation(undefined, onBeforeOAuthRedirect),
|
navigation: new BrowserNavigation(undefined, onBeforeOAuthRedirect),
|
||||||
redirectUrlProvider,
|
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 {
|
): OAuthStorage {
|
||||||
return {
|
return {
|
||||||
|
load: vi.fn().mockResolvedValue(undefined),
|
||||||
getTokens: vi.fn().mockResolvedValue(overrides.tokens),
|
getTokens: vi.fn().mockResolvedValue(overrides.tokens),
|
||||||
getClientInformation: vi.fn(async (_url, isPreregistered) =>
|
getClientInformation: vi.fn(async (_url, isPreregistered) =>
|
||||||
isPreregistered ? overrides.preregistered : overrides.dynamic,
|
isPreregistered ? overrides.preregistered : overrides.dynamic,
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ function createMemoryStorage(
|
||||||
const clientInfoByKey: Record<string, unknown> = {};
|
const clientInfoByKey: Record<string, unknown> = {};
|
||||||
const codeVerifierByKey: Record<string, string> = {};
|
const codeVerifierByKey: Record<string, string> = {};
|
||||||
return {
|
return {
|
||||||
|
load: vi.fn().mockResolvedValue(undefined),
|
||||||
getIdpSession: vi.fn(async (issuer: string) => idpSessions[issuer]),
|
getIdpSession: vi.fn(async (issuer: string) => idpSessions[issuer]),
|
||||||
saveIdpSession: vi.fn(async (issuer: string, updates) => {
|
saveIdpSession: vi.fn(async (issuer: string, updates) => {
|
||||||
idpSessions[issuer] = { ...idpSessions[issuer], ...updates };
|
idpSessions[issuer] = { ...idpSessions[issuer], ...updates };
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ describe("idpOidc refresh", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
Object.keys(idpSessions).forEach((k) => delete idpSessions[k]);
|
Object.keys(idpSessions).forEach((k) => delete idpSessions[k]);
|
||||||
storage = {
|
storage = {
|
||||||
|
load: vi.fn().mockResolvedValue(undefined),
|
||||||
getIdpSession: vi.fn(async (issuer: string) => idpSessions[issuer]),
|
getIdpSession: vi.fn(async (issuer: string) => idpSessions[issuer]),
|
||||||
saveIdpSession: vi.fn(async (issuer: string, updates) => {
|
saveIdpSession: vi.fn(async (issuer: string, updates) => {
|
||||||
idpSessions[issuer] = { ...idpSessions[issuer], ...updates };
|
idpSessions[issuer] = { ...idpSessions[issuer], ...updates };
|
||||||
|
|
@ -264,6 +265,8 @@ describe("startIdpOidcAuthorization", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
Object.keys(saved).forEach((k) => delete saved[k]);
|
Object.keys(saved).forEach((k) => delete saved[k]);
|
||||||
storage = {
|
storage = {
|
||||||
|
load: vi.fn().mockResolvedValue(undefined),
|
||||||
|
getServerMetadata: vi.fn(() => null),
|
||||||
saveCodeVerifier: vi.fn(async (key: string, value: string) => {
|
saveCodeVerifier: vi.fn(async (key: string, value: string) => {
|
||||||
saved[`cv:${key}`] = value;
|
saved[`cv:${key}`] = value;
|
||||||
}),
|
}),
|
||||||
|
|
@ -331,6 +334,7 @@ describe("completeIdpOidcAuthorization", () => {
|
||||||
|
|
||||||
function buildStorage(overrides: Partial<OAuthStorage> = {}): OAuthStorage {
|
function buildStorage(overrides: Partial<OAuthStorage> = {}): OAuthStorage {
|
||||||
return {
|
return {
|
||||||
|
load: vi.fn().mockResolvedValue(undefined),
|
||||||
getServerMetadata: vi.fn(() => minimalOAuthAsMetadata(IDP_ISSUER)),
|
getServerMetadata: vi.fn(() => minimalOAuthAsMetadata(IDP_ISSUER)),
|
||||||
getClientInformation: vi.fn(async () => ({
|
getClientInformation: vi.fn(async () => ({
|
||||||
client_id: "idp-client",
|
client_id: "idp-client",
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ describe("idpSession", () => {
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
storage = {
|
storage = {
|
||||||
|
load: vi.fn().mockResolvedValue(undefined),
|
||||||
getIdpSession: vi.fn(),
|
getIdpSession: vi.fn(),
|
||||||
saveIdpSession: vi.fn(),
|
saveIdpSession: vi.fn(),
|
||||||
clearIdpSession: vi.fn(),
|
clearIdpSession: vi.fn(),
|
||||||
|
|
@ -73,15 +74,15 @@ describe("idpSession", () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clearEmaIdpSession clears idp session, leg-1 key, and tagged resource servers", () => {
|
it("clearEmaIdpSession clears idp session, leg-1 key, and tagged resource servers", async () => {
|
||||||
clearEmaIdpSession(storage, "https://idp.test/");
|
await clearEmaIdpSession(storage, "https://idp.test/");
|
||||||
expect(storage.clearIdpSession).toHaveBeenCalledWith("https://idp.test");
|
expect(storage.clearIdpSession).toHaveBeenCalledWith("https://idp.test");
|
||||||
expect(storage.clear).toHaveBeenCalledWith("ema-idp:https://idp.test");
|
expect(storage.clear).toHaveBeenCalledWith("ema-idp:https://idp.test");
|
||||||
expect(storage.clearEnterpriseManagedResourceServers).toHaveBeenCalled();
|
expect(storage.clearEnterpriseManagedResourceServers).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clearEmaIdpSession no-ops when issuer normalizes to empty", () => {
|
it("clearEmaIdpSession no-ops when issuer normalizes to empty", async () => {
|
||||||
clearEmaIdpSession(storage, "");
|
await clearEmaIdpSession(storage, "");
|
||||||
expect(storage.clearIdpSession).not.toHaveBeenCalled();
|
expect(storage.clearIdpSession).not.toHaveBeenCalled();
|
||||||
expect(storage.clear).not.toHaveBeenCalled();
|
expect(storage.clear).not.toHaveBeenCalled();
|
||||||
expect(
|
expect(
|
||||||
|
|
|
||||||
|
|
@ -213,6 +213,7 @@ describe("OAuthNavigation", () => {
|
||||||
|
|
||||||
function makeStorage(): OAuthStorage {
|
function makeStorage(): OAuthStorage {
|
||||||
return {
|
return {
|
||||||
|
load: vi.fn().mockResolvedValue(undefined),
|
||||||
getScope: vi.fn(() => undefined),
|
getScope: vi.fn(() => undefined),
|
||||||
getClientInformation: vi.fn(async () => undefined),
|
getClientInformation: vi.fn(async () => undefined),
|
||||||
saveClientInformation: vi.fn(async () => undefined),
|
saveClientInformation: vi.fn(async () => undefined),
|
||||||
|
|
@ -240,11 +241,11 @@ describe("OAuthNavigation", () => {
|
||||||
return new BaseOAuthClientProvider(SERVER, config);
|
return new BaseOAuthClientProvider(SERVER, config);
|
||||||
}
|
}
|
||||||
|
|
||||||
it("clear() delegates to storage.clear with the server url", () => {
|
it("clear() delegates to storage.clear with the server url", async () => {
|
||||||
const storage = makeStorage();
|
const storage = makeStorage();
|
||||||
const provider = makeProvider(storage);
|
const provider = makeProvider(storage);
|
||||||
|
|
||||||
provider.clear();
|
await provider.clear();
|
||||||
|
|
||||||
expect(storage.clear).toHaveBeenCalledWith(SERVER);
|
expect(storage.clear).toHaveBeenCalledWith(SERVER);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
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 {
|
import type {
|
||||||
OAuthClientInformation,
|
OAuthClientInformation,
|
||||||
OAuthTokens,
|
OAuthTokens,
|
||||||
|
|
@ -41,6 +44,14 @@ const mockSessionStorage = new MockSessionStorage();
|
||||||
(global as typeof globalThis & { sessionStorage?: Storage }).sessionStorage =
|
(global as typeof globalThis & { sessionStorage?: Storage }).sessionStorage =
|
||||||
mockSessionStorage;
|
mockSessionStorage;
|
||||||
|
|
||||||
|
describe("getBrowserOAuthStorage", () => {
|
||||||
|
it("returns the same singleton on repeated calls", () => {
|
||||||
|
const first = getBrowserOAuthStorage();
|
||||||
|
const second = getBrowserOAuthStorage();
|
||||||
|
expect(second).toBe(first);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("BrowserOAuthStorage", () => {
|
describe("BrowserOAuthStorage", () => {
|
||||||
let storage: BrowserOAuthStorage;
|
let storage: BrowserOAuthStorage;
|
||||||
const testServerUrl = "http://localhost:3000";
|
const testServerUrl = "http://localhost:3000";
|
||||||
|
|
@ -66,7 +77,7 @@ describe("BrowserOAuthStorage", () => {
|
||||||
client_secret: "test-secret",
|
client_secret: "test-secret",
|
||||||
};
|
};
|
||||||
|
|
||||||
storage.saveClientInformation(testServerUrl, clientInfo, {
|
await storage.saveClientInformation(testServerUrl, clientInfo, {
|
||||||
registrationKind: "dcr",
|
registrationKind: "dcr",
|
||||||
});
|
});
|
||||||
const result = await storage.getClientInformation(testServerUrl);
|
const result = await storage.getClientInformation(testServerUrl);
|
||||||
|
|
@ -82,7 +93,7 @@ describe("BrowserOAuthStorage", () => {
|
||||||
|
|
||||||
// Use the storage API instead of manually setting sessionStorage
|
// Use the storage API instead of manually setting sessionStorage
|
||||||
// since BrowserOAuthStorage now uses Zustand with a different storage format
|
// since BrowserOAuthStorage now uses Zustand with a different storage format
|
||||||
storage.savePreregisteredClientInformation(
|
await storage.savePreregisteredClientInformation(
|
||||||
testServerUrl,
|
testServerUrl,
|
||||||
preregisteredInfo,
|
preregisteredInfo,
|
||||||
);
|
);
|
||||||
|
|
@ -102,7 +113,7 @@ describe("BrowserOAuthStorage", () => {
|
||||||
client_id: "test-client-id",
|
client_id: "test-client-id",
|
||||||
};
|
};
|
||||||
|
|
||||||
storage.saveClientInformation(testServerUrl, clientInfo, {
|
await storage.saveClientInformation(testServerUrl, clientInfo, {
|
||||||
registrationKind: "dcr",
|
registrationKind: "dcr",
|
||||||
});
|
});
|
||||||
const result = await storage.getClientInformation(testServerUrl);
|
const result = await storage.getClientInformation(testServerUrl);
|
||||||
|
|
@ -119,10 +130,10 @@ describe("BrowserOAuthStorage", () => {
|
||||||
client_id: "second-id",
|
client_id: "second-id",
|
||||||
};
|
};
|
||||||
|
|
||||||
storage.saveClientInformation(testServerUrl, firstInfo, {
|
await storage.saveClientInformation(testServerUrl, firstInfo, {
|
||||||
registrationKind: "dcr",
|
registrationKind: "dcr",
|
||||||
});
|
});
|
||||||
storage.saveClientInformation(testServerUrl, secondInfo, {
|
await storage.saveClientInformation(testServerUrl, secondInfo, {
|
||||||
registrationKind: "cimd",
|
registrationKind: "cimd",
|
||||||
});
|
});
|
||||||
const result = await storage.getClientInformation(testServerUrl);
|
const result = await storage.getClientInformation(testServerUrl);
|
||||||
|
|
@ -152,7 +163,7 @@ describe("BrowserOAuthStorage", () => {
|
||||||
expires_in: 3600,
|
expires_in: 3600,
|
||||||
};
|
};
|
||||||
|
|
||||||
storage.saveTokens(testServerUrl, tokens);
|
await storage.saveTokens(testServerUrl, tokens);
|
||||||
const result = await storage.getTokens(testServerUrl);
|
const result = await storage.getTokens(testServerUrl);
|
||||||
|
|
||||||
expect(result).toEqual(tokens);
|
expect(result).toEqual(tokens);
|
||||||
|
|
@ -166,7 +177,7 @@ describe("BrowserOAuthStorage", () => {
|
||||||
token_type: "Bearer",
|
token_type: "Bearer",
|
||||||
};
|
};
|
||||||
|
|
||||||
storage.saveTokens(testServerUrl, tokens);
|
await storage.saveTokens(testServerUrl, tokens);
|
||||||
const result = await storage.getTokens(testServerUrl);
|
const result = await storage.getTokens(testServerUrl);
|
||||||
|
|
||||||
expect(result).toEqual(tokens);
|
expect(result).toEqual(tokens);
|
||||||
|
|
@ -183,7 +194,7 @@ describe("BrowserOAuthStorage", () => {
|
||||||
await storage.saveTokens(emaUrl, tokens, { enterpriseManaged: true });
|
await storage.saveTokens(emaUrl, tokens, { enterpriseManaged: true });
|
||||||
await storage.saveTokens(standardUrl, tokens);
|
await storage.saveTokens(standardUrl, tokens);
|
||||||
|
|
||||||
storage.clearEnterpriseManagedResourceServers();
|
await storage.clearEnterpriseManagedResourceServers();
|
||||||
|
|
||||||
expect(await storage.getTokens(emaUrl)).toBeUndefined();
|
expect(await storage.getTokens(emaUrl)).toBeUndefined();
|
||||||
expect(await storage.getTokens(standardUrl)).toEqual(tokens);
|
expect(await storage.getTokens(standardUrl)).toEqual(tokens);
|
||||||
|
|
@ -199,7 +210,7 @@ describe("BrowserOAuthStorage", () => {
|
||||||
it("should return stored code verifier", async () => {
|
it("should return stored code verifier", async () => {
|
||||||
const codeVerifier = "test-code-verifier";
|
const codeVerifier = "test-code-verifier";
|
||||||
|
|
||||||
storage.saveCodeVerifier(testServerUrl, codeVerifier);
|
await storage.saveCodeVerifier(testServerUrl, codeVerifier);
|
||||||
const result = await storage.getCodeVerifier(testServerUrl);
|
const result = await storage.getCodeVerifier(testServerUrl);
|
||||||
|
|
||||||
expect(result).toBe(codeVerifier);
|
expect(result).toBe(codeVerifier);
|
||||||
|
|
@ -210,7 +221,7 @@ describe("BrowserOAuthStorage", () => {
|
||||||
it("should save code verifier", async () => {
|
it("should save code verifier", async () => {
|
||||||
const codeVerifier = "test-code-verifier";
|
const codeVerifier = "test-code-verifier";
|
||||||
|
|
||||||
storage.saveCodeVerifier(testServerUrl, codeVerifier);
|
await storage.saveCodeVerifier(testServerUrl, codeVerifier);
|
||||||
const result = await storage.getCodeVerifier(testServerUrl);
|
const result = await storage.getCodeVerifier(testServerUrl);
|
||||||
|
|
||||||
expect(result).toBe(codeVerifier);
|
expect(result).toBe(codeVerifier);
|
||||||
|
|
@ -226,7 +237,7 @@ describe("BrowserOAuthStorage", () => {
|
||||||
it("should return stored scope", async () => {
|
it("should return stored scope", async () => {
|
||||||
const scope = "read write";
|
const scope = "read write";
|
||||||
|
|
||||||
storage.saveScope(testServerUrl, scope);
|
await storage.saveScope(testServerUrl, scope);
|
||||||
const result = await storage.getScope(testServerUrl);
|
const result = await storage.getScope(testServerUrl);
|
||||||
|
|
||||||
expect(result).toBe(scope);
|
expect(result).toBe(scope);
|
||||||
|
|
@ -237,7 +248,7 @@ describe("BrowserOAuthStorage", () => {
|
||||||
it("should save scope", async () => {
|
it("should save scope", async () => {
|
||||||
const scope = "read write";
|
const scope = "read write";
|
||||||
|
|
||||||
storage.saveScope(testServerUrl, scope);
|
await storage.saveScope(testServerUrl, scope);
|
||||||
const result = await storage.getScope(testServerUrl);
|
const result = await storage.getScope(testServerUrl);
|
||||||
|
|
||||||
expect(result).toBe(scope);
|
expect(result).toBe(scope);
|
||||||
|
|
@ -258,7 +269,7 @@ describe("BrowserOAuthStorage", () => {
|
||||||
response_types_supported: ["code"],
|
response_types_supported: ["code"],
|
||||||
};
|
};
|
||||||
|
|
||||||
storage.saveServerMetadata(testServerUrl, metadata);
|
await storage.saveServerMetadata(testServerUrl, metadata);
|
||||||
const result = await storage.getServerMetadata(testServerUrl);
|
const result = await storage.getServerMetadata(testServerUrl);
|
||||||
|
|
||||||
expect(result).toEqual(metadata);
|
expect(result).toEqual(metadata);
|
||||||
|
|
@ -274,7 +285,7 @@ describe("BrowserOAuthStorage", () => {
|
||||||
response_types_supported: ["code"],
|
response_types_supported: ["code"],
|
||||||
};
|
};
|
||||||
|
|
||||||
storage.saveServerMetadata(testServerUrl, metadata);
|
await storage.saveServerMetadata(testServerUrl, metadata);
|
||||||
const result = await storage.getServerMetadata(testServerUrl);
|
const result = await storage.getServerMetadata(testServerUrl);
|
||||||
|
|
||||||
expect(result).toEqual(metadata);
|
expect(result).toEqual(metadata);
|
||||||
|
|
@ -283,7 +294,7 @@ describe("BrowserOAuthStorage", () => {
|
||||||
|
|
||||||
describe("clearClientInformation", () => {
|
describe("clearClientInformation", () => {
|
||||||
it("removes the dynamically-registered client info by default", async () => {
|
it("removes the dynamically-registered client info by default", async () => {
|
||||||
storage.saveClientInformation(
|
await storage.saveClientInformation(
|
||||||
testServerUrl,
|
testServerUrl,
|
||||||
{ client_id: "dyn" },
|
{ client_id: "dyn" },
|
||||||
{ registrationKind: "dcr" },
|
{ registrationKind: "dcr" },
|
||||||
|
|
@ -291,18 +302,18 @@ describe("BrowserOAuthStorage", () => {
|
||||||
expect(await storage.getClientInformation(testServerUrl)).toEqual({
|
expect(await storage.getClientInformation(testServerUrl)).toEqual({
|
||||||
client_id: "dyn",
|
client_id: "dyn",
|
||||||
});
|
});
|
||||||
storage.clearClientInformation(testServerUrl);
|
await storage.clearClientInformation(testServerUrl);
|
||||||
expect(await storage.getClientInformation(testServerUrl)).toBeUndefined();
|
expect(await storage.getClientInformation(testServerUrl)).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("removes the preregistered client info when isPreregistered=true", async () => {
|
it("removes the preregistered client info when isPreregistered=true", async () => {
|
||||||
storage.savePreregisteredClientInformation(testServerUrl, {
|
await storage.savePreregisteredClientInformation(testServerUrl, {
|
||||||
client_id: "pre",
|
client_id: "pre",
|
||||||
});
|
});
|
||||||
expect(await storage.getClientInformation(testServerUrl, true)).toEqual({
|
expect(await storage.getClientInformation(testServerUrl, true)).toEqual({
|
||||||
client_id: "pre",
|
client_id: "pre",
|
||||||
});
|
});
|
||||||
storage.clearClientInformation(testServerUrl, true);
|
await storage.clearClientInformation(testServerUrl, true);
|
||||||
expect(
|
expect(
|
||||||
await storage.getClientInformation(testServerUrl, true),
|
await storage.getClientInformation(testServerUrl, true),
|
||||||
).toBeUndefined();
|
).toBeUndefined();
|
||||||
|
|
@ -311,26 +322,26 @@ describe("BrowserOAuthStorage", () => {
|
||||||
|
|
||||||
describe("individual clear methods", () => {
|
describe("individual clear methods", () => {
|
||||||
it("clearTokens removes only tokens", async () => {
|
it("clearTokens removes only tokens", async () => {
|
||||||
storage.saveTokens(testServerUrl, {
|
await storage.saveTokens(testServerUrl, {
|
||||||
access_token: "t",
|
access_token: "t",
|
||||||
token_type: "Bearer",
|
token_type: "Bearer",
|
||||||
});
|
});
|
||||||
expect(await storage.getTokens(testServerUrl)).toBeDefined();
|
expect(await storage.getTokens(testServerUrl)).toBeDefined();
|
||||||
storage.clearTokens(testServerUrl);
|
await storage.clearTokens(testServerUrl);
|
||||||
expect(await storage.getTokens(testServerUrl)).toBeUndefined();
|
expect(await storage.getTokens(testServerUrl)).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clearCodeVerifier removes only the PKCE verifier", async () => {
|
it("clearCodeVerifier removes only the PKCE verifier", async () => {
|
||||||
storage.saveCodeVerifier(testServerUrl, "verifier");
|
await storage.saveCodeVerifier(testServerUrl, "verifier");
|
||||||
expect(storage.getCodeVerifier(testServerUrl)).toBe("verifier");
|
expect(storage.getCodeVerifier(testServerUrl)).toBe("verifier");
|
||||||
storage.clearCodeVerifier(testServerUrl);
|
await storage.clearCodeVerifier(testServerUrl);
|
||||||
expect(storage.getCodeVerifier(testServerUrl)).toBeUndefined();
|
expect(storage.getCodeVerifier(testServerUrl)).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clearScope removes only the scope", async () => {
|
it("clearScope removes only the scope", async () => {
|
||||||
storage.saveScope(testServerUrl, "read");
|
await storage.saveScope(testServerUrl, "read");
|
||||||
expect(storage.getScope(testServerUrl)).toBe("read");
|
expect(storage.getScope(testServerUrl)).toBe("read");
|
||||||
storage.clearScope(testServerUrl);
|
await storage.clearScope(testServerUrl);
|
||||||
expect(storage.getScope(testServerUrl)).toBeUndefined();
|
expect(storage.getScope(testServerUrl)).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -341,9 +352,9 @@ describe("BrowserOAuthStorage", () => {
|
||||||
token_endpoint: "http://localhost:3000/token",
|
token_endpoint: "http://localhost:3000/token",
|
||||||
response_types_supported: ["code"],
|
response_types_supported: ["code"],
|
||||||
};
|
};
|
||||||
storage.saveServerMetadata(testServerUrl, metadata);
|
await storage.saveServerMetadata(testServerUrl, metadata);
|
||||||
expect(storage.getServerMetadata(testServerUrl)).toEqual(metadata);
|
expect(storage.getServerMetadata(testServerUrl)).toEqual(metadata);
|
||||||
storage.clearServerMetadata(testServerUrl);
|
await storage.clearServerMetadata(testServerUrl);
|
||||||
expect(storage.getServerMetadata(testServerUrl)).toBeNull();
|
expect(storage.getServerMetadata(testServerUrl)).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -358,12 +369,12 @@ describe("BrowserOAuthStorage", () => {
|
||||||
token_type: "Bearer",
|
token_type: "Bearer",
|
||||||
};
|
};
|
||||||
|
|
||||||
storage.saveClientInformation(testServerUrl, clientInfo, {
|
await storage.saveClientInformation(testServerUrl, clientInfo, {
|
||||||
registrationKind: "dcr",
|
registrationKind: "dcr",
|
||||||
});
|
});
|
||||||
storage.saveTokens(testServerUrl, tokens);
|
await storage.saveTokens(testServerUrl, tokens);
|
||||||
|
|
||||||
storage.clear(testServerUrl);
|
await storage.clear(testServerUrl);
|
||||||
|
|
||||||
expect(await storage.getClientInformation(testServerUrl)).toBeUndefined();
|
expect(await storage.getClientInformation(testServerUrl)).toBeUndefined();
|
||||||
expect(await storage.getTokens(testServerUrl)).toBeUndefined();
|
expect(await storage.getTokens(testServerUrl)).toBeUndefined();
|
||||||
|
|
@ -375,14 +386,14 @@ describe("BrowserOAuthStorage", () => {
|
||||||
client_id: "test-client-id",
|
client_id: "test-client-id",
|
||||||
};
|
};
|
||||||
|
|
||||||
storage.saveClientInformation(testServerUrl, clientInfo, {
|
await storage.saveClientInformation(testServerUrl, clientInfo, {
|
||||||
registrationKind: "dcr",
|
registrationKind: "dcr",
|
||||||
});
|
});
|
||||||
storage.saveClientInformation(otherServerUrl, clientInfo, {
|
await storage.saveClientInformation(otherServerUrl, clientInfo, {
|
||||||
registrationKind: "dcr",
|
registrationKind: "dcr",
|
||||||
});
|
});
|
||||||
|
|
||||||
storage.clear(testServerUrl);
|
await storage.clear(testServerUrl);
|
||||||
|
|
||||||
expect(await storage.getClientInformation(testServerUrl)).toBeUndefined();
|
expect(await storage.getClientInformation(testServerUrl)).toBeUndefined();
|
||||||
expect(await storage.getClientInformation(otherServerUrl)).toEqual(
|
expect(await storage.getClientInformation(otherServerUrl)).toEqual(
|
||||||
|
|
@ -404,10 +415,10 @@ describe("BrowserOAuthStorage", () => {
|
||||||
client_id: "client-2",
|
client_id: "client-2",
|
||||||
};
|
};
|
||||||
|
|
||||||
storage.saveClientInformation(server1Url, clientInfo1, {
|
await storage.saveClientInformation(server1Url, clientInfo1, {
|
||||||
registrationKind: "dcr",
|
registrationKind: "dcr",
|
||||||
});
|
});
|
||||||
storage.saveClientInformation(server2Url, clientInfo2, {
|
await storage.saveClientInformation(server2Url, clientInfo2, {
|
||||||
registrationKind: "dcr",
|
registrationKind: "dcr",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
import { RemoteOAuthStorage } from "@inspector/core/auth/remote/storage-remote.js";
|
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(
|
const NOOP_FETCH = vi.fn(
|
||||||
async () =>
|
async () =>
|
||||||
|
|
@ -50,7 +54,7 @@ describe("RemoteOAuthStorage (unit, mocked fetch)", () => {
|
||||||
{ client_id: "dyn" },
|
{ client_id: "dyn" },
|
||||||
{ registrationKind: "dcr" },
|
{ registrationKind: "dcr" },
|
||||||
);
|
);
|
||||||
storage.clearClientInformation(serverUrl);
|
await storage.clearClientInformation(serverUrl);
|
||||||
expect(await storage.getClientInformation(serverUrl)).toBeUndefined();
|
expect(await storage.getClientInformation(serverUrl)).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -58,7 +62,7 @@ describe("RemoteOAuthStorage (unit, mocked fetch)", () => {
|
||||||
await storage.savePreregisteredClientInformation(serverUrl, {
|
await storage.savePreregisteredClientInformation(serverUrl, {
|
||||||
client_id: "pre",
|
client_id: "pre",
|
||||||
});
|
});
|
||||||
storage.clearClientInformation(serverUrl, true);
|
await storage.clearClientInformation(serverUrl, true);
|
||||||
expect(await storage.getClientInformation(serverUrl, true)).toBeUndefined();
|
expect(await storage.getClientInformation(serverUrl, true)).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -66,21 +70,21 @@ describe("RemoteOAuthStorage (unit, mocked fetch)", () => {
|
||||||
const tokens = { access_token: "t", token_type: "Bearer" };
|
const tokens = { access_token: "t", token_type: "Bearer" };
|
||||||
await storage.saveTokens(serverUrl, tokens);
|
await storage.saveTokens(serverUrl, tokens);
|
||||||
expect(await storage.getTokens(serverUrl)).toEqual(tokens);
|
expect(await storage.getTokens(serverUrl)).toEqual(tokens);
|
||||||
storage.clearTokens(serverUrl);
|
await storage.clearTokens(serverUrl);
|
||||||
expect(await storage.getTokens(serverUrl)).toBeUndefined();
|
expect(await storage.getTokens(serverUrl)).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("codeVerifier round-trip and clearCodeVerifier", async () => {
|
it("codeVerifier round-trip and clearCodeVerifier", async () => {
|
||||||
await storage.saveCodeVerifier(serverUrl, "verifier");
|
await storage.saveCodeVerifier(serverUrl, "verifier");
|
||||||
expect(storage.getCodeVerifier(serverUrl)).toBe("verifier");
|
expect(storage.getCodeVerifier(serverUrl)).toBe("verifier");
|
||||||
storage.clearCodeVerifier(serverUrl);
|
await storage.clearCodeVerifier(serverUrl);
|
||||||
expect(storage.getCodeVerifier(serverUrl)).toBeUndefined();
|
expect(storage.getCodeVerifier(serverUrl)).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("scope round-trip and clearScope", async () => {
|
it("scope round-trip and clearScope", async () => {
|
||||||
await storage.saveScope(serverUrl, "read write");
|
await storage.saveScope(serverUrl, "read write");
|
||||||
expect(storage.getScope(serverUrl)).toBe("read write");
|
expect(storage.getScope(serverUrl)).toBe("read write");
|
||||||
storage.clearScope(serverUrl);
|
await storage.clearScope(serverUrl);
|
||||||
expect(storage.getScope(serverUrl)).toBeUndefined();
|
expect(storage.getScope(serverUrl)).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -93,7 +97,7 @@ describe("RemoteOAuthStorage (unit, mocked fetch)", () => {
|
||||||
};
|
};
|
||||||
await storage.saveServerMetadata(serverUrl, md);
|
await storage.saveServerMetadata(serverUrl, md);
|
||||||
expect(storage.getServerMetadata(serverUrl)).toEqual(md);
|
expect(storage.getServerMetadata(serverUrl)).toEqual(md);
|
||||||
storage.clearServerMetadata(serverUrl);
|
await storage.clearServerMetadata(serverUrl);
|
||||||
expect(storage.getServerMetadata(serverUrl)).toBeNull();
|
expect(storage.getServerMetadata(serverUrl)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -107,7 +111,7 @@ describe("RemoteOAuthStorage (unit, mocked fetch)", () => {
|
||||||
access_token: "t",
|
access_token: "t",
|
||||||
token_type: "Bearer",
|
token_type: "Bearer",
|
||||||
});
|
});
|
||||||
storage.clear(serverUrl);
|
await storage.clear(serverUrl);
|
||||||
expect(await storage.getClientInformation(serverUrl)).toBeUndefined();
|
expect(await storage.getClientInformation(serverUrl)).toBeUndefined();
|
||||||
expect(await storage.getTokens(serverUrl)).toBeUndefined();
|
expect(await storage.getTokens(serverUrl)).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
@ -120,4 +124,61 @@ describe("RemoteOAuthStorage (unit, mocked fetch)", () => {
|
||||||
// No public accessor; constructing without throwing covers the default-branch.
|
// No public accessor; constructing without throwing covers the default-branch.
|
||||||
expect(s).toBeInstanceOf(RemoteOAuthStorage);
|
expect(s).toBeInstanceOf(RemoteOAuthStorage);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("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 dispatchOAuthError = vi.fn();
|
||||||
|
|
||||||
const storage = {
|
const storage = {
|
||||||
|
load: vi.fn().mockResolvedValue(undefined),
|
||||||
getScope: vi.fn().mockReturnValue(undefined),
|
getScope: vi.fn().mockReturnValue(undefined),
|
||||||
getClientInformation: vi.fn().mockResolvedValue(undefined),
|
getClientInformation: vi.fn().mockResolvedValue(undefined),
|
||||||
getClientRegistrationKind: vi.fn().mockReturnValue(undefined),
|
getClientRegistrationKind: vi.fn().mockReturnValue(undefined),
|
||||||
|
|
@ -45,7 +46,7 @@ function createMockParams(
|
||||||
saveTokens: vi.fn().mockResolvedValue(undefined),
|
saveTokens: vi.fn().mockResolvedValue(undefined),
|
||||||
getCodeVerifier: vi.fn().mockReturnValue("verifier"),
|
getCodeVerifier: vi.fn().mockReturnValue("verifier"),
|
||||||
saveCodeVerifier: vi.fn().mockResolvedValue(undefined),
|
saveCodeVerifier: vi.fn().mockResolvedValue(undefined),
|
||||||
clear: vi.fn(),
|
clear: vi.fn().mockResolvedValue(undefined),
|
||||||
clearClientInformation: vi.fn(),
|
clearClientInformation: vi.fn(),
|
||||||
clearTokens: vi.fn(),
|
clearTokens: vi.fn(),
|
||||||
clearCodeVerifier: vi.fn(),
|
clearCodeVerifier: vi.fn(),
|
||||||
|
|
@ -126,10 +127,10 @@ describe("OAuthManager", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("clearOAuthTokens", () => {
|
describe("clearOAuthTokens", () => {
|
||||||
it("calls storage.clear(serverUrl) when storage is configured", () => {
|
it("calls storage.clear(serverUrl) when storage is configured", async () => {
|
||||||
const params = createMockParams();
|
const params = createMockParams();
|
||||||
const manager = new OAuthManager(params);
|
const manager = new OAuthManager(params);
|
||||||
manager.clearOAuthTokens();
|
await manager.clearOAuthTokens();
|
||||||
expect(params.initialConfig.storage!.clear).toHaveBeenCalledWith(
|
expect(params.initialConfig.storage!.clear).toHaveBeenCalledWith(
|
||||||
SERVER_URL,
|
SERVER_URL,
|
||||||
);
|
);
|
||||||
|
|
@ -137,7 +138,7 @@ describe("OAuthManager", () => {
|
||||||
expect(manager.getOAuthFlowStep()).toBeUndefined();
|
expect(manager.getOAuthFlowStep()).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("no-ops when storage is not configured", () => {
|
it("no-ops when storage is not configured", async () => {
|
||||||
const params = createMockParams({
|
const params = createMockParams({
|
||||||
initialConfig: {
|
initialConfig: {
|
||||||
redirectUrlProvider: {
|
redirectUrlProvider: {
|
||||||
|
|
@ -147,7 +148,7 @@ describe("OAuthManager", () => {
|
||||||
} as OAuthManagerConfig,
|
} as OAuthManagerConfig,
|
||||||
});
|
});
|
||||||
const manager = new OAuthManager(params);
|
const manager = new OAuthManager(params);
|
||||||
manager.clearOAuthTokens();
|
await manager.clearOAuthTokens();
|
||||||
expect(params.getServerUrl).not.toHaveBeenCalled();
|
expect(params.getServerUrl).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,13 @@ describe("useEmaIdpLoginState", () => {
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
storage = {
|
storage = {
|
||||||
|
load: vi.fn().mockResolvedValue(undefined),
|
||||||
getIdpSession: vi.fn().mockResolvedValue(undefined),
|
getIdpSession: vi.fn().mockResolvedValue(undefined),
|
||||||
clearIdpSession: vi.fn(),
|
clearIdpSession: vi.fn().mockResolvedValue(undefined),
|
||||||
clear: vi.fn(),
|
clear: vi.fn().mockResolvedValue(undefined),
|
||||||
clearEnterpriseManagedResourceServers: vi.fn(),
|
clearEnterpriseManagedResourceServers: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(undefined),
|
||||||
} as unknown as OAuthStorage;
|
} as unknown as OAuthStorage;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -56,10 +59,64 @@ describe("useEmaIdpLoginState", () => {
|
||||||
result.current.logout();
|
result.current.logout();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(storage.clearIdpSession).toHaveBeenCalledWith("https://idp.test");
|
await waitFor(() => {
|
||||||
expect(storage.clear).toHaveBeenCalledWith("ema-idp:https://idp.test");
|
expect(storage.clearIdpSession).toHaveBeenCalledWith("https://idp.test");
|
||||||
expect(storage.clearEnterpriseManagedResourceServers).toHaveBeenCalled();
|
expect(storage.clear).toHaveBeenCalledWith("ema-idp:https://idp.test");
|
||||||
expect(result.current.loginState).toBe("none");
|
expect(storage.clearEnterpriseManagedResourceServers).toHaveBeenCalled();
|
||||||
|
expect(result.current.loginState).toBe("none");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("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 () => {
|
it("reports 'expired' for an expired token with no refresh token", async () => {
|
||||||
|
|
|
||||||
|
|
@ -126,10 +126,10 @@ describe("NodeOAuthStorage", () => {
|
||||||
client_id: "second-id",
|
client_id: "second-id",
|
||||||
};
|
};
|
||||||
|
|
||||||
storage.saveClientInformation(testServerUrl, firstInfo, {
|
await storage.saveClientInformation(testServerUrl, firstInfo, {
|
||||||
registrationKind: "dcr",
|
registrationKind: "dcr",
|
||||||
});
|
});
|
||||||
storage.saveClientInformation(testServerUrl, secondInfo, {
|
await storage.saveClientInformation(testServerUrl, secondInfo, {
|
||||||
registrationKind: "dcr",
|
registrationKind: "dcr",
|
||||||
});
|
});
|
||||||
const result = await storage.getClientInformation(testServerUrl);
|
const result = await storage.getClientInformation(testServerUrl);
|
||||||
|
|
@ -310,7 +310,7 @@ describe("NodeOAuthStorage", () => {
|
||||||
expect(await storage.getClientInformation(testServerUrl)).toEqual({
|
expect(await storage.getClientInformation(testServerUrl)).toEqual({
|
||||||
client_id: "dyn",
|
client_id: "dyn",
|
||||||
});
|
});
|
||||||
storage.clearClientInformation(testServerUrl);
|
await storage.clearClientInformation(testServerUrl);
|
||||||
expect(await storage.getClientInformation(testServerUrl)).toBeUndefined();
|
expect(await storage.getClientInformation(testServerUrl)).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -321,7 +321,7 @@ describe("NodeOAuthStorage", () => {
|
||||||
expect(await storage.getClientInformation(testServerUrl, true)).toEqual({
|
expect(await storage.getClientInformation(testServerUrl, true)).toEqual({
|
||||||
client_id: "pre",
|
client_id: "pre",
|
||||||
});
|
});
|
||||||
storage.clearClientInformation(testServerUrl, true);
|
await storage.clearClientInformation(testServerUrl, true);
|
||||||
expect(
|
expect(
|
||||||
await storage.getClientInformation(testServerUrl, true),
|
await storage.getClientInformation(testServerUrl, true),
|
||||||
).toBeUndefined();
|
).toBeUndefined();
|
||||||
|
|
@ -335,21 +335,21 @@ describe("NodeOAuthStorage", () => {
|
||||||
token_type: "Bearer",
|
token_type: "Bearer",
|
||||||
});
|
});
|
||||||
expect(await storage.getTokens(testServerUrl)).toBeDefined();
|
expect(await storage.getTokens(testServerUrl)).toBeDefined();
|
||||||
storage.clearTokens(testServerUrl);
|
await storage.clearTokens(testServerUrl);
|
||||||
expect(await storage.getTokens(testServerUrl)).toBeUndefined();
|
expect(await storage.getTokens(testServerUrl)).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clearCodeVerifier removes only the PKCE verifier", async () => {
|
it("clearCodeVerifier removes only the PKCE verifier", async () => {
|
||||||
await storage.saveCodeVerifier(testServerUrl, "verifier");
|
await storage.saveCodeVerifier(testServerUrl, "verifier");
|
||||||
expect(storage.getCodeVerifier(testServerUrl)).toBe("verifier");
|
expect(storage.getCodeVerifier(testServerUrl)).toBe("verifier");
|
||||||
storage.clearCodeVerifier(testServerUrl);
|
await storage.clearCodeVerifier(testServerUrl);
|
||||||
expect(storage.getCodeVerifier(testServerUrl)).toBeUndefined();
|
expect(storage.getCodeVerifier(testServerUrl)).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clearScope removes only the scope", async () => {
|
it("clearScope removes only the scope", async () => {
|
||||||
await storage.saveScope(testServerUrl, "read write");
|
await storage.saveScope(testServerUrl, "read write");
|
||||||
expect(storage.getScope(testServerUrl)).toBe("read write");
|
expect(storage.getScope(testServerUrl)).toBe("read write");
|
||||||
storage.clearScope(testServerUrl);
|
await storage.clearScope(testServerUrl);
|
||||||
expect(storage.getScope(testServerUrl)).toBeUndefined();
|
expect(storage.getScope(testServerUrl)).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -362,7 +362,7 @@ describe("NodeOAuthStorage", () => {
|
||||||
};
|
};
|
||||||
await storage.saveServerMetadata(testServerUrl, metadata);
|
await storage.saveServerMetadata(testServerUrl, metadata);
|
||||||
expect(storage.getServerMetadata(testServerUrl)).toEqual(metadata);
|
expect(storage.getServerMetadata(testServerUrl)).toEqual(metadata);
|
||||||
storage.clearServerMetadata(testServerUrl);
|
await storage.clearServerMetadata(testServerUrl);
|
||||||
expect(storage.getServerMetadata(testServerUrl)).toBeNull();
|
expect(storage.getServerMetadata(testServerUrl)).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -382,7 +382,7 @@ describe("NodeOAuthStorage", () => {
|
||||||
});
|
});
|
||||||
await storage.saveTokens(testServerUrl, tokens);
|
await storage.saveTokens(testServerUrl, tokens);
|
||||||
|
|
||||||
storage.clear(testServerUrl);
|
await storage.clear(testServerUrl);
|
||||||
|
|
||||||
expect(await storage.getClientInformation(testServerUrl)).toBeUndefined();
|
expect(await storage.getClientInformation(testServerUrl)).toBeUndefined();
|
||||||
expect(await storage.getTokens(testServerUrl)).toBeUndefined();
|
expect(await storage.getTokens(testServerUrl)).toBeUndefined();
|
||||||
|
|
@ -401,7 +401,7 @@ describe("NodeOAuthStorage", () => {
|
||||||
registrationKind: "dcr",
|
registrationKind: "dcr",
|
||||||
});
|
});
|
||||||
|
|
||||||
storage.clear(testServerUrl);
|
await storage.clear(testServerUrl);
|
||||||
|
|
||||||
expect(await storage.getClientInformation(testServerUrl)).toBeUndefined();
|
expect(await storage.getClientInformation(testServerUrl)).toBeUndefined();
|
||||||
const otherResult = await storage.getClientInformation(otherServerUrl);
|
const otherResult = await storage.getClientInformation(otherServerUrl);
|
||||||
|
|
@ -424,10 +424,10 @@ describe("NodeOAuthStorage", () => {
|
||||||
client_id: "client-2",
|
client_id: "client-2",
|
||||||
};
|
};
|
||||||
|
|
||||||
storage.saveClientInformation(server1Url, clientInfo1, {
|
await storage.saveClientInformation(server1Url, clientInfo1, {
|
||||||
registrationKind: "dcr",
|
registrationKind: "dcr",
|
||||||
});
|
});
|
||||||
storage.saveClientInformation(server2Url, clientInfo2, {
|
await storage.saveClientInformation(server2Url, clientInfo2, {
|
||||||
registrationKind: "dcr",
|
registrationKind: "dcr",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -547,7 +547,7 @@ describe("NodeOAuthStorage idpSessions (EMA)", () => {
|
||||||
expect(session?.idToken).toBe("eyJ.id.token");
|
expect(session?.idToken).toBe("eyJ.id.token");
|
||||||
expect(session?.refreshToken).toBe("rt-1");
|
expect(session?.refreshToken).toBe("rt-1");
|
||||||
|
|
||||||
storage.clearIdpSession(issuer);
|
await storage.clearIdpSession(issuer);
|
||||||
await flushStoreFileWrites(testStatePath);
|
await flushStoreFileWrites(testStatePath);
|
||||||
expect(await storage.getIdpSession(issuer)).toBeUndefined();
|
expect(await storage.getIdpSession(issuer)).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
@ -635,12 +635,10 @@ describe("NodeOAuthStorage with custom storagePath", () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const defaultStore = getOAuthStore();
|
const defaultStorage = new NodeOAuthStorage();
|
||||||
defaultStore.getState().setServerState(testServerUrl, {
|
await defaultStorage.saveTokens(testServerUrl, {
|
||||||
tokens: {
|
access_token: "default-token",
|
||||||
access_token: "default-token",
|
token_type: "Bearer",
|
||||||
token_type: "Bearer",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const customStorage = new NodeOAuthStorage(customPath);
|
const customStorage = new NodeOAuthStorage(customPath);
|
||||||
|
|
@ -652,11 +650,10 @@ describe("NodeOAuthStorage with custom storagePath", () => {
|
||||||
const fromCustom = await customStorage.getTokens(testServerUrl);
|
const fromCustom = await customStorage.getTokens(testServerUrl);
|
||||||
expect(fromCustom?.access_token).toBe("custom-token");
|
expect(fromCustom?.access_token).toBe("custom-token");
|
||||||
|
|
||||||
const defaultStorage = new NodeOAuthStorage();
|
|
||||||
const fromDefault = await defaultStorage.getTokens(testServerUrl);
|
const fromDefault = await defaultStorage.getTokens(testServerUrl);
|
||||||
expect(fromDefault?.access_token).toBe("default-token");
|
expect(fromDefault?.access_token).toBe("default-token");
|
||||||
|
|
||||||
defaultStore.getState().clearServerState(testServerUrl);
|
getOAuthStore().getState().clearServerState(testServerUrl);
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
try {
|
||||||
await fs.unlink(customPath);
|
await fs.unlink(customPath);
|
||||||
|
|
|
||||||
|
|
@ -630,7 +630,7 @@ describe("InspectorClient OAuth E2E", () => {
|
||||||
expect(client.getStatus()).toBe("connected");
|
expect(client.getStatus()).toBe("connected");
|
||||||
|
|
||||||
await client.disconnect();
|
await client.disconnect();
|
||||||
client.clearOAuthTokens();
|
await client.clearOAuthTokens();
|
||||||
|
|
||||||
const authUrlSecond = await client.authenticate();
|
const authUrlSecond = await client.authenticate();
|
||||||
if (!authUrlSecond) throw new Error("Expected authorization URL");
|
if (!authUrlSecond) throw new Error("Expected authorization URL");
|
||||||
|
|
@ -995,7 +995,7 @@ describe("InspectorClient OAuth E2E", () => {
|
||||||
expect(tokens?.access_token).toBeDefined();
|
expect(tokens?.access_token).toBeDefined();
|
||||||
expect(await client.isOAuthAuthorized()).toBe(true);
|
expect(await client.isOAuthAuthorized()).toBe(true);
|
||||||
|
|
||||||
client.clearOAuthTokens();
|
await client.clearOAuthTokens();
|
||||||
expect(await client.isOAuthAuthorized()).toBe(false);
|
expect(await client.isOAuthAuthorized()).toBe(false);
|
||||||
expect(await client.getOAuthTokens()).toBeUndefined();
|
expect(await client.getOAuthTokens()).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -173,8 +173,8 @@ describe("InspectorClient OAuth", () => {
|
||||||
expect(tokens).toBeUndefined();
|
expect(tokens).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should clear OAuth tokens", () => {
|
it("should clear OAuth tokens", async () => {
|
||||||
client.clearOAuthTokens();
|
await client.clearOAuthTokens();
|
||||||
// Should not throw
|
// Should not throw
|
||||||
expect(client).toBeDefined();
|
expect(client).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -4949,7 +4949,7 @@ describe("InspectorClient", () => {
|
||||||
expect(c.getOAuthFlowState()).toBeUndefined();
|
expect(c.getOAuthFlowState()).toBeUndefined();
|
||||||
await expect(c.getOAuthState()).resolves.toBeUndefined();
|
await expect(c.getOAuthState()).resolves.toBeUndefined();
|
||||||
// clearOAuthTokens is a no-op when there is no manager
|
// clearOAuthTokens is a no-op when there is no manager
|
||||||
expect(() => c.clearOAuthTokens()).not.toThrow();
|
await expect(c.clearOAuthTokens()).resolves.toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("setOAuthConfig throws when oauthManager is unset", () => {
|
it("setOAuthConfig throws when oauthManager is unset", () => {
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,26 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
import { getBrowserOAuthStorage } from "@inspector/core/auth/browser/index.js";
|
import { BrowserOAuthStorage } from "@inspector/core/auth/browser/storage.js";
|
||||||
|
import type { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
|
||||||
import { clearServerOAuthState } from "./clearServerOAuthState";
|
import { clearServerOAuthState } from "./clearServerOAuthState";
|
||||||
|
|
||||||
describe("clearServerOAuthState", () => {
|
describe("clearServerOAuthState", () => {
|
||||||
beforeEach(() => {
|
let storage: BrowserOAuthStorage;
|
||||||
getBrowserOAuthStorage().clear("https://mcp.example.com/mcp");
|
|
||||||
|
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 () => {
|
it("clears storage by server URL when not the active connection", async () => {
|
||||||
const storage = getBrowserOAuthStorage();
|
|
||||||
await storage.saveTokens("https://mcp.example.com/mcp", {
|
await storage.saveTokens("https://mcp.example.com/mcp", {
|
||||||
access_token: "tok",
|
access_token: "tok",
|
||||||
token_type: "Bearer",
|
token_type: "Bearer",
|
||||||
});
|
});
|
||||||
|
|
||||||
const cleared = clearServerOAuthState({
|
const cleared = await clearServerOAuthState({
|
||||||
config: { type: "streamable-http", url: "https://mcp.example.com/mcp" },
|
config: { type: "streamable-http", url: "https://mcp.example.com/mcp" },
|
||||||
isActiveConnection: false,
|
isActiveConnection: false,
|
||||||
|
oauthStorage: storage,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(cleared).toBe(true);
|
expect(cleared).toBe(true);
|
||||||
|
|
@ -25,27 +29,28 @@ describe("clearServerOAuthState", () => {
|
||||||
).toBeUndefined();
|
).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses the live client when clearing the active connection", () => {
|
it("uses the live client when clearing the active connection", async () => {
|
||||||
const inspectorClient = {
|
const clearOAuthTokens = vi.fn<InspectorClient["clearOAuthTokens"]>();
|
||||||
clearOAuthTokens: vi.fn(),
|
const inspectorClient = { clearOAuthTokens };
|
||||||
};
|
|
||||||
|
|
||||||
const cleared = clearServerOAuthState({
|
const cleared = await clearServerOAuthState({
|
||||||
config: { type: "streamable-http", url: "https://mcp.example.com/mcp" },
|
config: { type: "streamable-http", url: "https://mcp.example.com/mcp" },
|
||||||
inspectorClient: inspectorClient as never,
|
inspectorClient,
|
||||||
isActiveConnection: true,
|
isActiveConnection: true,
|
||||||
|
oauthStorage: storage,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(cleared).toBe(true);
|
expect(cleared).toBe(true);
|
||||||
expect(inspectorClient.clearOAuthTokens).toHaveBeenCalledTimes(1);
|
expect(clearOAuthTokens).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns false for stdio servers", () => {
|
it("returns false for stdio servers", async () => {
|
||||||
expect(
|
await expect(
|
||||||
clearServerOAuthState({
|
clearServerOAuthState({
|
||||||
config: { type: "stdio", command: "node", args: [] },
|
config: { type: "stdio", command: "node", args: [] },
|
||||||
isActiveConnection: false,
|
isActiveConnection: false,
|
||||||
|
oauthStorage: storage,
|
||||||
}),
|
}),
|
||||||
).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