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>
This commit is contained in:
parent
7f27d7e9c7
commit
4a24c4880d
3 changed files with 278 additions and 20 deletions
|
|
@ -92,6 +92,7 @@ import {
|
|||
type PendingClientRequestContent,
|
||||
} from "./components/groups/PendingClientRequestModal/PendingClientRequestModal";
|
||||
import { downloadJsonFile } from "./lib/downloadFile";
|
||||
import { serverWithDraftSettings } from "./utils/serverWithDraftSettings";
|
||||
import { enrichProtocolEntries } from "./utils/correlateTransportErrors";
|
||||
import { visibleMalformedListItems } from "./utils/malformedListReport";
|
||||
import { parseDeepLink, deepLinkParseStatus } from "./utils/deepLink";
|
||||
|
|
@ -1327,16 +1328,10 @@ function App() {
|
|||
const handleClearStoredOAuthFromSettings = useCallback(() => {
|
||||
if (!settingsModalTarget) return;
|
||||
// Clear from *inside* the settings modal, so the draft is what the user is
|
||||
// looking at: `settingsModalTarget` comes from the persisted `servers`
|
||||
// list, and edits only reach it after the save debounce. Reading it would
|
||||
// mean toggling "Revoke tokens on clear" and immediately clearing used the
|
||||
// previous value — revoking despite an opt-out, or skipping despite an
|
||||
// opt-in (#2144). The draft is the live answer; `settingsModalValue` falls
|
||||
// back to the persisted entry whenever there is no draft.
|
||||
void clearServerOAuthAndDisconnect({
|
||||
...settingsModalTarget,
|
||||
settings: settingsDraft ?? settingsModalTarget.settings,
|
||||
});
|
||||
// looking at rather than what the debounced save has persisted (#2144).
|
||||
void clearServerOAuthAndDisconnect(
|
||||
serverWithDraftSettings(settingsModalTarget, settingsDraft),
|
||||
);
|
||||
}, [settingsModalTarget, settingsDraft, clearServerOAuthAndDisconnect]);
|
||||
|
||||
const onSettingsModalClose = useCallback(() => {
|
||||
|
|
|
|||
67
clients/web/src/utils/serverWithDraftSettings.test.ts
Normal file
67
clients/web/src/utils/serverWithDraftSettings.test.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import type {
|
||||
InspectorServerSettings,
|
||||
ServerEntry,
|
||||
} from "@inspector/core/mcp/types.js";
|
||||
import { serverWithDraftSettings } from "./serverWithDraftSettings";
|
||||
|
||||
const settings = (
|
||||
over: Partial<InspectorServerSettings> = {},
|
||||
): InspectorServerSettings => ({
|
||||
headers: [],
|
||||
env: [],
|
||||
metadata: {},
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
taskTtl: 60000,
|
||||
maxFetchRequests: 1000,
|
||||
roots: [],
|
||||
...over,
|
||||
});
|
||||
|
||||
const entry = (over: Partial<ServerEntry> = {}): ServerEntry =>
|
||||
({
|
||||
id: "a",
|
||||
name: "Server A",
|
||||
config: { type: "streamable-http", url: "https://mcp.example/mcp" },
|
||||
connection: { status: "disconnected" },
|
||||
...over,
|
||||
}) as ServerEntry;
|
||||
|
||||
describe("serverWithDraftSettings", () => {
|
||||
// The case the helper exists for (#2144): an action in the settings modal
|
||||
// must read the value on screen, not the one the debounced save has written.
|
||||
it("prefers an unsaved draft over the persisted settings", () => {
|
||||
const result = serverWithDraftSettings(
|
||||
entry({ settings: settings({ oauthRevokeOnClear: undefined }) }),
|
||||
settings({ oauthRevokeOnClear: false }),
|
||||
);
|
||||
expect(result.settings?.oauthRevokeOnClear).toBe(false);
|
||||
});
|
||||
|
||||
// The other direction matters just as much: re-checking the box before the
|
||||
// save lands must not leave the clear skipping revocation.
|
||||
it("prefers a draft that turns the setting back on", () => {
|
||||
const result = serverWithDraftSettings(
|
||||
entry({ settings: settings({ oauthRevokeOnClear: false }) }),
|
||||
settings({ oauthRevokeOnClear: undefined }),
|
||||
);
|
||||
expect(result.settings?.oauthRevokeOnClear).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns the entry untouched when there is no draft", () => {
|
||||
const original = entry({
|
||||
settings: settings({ oauthRevokeOnClear: false }),
|
||||
});
|
||||
expect(serverWithDraftSettings(original, undefined)).toBe(original);
|
||||
});
|
||||
|
||||
it("keeps the entry's identity and config", () => {
|
||||
const original = entry({ settings: settings() });
|
||||
const result = serverWithDraftSettings(original, settings({ taskTtl: 1 }));
|
||||
expect(result.id).toBe(original.id);
|
||||
expect(result.name).toBe(original.name);
|
||||
expect(result.config).toBe(original.config);
|
||||
expect(result).not.toBe(original);
|
||||
});
|
||||
});
|
||||
34
clients/web/src/utils/serverWithDraftSettings.ts
Normal file
34
clients/web/src/utils/serverWithDraftSettings.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import type {
|
||||
InspectorServerSettings,
|
||||
ServerEntry,
|
||||
} from "@inspector/core/mcp/types.js";
|
||||
|
||||
/**
|
||||
* A server entry whose `settings` reflect the **unsaved** settings draft.
|
||||
*
|
||||
* Actions taken from inside the Server Settings modal have to act on what the
|
||||
* user is looking at, not on what has been persisted. The entry comes from the
|
||||
* `servers` list, which the debounced save has not reached yet, so reading its
|
||||
* `settings` means a control toggled a moment ago is still at its previous
|
||||
* value. That is invisible for most settings — they are read on the next
|
||||
* connect — but not for one consumed by a button in the same dialog:
|
||||
* unchecking "Revoke tokens on clear" and immediately clearing would still
|
||||
* revoke, and re-checking it would still skip (#2144).
|
||||
*
|
||||
* `draft` is nullish before the modal has produced one — `useSettingsDraft`
|
||||
* types it `| null` and callers may hold `| undefined` — in which case the
|
||||
* entry's own settings are already the current answer. Both are accepted so a
|
||||
* caller never has to normalize one into the other at the call site, which is
|
||||
* exactly where the distinction would get lost.
|
||||
*
|
||||
* Extracted rather than inlined at the call site so the rule has a test: the
|
||||
* hook tests below `clearServerOAuthAndDisconnect` receive whatever they are
|
||||
* handed and cannot tell a draft from a persisted entry, and `App.tsx`'s own
|
||||
* settings harness connects a stdio server, where the OAuth section never
|
||||
* renders at all.
|
||||
*/
|
||||
export function serverWithDraftSettings<
|
||||
T extends Pick<ServerEntry, "settings">,
|
||||
>(entry: T, draft: InspectorServerSettings | null | undefined): T {
|
||||
return draft == null ? entry : { ...entry, settings: draft };
|
||||
}
|
||||
Loading…
Reference in a new issue