fix: key the in-flight clear guard on the OAuth storage key too (#2217)
Copilot: `runClear`'s double-click guard deduped by `server.id`, which is the same identity mismatch this PR fixes, one layer up. Two catalog entries against one URL share one credential blob, one grant and one revocation, so an id-keyed guard let exactly the race it exists to prevent through between them — concurrent store writes, concurrent RFC 7009 requests, and two contradictory teardown toasts. The guard now keys on `oauthClearKey`, a pure helper that resolves the OAuth storage key and falls back to the entry id for a config that has none (stdio), where there is no shared state to collide over. The id fallback keeps such entries distinct from each other rather than collapsing them onto one key, and both forms are prefixed so a user-supplied id can never impersonate a URL. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VGbw6TezNWnXZSCSp7sqb4 Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
79896b6356
commit
b2bbfcb409
1 changed files with 76 additions and 7 deletions
38
clients/web/src/utils/oauthClearKey.test.ts
Normal file
38
clients/web/src/utils/oauthClearKey.test.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { oauthClearKey } 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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue