Merge pull request #2238 from modelcontextprotocol/v2/fix/2217-oauth-clear-shared-url
fix: key the OAuth clear's active-session check on the storage key
This commit is contained in:
commit
e915d4f038
6 changed files with 558 additions and 18 deletions
|
|
@ -151,12 +151,18 @@ vi.mock("@inspector/core/mcp/index.js", async (importOriginal) => {
|
|||
clearOAuthTokens = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ status: "skipped", reason: "no_endpoint" });
|
||||
// #2217: the clear path resolves the session's OAuth key from the config
|
||||
// the client was BUILT with, not the (mutable) catalog entry — so the fake
|
||||
// has to carry the constructor's config the way the real client does.
|
||||
transportConfig: unknown = undefined;
|
||||
getTransportConfig = vi.fn(() => this.transportConfig);
|
||||
}
|
||||
const instances: FakeInspectorClient[] = [];
|
||||
return {
|
||||
...actual,
|
||||
InspectorClient: vi.fn(function () {
|
||||
InspectorClient: vi.fn(function (transportConfig: unknown) {
|
||||
const client = new FakeInspectorClient();
|
||||
client.transportConfig = transportConfig;
|
||||
instances.push(client);
|
||||
return client;
|
||||
}),
|
||||
|
|
@ -244,7 +250,13 @@ vi.mock("@inspector/core/mcp/state/stderrLogState.js", () => ({
|
|||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@inspector/core/mcp/remote/index.js", () => ({
|
||||
vi.mock("@inspector/core/mcp/remote/index.js", async (importOriginal) => ({
|
||||
// Partial: `getWebProxiedFetch` (the OAuth clear's revocation fetch, #2144)
|
||||
// reaches for `createRemoteFetch` from this module, and a bare stub would
|
||||
// throw a missing-export error before the clear under test ever ran.
|
||||
...(await importOriginal<
|
||||
typeof import("@inspector/core/mcp/remote/index.js")
|
||||
>()),
|
||||
RemoteInspectorClientStorage: vi.fn(function () {
|
||||
return { saveSession: vi.fn() };
|
||||
}),
|
||||
|
|
@ -601,6 +613,11 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
|
|||
<button onClick={() => props.servers.onServerSettings("A")}>
|
||||
open-settings
|
||||
</button>
|
||||
{/* A second settings target, so a test can drive a clear against an
|
||||
entry other than the active one (#2217). */}
|
||||
<button onClick={() => props.servers.onServerSettings("B")}>
|
||||
open-settings-b
|
||||
</button>
|
||||
{/* The real server grid (and its Add / Edit controls) lives inside this
|
||||
mocked view, so the config modal is only reachable through these
|
||||
callbacks — and the highlight batch only observable through this prop. */}
|
||||
|
|
@ -4214,3 +4231,123 @@ describe("App MCP App listed-resource metadata wiring (#2055)", () => {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
// #2217 — persisted OAuth state is keyed by server URL and `serverList`
|
||||
// enforces no URL uniqueness, so two catalog entries can share one credential
|
||||
// blob, one grant and one revocation. `runClear`'s in-flight guard used to
|
||||
// dedupe by catalog id, which cannot see that: clearing both entries while the
|
||||
// first revocation was still out ran concurrent store writes, concurrent RFC
|
||||
// 7009 requests and two contradictory teardown toasts — the exact race the
|
||||
// guard exists to prevent (Copilot, on this PR).
|
||||
describe("App dedupes concurrent OAuth clears that share a storage key (#2217)", () => {
|
||||
const SHARED_URL = "https://shared.example/mcp";
|
||||
const sharedEntry = (id: string, name: string): ServerEntry => ({
|
||||
id,
|
||||
name,
|
||||
config: { type: "streamable-http", url: SHARED_URL },
|
||||
connection: { status: "disconnected" },
|
||||
});
|
||||
|
||||
let previousUseServers: typeof useServers | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
clientInstances.length = 0;
|
||||
previousUseServers = vi.mocked(useServers).getMockImplementation();
|
||||
vi.mocked(useInspectorClient).mockReturnValue(DEFAULT_USE_INSPECTOR_CLIENT);
|
||||
vi.mocked(useServers).mockReturnValue({
|
||||
servers: [sharedEntry("A", "PlotRocket"), sharedEntry("B", "Same URL")],
|
||||
loading: false,
|
||||
error: undefined,
|
||||
refresh: vi.fn().mockResolvedValue(undefined),
|
||||
addServer: addServerSpy,
|
||||
updateServer: updateServerSpy,
|
||||
updateServerSettings: updateServerSettingsSpy,
|
||||
removeServer: vi.fn(),
|
||||
reorderServers: vi.fn(),
|
||||
importSource: vi.fn().mockResolvedValue({ servers: {} }),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (previousUseServers) {
|
||||
vi.mocked(useServers).mockImplementation(previousUseServers);
|
||||
}
|
||||
});
|
||||
|
||||
/** Open the settings modal for `which` and press its OAuth-section clear. */
|
||||
async function clearFromSettings(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
which: "open-settings" | "open-settings-b",
|
||||
): Promise<void> {
|
||||
await user.click(screen.getByText(which));
|
||||
// The control lives in an accordion section. The modal stays mounted
|
||||
// across a target switch, so the section may already be open from a
|
||||
// previous call — toggling it again would close it.
|
||||
const section = await screen.findByRole("button", {
|
||||
name: "OAuth Settings",
|
||||
});
|
||||
if (section.getAttribute("aria-expanded") !== "true") {
|
||||
await user.click(section);
|
||||
}
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "Clear stored OAuth state" }),
|
||||
);
|
||||
}
|
||||
|
||||
// Three full modal interaction sequences against the whole App tree, so this
|
||||
// one runs long enough to trip the 5s default when the suite is under load.
|
||||
it(
|
||||
"suppresses a second clear for another entry with the same URL, and allows one after it settles",
|
||||
{ timeout: 20000 },
|
||||
async () => {
|
||||
// `delay: null` drops userEvent's inter-event waits, which dominate here.
|
||||
const user = userEvent.setup({ delay: null });
|
||||
renderWithMantine(<App />);
|
||||
|
||||
await user.click(screen.getByText("connect"));
|
||||
await waitFor(() => expect(clientInstances).toHaveLength(1));
|
||||
// The instances are typed `EventTarget`; an intersection names the
|
||||
// test-only spy without erasing that (Copilot).
|
||||
const client = clientInstances[0] as EventTarget & {
|
||||
clearOAuthTokens: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
// Hold the first clear open, as a pending RFC 7009 request would.
|
||||
let settle: (v: { status: string; reason: string }) => void = () => {};
|
||||
client.clearOAuthTokens.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
settle = resolve as typeof settle;
|
||||
}),
|
||||
);
|
||||
|
||||
await clearFromSettings(user, "open-settings");
|
||||
await waitFor(() =>
|
||||
expect(client.clearOAuthTokens).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
|
||||
// Entry B: a different catalog id, the same OAuth storage key. Both clears
|
||||
// route through the live client precisely because they share that key, so
|
||||
// an id-keyed guard would let this second one straight through.
|
||||
await clearFromSettings(user, "open-settings-b");
|
||||
expect(client.clearOAuthTokens).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Suppression is for the duration of the in-flight clear only — the key
|
||||
// must be released when it settles, or the control is dead for the rest of
|
||||
// the session.
|
||||
await act(async () => {
|
||||
settle({ status: "skipped", reason: "no_endpoint" });
|
||||
await Promise.resolve();
|
||||
});
|
||||
client.clearOAuthTokens.mockResolvedValue({
|
||||
status: "skipped",
|
||||
reason: "no_endpoint",
|
||||
});
|
||||
await clearFromSettings(user, "open-settings-b");
|
||||
await waitFor(() =>
|
||||
expect(client.clearOAuthTokens).toHaveBeenCalledTimes(2),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -115,6 +115,7 @@ import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js";
|
|||
import { getAuthToken } from "./lib/authToken";
|
||||
import { messagesToLogEntries } from "./lib/protocolReplay";
|
||||
import { EMPTY_SETTINGS } from "./utils/serverSettingsDefaults";
|
||||
import { resolveOAuthClearIdentity } from "./utils/oauthClearKey";
|
||||
import {
|
||||
bodyDroppedToastId,
|
||||
CLIENT_CONFIG_LOAD_ERROR_NOTIFICATION_ID,
|
||||
|
|
@ -1350,10 +1351,10 @@ function App() {
|
|||
const settingsModalIsStdio = settingsModalServerType === "stdio";
|
||||
|
||||
/**
|
||||
* Servers whose clear is in flight (#2144). Keyed by id, not a single flag:
|
||||
* the callback explicitly supports clearing a server other than the active
|
||||
* one, so a global lock would silently drop B's click while A's revocation
|
||||
* was still out. See `runClear`.
|
||||
* Servers whose clear is in flight (#2144). Keyed per server, not a single
|
||||
* flag: the callback explicitly supports clearing a server other than the
|
||||
* active one, so a global lock would silently drop B's click while A's
|
||||
* revocation was still out. See `runClear`.
|
||||
*/
|
||||
const clearOAuthInFlightRef = useRef<Set<string>>(new Set());
|
||||
|
||||
|
|
@ -1375,11 +1376,27 @@ function App() {
|
|||
// — and with revocation taking up to five seconds, that means concurrent
|
||||
// RFC 7009 requests, concurrent store writes, and two contradictory
|
||||
// toasts. Keyed by server so a *different* server's clear is unaffected.
|
||||
if (clearOAuthInFlightRef.current.has(server.id)) return;
|
||||
clearOAuthInFlightRef.current.add(server.id);
|
||||
//
|
||||
// "Different server" is the OAuth storage key, not the catalog id
|
||||
// (#2217, Copilot): two entries against one URL share one blob, one
|
||||
// grant and one revocation, so an id-keyed guard lets exactly the race
|
||||
// above through between them. And for anything touching the live
|
||||
// session the key is the *client's*, not the entry's — an entry edited
|
||||
// while connected reads a URL the session never authorized against, so
|
||||
// an entry-keyed lock would name an operation nobody is performing.
|
||||
// `resolveOAuthClearIdentity` is the same call the clear itself makes,
|
||||
// so the two cannot disagree.
|
||||
const { inFlightKey } = resolveOAuthClearIdentity({
|
||||
server,
|
||||
activeServerId,
|
||||
activeClientConfig: inspectorClient?.getTransportConfig(),
|
||||
activeEntryConfig: activeServer?.config,
|
||||
});
|
||||
if (clearOAuthInFlightRef.current.has(inFlightKey)) return;
|
||||
clearOAuthInFlightRef.current.add(inFlightKey);
|
||||
clearServerOAuthAndDisconnect(server)
|
||||
.finally(() => {
|
||||
clearOAuthInFlightRef.current.delete(server.id);
|
||||
clearOAuthInFlightRef.current.delete(inFlightKey);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
notifications.show({
|
||||
|
|
@ -1392,7 +1409,12 @@ function App() {
|
|||
});
|
||||
});
|
||||
},
|
||||
[clearServerOAuthAndDisconnect],
|
||||
[
|
||||
clearServerOAuthAndDisconnect,
|
||||
activeServerId,
|
||||
inspectorClient,
|
||||
activeServer,
|
||||
],
|
||||
);
|
||||
|
||||
const handleClearConnectionOAuth = useCallback(() => {
|
||||
|
|
|
|||
|
|
@ -112,6 +112,12 @@ const entry = (
|
|||
...over,
|
||||
});
|
||||
|
||||
/** A catalog entry pointing at a different URL, so a different OAuth blob. */
|
||||
const otherUrlEntry = (id: string): ServerEntry =>
|
||||
entry(id, {
|
||||
config: { type: "streamable-http", url: "https://other.example/mcp" },
|
||||
});
|
||||
|
||||
const challenge = (
|
||||
reason: AuthChallenge["reason"] = "unauthorized",
|
||||
): AuthChallenge => ({ reason });
|
||||
|
|
@ -151,6 +157,13 @@ function fakeClient(over: Partial<Record<string, unknown>> = {}) {
|
|||
handleAuthChallenge: vi.fn().mockResolvedValue({ kind: "failed" }),
|
||||
disconnect: vi.fn().mockResolvedValue(undefined),
|
||||
resumeAfterOAuth: vi.fn().mockResolvedValue(undefined),
|
||||
// #2217: the clear path resolves the session's OAuth key from the config
|
||||
// the client was built with, since a catalog entry can be edited while
|
||||
// connected without rebuilding it. Defaults to the shared fixture URL.
|
||||
getTransportConfig: vi.fn(() => ({
|
||||
type: "streamable-http" as const,
|
||||
url: "https://mcp.example/mcp",
|
||||
})),
|
||||
...over,
|
||||
});
|
||||
}
|
||||
|
|
@ -1801,7 +1814,8 @@ describe("useOAuthRecovery", () => {
|
|||
const client = fakeClient();
|
||||
const h = harness({ servers: [entry("a")], activeServerId: "a", client });
|
||||
await act(async () => {
|
||||
await h.api().clearServerOAuthAndDisconnect(entry("b"));
|
||||
// A genuinely unrelated entry: its own URL, so its own OAuth blob.
|
||||
await h.api().clearServerOAuthAndDisconnect(otherUrlEntry("b"));
|
||||
});
|
||||
expect(client.disconnect).not.toHaveBeenCalled();
|
||||
expect(
|
||||
|
|
@ -1809,6 +1823,96 @@ describe("useOAuthRecovery", () => {
|
|||
).toBeDefined();
|
||||
});
|
||||
|
||||
// #2217 — OAuth state is keyed by URL, so two catalog entries against the
|
||||
// same URL share one blob. Clearing the inactive one destroys the active
|
||||
// session's tokens (and revokes its grant), which an id-only check cannot
|
||||
// see; the session was left connected on dead credentials with no notice.
|
||||
it("disconnects the active session when clearing an entry that shares its URL", async () => {
|
||||
const client = fakeClient();
|
||||
const h = harness({
|
||||
servers: [entry("a"), entry("b")],
|
||||
activeServerId: "a",
|
||||
client,
|
||||
});
|
||||
await act(async () => {
|
||||
await h.api().clearServerOAuthAndDisconnect(entry("b"));
|
||||
});
|
||||
expect(client.disconnect).toHaveBeenCalled();
|
||||
// The live client owns the clear, so in-memory flow state goes too.
|
||||
expect(clearServerOAuthStateMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
isActiveConnection: true,
|
||||
inspectorClient: client,
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
toastWith(
|
||||
"authorizes against the same URL, so its stored tokens went too",
|
||||
),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
// Copilot on this PR: a card can be edited while connected and the catalog
|
||||
// write does not rebuild the client, so the active *entry* can read a URL
|
||||
// the live session never authorized against. Reading the entry would miss
|
||||
// an entry still sitting on the client's real URL.
|
||||
it("compares against the live client's URL, not the edited catalog entry's", async () => {
|
||||
const client = fakeClient({
|
||||
// Still authorized against the shared URL...
|
||||
getTransportConfig: vi.fn(() => ({
|
||||
type: "streamable-http" as const,
|
||||
url: "https://mcp.example/mcp",
|
||||
})),
|
||||
});
|
||||
const h = harness({
|
||||
// ...while A's catalog entry has since been edited elsewhere.
|
||||
servers: [otherUrlEntry("a"), entry("b")],
|
||||
activeServerId: "a",
|
||||
client,
|
||||
});
|
||||
await act(async () => {
|
||||
await h.api().clearServerOAuthAndDisconnect(entry("b"));
|
||||
});
|
||||
expect(client.disconnect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// The same-URL branch still snapshots the session it acted on, so a switch
|
||||
// during the in-flight clear must not drag the cleanup onto the new one.
|
||||
it("does not disconnect a session switched to during a shared-URL clear", async () => {
|
||||
let settle: (r: { cleared: boolean }) => void = () => {};
|
||||
clearServerOAuthStateMock.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
settle = resolve as typeof settle;
|
||||
}),
|
||||
);
|
||||
const client = fakeClient();
|
||||
const h = harness({
|
||||
servers: [entry("a"), entry("b"), otherUrlEntry("c")],
|
||||
activeServerId: "a",
|
||||
client,
|
||||
});
|
||||
|
||||
let done: Promise<void>;
|
||||
await act(async () => {
|
||||
done = h.api().clearServerOAuthAndDisconnect(entry("b"));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
h.rerender({
|
||||
servers: [entry("a"), entry("b"), otherUrlEntry("c")],
|
||||
activeServerId: "c",
|
||||
client,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
settle({ cleared: true });
|
||||
await done;
|
||||
});
|
||||
|
||||
expect(client.disconnect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// #2144 — this is the web client's production wiring for revocation.
|
||||
// Without asserting the arguments, removing the per-server opt-out or
|
||||
// handing it the page-origin fetch would leave every test green.
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import { oauthDetailsFromConnectionState } from "../components/groups/Connection
|
|||
import { getWebRemoteOAuthStorage } from "../lib/remoteOAuthStorage";
|
||||
import { getWebProxiedFetch } from "../lib/webProxiedFetch";
|
||||
import { clearServerOAuthState } from "../lib/clearServerOAuthState";
|
||||
import { resolveOAuthClearIdentity } from "../utils/oauthClearKey";
|
||||
import { getAuthToken } from "../lib/authToken";
|
||||
import {
|
||||
isBrowserTabVisible,
|
||||
|
|
@ -1430,8 +1431,31 @@ export function useOAuthRecovery({
|
|||
|
||||
const clearServerOAuthAndDisconnect = useCallback(
|
||||
async (server: ClearableServer) => {
|
||||
const isActive = server.id === activeServerId;
|
||||
const client = isActive ? inspectorClient : null;
|
||||
// OAuth state is keyed by the server URL, but "is this the active
|
||||
// connection" was keyed by catalog entry id — and `serverList` enforces
|
||||
// no URL uniqueness, so two entries with different ids and the same URL
|
||||
// are a supported (and useful) state: separate names, headers or
|
||||
// settings against one server. Clearing the inactive one deletes the
|
||||
// *shared* blob and, with #2144 in, revokes the active session's grant —
|
||||
// while an id-only check takes the inactive branch and leaves that
|
||||
// session connected on credentials that no longer exist, silently
|
||||
// (#2217). Comparing the storage keys is what sees it; the id check
|
||||
// cannot, by construction.
|
||||
//
|
||||
// The live client's own config is what the comparison uses, because a
|
||||
// card can be edited while connected and the catalog write does not
|
||||
// rebuild the client (Copilot). `resolveOAuthClearIdentity` owns the
|
||||
// rule; `App`'s in-flight guard reads the same answer from it.
|
||||
const { isActive, sharesActiveOAuthKey, affectsActiveSession } =
|
||||
resolveOAuthClearIdentity({
|
||||
server,
|
||||
activeServerId,
|
||||
activeClientConfig: inspectorClient?.getTransportConfig(),
|
||||
activeEntryConfig: sessionRef.current.servers.find(
|
||||
(s) => s.id === activeServerId,
|
||||
)?.config,
|
||||
});
|
||||
const client = affectsActiveSession ? inspectorClient : null;
|
||||
// The RFC 7009 leg is a bounded network request (#2144), so this callback
|
||||
// can stay suspended for seconds — long enough for the user to close the
|
||||
// modal and switch servers. `isActive` and `inspectorClient` were
|
||||
|
|
@ -1446,15 +1470,19 @@ export function useOAuthRecovery({
|
|||
// disconnect/reconnect to the SAME server builds a replacement
|
||||
// `InspectorClient`, so an id-only check passes again and the old clear
|
||||
// would run its session-wide cleanup against the new session.
|
||||
//
|
||||
// The id compared is the *active* one snapshotted alongside `isActive`,
|
||||
// not the cleared entry's — for a shared-key clear those differ, and the
|
||||
// session being protected is the active one.
|
||||
const stillTargetsActiveSession = (): boolean =>
|
||||
isActive &&
|
||||
sessionRef.current.activeServerId === server.id &&
|
||||
affectsActiveSession &&
|
||||
sessionRef.current.activeServerId === activeServerId &&
|
||||
sessionRef.current.inspectorClient === client;
|
||||
|
||||
const { cleared, revocation } = await clearServerOAuthState({
|
||||
config: server.config,
|
||||
inspectorClient: client,
|
||||
isActiveConnection: isActive,
|
||||
isActiveConnection: affectsActiveSession,
|
||||
oauthStorage: webOAuthStorage,
|
||||
revoke: server.settings?.oauthRevokeOnClear !== false,
|
||||
fetchFn: getWebProxiedFetch(getAuthToken()),
|
||||
|
|
@ -1481,7 +1509,7 @@ export function useOAuthRecovery({
|
|||
finalizeExplicitDisconnect();
|
||||
}
|
||||
}
|
||||
} else if (!isActive || stillTargetsActiveSession()) {
|
||||
} else if (!affectsActiveSession || stillTargetsActiveSession()) {
|
||||
// No client to disconnect — either this is a stored-only clear, or the
|
||||
// active session has none yet (it is being built or torn down). Either
|
||||
// way the resume snapshot is stale and must go; skipping it would leave
|
||||
|
|
@ -1495,7 +1523,14 @@ export function useOAuthRecovery({
|
|||
title: "OAuth state cleared",
|
||||
message: isActive
|
||||
? `Stored tokens and client registration were removed. Reconnect to run a fresh authorization flow.${revocationSuffix(revocation)}`
|
||||
: `Stored OAuth state was removed for "${server.name}". Connect to authorize again.${revocationSuffix(revocation)}`,
|
||||
: sharesActiveOAuthKey
|
||||
? // States the credential impact, which is certain, rather than
|
||||
// the disconnect, which is not: the stale-session guard skips it
|
||||
// after a switch, and `disconnect()` can reject (that failure
|
||||
// gets its own toast above). Either way the shared state is gone,
|
||||
// so the session must reconnect (Copilot).
|
||||
`Stored OAuth state was removed for "${server.name}". The active session authorizes against the same URL, so its stored tokens went too — reconnect to run a fresh authorization flow.${revocationSuffix(revocation)}`
|
||||
: `Stored OAuth state was removed for "${server.name}". Connect to authorize again.${revocationSuffix(revocation)}`,
|
||||
color: "blue",
|
||||
});
|
||||
},
|
||||
|
|
|
|||
151
clients/web/src/utils/oauthClearKey.test.ts
Normal file
151
clients/web/src/utils/oauthClearKey.test.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { oauthClearKey, resolveOAuthClearIdentity } from "./oauthClearKey";
|
||||
|
||||
describe("oauthClearKey", () => {
|
||||
// #2217 — the whole point: two entries against one URL share one OAuth blob,
|
||||
// so they must share one clear identity even though their ids differ.
|
||||
it("keys two entries with the same URL identically", () => {
|
||||
const config = {
|
||||
type: "streamable-http",
|
||||
url: "https://mcp.example/mcp",
|
||||
} as const;
|
||||
expect(oauthClearKey(config, "a")).toBe(oauthClearKey(config, "b"));
|
||||
});
|
||||
|
||||
it("separates entries pointing at different URLs", () => {
|
||||
expect(
|
||||
oauthClearKey({ type: "sse", url: "https://one.example/mcp" }, "a"),
|
||||
).not.toBe(
|
||||
oauthClearKey({ type: "sse", url: "https://two.example/mcp" }, "a"),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the entry id when there is no OAuth server URL", () => {
|
||||
const stdio = { type: "stdio", command: "node" } as const;
|
||||
expect(oauthClearKey(stdio, "a")).toBe("id:a");
|
||||
expect(oauthClearKey(stdio, "a")).not.toBe(oauthClearKey(stdio, "b"));
|
||||
});
|
||||
|
||||
// The prefix is what keeps a stdio entry named "url:…" from colliding with a
|
||||
// URL-keyed one — an id is user-supplied and a URL is not a reserved shape.
|
||||
it("cannot collide a URL key with an id key", () => {
|
||||
expect(
|
||||
oauthClearKey({ type: "stdio", command: "node" }, "url:https://x/mcp"),
|
||||
).not.toBe(
|
||||
oauthClearKey({ type: "sse", url: "https://x/mcp" }, "anything"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveOAuthClearIdentity", () => {
|
||||
const http = (url: string) => ({ type: "streamable-http", url }) as const;
|
||||
const X = http("https://x.example/mcp");
|
||||
const Y = http("https://y.example/mcp");
|
||||
|
||||
const resolve = (
|
||||
over: Partial<Parameters<typeof resolveOAuthClearIdentity>[0]> = {},
|
||||
) =>
|
||||
resolveOAuthClearIdentity({
|
||||
server: { id: "A", config: X },
|
||||
activeServerId: "A",
|
||||
activeClientConfig: X,
|
||||
activeEntryConfig: X,
|
||||
...over,
|
||||
});
|
||||
|
||||
it("treats the active entry as affecting the session", () => {
|
||||
const id = resolve();
|
||||
expect(id.isActive).toBe(true);
|
||||
expect(id.sharesActiveOAuthKey).toBe(false);
|
||||
expect(id.affectsActiveSession).toBe(true);
|
||||
});
|
||||
|
||||
it("treats an unrelated entry as affecting nothing", () => {
|
||||
const id = resolve({ server: { id: "B", config: Y } });
|
||||
expect(id.affectsActiveSession).toBe(false);
|
||||
expect(id.inFlightKey).toBe("url:https://y.example/mcp");
|
||||
});
|
||||
|
||||
// The #2217 case: distinct ids, one blob.
|
||||
it("treats a different entry with the session's URL as affecting the session", () => {
|
||||
const id = resolve({ server: { id: "B", config: X } });
|
||||
expect(id.isActive).toBe(false);
|
||||
expect(id.sharesActiveOAuthKey).toBe(true);
|
||||
expect(id.affectsActiveSession).toBe(true);
|
||||
});
|
||||
|
||||
// Copilot: a card can be edited while connected, and the catalog write does
|
||||
// not rebuild the client — so the entry reads Y while the live session is
|
||||
// still authorized against X. Reading the entry would miss entry B at X.
|
||||
it("follows the live client's URL, not the edited catalog entry's", () => {
|
||||
const edited = resolve({
|
||||
server: { id: "B", config: X },
|
||||
activeClientConfig: X,
|
||||
activeEntryConfig: Y,
|
||||
});
|
||||
expect(edited.sharesActiveOAuthKey).toBe(true);
|
||||
// And an entry at the entry's *new* URL is not the session's credentials.
|
||||
const notShared = resolve({
|
||||
server: { id: "B", config: Y },
|
||||
activeClientConfig: X,
|
||||
activeEntryConfig: Y,
|
||||
});
|
||||
expect(notShared.sharesActiveOAuthKey).toBe(false);
|
||||
});
|
||||
|
||||
// The lock has to name the storage operation actually performed. Clearing
|
||||
// the edited active entry routes through the live client, which still acts
|
||||
// on X — so an entry-keyed lock (`url:Y`) would not collide with a
|
||||
// concurrent clear of entry B at X, which performs the very same operation.
|
||||
it("locks an active-session clear on the client's key, not the entry's", () => {
|
||||
expect(
|
||||
resolve({
|
||||
server: { id: "A", config: Y },
|
||||
activeClientConfig: X,
|
||||
activeEntryConfig: Y,
|
||||
}).inFlightKey,
|
||||
).toBe("url:https://x.example/mcp");
|
||||
expect(
|
||||
resolve({
|
||||
server: { id: "B", config: X },
|
||||
activeClientConfig: X,
|
||||
activeEntryConfig: Y,
|
||||
}).inFlightKey,
|
||||
).toBe("url:https://x.example/mcp");
|
||||
});
|
||||
|
||||
it("falls back to the active entry's config when there is no live client", () => {
|
||||
const id = resolve({
|
||||
server: { id: "B", config: X },
|
||||
activeClientConfig: undefined,
|
||||
activeEntryConfig: X,
|
||||
});
|
||||
expect(id.sharesActiveOAuthKey).toBe(true);
|
||||
expect(id.inFlightKey).toBe("url:https://x.example/mcp");
|
||||
});
|
||||
|
||||
it("handles no active server at all", () => {
|
||||
const id = resolve({
|
||||
server: { id: "B", config: X },
|
||||
activeServerId: undefined,
|
||||
activeClientConfig: undefined,
|
||||
activeEntryConfig: undefined,
|
||||
});
|
||||
expect(id.isActive).toBe(false);
|
||||
expect(id.affectsActiveSession).toBe(false);
|
||||
expect(id.inFlightKey).toBe("url:https://x.example/mcp");
|
||||
});
|
||||
|
||||
// A stdio active session has no OAuth key, so an active clear against it
|
||||
// falls back to the entry id rather than collapsing onto a shared URL key.
|
||||
it("falls back to the entry id when the session has no OAuth URL", () => {
|
||||
const stdio = { type: "stdio", command: "node" } as const;
|
||||
expect(
|
||||
resolve({
|
||||
server: { id: "A", config: stdio },
|
||||
activeClientConfig: stdio,
|
||||
activeEntryConfig: stdio,
|
||||
}).inFlightKey,
|
||||
).toBe("id:A");
|
||||
});
|
||||
});
|
||||
91
clients/web/src/utils/oauthClearKey.ts
Normal file
91
clients/web/src/utils/oauthClearKey.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { getOAuthServerUrl } from "@inspector/core/mcp/config.js";
|
||||
import type { MCPServerConfig } from "@inspector/core/mcp/types.js";
|
||||
|
||||
/**
|
||||
* The identity a *clear* of stored OAuth state acts on, for a config on its
|
||||
* own.
|
||||
*
|
||||
* Persisted OAuth state is keyed by the server URL, and `core/mcp/serverList`
|
||||
* enforces no URL uniqueness — so two catalog entries with distinct ids can
|
||||
* point at one URL and therefore share one credential blob, one live grant and
|
||||
* one revocation. Anything that dedupes, locks or compares *clears* has to key
|
||||
* on that shared identity rather than on the entry id, which cannot see it
|
||||
* (#2217).
|
||||
*
|
||||
* A config with no OAuth server URL (stdio, and anything else
|
||||
* `getOAuthServerUrl` declines) has no shared blob to collide over, so it falls
|
||||
* back to the entry id — which keeps every such entry distinct from every
|
||||
* other, rather than collapsing them all onto one shared key.
|
||||
*/
|
||||
export function oauthClearKey(config: MCPServerConfig, id: string): string {
|
||||
const url = getOAuthServerUrl(config);
|
||||
// Prefixed so a URL-keyed entry can never collide with an id-keyed one.
|
||||
return url !== undefined ? `url:${url}` : `id:${id}`;
|
||||
}
|
||||
|
||||
/** What a clear is being asked to do, relative to the live session. */
|
||||
export interface OAuthClearIdentityInput {
|
||||
/** The catalog entry whose OAuth state the user asked to clear. */
|
||||
server: { id: string; config: MCPServerConfig };
|
||||
activeServerId: string | undefined;
|
||||
/**
|
||||
* The config the **live** `InspectorClient` was built with
|
||||
* (`getTransportConfig()`), when there is one.
|
||||
*
|
||||
* This — not the catalog entry — is what the session's credentials are keyed
|
||||
* under. A card can be edited while connected and the catalog write does not
|
||||
* rebuild the client, so after A connects to X and is edited to Y, the live
|
||||
* client still authorizes against X while the entry reads Y (Copilot).
|
||||
*/
|
||||
activeClientConfig: MCPServerConfig | undefined;
|
||||
/** The active entry's catalog config, used only when there is no client. */
|
||||
activeEntryConfig: MCPServerConfig | undefined;
|
||||
}
|
||||
|
||||
export interface OAuthClearIdentity {
|
||||
/** The cleared entry *is* the active one. */
|
||||
isActive: boolean;
|
||||
/** A different entry, whose OAuth state the live session is using. */
|
||||
sharesActiveOAuthKey: boolean;
|
||||
/** Either of the above: the active session's credentials are being destroyed. */
|
||||
affectsActiveSession: boolean;
|
||||
/**
|
||||
* The identity to lock an in-flight clear on. Names the storage operation
|
||||
* that will actually be performed — for anything touching the live session
|
||||
* that is the client's key, which is not necessarily the entry's own.
|
||||
*/
|
||||
inFlightKey: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve who a clear affects and what it locks, from one place.
|
||||
*
|
||||
* Both consumers need the same answer and would otherwise each re-derive it:
|
||||
* `useOAuthRecovery` to decide whether to route through the live client and
|
||||
* disconnect, and `App`'s `runClear` to dedupe concurrent clears. Two copies of
|
||||
* this rule that disagree is exactly the class of bug #2217 is.
|
||||
*/
|
||||
export function resolveOAuthClearIdentity({
|
||||
server,
|
||||
activeServerId,
|
||||
activeClientConfig,
|
||||
activeEntryConfig,
|
||||
}: OAuthClearIdentityInput): OAuthClearIdentity {
|
||||
const isActive = activeServerId !== undefined && server.id === activeServerId;
|
||||
// Client first: the entry is mutable, the built client is not.
|
||||
const activeConfig = activeClientConfig ?? activeEntryConfig;
|
||||
const activeKey = activeConfig ? getOAuthServerUrl(activeConfig) : undefined;
|
||||
const clearedKey = getOAuthServerUrl(server.config);
|
||||
const sharesActiveOAuthKey =
|
||||
!isActive && clearedKey !== undefined && clearedKey === activeKey;
|
||||
const affectsActiveSession = isActive || sharesActiveOAuthKey;
|
||||
return {
|
||||
isActive,
|
||||
sharesActiveOAuthKey,
|
||||
affectsActiveSession,
|
||||
inFlightKey:
|
||||
affectsActiveSession && activeKey !== undefined
|
||||
? `url:${activeKey}`
|
||||
: oauthClearKey(server.config, server.id),
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue