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:
commit
32a3610737
22 changed files with 5046 additions and 3271 deletions
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
225
clients/web/src/test/core/auth/oauth-persist.test.ts
Normal file
225
clients/web/src/test/core/auth/oauth-persist.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
84
clients/web/src/test/core/auth/store.test.ts
Normal file
84
clients/web/src/test/core/auth/store.test.ts
Normal 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: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue