Merge remote-tracking branch 'origin/v2/main' into 1579-wave-1

# Conflicts:
#	clients/cli/package-lock.json
#	clients/cli/package.json
#	clients/web/src/App.tsx
#	clients/web/src/lib/environmentFactory.test.ts
#	clients/web/src/lib/environmentFactory.ts
#	clients/web/src/lib/remoteOAuthStorage.test.ts
#	clients/web/src/lib/remoteOAuthStorage.ts
#	clients/web/src/test/core/auth/connection-state.test.ts
#	clients/web/src/test/core/auth/providers.test.ts
#	clients/web/src/test/core/auth/storage-browser.test.ts
#	clients/web/src/test/core/auth/storage-remote.test.ts
#	clients/web/src/test/core/mcp/oauthManager.test.ts
#	clients/web/src/test/integration/auth/node/storage.test.ts
#	clients/web/src/test/integration/storage/adapters.test.ts
#	clients/web/src/utils/clearServerOAuthState.test.ts
#	clients/web/src/utils/clearServerOAuthState.ts
#	core/auth/connection-state.ts
#	core/auth/oauth-storage.ts
#	core/auth/providers.ts
#	core/auth/storage.ts
#	core/auth/store.ts
#	core/storage/adapters/remote-storage.ts
This commit is contained in:
cliffhall 2026-07-09 09:41:08 -04:00
commit 32a3610737
22 changed files with 5046 additions and 3271 deletions

View file

