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:
cliffhall 2026-08-28 01:47:48 -04:00
commit 4a24c4880d

View file

@ -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(() => {