Skip to content

Commit 4a24c48

Browse files
cliffhallclaude
andcommitted
fix(auth): address Copilot review round 2 on the RFC 7009 revocation (#2144)
- `revokeStoredKeys`' pre-read of `getTokens` sat outside the best-effort catch. `getTokens` parses through `OAuthTokensSchema`, so a persisted token that no longer validates rejected and abandoned the local delete `--relogin` promises — over a grant that could not have been revoked anyway. Caught and retained as a failed outcome; the clear proceeds. - The multi-key bookkeeping was wrong twice. A token is now marked spent only on a `revoked` outcome, so a failed or unsupported attempt no longer skips a duplicate entry under the other key that may hold the credentials or metadata that would have worked. And a failure now outranks an earlier success for reporting, so a stale grant left live cannot be hidden behind the first key's 200. - `--no-revoke` is now rejected without `--relogin`, ahead of the short-circuit returns, matching the parser's existing stance on accepted-but-inert flags (`--strict`). On its own it reads as "this run will not revoke anything", true only because nothing was being cleared. - Extracted the App draft merge as `utils/serverWithDraftSettings`, so the rule has a test. The hook tests receive whatever they are handed and cannot tell a draft from a persisted entry, and `App.test.tsx`'s settings harness connects a stdio server where the OAuth section never renders. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh Signed-off-by: cliffhall <cliff@futurescale.com>
1 parent 7f27d7e commit 4a24c48

7 files changed

Lines changed: 278 additions & 20 deletions

File tree

‎clients/cli/__tests__/clear-stored-auth-for-relogin.test.ts‎

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,44 @@ describe("clearStoredAuthForRelogin", () => {
103103
return file;
104104
}
105105

106+
/** Seed both key spellings of one server, each with its own refresh token. */
107+
function seedBothSpellings(
108+
normalizedToken: string,
109+
rawToken: string,
110+
): void {
111+
dir = fs.mkdtempSync(path.join(os.tmpdir(), "cli-relogin-two-"));
112+
const file = path.join(dir, "oauth.json");
113+
const metadata = {
114+
issuer: "https://as.example.com",
115+
authorization_endpoint: "https://as.example.com/authorize",
116+
token_endpoint: "https://as.example.com/token",
117+
revocation_endpoint: "https://as.example.com/revoke",
118+
response_types_supported: ["code"],
119+
};
120+
const entry = (refresh: string) => ({
121+
tokens: {
122+
access_token: `a-${refresh}`,
123+
token_type: "Bearer",
124+
refresh_token: refresh,
125+
},
126+
serverMetadata: metadata,
127+
});
128+
fs.writeFileSync(
129+
file,
130+
JSON.stringify({
131+
servers: {
132+
"https://example.com": entry(rawToken),
133+
"https://example.com/": entry(normalizedToken),
134+
},
135+
idpSessions: {},
136+
}),
137+
"utf8",
138+
);
139+
prevPath = process.env.MCP_INSPECTOR_OAUTH_STATE_PATH;
140+
process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = file;
141+
resetNodeOAuthStorageCache();
142+
}
143+
106144
it("revokes the stored grant before deleting it", async () => {
107145
const file = seed();
108146
const fetchSpy = vi
@@ -249,6 +287,74 @@ describe("clearStoredAuthForRelogin", () => {
249287
}
250288
});
251289

290+
// A token the AS never accepted is not spent, so the other key — which may
291+
// carry the credentials or metadata that would have worked — must still be
292+
// tried rather than skipped as a duplicate.
293+
it("does not treat a failed attempt as having ended the grant", async () => {
294+
seedBothSpellings("same-r", "same-r");
295+
const fetchSpy = vi
296+
.spyOn(globalThis, "fetch")
297+
.mockResolvedValue(new Response(null, { status: 500 }));
298+
try {
299+
await expect(
300+
clearStoredAuthForRelogin("https://example.com"),
301+
).resolves.toMatchObject({ status: "failed" });
302+
expect(fetchSpy).toHaveBeenCalledTimes(2);
303+
} finally {
304+
fetchSpy.mockRestore();
305+
}
306+
});
307+
308+
// A grant still live at the authorization server is what the user needs to
309+
// hear about; reporting the earlier success would print no warning at all.
310+
it("reports a later failure over an earlier success", async () => {
311+
seedBothSpellings("live-r", "stale-r");
312+
const fetchSpy = vi
313+
.spyOn(globalThis, "fetch")
314+
.mockResolvedValueOnce(new Response(null, { status: 200 }))
315+
.mockResolvedValue(new Response(null, { status: 500 }));
316+
try {
317+
await expect(
318+
clearStoredAuthForRelogin("https://example.com"),
319+
).resolves.toMatchObject({ status: "failed" });
320+
} finally {
321+
fetchSpy.mockRestore();
322+
}
323+
});
324+
325+
// `getTokens` parses through `OAuthTokensSchema`, so a persisted token that
326+
// no longer validates rejects. That must not abandon the local delete
327+
// `--relogin` promises — over a grant that could not have been revoked.
328+
it("still clears when the stored token cannot be parsed", async () => {
329+
dir = fs.mkdtempSync(path.join(os.tmpdir(), "cli-relogin-bad-token-"));
330+
const file = path.join(dir, "oauth.json");
331+
fs.writeFileSync(
332+
file,
333+
JSON.stringify({
334+
servers: {
335+
"https://example.com/mcp": {
336+
// No `token_type` — fails OAuthTokensSchema.
337+
tokens: { access_token: 42 },
338+
},
339+
},
340+
idpSessions: {},
341+
}),
342+
"utf8",
343+
);
344+
prevPath = process.env.MCP_INSPECTOR_OAUTH_STATE_PATH;
345+
process.env.MCP_INSPECTOR_OAUTH_STATE_PATH = file;
346+
resetNodeOAuthStorageCache();
347+
348+
await expect(
349+
clearStoredAuthForRelogin("https://example.com/mcp"),
350+
).resolves.toMatchObject({ status: "failed" });
351+
352+
const blob = JSON.parse(fs.readFileSync(file, "utf8")) as {
353+
servers: Record<string, unknown>;
354+
};
355+
expect(blob.servers["https://example.com/mcp"]).toBeUndefined();
356+
});
357+
252358
it("reports no_tokens when the store holds nothing for the server", async () => {
253359
dir = fs.mkdtempSync(path.join(os.tmpdir(), "cli-relogin-empty-"));
254360
const file = path.join(dir, "oauth.json");

‎clients/cli/__tests__/relogin-revocation.test.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,20 @@ describe("--relogin token revocation", () => {
185185
}
186186
});
187187

188+
// Inert-but-accepted flags are rejected in this parser (see the `--strict`
189+
// rationale in `cli.ts`). On its own `--no-revoke` reads as "this run will
190+
// not revoke anything", which is true only because nothing was being cleared.
191+
it("rejects --no-revoke without --relogin", async () => {
192+
const result = await runCli([
193+
"--no-revoke",
194+
"--server-url",
195+
SERVER_URL,
196+
"--method",
197+
"tools/list",
198+
]);
199+
expect(result.stderr).toMatch(/--no-revoke requires --relogin/);
200+
});
201+
188202
// A failed revocation must be visible — the grant is still live at the
189203
// authorization server — without turning `--relogin` into a failure, which
190204
// is a local delete the user still gets.

‎clients/cli/src/clear-stored-auth-for-relogin.ts‎

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -70,13 +70,19 @@ export async function clearStoredAuthForRelogin(
7070
* under the other spelling is still a live grant at the authorization server,
7171
* and deleting it locally without revoking is the leak this whole change
7272
* closes. Two keys holding the *same* token are one grant, so the duplicate is
73-
* skipped rather than producing a second request for something already ended.
73+
* skipped rather than producing a second request for something already ended —
74+
* but only once one has actually been *revoked*, since a failed or unsupported
75+
* attempt has ended nothing and the other key may hold the credentials that
76+
* would have worked.
7477
*
75-
* The reported outcome is the first key's that was not "nothing to do", which
76-
* with `keys` in `findStoredServerState` precedence means the grant the CLI
77-
* would actually have connected with. When no key holds a token, the last
78-
* "nothing to do" answer is returned so the caller can still tell "no tokens"
79-
* from "this authorization server advertises no revocation endpoint".
78+
* Reporting prefers a **failure** over any earlier success — a grant still live
79+
* at the authorization server is what the user needs to hear about, and an
80+
* earlier success would otherwise silence the warning. Failing that, it is the
81+
* first key's outcome that was not "nothing to do", which with `keys` in
82+
* `findStoredServerState` precedence means the grant the CLI would actually
83+
* have connected with. When no key holds a token, the last "nothing to do"
84+
* answer is returned so the caller can still tell "no tokens" from "this
85+
* authorization server advertises no revocation endpoint".
8086
*/
8187
async function revokeStoredKeys(
8288
storage: NodeOAuthStorage,
@@ -87,7 +93,21 @@ async function revokeStoredKeys(
8793
let reported: TokenRevocationOutcome | undefined;
8894
let lastSkip: TokenRevocationOutcome | undefined;
8995
for (const key of new Set(keys)) {
90-
const token = selectRevocableToken(await storage.getTokens(key))?.token;
96+
// This read is OUTSIDE `revokeStoredOAuthTokens`'s own best-effort catch,
97+
// so it needs its own: `getTokens` parses through `OAuthTokensSchema` and
98+
// rejects on a persisted token that no longer validates. Letting that
99+
// escape would abandon the *local* delete `--relogin` promises, over a
100+
// grant we could not have revoked anyway.
101+
let token: string | undefined;
102+
try {
103+
token = selectRevocableToken(await storage.getTokens(key))?.token;
104+
} catch (err) {
105+
reported ??= {
106+
status: "failed",
107+
detail: err instanceof Error ? err.message : String(err),
108+
};
109+
continue;
110+
}
91111
if (token !== undefined && revokedTokens.has(token)) continue;
92112
const outcome = await revokeStoredOAuthTokens({
93113
serverUrl: key,
@@ -98,8 +118,21 @@ async function revokeStoredKeys(
98118
lastSkip = outcome;
99119
continue;
100120
}
101-
if (token !== undefined) revokedTokens.add(token);
102-
reported ??= outcome;
121+
// Only a token the authorization server actually accepted counts as spent.
122+
// Marking one revoked on a `failed` or `no_endpoint` outcome would skip a
123+
// duplicate entry under the other key that may carry usable metadata or
124+
// credentials — the one chance left to end that grant.
125+
if (token !== undefined && outcome.status === "revoked") {
126+
revokedTokens.add(token);
127+
}
128+
// A failure outranks an earlier success for reporting: a grant that is
129+
// still live is what the user needs to hear about, and reporting the
130+
// success would print no warning while a stale grant survives.
131+
if (outcome.status === "failed") {
132+
if (reported?.status !== "failed") reported = outcome;
133+
} else {
134+
reported ??= outcome;
135+
}
103136
}
104137
return reported ?? lastSkip;
105138
}

‎clients/cli/src/cli.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -780,7 +780,7 @@ async function parseArgs(argv?: string[]): Promise<ParseResult> {
780780
)
781781
.option(
782782
"--no-revoke",
783-
"With --relogin, skip the RFC 7009 revocation request that would otherwise end the grant at the authorization server before the local state is deleted. Also skipped when the server entry sets oauth.revokeOnClear to false.",
783+
"Requires --relogin. Skips the RFC 7009 revocation request that would otherwise end the grant at the authorization server before the local state is deleted. Also skipped when the server entry sets oauth.revokeOnClear to false.",
784784
)
785785
.option(
786786
"--wait-for-auth <sec>",
@@ -867,6 +867,15 @@ async function parseArgs(argv?: string[]): Promise<ParseResult> {
867867
}
868868
}
869869

870+
// `--no-revoke` only means anything alongside `--relogin` — it suppresses the
871+
// RFC 7009 request that clear makes. Accepted on its own it is inert, and
872+
// worse than inert: it reads as "this run will not revoke anything", which is
873+
// true only because nothing was going to be cleared. Rejected here, ahead of
874+
// the short-circuit returns, for the same reason `--strict` is (#2144).
875+
if (options.revoke === false && !options.relogin) {
876+
throw new Error("--no-revoke requires --relogin (it has no other effect).");
877+
}
878+
870879
// `--strict` is checked HERE, ahead of every short-circuit return below
871880
// (`--list-stored-auth`, `--print-handoff`, `servers/list`, `servers/show`),
872881
// rather than beside the other method-shaped validations further down. Those

‎clients/web/src/App.tsx‎

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ import {
9292
type PendingClientRequestContent,
9393
} from "./components/groups/PendingClientRequestModal/PendingClientRequestModal";
9494
import { downloadJsonFile } from "./lib/downloadFile";
95+
import { serverWithDraftSettings } from "./utils/serverWithDraftSettings";
9596
import { enrichProtocolEntries } from "./utils/correlateTransportErrors";
9697
import { visibleMalformedListItems } from "./utils/malformedListReport";
9798
import { parseDeepLink, deepLinkParseStatus } from "./utils/deepLink";
@@ -1327,16 +1328,10 @@ function App() {
13271328
const handleClearStoredOAuthFromSettings = useCallback(() => {
13281329
if (!settingsModalTarget) return;
13291330
// Clear from *inside* the settings modal, so the draft is what the user is
1330-
// looking at: `settingsModalTarget` comes from the persisted `servers`
1331-
// list, and edits only reach it after the save debounce. Reading it would
1332-
// mean toggling "Revoke tokens on clear" and immediately clearing used the
1333-
// previous value — revoking despite an opt-out, or skipping despite an
1334-
// opt-in (#2144). The draft is the live answer; `settingsModalValue` falls
1335-
// back to the persisted entry whenever there is no draft.
1336-
void clearServerOAuthAndDisconnect({
1337-
...settingsModalTarget,
1338-
settings: settingsDraft ?? settingsModalTarget.settings,
1339-
});
1331+
// looking at rather than what the debounced save has persisted (#2144).
1332+
void clearServerOAuthAndDisconnect(
1333+
serverWithDraftSettings(settingsModalTarget, settingsDraft),
1334+
);
13401335
}, [settingsModalTarget, settingsDraft, clearServerOAuthAndDisconnect]);
13411336

13421337
const onSettingsModalClose = useCallback(() => {
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { describe, it, expect } from "vitest";
2+
import type {
3+
InspectorServerSettings,
4+
ServerEntry,
5+
} from "@inspector/core/mcp/types.js";
6+
import { serverWithDraftSettings } from "./serverWithDraftSettings";
7+
8+
const settings = (
9+
over: Partial<InspectorServerSettings> = {},
10+
): InspectorServerSettings => ({
11+
headers: [],
12+
env: [],
13+
metadata: {},
14+
connectionTimeout: 0,
15+
requestTimeout: 0,
16+
taskTtl: 60000,
17+
maxFetchRequests: 1000,
18+
roots: [],
19+
...over,
20+
});
21+
22+
const entry = (over: Partial<ServerEntry> = {}): ServerEntry =>
23+
({
24+
id: "a",
25+
name: "Server A",
26+
config: { type: "streamable-http", url: "https://mcp.example/mcp" },
27+
connection: { status: "disconnected" },
28+
...over,
29+
}) as ServerEntry;
30+
31+
describe("serverWithDraftSettings", () => {
32+
// The case the helper exists for (#2144): an action in the settings modal
33+
// must read the value on screen, not the one the debounced save has written.
34+
it("prefers an unsaved draft over the persisted settings", () => {
35+
const result = serverWithDraftSettings(
36+
entry({ settings: settings({ oauthRevokeOnClear: undefined }) }),
37+
settings({ oauthRevokeOnClear: false }),
38+
);
39+
expect(result.settings?.oauthRevokeOnClear).toBe(false);
40+
});
41+
42+
// The other direction matters just as much: re-checking the box before the
43+
// save lands must not leave the clear skipping revocation.
44+
it("prefers a draft that turns the setting back on", () => {
45+
const result = serverWithDraftSettings(
46+
entry({ settings: settings({ oauthRevokeOnClear: false }) }),
47+
settings({ oauthRevokeOnClear: undefined }),
48+
);
49+
expect(result.settings?.oauthRevokeOnClear).toBeUndefined();
50+
});
51+
52+
it("returns the entry untouched when there is no draft", () => {
53+
const original = entry({
54+
settings: settings({ oauthRevokeOnClear: false }),
55+
});
56+
expect(serverWithDraftSettings(original, undefined)).toBe(original);
57+
});
58+
59+
it("keeps the entry's identity and config", () => {
60+
const original = entry({ settings: settings() });
61+
const result = serverWithDraftSettings(original, settings({ taskTtl: 1 }));
62+
expect(result.id).toBe(original.id);
63+
expect(result.name).toBe(original.name);
64+
expect(result.config).toBe(original.config);
65+
expect(result).not.toBe(original);
66+
});
67+
});
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import type {
2+
InspectorServerSettings,
3+
ServerEntry,
4+
} from "@inspector/core/mcp/types.js";
5+
6+
/**
7+
* A server entry whose `settings` reflect the **unsaved** settings draft.
8+
*
9+
* Actions taken from inside the Server Settings modal have to act on what the
10+
* user is looking at, not on what has been persisted. The entry comes from the
11+
* `servers` list, which the debounced save has not reached yet, so reading its
12+
* `settings` means a control toggled a moment ago is still at its previous
13+
* value. That is invisible for most settings — they are read on the next
14+
* connect — but not for one consumed by a button in the same dialog:
15+
* unchecking "Revoke tokens on clear" and immediately clearing would still
16+
* revoke, and re-checking it would still skip (#2144).
17+
*
18+
* `draft` is nullish before the modal has produced one — `useSettingsDraft`
19+
* types it `| null` and callers may hold `| undefined` — in which case the
20+
* entry's own settings are already the current answer. Both are accepted so a
21+
* caller never has to normalize one into the other at the call site, which is
22+
* exactly where the distinction would get lost.
23+
*
24+
* Extracted rather than inlined at the call site so the rule has a test: the
25+
* hook tests below `clearServerOAuthAndDisconnect` receive whatever they are
26+
* handed and cannot tell a draft from a persisted entry, and `App.tsx`'s own
27+
* settings harness connects a stdio server, where the OAuth section never
28+
* renders at all.
29+
*/
30+
export function serverWithDraftSettings<
31+
T extends Pick<ServerEntry, "settings">,
32+
>(entry: T, draft: InspectorServerSettings | null | undefined): T {
33+
return draft == null ? entry : { ...entry, settings: draft };
34+
}

0 commit comments

Comments
 (0)