@ -24,7 +24,7 @@ describe("ensureCimdClientRegistration", () => {
storage = {
getClientInformation: vi.fn(async () => undefined),
saveClientInformation: vi.fn(async () => {}),
getScope: vi.fn(() => undefined),
getScope: vi.fn().mockResolvedValue(undefined),
getTokens: vi.fn(async () => undefined),
saveTokens: vi.fn(async () => {}),
clear: vi.fn(),

View file

@ -14,7 +14,9 @@ function createStorage(
tokens: Awaited<ReturnType<OAuthStorage["getTokens"]>>;
preregistered: Awaited<ReturnType<OAuthStorage["getClientInformation"]>>;
dynamic: Awaited<ReturnType<OAuthStorage["getClientInformation"]>>;
registrationKind: ReturnType<OAuthStorage["getClientRegistrationKind"]>;
registrationKind: Awaited<
ReturnType<OAuthStorage["getClientRegistrationKind"]>
>;
scope: string | undefined;
serverMetadata: Awaited<ReturnType<OAuthStorage["getServerMetadata"]>>;
idpSession: Awaited<ReturnType<OAuthStorage["getIdpSession"]>>;
@ -22,12 +24,12 @@ function createStorage(
}> = {},
): OAuthStorage {
return {
ready: vi.fn().mockResolvedValue(undefined),
load: vi.fn().mockResolvedValue(undefined),
getTokens: vi.fn().mockResolvedValue(overrides.tokens),
getClientInformation: vi.fn(async (_url, isPreregistered) =>
isPreregistered ? overrides.preregistered : overrides.dynamic,
),
getScope: vi.fn().mockReturnValue(overrides.scope),
getScope: vi.fn().mockResolvedValue(overrides.scope),
getServerMetadata: vi.fn(async (url: string) => {
if (url.startsWith("ema-idp:")) {
return overrides.idpMetadata ?? null;
@ -35,7 +37,9 @@ function createStorage(
return overrides.serverMetadata ?? null;
}),
getIdpSession: vi.fn().mockResolvedValue(overrides.idpSession),
getClientRegistrationKind: vi.fn(() => overrides.registrationKind),
getClientRegistrationKind: vi
.fn()
.mockResolvedValue(overrides.registrationKind),
saveClientInformation: vi.fn(),
savePreregisteredClientInformation: vi.fn(),
saveTokens: vi.fn(),

View file

@ -43,24 +43,27 @@ 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 };
}),
clearIdpSession: vi.fn(),
getServerMetadata: vi.fn((key: string) => metadataByKey[key]),
saveServerMetadata: vi.fn((key: string, metadata: unknown) => {
getServerMetadata: vi.fn(async (key: string) => metadataByKey[key] ?? null),
saveServerMetadata: vi.fn(async (key: string, metadata: unknown) => {
metadataByKey[key] = metadata;
}),
getClientInformation: vi.fn((key: string) => clientInfoByKey[key]),
savePreregisteredClientInformation: vi.fn((key: string, info: unknown) => {
clientInfoByKey[key] = info;
}),
getCodeVerifier: vi.fn((key: string) => codeVerifierByKey[key]),
saveCodeVerifier: vi.fn((key: string, verifier: string) => {
getClientInformation: vi.fn(async (key: string) => clientInfoByKey[key]),
savePreregisteredClientInformation: vi.fn(
async (key: string, info: unknown) => {
clientInfoByKey[key] = info;
},
),
getCodeVerifier: vi.fn(async (key: string) => codeVerifierByKey[key]),
saveCodeVerifier: vi.fn(async (key: string, verifier: string) => {
codeVerifierByKey[key] = verifier;
}),
clearCodeVerifier: vi.fn((key: string) => {
clearCodeVerifier: vi.fn(async (key: string) => {
delete codeVerifierByKey[key];
}),
getTokens: vi.fn(async (url: string) => savedTokens[url] as never),

View file

@ -29,11 +29,12 @@ 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 };
}),
getServerMetadata: vi.fn(() => minimalOAuthAsMetadata(IDP_ISSUER)),
getServerMetadata: vi.fn(async () => minimalOAuthAsMetadata(IDP_ISSUER)),
clearIdpSession: vi.fn(),
} as unknown as OAuthStorage;
});
@ -264,6 +265,8 @@ describe("startIdpOidcAuthorization", () => {
beforeEach(() => {
Object.keys(saved).forEach((k) => delete saved[k]);
storage = {
load: vi.fn().mockResolvedValue(undefined),
getServerMetadata: vi.fn(async () => null),
saveCodeVerifier: vi.fn(async (key: string, value: string) => {
saved[`cv:${key}`] = value;
}),
@ -331,13 +334,14 @@ describe("completeIdpOidcAuthorization", () => {
function buildStorage(overrides: Partial<OAuthStorage> = {}): OAuthStorage {
return {
getServerMetadata: vi.fn(() => minimalOAuthAsMetadata(IDP_ISSUER)),
load: vi.fn().mockResolvedValue(undefined),
getServerMetadata: vi.fn(async () => minimalOAuthAsMetadata(IDP_ISSUER)),
getClientInformation: vi.fn(async () => ({
client_id: "idp-client",
client_secret: "idp-secret",
token_endpoint_auth_method: "client_secret_post",
})),
getCodeVerifier: vi.fn(() => "verifier-123"),
getCodeVerifier: vi.fn(async () => "verifier-123"),
clearCodeVerifier: vi.fn((key: string) => {
clearedVerifiers.push(key);
}),

View file

@ -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(

View file

@ -0,0 +1,66 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
/**
* Regression guard for the browser bundle. The browser's `RemoteOAuthStorage`
* (App.tsx -> lib/remoteOAuthStorage.ts -> remote/storage-remote.ts) chains
* through a handful of `core/auth` + `core/storage` modules, so ALL of them
* must stay Node-free. `oauth-persist.ts` previously value-imported
* `store-io.js`, which pulls `node:fs` + `atomically` (-> `stubborn-fs` ->
* `node:process.getuid`) into the browser bundle and blanked the app at
* runtime. The Node-only file backend now lives in
* `core/auth/node/oauth-persist-file.ts`.
*
* This guard is transitive: it scans every module reachable from
* `storage-remote.ts` (short of the `node/` boundary), so a forbidden import
* added one hop away — e.g. through `store-serialize.ts` or `store.ts` — is
* caught too, not just a direct import in `oauth-persist.ts`.
*/
const here = dirname(fileURLToPath(import.meta.url));
const coreDir = resolve(here, "../../../../../../core");
// Browser-reachable modules (value-import graph from storage-remote.ts,
// excluding the intentional `node/` backend which is never bundled).
const BROWSER_REACHABLE = [
"auth/remote/storage-remote.ts",
"auth/oauth-storage.ts",
"auth/store.ts",
"auth/oauth-persist.ts",
"storage/store-serialize.ts",
];
const FORBIDDEN = [/(^|\/)store-io(\.js)?$/, /^node:/, /^atomically$/];
function importSpecifiers(source: string): string[] {
const specs: string[] = [];
const re = /(?:import|export)[^;]*?from\s*["']([^"']+)["']/g;
let m: RegExpExecArray | null;
while ((m = re.exec(source)) !== null) {
specs.push(m[1]);
}
return specs;
}
describe("browser-reachable OAuth storage modules stay Node-free", () => {
it.each(BROWSER_REACHABLE)(
"%s imports no Node built-ins, atomically, or store-io",
(relPath) => {
const source = readFileSync(resolve(coreDir, relPath), "utf-8");
const offenders = importSpecifiers(source).filter((spec) =>
FORBIDDEN.some((re) => re.test(spec)),
);
expect(offenders).toEqual([]);
},
);
it("oauth-persist.ts sources its (de)serializers from store-serialize", () => {
const source = readFileSync(
resolve(coreDir, "auth/oauth-persist.ts"),
"utf-8",
);
expect(importSpecifiers(source)).toContain("../storage/store-serialize.js");
});
});

View file

@ -0,0 +1,225 @@
import { describe, it, expect, vi } from "vitest";
import {
parseOAuthPersistBlob,
serializeOAuthPersistBlob,
createRemoteOAuthPersistBackend,
createSessionOAuthPersistBackend,
OAUTH_PERSIST_STORAGE_KEY,
} from "@inspector/core/auth/oauth-persist.js";
import type { OAuthPersistSnapshot } from "@inspector/core/auth/oauth-persist.js";
const SNAPSHOT: OAuthPersistSnapshot = {
servers: { "http://s": { scope: "read" } },
idpSessions: {},
};
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { status });
}
describe("parseOAuthPersistBlob", () => {
it("returns null for empty input", () => {
expect(parseOAuthPersistBlob(null)).toBeNull();
});
it("returns null for an empty object (server missing-file response)", () => {
// The remote read() path relies on this: the server answers a missing
// store with `c.json({}, 200)`, and the backend passes that straight
// through instead of special-casing an empty object.
expect(parseOAuthPersistBlob({})).toBeNull();
});
it("reads plain JSON with servers and idpSessions", () => {
const snapshot = {
servers: {
"http://example.com": { codeVerifier: "v1" },
},
idpSessions: {
"https://idp.example": { idToken: "token" },
},
};
expect(parseOAuthPersistBlob(JSON.stringify(snapshot))).toEqual(snapshot);
});
it("accepts an already-parsed object without re-serializing", () => {
const snapshot = {
servers: {
"http://example.com": { codeVerifier: "v1" },
},
idpSessions: {},
};
expect(parseOAuthPersistBlob(snapshot)).toEqual(snapshot);
});
it("promotes legacy persist envelope state to the top level", () => {
const legacy = {
state: {
servers: {
"http://example.com": {
tokens: { access_token: "t", token_type: "Bearer" },
},
},
idpSessions: {},
},
version: 0,
};
expect(parseOAuthPersistBlob(JSON.stringify(legacy))).toEqual({
servers: legacy.state.servers,
idpSessions: {},
});
});
});
describe("serializeOAuthPersistBlob", () => {
it("writes plain JSON without a state/version envelope", () => {
const snapshot = {
servers: { "http://example.com": { scope: "read" } },
idpSessions: {},
};
const raw = serializeOAuthPersistBlob(snapshot);
expect(JSON.parse(raw)).toEqual(snapshot);
expect(raw).not.toContain('"version"');
expect(raw).not.toMatch(/"state"\s*:/);
});
});
describe("createRemoteOAuthPersistBackend", () => {
const baseUrl = "http://remote.example/";
const storeId = "oauth";
const url = "http://remote.example/api/storage/oauth";
it("read() returns the parsed snapshot and sends the auth header", async () => {
const fetchFn = vi.fn(async () => jsonResponse(SNAPSHOT));
const backend = createRemoteOAuthPersistBackend({
baseUrl,
storeId,
authToken: "tok",
fetchFn: fetchFn as unknown as typeof fetch,
});
expect(await backend.read()).toEqual(SNAPSHOT);
expect(fetchFn).toHaveBeenCalledWith(url, {
method: "GET",
headers: { "x-mcp-remote-auth": "Bearer tok" },
});
});
it("read() returns null for the empty-object missing-file response", async () => {
const backend = createRemoteOAuthPersistBackend({
baseUrl,
storeId,
fetchFn: (async () => jsonResponse({})) as unknown as typeof fetch,
});
expect(await backend.read()).toBeNull();
});
it("read() returns null on 404 and throws on other errors", async () => {
const notFound = createRemoteOAuthPersistBackend({
baseUrl,
storeId,
fetchFn: (async () =>
new Response("", { status: 404 })) as unknown as typeof fetch,
});
expect(await notFound.read()).toBeNull();
const failing = createRemoteOAuthPersistBackend({
baseUrl,
storeId,
fetchFn: (async () =>
new Response("", { status: 500 })) as unknown as typeof fetch,
});
await expect(failing.read()).rejects.toThrow(/Failed to read store: 500/);
});
it("write() POSTs the serialized snapshot and throws on failure", async () => {
let capturedBody: string | undefined;
const ok = vi.fn<typeof fetch>(async (_input, init) => {
capturedBody = init?.body as string | undefined;
return new Response("", { status: 200 });
});
const backend = createRemoteOAuthPersistBackend({
baseUrl,
storeId,
fetchFn: ok,
});
await backend.write(SNAPSHOT);
expect(ok).toHaveBeenCalledWith(
url,
expect.objectContaining({ method: "POST" }),
);
expect(JSON.parse(capturedBody ?? "")).toEqual(SNAPSHOT);
const failing = createRemoteOAuthPersistBackend({
baseUrl,
storeId,
fetchFn: (async () =>
new Response("", { status: 500 })) as unknown as typeof fetch,
});
await expect(failing.write(SNAPSHOT)).rejects.toThrow(
/Failed to write store: 500/,
);
});
it("remove() DELETEs, tolerates 404, and throws on other errors", async () => {
const ok = createRemoteOAuthPersistBackend({
baseUrl,
storeId,
authToken: "tok",
fetchFn: (async () =>
new Response("", { status: 200 })) as unknown as typeof fetch,
});
await expect(ok.remove!()).resolves.toBeUndefined();
const gone = createRemoteOAuthPersistBackend({
baseUrl,
storeId,
fetchFn: (async () =>
new Response("", { status: 404 })) as unknown as typeof fetch,
});
await expect(gone.remove!()).resolves.toBeUndefined();
const failing = createRemoteOAuthPersistBackend({
baseUrl,
storeId,
fetchFn: (async () =>
new Response("", { status: 500 })) as unknown as typeof fetch,
});
await expect(failing.remove!()).rejects.toThrow(
/Failed to delete store: 500/,
);
});
});
describe("createSessionOAuthPersistBackend", () => {
function fakeStorage() {
const map = new Map<string, string>();
return {
getItem: (k: string) => map.get(k) ?? null,
setItem: (k: string, v: string) => void map.set(k, v),
removeItem: (k: string) => void map.delete(k),
map,
} as unknown as Storage & { map: Map<string, string> };
}
it("round-trips a snapshot through the default storage key", async () => {
const storage = fakeStorage();
const backend = createSessionOAuthPersistBackend({
getStorage: () => storage,
});
expect(await backend.read()).toBeNull();
await backend.write(SNAPSHOT);
expect(storage.map.has(OAUTH_PERSIST_STORAGE_KEY)).toBe(true);
expect(await backend.read()).toEqual(SNAPSHOT);
await backend.remove!();
expect(await backend.read()).toBeNull();
});
it("honors a custom storage key", async () => {
const storage = fakeStorage();
const backend = createSessionOAuthPersistBackend({
storageKey: "custom-key",
getStorage: () => storage,
});
await backend.write(SNAPSHOT);
expect(storage.map.has("custom-key")).toBe(true);
});
});

View file

@ -1,329 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { createJSONStorage } from "zustand/middleware";
import { OAuthStorageBase } from "@inspector/core/auth/oauth-storage.js";
import {
createOAuthStore,
normalizeServerUrl,
} from "@inspector/core/auth/store.js";
/**
* Builds an OAuthStorageBase backed by an *async* in-memory storage adapter
* pre-seeded with the given persisted blob. Hydration of the store completes
* only after the returned `release()` is called, so tests can assert that
* getters wait for it.
*/
function makeAsyncBackedStorage(
persistedServers: Record<string, unknown> = {},
) {
let mem: string | null = JSON.stringify({
state: { servers: persistedServers },
version: 0,
});
let releaseGetItem!: () => void;
const gate = new Promise<void>((resolve) => {
releaseGetItem = resolve;
});
const adapter = createJSONStorage(() => ({
getItem: async () => {
await gate;
return mem;
},
setItem: async (name: string, value: string) => {
void name;
mem = value;
},
removeItem: async () => {
mem = null;
},
}));
const store = createOAuthStore(adapter);
return { storage: new OAuthStorageBase(store), release: releaseGetItem };
}
const SERVER = "https://example.com/mcp";
const METADATA = {
issuer: SERVER,
authorization_endpoint: `${SERVER}/authorize`,
token_endpoint: `${SERVER}/token`,
response_types_supported: ["code"],
};
describe("OAuthStorageBase — async hydration", () => {
it("getCodeVerifier awaits hydration before reading state", async () => {
const { storage, release } = makeAsyncBackedStorage({
[SERVER]: { codeVerifier: "persisted-verifier" },
});
// The store is empty until release() lets the async getItem resolve.
let resolvedWith: string | undefined | "pending" = "pending";
const p = storage.getCodeVerifier(SERVER).then((v) => {
resolvedWith = v;
});
// Give the microtask queue a chance — the getter should NOT have resolved
// because hydration is still gated.
await Promise.resolve();
expect(resolvedWith).toBe("pending");
release();
await p;
expect(resolvedWith).toBe("persisted-verifier");
});
it("getServerMetadata awaits hydration before reading state", async () => {
const { storage, release } = makeAsyncBackedStorage({
[SERVER]: { serverMetadata: METADATA },
});
const p = storage.getServerMetadata(SERVER);
release();
expect(await p).toEqual(METADATA);
});
it("getTokens and getClientInformation await hydration", async () => {
const { storage, release } = makeAsyncBackedStorage({
[SERVER]: {
tokens: { access_token: "t", token_type: "Bearer" },
clientInformation: { client_id: "abc" },
},
});
const tokens = storage.getTokens(SERVER);
const client = storage.getClientInformation(SERVER);
release();
expect(await tokens).toEqual({ access_token: "t", token_type: "Bearer" });
expect(await client).toEqual({ client_id: "abc" });
});
it("ready() resolves once hydration completes", async () => {
const { storage, release } = makeAsyncBackedStorage();
let ready = false;
void storage.ready().then(() => {
ready = true;
});
await Promise.resolve();
expect(ready).toBe(false);
release();
await storage.ready();
expect(ready).toBe(true);
});
it("ready() resolves promptly when the adapter is synchronous", async () => {
let mem: string | null = null;
const syncAdapter = createJSONStorage(() => ({
getItem: () => mem,
setItem: (name: string, value: string) => {
void name;
mem = value;
},
removeItem: () => {
mem = null;
},
}));
const storage = new OAuthStorageBase(createOAuthStore(syncAdapter));
// With a sync adapter the constructor's rehydrate() resolves on the next
// microtask, so ready() and the getters settle without any release step.
await storage.ready();
expect(await storage.getCodeVerifier(SERVER)).toBeUndefined();
});
it("save* awaits hydration so a late merge cannot clobber the write", async () => {
const { storage, release } = makeAsyncBackedStorage({
[SERVER]: { codeVerifier: "persisted-old" },
});
// Kick off a save while hydration is still gated. It must not write until
// hydration completes, otherwise the persist merge would overwrite "new"
// with "persisted-old".
const save = storage.saveCodeVerifier(SERVER, "new");
let saved = false;
void save.then(() => {
saved = true;
});
await Promise.resolve();
expect(saved).toBe(false);
release();
await save;
expect(await storage.getCodeVerifier(SERVER)).toBe("new");
});
it("clear() before hydration is not resurrected by the late merge", async () => {
// A clear issued while hydration is still gated must survive the pending
// rehydrate() that would otherwise merge the persisted (un-cleared) blob
// back on top and silently restore the credential.
const { storage, release } = makeAsyncBackedStorage({
[SERVER]: { tokens: { access_token: "t", token_type: "Bearer" } },
});
storage.clear(SERVER);
release();
await storage.ready();
// Give the deferred re-apply (scheduled off `hydrated`) a microtask to run.
await Promise.resolve();
expect(await storage.getTokens(SERVER)).toBeUndefined();
});
it("clearTokens before hydration clears only tokens and survives the merge", async () => {
const { storage, release } = makeAsyncBackedStorage({
[SERVER]: {
tokens: { access_token: "t", token_type: "Bearer" },
codeVerifier: "keep-me",
},
});
storage.clearTokens(SERVER);
release();
await storage.ready();
await Promise.resolve();
// Tokens cleared, but the sibling field from the persisted blob remains.
expect(await storage.getTokens(SERVER)).toBeUndefined();
expect(await storage.getCodeVerifier(SERVER)).toBe("keep-me");
});
it("clearClientInformation before hydration is deferred and clears only client info", async () => {
const { storage, release } = makeAsyncBackedStorage({
[SERVER]: {
clientInformation: { client_id: "abc" },
clientRegistrationKind: "dynamic",
tokens: { access_token: "t", token_type: "Bearer" },
},
});
storage.clearClientInformation(SERVER);
release();
await storage.ready();
await Promise.resolve();
// Client info cleared, but the sibling token from the persisted blob (and
// every other server's blob, in the whole-blob adapter) survives — proving
// the pre-hydration clear did not clobber on-disk state.
expect(await storage.getClientInformation(SERVER)).toBeUndefined();
expect(await storage.getTokens(SERVER)).toEqual({
access_token: "t",
token_type: "Bearer",
});
});
it("clear() after hydration does not schedule a deferred re-apply", async () => {
const { storage, release } = makeAsyncBackedStorage({
[SERVER]: { tokens: { access_token: "t", token_type: "Bearer" } },
});
release();
await storage.ready();
// Hydration already settled → synchronous clear, immediately observable.
storage.clear(SERVER);
expect(await storage.getTokens(SERVER)).toBeUndefined();
});
it("normalizeServerUrl canonicalizes host case, bare-origin trailing slash, default port, and whitespace", () => {
expect(normalizeServerUrl("https://Example.COM/mcp")).toBe(
"https://example.com/mcp",
);
expect(normalizeServerUrl("https://example.com")).toBe(
"https://example.com/",
);
expect(normalizeServerUrl("https://example.com:443/mcp")).toBe(
"https://example.com/mcp",
);
expect(normalizeServerUrl(" https://example.com/mcp ")).toBe(
"https://example.com/mcp",
);
// Non-URL strings (e.g. a stdio server name) are passed through trimmed.
expect(normalizeServerUrl(" my-stdio-server ")).toBe("my-stdio-server");
});
it("store keys are normalized so save and get tolerate cosmetic URL differences", async () => {
const { storage, release } = makeAsyncBackedStorage();
release();
await storage.saveCodeVerifier("https://Example.COM/mcp", "v");
expect(await storage.getCodeVerifier("https://example.com/mcp")).toBe("v");
// Bare-origin: the web inspector stores `new URL().href` (trailing slash);
// a CLI caller passing the slash-less form must still hit it.
await storage.saveTokens("https://api.partner.dev/", {
access_token: "t",
token_type: "Bearer",
});
expect(await storage.getTokens("https://api.partner.dev")).toEqual({
access_token: "t",
token_type: "Bearer",
});
});
it("a partial write migrates a pre-normalization raw-key blob onto the canonical key (no orphaned fields)", async () => {
// Persisted under the bare-origin raw key (no trailing slash), the form a
// pre-normalization writer used; canonical form is `https://api.partner.dev/`.
// The caller consistently addresses the endpoint with that same raw string
// (matching getServerState's raw-key fallback).
const RAW = "https://api.partner.dev";
const { storage, release } = makeAsyncBackedStorage({
[RAW]: { tokens: { access_token: "existing", token_type: "Bearer" } },
});
release();
await storage.ready();
// A partial write must MERGE onto the existing blob, not shadow it with a
// fresh canonical entry — the pre-existing token must remain reachable.
await storage.saveCodeVerifier(RAW, "v");
expect(await storage.getCodeVerifier(RAW)).toBe("v");
expect(await storage.getTokens(RAW)).toEqual({
access_token: "existing",
token_type: "Bearer",
});
// The blob now lives under the canonical key, so the canonical form finds it
// too and the raw-key orphan no longer shadows anything.
expect(await storage.getTokens("https://api.partner.dev/")).toEqual({
access_token: "existing",
token_type: "Bearer",
});
});
it("clear() removes a pre-normalization raw-key blob, not just the canonical key", async () => {
const RAW = "https://api.partner.dev";
const { storage, release } = makeAsyncBackedStorage({
[RAW]: { tokens: { access_token: "existing", token_type: "Bearer" } },
});
release();
await storage.ready();
storage.clear(RAW);
// Both the canonical and raw keys must be gone — no orphan resurfacing via
// getServerState's raw-key fallback.
expect(await storage.getTokens(RAW)).toBeUndefined();
expect(await storage.getTokens("https://api.partner.dev/")).toBeUndefined();
});
it("getServerState falls back to a pre-normalization persisted key", async () => {
// A blob persisted before normalization existed may carry a raw key that
// the canonical form differs from (here: bare-origin without slash).
const { storage, release } = makeAsyncBackedStorage({
"https://legacy.example": { codeVerifier: "legacy" },
});
release();
expect(await storage.getCodeVerifier("https://legacy.example")).toBe(
"legacy",
);
});
it("ready()/getters resolve (empty) when the adapter throws instead of hanging", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
const throwingAdapter = createJSONStorage(() => ({
getItem: async () => {
throw new Error("Failed to read store: 500");
},
setItem: async () => {},
removeItem: async () => {},
}));
const storage = new OAuthStorageBase(createOAuthStore(throwingAdapter));
// Race ready() against a short timeout — if the catch path doesn't
// resolve `hydrated`, this would hit "TIMEOUT" instead of "ok".
const result = await Promise.race([
storage.ready().then(() => "ok"),
new Promise((r) => setTimeout(() => r("TIMEOUT"), 200)),
]);
expect(result).toBe("ok");
expect(await storage.getTokens(SERVER)).toBeUndefined();
expect(warn).toHaveBeenCalled();
// The failure is recorded so callers can distinguish "no token" from
// "store unreadable".
expect(storage.getHydrationError()).toBeInstanceOf(Error);
} finally {
warn.mockRestore();
}
});
it("getHydrationError() is undefined after a successful hydration", async () => {
const { storage, release } = makeAsyncBackedStorage();
release();
await storage.ready();
expect(storage.getHydrationError()).toBeUndefined();
});
});

View file

@ -213,7 +213,8 @@ describe("OAuthNavigation", () => {
function makeStorage(): OAuthStorage {
return {
getScope: vi.fn(() => undefined),
load: vi.fn().mockResolvedValue(undefined),
getScope: vi.fn().mockResolvedValue(undefined),
getClientInformation: vi.fn(async () => undefined),
saveClientInformation: vi.fn(async () => undefined),
savePreregisteredClientInformation: vi.fn(async () => undefined),
@ -221,9 +222,9 @@ describe("OAuthNavigation", () => {
getTokens: vi.fn(async () => undefined),
saveTokens: vi.fn(async () => undefined),
saveCodeVerifier: vi.fn(async () => undefined),
getCodeVerifier: vi.fn(async () => undefined),
getCodeVerifier: vi.fn().mockResolvedValue(undefined),
clear: vi.fn(),
getServerMetadata: vi.fn(async () => null),
getServerMetadata: vi.fn().mockResolvedValue(null),
saveServerMetadata: vi.fn(async () => undefined),
} as unknown as OAuthStorage;
}
@ -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);
});
@ -271,7 +272,7 @@ describe("OAuthNavigation", () => {
expect(await provider.clientInformation()).toEqual({ client_id: "dyn" });
});
it("codeVerifier() rejects when none is saved", async () => {
it("codeVerifier() throws when none is saved", async () => {
const storage = makeStorage();
const provider = makeProvider(storage);
@ -288,10 +289,11 @@ describe("OAuthNavigation", () => {
expect(await provider.codeVerifier()).toBe("cv-1");
});
it("clientMetadata reflects the stored scope when present", () => {
it("clientMetadata reflects the stored scope when present", async () => {
const storage = makeStorage();
vi.mocked(storage.getScope).mockReturnValue("read write");
vi.mocked(storage.getScope).mockResolvedValue("read write");
const provider = makeProvider(storage);
await provider.prepareForAuth();
expect(provider.clientMetadata.scope).toBe("read write");
});

View file

@ -45,11 +45,9 @@ const mockSessionStorage = new MockSessionStorage();
mockSessionStorage;
describe("getBrowserOAuthStorage", () => {
it("returns a memoized singleton across calls", () => {
it("returns the same singleton on repeated calls", () => {
const first = getBrowserOAuthStorage();
const second = getBrowserOAuthStorage();
expect(first).toBeInstanceOf(BrowserOAuthStorage);
// Second call hits the cached-instance branch rather than constructing anew.
expect(second).toBe(first);
});
});
@ -79,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);
@ -93,16 +91,11 @@ describe("BrowserOAuthStorage", () => {
client_secret: "preregistered-secret",
};
// 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,
);
// Wait for Zustand to persist
await new Promise((resolve) => setTimeout(resolve, 100));
const result = await storage.getClientInformation(testServerUrl, true);
expect(result).toEqual(preregisteredInfo);
@ -115,7 +108,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);
@ -132,23 +125,27 @@ 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);
expect(result).toEqual(secondInfo);
expect(storage.getClientRegistrationKind(testServerUrl)).toBe("cimd");
expect(await storage.getClientRegistrationKind(testServerUrl)).toBe(
"cimd",
);
});
it("savePreregisteredClientInformation sets static registration kind", async () => {
await storage.savePreregisteredClientInformation(testServerUrl, {
client_id: "static-id",
});
expect(storage.getClientRegistrationKind(testServerUrl)).toBe("static");
expect(await storage.getClientRegistrationKind(testServerUrl)).toBe(
"static",
);
});
});
@ -165,7 +162,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);
@ -179,7 +176,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);
@ -196,7 +193,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);
@ -212,7 +209,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);
@ -223,7 +220,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);
@ -239,10 +236,8 @@ describe("BrowserOAuthStorage", () => {
it("should return stored scope", async () => {
const scope = "read write";
// getScope is intentionally synchronous; await the save (which waits for
// hydration) so the value has landed before the sync read.
await storage.saveScope(testServerUrl, scope);
const result = storage.getScope(testServerUrl);
const result = await storage.getScope(testServerUrl);
expect(result).toBe(scope);
});
@ -253,7 +248,7 @@ describe("BrowserOAuthStorage", () => {
const scope = "read write";
await storage.saveScope(testServerUrl, scope);
const result = storage.getScope(testServerUrl);
const result = await storage.getScope(testServerUrl);
expect(result).toBe(scope);
});
@ -273,7 +268,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);
@ -289,7 +284,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);
@ -298,7 +293,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" },
@ -306,18 +301,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();
@ -326,27 +321,27 @@ 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(await storage.getCodeVerifier(testServerUrl)).toBe("verifier");
storage.clearCodeVerifier(testServerUrl);
await storage.clearCodeVerifier(testServerUrl);
expect(await storage.getCodeVerifier(testServerUrl)).toBeUndefined();
});
it("clearScope removes only the scope", async () => {
await storage.saveScope(testServerUrl, "read");
expect(storage.getScope(testServerUrl)).toBe("read");
storage.clearScope(testServerUrl);
expect(storage.getScope(testServerUrl)).toBeUndefined();
expect(await storage.getScope(testServerUrl)).toBe("read");
await storage.clearScope(testServerUrl);
expect(await storage.getScope(testServerUrl)).toBeUndefined();
});
it("clearServerMetadata removes only the cached metadata", async () => {
@ -356,9 +351,9 @@ describe("BrowserOAuthStorage", () => {
token_endpoint: "http://localhost:3000/token",
response_types_supported: ["code"],
};
storage.saveServerMetadata(testServerUrl, metadata);
await storage.saveServerMetadata(testServerUrl, metadata);
expect(await storage.getServerMetadata(testServerUrl)).toEqual(metadata);
storage.clearServerMetadata(testServerUrl);
await storage.clearServerMetadata(testServerUrl);
expect(await storage.getServerMetadata(testServerUrl)).toBeNull();
});
});
@ -378,7 +373,7 @@ describe("BrowserOAuthStorage", () => {
});
await storage.saveTokens(testServerUrl, tokens);
storage.clear(testServerUrl);
await storage.clear(testServerUrl);
expect(await storage.getClientInformation(testServerUrl)).toBeUndefined();
expect(await storage.getTokens(testServerUrl)).toBeUndefined();
@ -397,7 +392,7 @@ describe("BrowserOAuthStorage", () => {
registrationKind: "dcr",
});
storage.clear(testServerUrl);
await storage.clear(testServerUrl);
expect(await storage.getClientInformation(testServerUrl)).toBeUndefined();
expect(await storage.getClientInformation(otherServerUrl)).toEqual(
@ -419,10 +414,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",
});

View file

@ -50,7 +50,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 +58,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,22 +66,22 @@ 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(await storage.getCodeVerifier(serverUrl)).toBe("verifier");
storage.clearCodeVerifier(serverUrl);
await storage.clearCodeVerifier(serverUrl);
expect(await 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);
expect(storage.getScope(serverUrl)).toBeUndefined();
expect(await storage.getScope(serverUrl)).toBe("read write");
await storage.clearScope(serverUrl);
expect(await storage.getScope(serverUrl)).toBeUndefined();
});
it("serverMetadata round-trip and clearServerMetadata", async () => {
@ -93,7 +93,7 @@ describe("RemoteOAuthStorage (unit, mocked fetch)", () => {
};
await storage.saveServerMetadata(serverUrl, md);
expect(await storage.getServerMetadata(serverUrl)).toEqual(md);
storage.clearServerMetadata(serverUrl);
await storage.clearServerMetadata(serverUrl);
expect(await storage.getServerMetadata(serverUrl)).toBeNull();
});
@ -107,7 +107,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();
});
@ -121,64 +121,49 @@ describe("RemoteOAuthStorage (unit, mocked fetch)", () => {
expect(s).toBeInstanceOf(RemoteOAuthStorage);
});
it("getCodeVerifier waits for the async hydration GET before reading", async () => {
// Unlike the other tests in this file (which write-then-read in the same
// session), this asserts that a value PERSISTED on the backend before the
// store was constructed is still returned — i.e. the getter awaits the
// remote storage adapter's hydration GET.
let releaseGet!: () => void;
const getGate = new Promise<void>((resolve) => {
releaseGet = resolve;
});
const fetchFn = vi.fn(
async (url: RequestInfo | URL, init?: RequestInit) => {
if ((init?.method ?? "GET") === "GET") {
await getGate;
return new Response(
JSON.stringify({
state: {
servers: { [serverUrl]: { codeVerifier: "from-disk" } },
},
version: 0,
}),
{ status: 200 },
);
}
void url;
return new Response("{}", { status: 200 });
it("getCodeVerifier loads remote state automatically when not preloaded", 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 s = new RemoteOAuthStorage({
const delayedStorage = new RemoteOAuthStorage({
baseUrl: "http://remote.example",
storeId: "hydration-test",
fetchFn: fetchFn as unknown as typeof fetch,
storeId: `delayed-${Math.random().toString(36).slice(2)}`,
fetchFn: delayedFetch,
});
const p = s.getCodeVerifier(serverUrl);
releaseGet();
expect(await p).toBe("from-disk");
const getPromise = delayedStorage.getCodeVerifier(serverUrl);
resolveFetch(new Response(JSON.stringify(persisted), { status: 200 }));
expect(await getPromise).toBe("persisted-verifier");
});
it("persist POST is sent with keepalive so it survives an immediate redirect", async () => {
const fetchFn = vi.fn(
async (url: RequestInfo | URL, init?: RequestInit) => {
void url;
void init;
return new Response("{}", { status: 200 });
},
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 s = new RemoteOAuthStorage({
const failingStorage = new RemoteOAuthStorage({
baseUrl: "http://remote.example",
storeId: "keepalive-test",
fetchFn: fetchFn as unknown as typeof fetch,
storeId: `fail-${Math.random().toString(36).slice(2)}`,
fetchFn: failingFetch,
});
await s.ready();
await s.saveCodeVerifier(serverUrl, "v");
// Zustand persist fires setItem on the next microtask after set(); flush
// a couple of ticks so the POST has been issued.
await Promise.resolve();
await Promise.resolve();
const post = fetchFn.mock.calls.find((c) => c[1]?.method === "POST");
expect(post).toBeDefined();
expect(post?.[1]?.keepalive).toBe(true);
await expect(failingStorage.load()).rejects.toThrow(
"Failed to read store: 500",
);
});
});

View file

@ -0,0 +1,84 @@
import { describe, it, expect } from "vitest";
import { OAuthMemoryStore } from "@inspector/core/auth/store.js";
describe("OAuthMemoryStore", () => {
it("returns empty defaults for unknown server / issuer", () => {
const store = new OAuthMemoryStore();
expect(store.getState().getServerState("http://x")).toEqual({});
expect(store.getState().getIdpSession("http://idp")).toEqual({});
});
it("merges partial updates immutably via setServerState / setIdpSession", () => {
const store = new OAuthMemoryStore();
const s = store.getState();
s.setServerState("http://x", { scope: "read" });
s.setServerState("http://x", { codeVerifier: "v" });
expect(store.getState().getServerState("http://x")).toEqual({
scope: "read",
codeVerifier: "v",
});
s.setIdpSession("http://idp", { idToken: "t" });
expect(store.getState().getIdpSession("http://idp")).toEqual({
idToken: "t",
});
});
it("clears a single server and a single idp session", () => {
const store = new OAuthMemoryStore({
servers: { "http://x": { scope: "read" } },
idpSessions: { "http://idp": { idToken: "t" } },
});
store.getState().clearServerState("http://x");
store.getState().clearIdpSession("http://idp");
expect(store.getState().getServerState("http://x")).toEqual({});
expect(store.getState().getIdpSession("http://idp")).toEqual({});
});
it("clears only enterprise-managed servers", () => {
const store = new OAuthMemoryStore({
servers: {
"http://managed": { enterpriseManaged: true },
"http://plain": { scope: "read" },
},
idpSessions: {},
});
store.getState().clearEnterpriseManagedResourceServers();
expect(store.getState().getServerState("http://managed")).toEqual({});
expect(store.getState().getServerState("http://plain")).toEqual({
scope: "read",
});
});
it("snapshot() returns a shallow copy of current state", () => {
const store = new OAuthMemoryStore({
servers: { "http://x": { scope: "read" } },
idpSessions: {},
});
const snap = store.snapshot();
expect(snap).toEqual({
servers: { "http://x": { scope: "read" } },
idpSessions: {},
});
// Mutating the store afterward must not change the earlier snapshot.
store.getState().setServerState("http://y", { scope: "write" });
expect(snap.servers["http://y"]).toBeUndefined();
});
it("replace() tolerates a snapshot missing servers or idpSessions", () => {
// Exercises both `?? {}` fallbacks in replace().
const noServers = new OAuthMemoryStore({
idpSessions: { "http://idp": { idToken: "t" } },
} as never);
expect(noServers.snapshot()).toEqual({
servers: {},
idpSessions: { "http://idp": { idToken: "t" } },
});
const noSessions = new OAuthMemoryStore({
servers: { "http://x": { scope: "read" } },
} as never);
expect(noSessions.snapshot()).toEqual({
servers: { "http://x": { scope: "read" } },
idpSessions: {},
});
});
});

View file

@ -35,24 +35,24 @@ function createMockParams(
const dispatchOAuthError = vi.fn();
const storage = {
ready: vi.fn().mockResolvedValue(undefined),
getScope: vi.fn().mockReturnValue(undefined),
load: vi.fn().mockResolvedValue(undefined),
getScope: vi.fn().mockResolvedValue(undefined),
getClientInformation: vi.fn().mockResolvedValue(undefined),
getClientRegistrationKind: vi.fn().mockReturnValue(undefined),
getClientRegistrationKind: vi.fn().mockResolvedValue(undefined),
saveClientInformation: vi.fn().mockResolvedValue(undefined),
savePreregisteredClientInformation: vi.fn().mockResolvedValue(undefined),
saveScope: vi.fn().mockResolvedValue(undefined),
getTokens: vi.fn().mockResolvedValue(undefined),
saveTokens: vi.fn().mockResolvedValue(undefined),
getCodeVerifier: vi.fn().mockReturnValue("verifier"),
getCodeVerifier: vi.fn().mockResolvedValue("verifier"),
saveCodeVerifier: vi.fn().mockResolvedValue(undefined),
clear: vi.fn(),
clear: vi.fn().mockResolvedValue(undefined),
clearClientInformation: vi.fn(),
clearTokens: vi.fn(),
clearCodeVerifier: vi.fn(),
clearScope: vi.fn(),
clearServerMetadata: vi.fn(),
getServerMetadata: vi.fn().mockReturnValue(null),
getServerMetadata: vi.fn().mockResolvedValue(null),
saveServerMetadata: vi.fn().mockResolvedValue(undefined),
getIdpSession: vi.fn().mockResolvedValue(undefined),
saveIdpSession: vi.fn().mockResolvedValue(undefined),
@ -127,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,
);
@ -138,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: {
@ -148,7 +148,7 @@ describe("OAuthManager", () => {
} as OAuthManagerConfig,
});
const manager = new OAuthManager(params);
manager.clearOAuthTokens();
await manager.clearOAuthTokens();
expect(params.getServerUrl).not.toHaveBeenCalled();
});
});
@ -428,7 +428,7 @@ describe("OAuthManager", () => {
const params = createMockParams({ onBeforeOAuthRedirect });
// A configured scope exercises the saveScope branch in createOAuthProvider.
params.initialConfig.scope = "read write";
storageOf(params).getScope.mockReturnValue(undefined);
storageOf(params).getScope.mockResolvedValue(undefined);
storageOf(params).getClientInformation.mockResolvedValue({
client_id: "cid",
});
@ -466,7 +466,9 @@ describe("OAuthManager", () => {
mockedMcpAuth.mockResolvedValue("REDIRECT");
const params = createMockParams();
params.initialConfig.scope = "mcp tools:read";
storageOf(params).getScope.mockReturnValue("mcp tools:read weather:read");
storageOf(params).getScope.mockResolvedValue(
"mcp tools:read weather:read",
);
storageOf(params).getClientInformation.mockResolvedValue({
client_id: "cid",
});
@ -586,7 +588,7 @@ describe("OAuthManager", () => {
.mockResolvedValueOnce("REDIRECT")
.mockResolvedValueOnce("AUTHORIZED");
const params = createMockParams();
storageOf(params).getScope.mockReturnValue("mcp");
storageOf(params).getScope.mockResolvedValue("mcp");
storageOf(params).getTokens.mockResolvedValue({
access_token: "access",
refresh_token: "refresh",
@ -629,7 +631,7 @@ describe("OAuthManager", () => {
it("persists granted scope when AS down-scopes the token response", async () => {
mockedMcpAuth.mockResolvedValue("AUTHORIZED");
const params = createMockParams();
storageOf(params).getScope.mockReturnValue("mcp");
storageOf(params).getScope.mockResolvedValue("mcp");
storageOf(params).getTokens.mockResolvedValue({
access_token: "access",
token_type: "Bearer",
@ -984,7 +986,9 @@ describe("OAuthManager", () => {
token_type: "Bearer",
scope: "mcp tools:read tools:write",
});
storageOf(params).getScope.mockReturnValue("mcp tools:read tools:write");
storageOf(params).getScope.mockResolvedValue(
"mcp tools:read tools:write",
);
const manager = new OAuthManager(params);
expect(
@ -1002,7 +1006,7 @@ describe("OAuthManager", () => {
token_type: "Bearer",
scope: "mcp tools:read",
});
storageOf(params).getScope.mockReturnValue("mcp tools:read");
storageOf(params).getScope.mockResolvedValue("mcp tools:read");
const manager = new OAuthManager(params);
expect(
@ -1020,7 +1024,7 @@ describe("OAuthManager", () => {
token_type: "Bearer",
scope: "mcp",
});
storageOf(params).getScope.mockReturnValue("mcp weather:read");
storageOf(params).getScope.mockResolvedValue("mcp weather:read");
const manager = new OAuthManager(params);
expect(
@ -1037,7 +1041,7 @@ describe("OAuthManager", () => {
access_token: "tok",
token_type: "Bearer",
});
storageOf(params).getScope.mockReturnValue(undefined);
storageOf(params).getScope.mockResolvedValue(undefined);
const manager = new OAuthManager(params);
expect(
@ -1054,7 +1058,9 @@ describe("OAuthManager", () => {
token_type: "Bearer",
scope: "mcp tools:read tools:write",
});
storageOf(params).getScope.mockReturnValue("mcp tools:read tools:write");
storageOf(params).getScope.mockResolvedValue(
"mcp tools:read tools:write",
);
const manager = new OAuthManager(params);
const outcome = await manager.handleAuthChallenge({
@ -1136,7 +1142,7 @@ describe("OAuthManager", () => {
const params = createMockParams();
const manager = new OAuthManager(params);
manager.setOAuthConfig({ scope: "catalog:scope" });
storageOf(params).getScope.mockReturnValue("stored union scope");
storageOf(params).getScope.mockResolvedValue("stored union scope");
const captureSpy = vi
.spyOn(
(await import("@inspector/core/auth/providers.js"))
@ -1164,7 +1170,7 @@ describe("OAuthManager", () => {
token_type: "Bearer",
scope: "mcp tools:read",
});
storageOf(params).getScope.mockReturnValue("mcp tools:read");
storageOf(params).getScope.mockResolvedValue("mcp tools:read");
const manager = new OAuthManager(params);
const outcome = await manager.handleAuthChallenge({
@ -1201,7 +1207,7 @@ describe("OAuthManager", () => {
);
mockedMcpAuth.mockResolvedValue("REDIRECT");
const params = createMockParams();
storageOf(params).getScope.mockReturnValue("mcp tools:read");
storageOf(params).getScope.mockResolvedValue("mcp tools:read");
const manager = new OAuthManager(params);
const captureSpy = vi
.spyOn(
@ -1234,7 +1240,7 @@ describe("OAuthManager", () => {
);
mockedMcpAuth.mockResolvedValue("REDIRECT");
const params = createMockParams();
storageOf(params).getScope.mockReturnValue("mcp tools:read");
storageOf(params).getScope.mockResolvedValue("mcp tools:read");
storageOf(params).getTokens.mockResolvedValue({
access_token: "access",
refresh_token: "refresh",
@ -1322,7 +1328,7 @@ describe("OAuthManager", () => {
},
},
});
storageOf(params).getScope.mockReturnValue("mcp");
storageOf(params).getScope.mockResolvedValue("mcp");
storageOf(params).getTokens.mockResolvedValue({
access_token: "tok",
token_type: "Bearer",
@ -1360,7 +1366,7 @@ describe("OAuthManager", () => {
},
},
});
storageOf(params).getScope.mockReturnValue("mcp tools:read");
storageOf(params).getScope.mockResolvedValue("mcp tools:read");
storageOf(params).getTokens.mockResolvedValue({
access_token: "tok",
token_type: "Bearer",
@ -1453,7 +1459,7 @@ describe("OAuthManager", () => {
},
},
});
storageOf(params).getScope.mockReturnValue("mcp");
storageOf(params).getScope.mockResolvedValue("mcp");
storageOf(params).getTokens.mockResolvedValue({
access_token: "tok",
token_type: "Bearer",
@ -1501,7 +1507,7 @@ describe("OAuthManager", () => {
},
},
});
storageOf(params).getScope.mockReturnValue("mcp tools:read");
storageOf(params).getScope.mockResolvedValue("mcp tools:read");
storageOf(params).getTokens.mockResolvedValue({
access_token: "old",
token_type: "Bearer",
@ -1635,7 +1641,7 @@ describe("OAuthManager", () => {
token_type: "Bearer",
scope: "mcp tools:read weather:read",
};
storageOf(params).getScope.mockReturnValue("mcp tools:read");
storageOf(params).getScope.mockResolvedValue("mcp tools:read");
storageOf(params)
.getTokens.mockResolvedValueOnce(insufficientTokens)
.mockResolvedValue(sufficientTokens);
@ -1665,7 +1671,7 @@ describe("OAuthManager", () => {
token_type: "Bearer",
scope: "mcp tools:read weather:read",
};
storageOf(params).getScope.mockReturnValue("mcp tools:read");
storageOf(params).getScope.mockResolvedValue("mcp tools:read");
storageOf(params)
.getTokens.mockResolvedValueOnce(insufficientTokens)
.mockResolvedValueOnce(insufficientTokens)
@ -1801,7 +1807,7 @@ describe("OAuthManager", () => {
.mockResolvedValueOnce("AUTHORIZED")
.mockResolvedValueOnce("REDIRECT");
const params = createMockParams();
storageOf(params).getScope.mockReturnValue("mcp tools:read");
storageOf(params).getScope.mockResolvedValue("mcp tools:read");
storageOf(params).getTokens.mockResolvedValue({
access_token: "access",
token_type: "Bearer",

View file

@ -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 () => {

View file

@ -1,10 +1,10 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import {
NodeOAuthStorage,
getOAuthStore,
getStateFilePath,
resetNodeOAuthStorageCache,
clearAllOAuthClientState,
} from "@inspector/core/auth/node/storage-node.js";
import { normalizeServerUrl } from "@inspector/core/auth/store.js";
import type {
OAuthClientInformation,
OAuthTokens,
@ -21,44 +21,27 @@ const testStatePath = path.join(
`mcp-inspector-oauth-${process.pid}-storage-node.json`,
);
async function resetTestStorage(stateFilePath: string): Promise<void> {
try {
await fs.unlink(stateFilePath);
} catch {
// Ignore if file doesn't exist
}
resetNodeOAuthStorageCache(stateFilePath);
}
describe("NodeOAuthStorage", () => {
let storage: NodeOAuthStorage;
const testServerUrl = "http://localhost:3000";
const stateFilePath = testStatePath;
beforeEach(async () => {
// Clean up any existing state file
try {
await fs.unlink(stateFilePath);
} catch {
// Ignore if file doesn't exist
}
// Reset store state by clearing all servers
const store = getOAuthStore(testStatePath);
const state = store.getState();
// Clear all server states
Object.keys(state.servers).forEach((url) => {
state.clearServerState(url);
});
await resetTestStorage(stateFilePath);
storage = new NodeOAuthStorage(testStatePath);
});
afterEach(async () => {
// Clean up state file after each test
try {
await fs.unlink(stateFilePath);
} catch {
// Ignore if file doesn't exist
}
// Reset store state
const store = getOAuthStore(testStatePath);
const state = store.getState();
Object.keys(state.servers).forEach((url) => {
state.clearServerState(url);
});
await resetTestStorage(stateFilePath);
});
describe("getClientInformation", () => {
@ -89,11 +72,10 @@ describe("NodeOAuthStorage", () => {
client_secret: "preregistered-secret",
};
// Store as preregistered by directly setting it in the store
const store = getOAuthStore(testStatePath);
store.getState().setServerState(testServerUrl, {
preregisteredClientInformation: preregisteredInfo,
});
await storage.savePreregisteredClientInformation(
testServerUrl,
preregisteredInfo,
);
const result = await storage.getClientInformation(testServerUrl, true);
@ -127,10 +109,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);
@ -311,7 +293,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();
});
@ -322,7 +304,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();
@ -336,22 +318,22 @@ 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(await storage.getCodeVerifier(testServerUrl)).toBe("verifier");
storage.clearCodeVerifier(testServerUrl);
await storage.clearCodeVerifier(testServerUrl);
expect(await 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);
expect(storage.getScope(testServerUrl)).toBeUndefined();
expect(await storage.getScope(testServerUrl)).toBe("read write");
await storage.clearScope(testServerUrl);
expect(await storage.getScope(testServerUrl)).toBeUndefined();
});
it("clearServerMetadata removes only the cached metadata", async () => {
@ -363,7 +345,7 @@ describe("NodeOAuthStorage", () => {
};
await storage.saveServerMetadata(testServerUrl, metadata);
expect(await storage.getServerMetadata(testServerUrl)).toEqual(metadata);
storage.clearServerMetadata(testServerUrl);
await storage.clearServerMetadata(testServerUrl);
expect(await storage.getServerMetadata(testServerUrl)).toBeNull();
});
});
@ -383,7 +365,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();
@ -402,7 +384,7 @@ describe("NodeOAuthStorage", () => {
registrationKind: "dcr",
});
storage.clear(testServerUrl);
await storage.clear(testServerUrl);
expect(await storage.getClientInformation(testServerUrl)).toBeUndefined();
const otherResult = await storage.getClientInformation(otherServerUrl);
@ -425,10 +407,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",
});
@ -438,83 +420,52 @@ describe("NodeOAuthStorage", () => {
expect(result2).toEqual(clientInfo2);
});
});
});
describe("OAuth Store (Zustand)", () => {
const stateFilePath = testStatePath;
it("reuses in-memory state for the same file path", async () => {
const serverUrl = "http://localhost:3000";
const tokens: OAuthTokens = {
access_token: "shared-memory-token",
token_type: "Bearer",
};
beforeEach(async () => {
try {
await fs.unlink(stateFilePath);
} catch {
// Ignore if file doesn't exist
}
await storage.saveTokens(serverUrl, tokens);
const otherView = new NodeOAuthStorage(testStatePath);
expect(await otherView.getTokens(serverUrl)).toEqual(tokens);
});
afterEach(async () => {
try {
await fs.unlink(stateFilePath);
} catch {
// Ignore if file doesn't exist
}
});
it("should create a new store", () => {
const store = getOAuthStore(testStatePath);
expect(store).toBeDefined();
expect(store.getState).toBeDefined();
expect(store.setState).toBeDefined();
});
it("should return the same store instance via getOAuthStore", () => {
const store1 = getOAuthStore(testStatePath);
const store2 = getOAuthStore(testStatePath);
expect(store1).toBe(store2);
});
it("should persist state to file", async () => {
// Use a unique path so no other test (e.g. "should overwrite..." with second-id) can
// write to the same file; Zustand persist is async and can race with shared paths.
it("persists state to file on save", async () => {
const persistTestPath = path.join(
os.tmpdir(),
`mcp-inspector-oauth-persist-${Date.now()}-${Math.random().toString(36).slice(2)}.json`,
);
try {
if (process.env.DEBUG_PERSIST_PATH === "1") {
console.error("[storage-node.test] state file path:", persistTestPath);
}
const store = getOAuthStore(persistTestPath);
resetNodeOAuthStorageCache(persistTestPath);
const fileStorage = new NodeOAuthStorage(persistTestPath);
const serverUrl = "http://localhost:3000";
const clientInfo: OAuthClientInformation = {
client_id: "test-client-id",
};
store.getState().setServerState(serverUrl, {
clientInformation: clientInfo,
await fileStorage.saveClientInformation(serverUrl, clientInfo, {
registrationKind: "dcr",
});
type StateShape = {
state: {
servers: Record<
string,
{ clientInformation?: OAuthClientInformation }
>;
};
servers: Record<string, { clientInformation?: OAuthClientInformation }>;
};
// Persistence is fire-and-forget; await the write rather than polling.
await flushStoreFileWrites(persistTestPath);
const parsed = JSON.parse(
await fs.readFile(persistTestPath, "utf-8"),
) as StateShape;
expect(
parsed.state.servers[normalizeServerUrl(serverUrl)]?.clientInformation,
).toEqual(clientInfo);
expect(parsed.servers[serverUrl]?.clientInformation).toEqual(clientInfo);
} finally {
try {
await fs.unlink(persistTestPath);
} catch {
/* ignore */
}
resetNodeOAuthStorageCache(persistTestPath);
}
});
});
@ -548,7 +499,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();
});
@ -573,9 +524,7 @@ describe("NodeOAuthStorage with custom storagePath", () => {
await storage.saveTokens(testServerUrl, tokens);
type StateShape = {
state: {
servers: Record<string, { tokens?: { access_token?: string } }>;
};
servers: Record<string, { tokens?: { access_token?: string } }>;
};
// Persistence is fire-and-forget; await the write rather than polling.
await flushStoreFileWrites(customPath);
@ -583,10 +532,9 @@ describe("NodeOAuthStorage with custom storagePath", () => {
await fs.readFile(customPath, "utf-8"),
) as StateShape;
expect(
parsed.state.servers[normalizeServerUrl(testServerUrl)]?.tokens
?.access_token,
).toBe(tokens.access_token);
expect(parsed.servers[testServerUrl]?.tokens?.access_token).toBe(
tokens.access_token,
);
const stored = await storage.getTokens(testServerUrl);
expect(stored?.access_token).toBe(tokens.access_token);
@ -601,33 +549,46 @@ describe("NodeOAuthStorage with custom storagePath", () => {
});
it("clearAllOAuthClientState clears every server in the default store", async () => {
const defaultStore = getOAuthStore();
defaultStore.getState().setServerState("http://server-a.test", {
tokens: { access_token: "a", token_type: "Bearer" },
resetNodeOAuthStorageCache();
const storage = new NodeOAuthStorage();
await storage.saveTokens("http://server-a.test", {
access_token: "a",
token_type: "Bearer",
});
defaultStore.getState().setServerState("http://server-b.test", {
tokens: { access_token: "b", token_type: "Bearer" },
await storage.saveTokens("http://server-b.test", {
access_token: "b",
token_type: "Bearer",
});
clearAllOAuthClientState();
const all = defaultStore.getState();
expect(all.getServerState("http://server-a.test").tokens).toBeUndefined();
expect(all.getServerState("http://server-b.test").tokens).toBeUndefined();
await flushStoreFileWrites(getStateFilePath());
await clearAllOAuthClientState();
expect(await storage.getTokens("http://server-a.test")).toBeUndefined();
expect(await storage.getTokens("http://server-b.test")).toBeUndefined();
resetNodeOAuthStorageCache();
});
it("clearAllOAuthClientState tolerates a store whose servers map is absent", () => {
// Exercises the `state.servers ?? {}` nullish fallback: when the persisted
// state lacks a `servers` map, the iteration must default to {} (no throw).
const defaultStore = getOAuthStore();
const clearServerState = vi.fn();
vi.spyOn(defaultStore, "getState").mockReturnValue({
servers: undefined,
clearServerState,
} as unknown as ReturnType<typeof defaultStore.getState>);
it("clearAllOAuthClientState tolerates persisted state without a servers map", async () => {
const filePath = getStateFilePath();
resetNodeOAuthStorageCache();
await fs.writeFile(filePath, JSON.stringify({ idpSessions: {} }), "utf-8");
expect(() => clearAllOAuthClientState()).not.toThrow();
expect(clearServerState).not.toHaveBeenCalled();
await expect(clearAllOAuthClientState()).resolves.toBeUndefined();
vi.mocked(defaultStore.getState).mockRestore();
resetNodeOAuthStorageCache();
});
it("clearAllOAuthClientState is a no-op when no store file exists", async () => {
// Exercises the null-snapshot branch: read() returns null, so
// `snapshot?.servers ?? {}` yields no urls and nothing is cleared.
const filePath = getStateFilePath();
resetNodeOAuthStorageCache();
await fs.unlink(filePath).catch(() => {});
await expect(clearAllOAuthClientState()).resolves.toBeUndefined();
resetNodeOAuthStorageCache();
});
it("should isolate state from default store", async () => {
@ -637,20 +598,14 @@ describe("NodeOAuthStorage with custom storagePath", () => {
);
try {
// The store is created with skipHydration: true; OAuthStorageBase
// normally drives rehydrate(), but this test writes via the raw store
// so hydrate it explicitly first (otherwise the later `new
// NodeOAuthStorage()` wrapper would drive the first rehydrate and clobber
// these unpersisted in-memory writes).
const defaultStore = getOAuthStore();
await defaultStore.persist.rehydrate();
defaultStore.getState().setServerState(testServerUrl, {
tokens: {
access_token: "default-token",
token_type: "Bearer",
},
resetNodeOAuthStorageCache();
const defaultStorage = new NodeOAuthStorage();
await defaultStorage.saveTokens(testServerUrl, {
access_token: "default-token",
token_type: "Bearer",
});
resetNodeOAuthStorageCache(customPath);
const customStorage = new NodeOAuthStorage(customPath);
await customStorage.saveTokens(testServerUrl, {
access_token: "custom-token",
@ -660,17 +615,18 @@ 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);
await defaultStorage.clear(testServerUrl);
} finally {
try {
await fs.unlink(customPath);
} catch {
/* ignore */
}
resetNodeOAuthStorageCache(customPath);
resetNodeOAuthStorageCache();
}
});
});

View file

@ -83,7 +83,7 @@ describe("InspectorClient EMA E2E", () => {
});
beforeEach(async () => {
clearAllOAuthClientState();
await clearAllOAuthClientState();
vi.spyOn(console, "log").mockImplementation(() => {});
const keys = await createEmaMockKeyMaterial();
@ -199,14 +199,12 @@ describe("InspectorClient EMA E2E", () => {
await flushStoreFileWrites(oauthTestStatePath);
const raw = JSON.parse(await fs.readFile(oauthTestStatePath, "utf-8")) as {
state: {
servers: Record<
string,
{ tokens?: { access_token?: string }; enterpriseManaged?: boolean }
>;
};
servers: Record<
string,
{ tokens?: { access_token?: string }; enterpriseManaged?: boolean }
>;
};
const entry = raw.state.servers[mcpUrl];
const entry = raw.servers[mcpUrl];
expect(entry?.tokens?.access_token).toBeDefined();
expect(entry?.enterpriseManaged).toBe(true);
});

View file

@ -91,9 +91,9 @@ describe("InspectorClient OAuth E2E", () => {
}
});
beforeEach(() => {
beforeEach(async () => {
clearOAuthTestData();
clearAllOAuthClientState();
await clearAllOAuthClientState();
// Capture console.log output instead of printing to stdout during tests
vi.spyOn(console, "log").mockImplementation(() => {});
});
@ -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();
});
@ -1072,16 +1072,14 @@ describe("InspectorClient OAuth E2E", () => {
expect(client.getStatus()).toBe("connected");
type StateShape = {
state?: {
servers?: Record<string, { tokens?: { access_token?: string } }>;
};
servers?: Record<string, { tokens?: { access_token?: string } }>;
};
// Persistence is fire-and-forget; await the write rather than polling.
await flushStoreFileWrites(customPath);
const parsed = JSON.parse(
await fs.readFile(customPath, "utf-8"),
) as StateShape;
const servers = parsed.state?.servers ?? {};
const servers = parsed.servers ?? {};
expect(Object.keys(servers).length).toBeGreaterThan(0);
expect(
Object.values(servers).some((s) => !!s?.tokens?.access_token),

View file

@ -388,7 +388,7 @@ describe("InspectorClient remote mid-session OAuth", () => {
expect(recovery?.authChallenge.reason).toBe("insufficient_scope");
const mcpServerUrl = `${serverUrl}/mcp`;
const scopeBeforeStepUp = oauthConfig.storage.getScope(mcpServerUrl);
const scopeBeforeStepUp = await oauthConfig.storage.getScope(mcpServerUrl);
expect(scopeBeforeStepUp).toContain("mcp");
expect(scopeBeforeStepUp).not.toContain("weather:read");
@ -413,7 +413,7 @@ describe("InspectorClient remote mid-session OAuth", () => {
expect(callbackClient.getStatus()).toBe("connected");
const scopeAfterStepUp = oauthConfig.storage.getScope(mcpServerUrl);
const scopeAfterStepUp = await oauthConfig.storage.getScope(mcpServerUrl);
expect(scopeAfterStepUp).toContain("weather:read");
expect(scopeAfterStepUp).toContain("mcp");

View file

@ -352,18 +352,11 @@ describe("InspectorClient OAuth E2E with Remote Storage", () => {
remoteAuthToken!,
(body) => {
const b = body as {
state?: {
servers?: Record<
string,
{ tokens?: { access_token?: string } }
>;
};
servers?: Record<string, { tokens?: { access_token?: string } }>;
};
return !!(
b?.state?.servers &&
Object.values(b.state.servers).some(
(s) => s?.tokens?.access_token,
)
b?.servers &&
Object.values(b.servers).some((s) => s?.tokens?.access_token)
);
},
);

View file

@ -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();
});

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