feat(auth): revoke OAuth tokens at the authorization server on clear (RFC 7009) - #2186
Conversation
…2144) Clearing the Inspector's OAuth state deleted the local copy of the tokens and stopped there. The access token — and the refresh token, which is long-lived by design — stayed valid at the authorization server until they expired on their own, so a day of connect/disconnect iteration left it holding grants for sessions that ended hours ago. RFC 7009 §1 describes exactly that case, and nothing in the repo constructed a revocation request, so a server author implementing `revocation_endpoint` had no way to exercise it with the tool that exists to exercise this. core/auth/revocation.ts builds and sends the request; the three clear paths call it, so the behavior is written once — web's "Clear OAuth state and disconnect" (both the active and stored-only paths), the TUI's Auth tab, and the CLI's --relogin. It runs BEFORE the local clear, because the token, the client credentials and the discovered `revocation_endpoint` all live in the store the clear is about to empty. The request names the refresh token when there is one: §2.1 asks the AS to also invalidate the access tokens issued under the same grant, so one request covers both halves. Best-effort by construction. Every path returns a TokenRevocationOutcome rather than throwing: no advertised endpoint means nothing is sent and that authorization server behaves exactly as before, and a network error, a non-2xx, a 5s timeout or an unreadable store is reported and nothing more. Forgetting the tokens is what the user asked for. `lost_authorization_state` recovery deliberately skips it — it clears a half-finished flow to retry it, and an authorization that never completed has no grant to revoke. Client authentication resolves the preregistered slot first, mirroring BaseOAuthClientProvider.clientInformation; reading only the dynamic slot would send no authentication for a server configured with `oauth.clientId`. The Basic credential is byte-identical to the SDK's applyBasicAuth so this request and the token request cannot present the same secret differently. Opt out per server with `oauth.revokeOnClear` (default on, only `false` written to disk) or per CLI run with --no-revoke. Read at clear time rather than connect time, so toggling needs no reconnect. Turning it off is a testing affordance: a client that walks away still holding live tokens is a case a server author may want to reproduce. test-servers now advertises `revocation_endpoint` and serves POST /oauth/revoke, faithful on §2.2 (an unknown token is a success) and §2.1 (revoking a refresh token invalidates the access tokens under the same grant), with oauth-revocation-http.json / oauth-no-revocation-http.json as showcase configs. 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>
Mirrors `remoteOAuthStorage.test.ts`, whose identical guard is covered the same way. Without it the file sat at 83.33% branches and failed the per-file gate. 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>
There was a problem hiding this comment.
Pull request overview
Adds best-effort RFC 7009 OAuth token revocation before clearing local state across the Web, TUI, and CLI clients.
Changes:
- Implements shared token revocation with endpoint discovery, client authentication, timeout handling, and outcomes.
- Adds per-server and CLI opt-outs plus user-facing status reporting.
- Extends OAuth fixtures, integration coverage, configuration validation, and documentation.
Reviewed changes
Copilot reviewed 40 out of 40 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
test-servers/src/test-server-oauth.ts |
Adds revocation endpoint and token linkage. |
test-servers/src/test-server-fixtures.ts |
Adds revocation fixture option. |
test-servers/src/load-config.ts |
Extends OAuth config schema. |
test-servers/src/composable-test-server.ts |
Documents server revocation support. |
test-servers/configs/oauth-revocation-http.json |
Adds revocation-enabled fixture. |
test-servers/configs/oauth-no-revocation-http.json |
Adds no-revocation fixture. |
README.md |
Documents manual revocation testing. |
docs/mcp-server-configuration.md |
Documents revokeOnClear. |
core/mcp/types.ts |
Adds revocation settings types. |
core/mcp/serverList.ts |
Maps persisted revocation settings. |
core/mcp/remote/node/server.ts |
Validates and persists the setting. |
core/mcp/oauthManager.ts |
Revokes before clearing storage. |
core/mcp/inspectorClient.ts |
Exposes revocation outcomes. |
core/auth/revocation.ts |
Implements RFC 7009 requests. |
core/auth/index.ts |
Exports revocation APIs. |
clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts |
Tests remote settings handling. |
clients/web/src/test/integration/mcp/inspectorClient.test.ts |
Updates clear outcome expectations. |
clients/web/src/test/integration/auth/revocation-e2e.test.ts |
Adds end-to-end revocation tests. |
clients/web/src/test/core/mcp/serverList.test.ts |
Tests settings persistence. |
clients/web/src/test/core/mcp/oauthManager.test.ts |
Tests revoke-before-clear behavior. |
clients/web/src/test/core/auth/revocation.test.ts |
Tests revocation request logic. |
clients/web/src/lib/webProxiedFetch.ts |
Creates cached backend-proxied fetches. |
clients/web/src/lib/webProxiedFetch.test.ts |
Tests proxied-fetch construction. |
clients/web/src/lib/clearServerOAuthState.ts |
Adds revocation to web clear paths. |
clients/web/src/lib/clearServerOAuthState.test.ts |
Tests web clear sequencing. |
clients/web/src/hooks/useOAuthRecovery.ts |
Wires settings and result notifications. |
clients/web/src/hooks/useOAuthRecovery.test.tsx |
Tests revocation notification text. |
clients/web/src/hooks/useConnectionLifecycle.ts |
Skips revocation during recovery. |
clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.tsx |
Maps the new setting. |
clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.test.tsx |
Tests modal setting conversion. |
clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx |
Adds the revocation checkbox. |
clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.test.tsx |
Tests checkbox behavior. |
clients/tui/src/App.tsx |
Adds revocation to TUI clearing. |
clients/tui/README.md |
Documents TUI behavior. |
clients/tui/__tests__/App.test.tsx |
Updates client mock outcome. |
clients/cli/src/cli.ts |
Adds --no-revoke and warnings. |
clients/cli/src/clear-stored-auth-for-relogin.ts |
Revokes before CLI relogin clearing. |
clients/cli/README.md |
Documents CLI revocation. |
clients/cli/__tests__/clear-stored-auth-for-relogin.test.ts |
Tests CLI revocation helper. |
AGENTS.md |
Records the architecture behavior. |
Suppressed comments (1)
clients/web/src/test/core/auth/revocation.test.ts:386
- This is another unjustified
as unknown as, which the repository's type-safety rules prohibit. Type the logger object against the parameter type so changes toInspectorLoggerremain compiler-checked.
logger: logger as unknown as Parameters<
typeof revokeStoredOAuthTokens
>[0]["logger"],
💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.
| .option( | ||
| "--no-revoke", | ||
| "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.", | ||
| ) |
There was a problem hiding this comment.
Fair — added clients/cli/__tests__/relogin-revocation.test.ts, driving runCli with a stubbed fetch that answers only the revocation endpoint. Five cases: revocation by default, --no-revoke skipping it (which is what pins Commander's --no-* mapping), the per-server oauth.revokeOnClear: false opt-out via --catalog, a catalog server that did not opt out, and the stderr warning on a 500.
Each case fails to connect on purpose — the stub refuses everything else — which is fine, because revocation runs before the connect.
…2144) - RFC 8414 §2 defaults an omitted `revocation_endpoint_auth_methods_supported` to `client_secret_basic`; it does not inherit the token endpoint's list. Inheriting made metadata advertising only `client_secret_post` there send POST credentials to an endpoint that never advertised the method. - The 5s timeout was inert on the web paths: `createRemoteFetch` re-issues the call as a POST to `/api/fetch` and drops `init.signal`, and the backend's outbound fetch gets none either, so a wedged AS could hold the teardown open indefinitely. A wall-clock race now enforces the deadline; the signal is kept because it genuinely cancels the direct-fetch paths. - The settings-modal Clear read `settingsModalTarget`, which comes from the persisted `servers` list, while edits live in `settingsDraft` until the save debounce. Toggling the checkbox and clearing immediately used the previous value. It now merges the draft. - `--relogin` clears both key spellings, so it now revokes from both rather than stopping at the first populated one — a stale entry under the other spelling is a live grant. The normalised key goes first, matching `findStoredServerState`, and a second key holding the same token is skipped. - The fixture's /oauth/revoke now ENFORCES RFC 7009 §2.1 client authentication (both RFC 6749 §2.3.1 forms), so the e2e's claim that the Inspector authenticates correctly is actually tested; two negative cases assert a wrong secret and an unidentified client are refused. - Dropped the `as unknown as` casts from the tests: a typed `InspectorLogger` double, and a spy on the real storage instead of a spread-and-cast. - Added CLI-level tests through `runCli` for `--no-revoke`, the per-server `oauth.revokeOnClear`, and the stderr warning, and TUI tests for the forwarded opt-out and the rendered failure. 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>
Review round 1 — all nine addressedMirroring the inline replies here, since they go outdated once the fixes are pushed.
One gap flagged rather than papered over (#3). The behavior is fixed, but there is no test.
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 43 out of 43 changed files in this pull request and generated 3 comments.
Suppressed comments (3)
Previously missed (3) — in code that hasn't changed since the last review.
clients/cli/src/cli.ts:784
--no-revokeis silently accepted when--reloginis absent, even though it then has no effect. This conflicts with the parser's established rejection of accepted-but-inert flags (see the--strictrationale atclients/cli/src/cli.ts:870-876) and can give users false confidence that another clear path was changed. Add an early parse-time requirement thatoptions.revoke === falseimpliesoptions.relogin, including before short-circuit returns.
.option(
"--no-revoke",
"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.",
)
clients/web/src/lib/clearServerOAuthState.ts:81
- When revocation is enabled but
fetchFnis unavailable, this reportsreason: "disabled", whose public type explicitly means the caller opted out. That makesClearServerOAuthStateResultinaccurate for callers and conflates an unavailable proxy with user intent. Add a distinct skip reason such asno_fetch/fetch_unavailableand return it for this branch.
const revocation: TokenRevocationOutcome =
revoke && fetchFn
? await revokeStoredOAuthTokens({
serverUrl,
storage: params.oauthStorage,
fetchFn,
})
: { status: "skipped", reason: "disabled" };
clients/web/src/hooks/useOAuthRecovery.ts:1263
- The web hook's per-server opt-out wiring is not covered: the added tests exercise
revocationSuffix, but none asserts thatserver.settings.oauthRevokeOnClear === falsereachesclearServerOAuthStateasrevoke: false. Add a hook test for both the default and opt-out values so this client cannot silently diverge from the tested CLI/TUI behavior.
revoke: server.settings?.oauthRevokeOnClear !== false,
fetchFn: getWebProxiedFetch(getAuthToken()),
| void clearServerOAuthAndDisconnect({ | ||
| ...settingsModalTarget, | ||
| settings: settingsDraft ?? settingsModalTarget.settings, | ||
| }); |
There was a problem hiding this comment.
Took the "extract a testable helper" option — you are right that the hook tests cannot see this, since they receive whatever they are handed and cannot tell a draft from a persisted entry.
utils/serverWithDraftSettings(entry, draft) now owns the rule and has four cases: the draft wins in both directions (unchecking and re-checking, since the second is just as wrong), a nullish draft returns the entry unchanged by identity, and the entry's id/name/config survive the merge. It takes | null | undefined deliberately — useSettingsDraft types its draft | null — so a caller never normalizes one into the other, which is exactly where the distinction would get lost.
An App-level test still is not practical: App.test.tsx's settings harness connects the stdio SERVER_A, so the OAuth section and its Clear button never render, and switching that fixture to HTTP changes the server type for every neighbouring test in the file.
…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>
Review round 2 — all four addressedIncluding the suppressed "previously missed" one, which was a fair catch.
Still no App-level test, and the reason is unchanged.
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 45 out of 45 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
Previously missed (2) — in code that hasn't changed since the last review.
test-servers/src/test-server-oauth.ts:748
- The authenticated client is not checked against the token owner before deletion.
RefreshTokenDataalready recordsclientId, yet any other valid registered client can revoke that refresh token; access tokens have no owner recorded at all. RFC 7009 §2.1 requires verifying that the token was issued to the requesting client. Keep the 200 response for unknown/foreign tokens, but only delete tokens owned by the authenticated client (and track ownership for access tokens too).
clients/web/src/components/groups/ServerSettingsForm/ServerSettingsForm.tsx:936 - This adds a visible web control and changes the TUI status output, but the PR body contains no before/after screenshots or interaction proof. AGENTS.md requires screenshots (or a short GIF) for every web UI or TUI change; please attach proof for both affected surfaces from the gitignored
pr-screenshots/workflow.
<Checkbox
label="Revoke tokens on clear"
description="Calls the authorization server's RFC 7009 revocation endpoint before clearing the stored OAuth state, so the grant ends when the session does instead of staying valid until it expires. Servers that advertise no revocation_endpoint are unaffected. Uncheck it to reproduce a client that disconnects still holding live tokens."
checked={settings.oauthRevokeOnClear ?? true}
onChange={handleRevokeOnClearChange}
/>
…2144) Copilot review round 3. `clear(serverUrl)` deletes EVERY `byIssuer` slot, but revocation read only the context-free (active-issuer) token. A server that had authorized against issuers A and B therefore revoked B, then destroyed the local record of A while A's grant stayed live at its authorization server — the same leak this feature exists to close, one level down. - `OAuthStorage.listIssuers(serverUrl)` is the new seam (one implementation: every backend extends `OAuthStorageBase`). `collectGrants` walks it plus the ctx-less read, deduping by token, so every grant the clear will delete is covered. - Metadata is cached once per server, not per issuer, so a grant bound to an issuer the cached document does not describe cannot be revoked: sending its token to that endpoint would hand a credential to a server that never minted it. That grant is reported as failed — dropped unrevoked — rather than silently discarded or misdirected. - `aggregateOutcomes` gives a failure precedence over another grant's success, so one live grant cannot hide behind another's 200. Exported for its own test, since `computeOutcome` returns early on the empty case. Also from that round: - The fixture's /oauth/revoke now verifies token ownership (RFC 7009 §2.1). Access tokens record their owning `client_id`; a token belonging to another client is left alone and still answered 200 (§2.2 — the response must not tell one client whether another's token exists). New e2e case proves a second registered client cannot revoke the first's grant. - The TUI rendered the revocation-failure note in cyan, the informational tone. It is a partial success — the local clear really happened — so it is not an `error` status, but the grant may still be live and cyan understated that. `AuthTab` takes an `oauthMessageTone`; the message says so explicitly. 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>
Review round 3 — all three addressed
On the TUI proof specifically — it is a terminal capture, not a PNG, and that is a deliberate limitation rather than a shortcut: Capturing it also turned up something worth fixing: the message was rendering in cyan, the informational tone. It is a partial success — the local clear really happened, so it is not an
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 53 out of 53 changed files in this pull request and generated 2 comments.
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
clients/web/src/hooks/useOAuthRecovery.ts:1263
- The hook tests mock
clearServerOAuthState, but none asserts these new arguments. As a result, removing the per-server opt-out or replacing the proxied fetch with the wrong fetch would leave the tests green, even though this is the web client's production wiring for revocation. Add a hook-level case withoauthRevokeOnClear: falsethat verifiesrevoke: falseand the backend-proxied fetch are passed.
revoke: server.settings?.oauthRevokeOnClear !== false,
fetchFn: getWebProxiedFetch(getAuthToken()),
…ng stale (#2144) Copilot review round 4. - `getTokens(url, issuer)` falls back to the legacy unkeyed slot when that issuer holds none — right for a connect, wrong for `collectGrants`, which labelled the fallback with the issuer it happened to be iterating and paired it with that issuer's credentials. During a partially migrated flow the clear could have sent an old, unbound token to a newly discovered authorization server. `OAuthStorage.getIssuerTokens` is the exact, no-fallback read; only the ctx-less read still falls back, which is the one place it belongs. - The TUI's warning tone was sticky. It lived in its own state while ~30 call sites set ordinary OAuth notes without resetting it, so one revocation failure left every later "Authorization updated" yellow, across server switches. It is now DERIVED — `oauthMessageToneFor(message, warningText)` — so it cannot outlive the message that earned it, with nothing to remember to clear. Both setters stay plain `useState` setters: a `useCallback` wrapper would be a value `react-hooks/exhaustive-deps` demands in seven dependency arrays for an identity that never changes. - Hook-level tests for the web wiring (previously missed): the per-server opt-out and the backend-proxied fetch are now asserted to reach `clearServerOAuthState`, so handing it the page-origin fetch — which a real authorization server's missing CORS headers would reject — fails a test. 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>
Review round 4 — all three addressed
Worth calling out on the tone: the obvious shape — one Also: the tone is not observable from a rendered frame —
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 55 out of 55 changed files in this pull request and generated 2 comments.
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
clients/cli/src/clear-stored-auth-for-relogin.ts:111
- This skips the entire second storage key based only on the single active token returned by
getTokens(), butrevokeStoredOAuthTokens()now enumerates all issuer slots under that key. If the raw and normalized entries share an active token while the second entry also contains another issuer-bound grant, that additional grant is never examined and is then deleted locally. Revoke each distinct key (duplicate RFC 7009 requests are harmless) or deduplicate the full enumerated grant set instead of pre-reading one token.
if (token !== undefined && revokedTokens.has(token)) continue;
…res (#2144) Copilot review round 5. - Deduplicating grants by token string alone could conflate two grants: a token is only meaningful to the authorization server that minted it, so two issuers minting the same opaque value are two grants. The second was dropped before the issuer-mismatch check could report it, and `clear` then deleted it. The key is now issuer+token; the ctx-less read stays suppressed only when its token already came from a slot, which is exactly the active-issuer duplicate and never the legacy one. - A read failure in one issuer slot aborted `collectGrants`, so one corrupt slot left every other revocable grant untouched while the clear deleted them all. Each slot is now read independently and its failure carried as an outcome alongside the grants that could still be revoked; the no-metadata and no-endpoint returns carry them too, so a failure is never swallowed by a skip. - The CLI no longer deduplicates its two key spellings. That could only be done by pre-reading a single token, and `revokeStoredOAuthTokens` now enumerates every issuer slot under a key — so a shared active token skipped the second key entirely, taking any other issuer-bound grant under it with the local delete. A duplicate RFC 7009 request is harmless (§2.2 makes an unknown token a success); a missed one is the leak. 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>
Review round 5 — all three addressedAll three were the same family: my grant enumeration was too coarse in one direction and too fragile in another.
Three new tests: two issuers minting the same token value are not collapsed, a corrupt slot doesn't stop the readable grants being revoked, and both CLI keys are revoked from even when they hold the same token.
|
Review round 16 — "no new comments", but three in the suppressed blockThe review body says generated no new comments; its Suppressed comments (3) section carried three real findings, all in the same family — a completion acting on shared state it no longer owns. Addressed:
Both new tests were verified to fail with their fix reverted — the first version of the web one passed either way (it asserted on the replacement client, which the code never touches regardless), so it now asserts the original client is not torn down, which is the actual behavior change.
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 55 out of 55 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
clients/cli/tests/clear-stored-auth-for-relogin.test.ts:73
- This comment reverses the implemented race-avoidance contract: only the request data is captured before deletion; the HTTP request is deliberately sent afterward. Update it so the test documentation does not reintroduce the old ordering.
// #2144 — RFC 7009. The request has to go out *before* the delete, since it
// is built from the token, the client id and the cached metadata the delete
// removes.
…urrent one (#2144) Copilot review round 17. `resolveClientInformation` mirrored `BaseOAuthClientProvider`, preferring the preregistered entry. That order answers "who should I authenticate as now"; revocation asks "who minted this token", and the two diverge — the store lets a preregistered client and an issuer-bound dynamic registration coexist, so after a server is switched from DCR to a configured `oauth.clientId` an older DCR grant was revoked with the configured client's credentials. RFC 7009 §2.2 answers 200 for a token the server does not recognise as the caller's, so that reported `revoked` while the grant stayed live and the local record was already gone — the worst available outcome. The registration bound to the grant's issuer now wins, with the preregistered entry as the fallback, which covers both directions: a token minted with the configured client leaves the issuer slot empty, since the SDK writes it only after DCR. Stated in the comment rather than implied: this is best available evidence, not proof. The store records client information per issuer, not per token, so binding identity at save time remains the real fix and is a storage-shape change beyond this PR. Also fixed a CLI test comment still describing the pre-round-13 ordering. 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>
Review round 17 — both addressed
The failure mode on the first is the sharp part and worth restating: RFC 7009 §2.2 answers 200 for a token the server does not recognise as the caller's, so wrong credentials report What is not fixed, stated plainly in the code rather than implied: the store records client information per issuer, not per token, so a server that re-registered dynamically under one issuer still cannot tell which registration minted which grant. Binding the client identity to the token at save time is the real fix; that is a storage-shape change plus a migration, and forcing reauthorization when the configured identity changes is a behavior change with its own blast radius. Both are beyond this PR — happy to file a follow-up issue. Two tests, one per direction (DCR grant after a configured id is added; configured-client grant with an empty issuer slot).
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 55 out of 55 changed files in this pull request and generated 4 comments.
Suppressed comments (2)
Previously missed (2) — in code that hasn't changed since the last review.
clients/tui/src/components/AuthTab.tsx:440
- A rejection can come from
disconnectInspector()afterclearOAuthTokens()has already succeeded, so this line incorrectly reports that OAuth state was not cleared. Distinguish a clear failure from a post-clear disconnect failure (and retain the successful-clear confirmation for the latter).
Could not clear OAuth state
clients/web/src/App.tsx:1353
- This title is false when
clearServerOAuthState()has already cleared storage but the laterclient.disconnect()rejects: the promise propagates that rejection, suppresses the success toast, and this handler tells the user the stored state was not cleared. Preserve/report the partial result so disconnect failures are distinguished from storage-clear failures.
title: "Could not clear the stored OAuth state",
| const plan = await planOAuthRevocation({ | ||
| serverUrl, | ||
| storage: params.oauthStorage, | ||
| enabled: revoke && fetchFn !== undefined, | ||
| }); |
There was a problem hiding this comment.
Same fix — this path now calls clearAndPlanRevocation, which takes and clears atomically, then sends. You are right that the useOAuthRecovery session checks are downstream of this helper and cannot protect the store.
Copilot review round 18. Three of its findings were the same check-then-act: `planOAuthRevocation` is asynchronous, so the gap between its reads and the following `storage.clear()` is still a window — shorter than the network one closed in round 13, but the same shape. An OAuth completion landing at any of those awaits saved a grant the clear then destroyed. `OAuthStorage.takeRevocationSnapshot(serverUrl)` reads what revocation needs and clears the server in ONE synchronous pass over the in-memory state, with the persist after the mutation. `clearAndPlanRevocation` replaces `planOAuthRevocation`, and there is no separate `clear` call left in any caller — the clear cannot be forgotten or reordered. The snapshot returns values unparsed, deliberately: schema validation is pure and belongs after the mutation, since running it inside would reintroduce the `await` this exists to remove. Two consequences worth naming. A failure of the take-and-clear now REJECTS rather than returning an outcome, because the state was not cleared and the caller's contract is that it was; both clients already have a rejection path. And cross-process atomicity is explicitly not claimed — nothing in this store takes a lock, every mutation is a read-modify-write over a loaded snapshot, and `clear` alone always had that property. Also from round 18: - The CLI's budget check ran before considering `plan.outcome`, so a key that needed no network was reported as budget-exhausted — warning that a grant may be live when that key held none. - A disconnect failure after a successful clear was reported as "could not clear OAuth state" in both clients. It now goes to the disconnect channel, and the success is still reported. 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>
Review round 18 — all six addressed (4 inline + 2 suppressed)Three of the four inline findings were the same check-then-act, and the criticism lands: I closed the network-sized window in round 13 and left the same shape behind at a smaller scale.
Two consequences worth stating rather than leaving to be discovered:
Test-suite note: the snapshot change moved what the tests can stub, so several were rewritten — including the shared-deadline one, which now drives
|
| │ │ # TWO halves — planOAuthRevocation then | ||
| │ │ # executeOAuthRevocation — and the order | ||
| │ │ # between them is the contract: plan → | ||
| │ │ # storage.clear() → execute. The snapshot |
There was a problem hiding this comment.
Fixed — and this one mattered more than a name: it also still told callers to make a separate storage.clear() call, which is exactly the second clear the atomic step exists to remove. Rewritten to describe clearAndPlanRevocation + takeRevocationSnapshot, including the explicit note that cross-process atomicity is not claimed.
… stale API names (#2144) Copilot review round 19. - Parsing the preconfigured client registration sat outside the per-slot salvage, so one malformed fallback aborted the whole plan and grants carrying their own valid credentials were never revoked. It is a *fallback*; a bad one is now recorded and skipped, matching every other slot. - `sendPlans` preserved an earlier success when a later key was never attempted, contradicting the failure-first rule two lines below — the unattempted key's grant may still be live. That branch turns out to be a bound rather than a path (`withDeadline` fails any plan that reaches the deadline, so a preceding plan cannot both succeed and leave zero budget), so it carries a justified `v8 ignore` saying exactly that. - `clearStoredAuthForRelogin` takes an optional `budgetMs`, which is what lets the shared-budget behavior be tested without a five-second test. - Three stale references to `planOAuthRevocation`, which no longer exists — two JSDoc links and the `AGENTS.md` entry, which additionally still told callers to make a separate `storage.clear()` call. Following that text would have reintroduced the second clear the atomic step exists to remove. 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>
Review round 19 — all five addressed
On the budget branch: the policy criticism was right, but the branch turns out to be a bound rather than a path — That also needed a seam:
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 55 out of 55 changed files in this pull request and generated no new comments.
Suppressed comments (4)
Previously missed (1) — in code that hasn't changed since the last review.
clients/web/src/App.tsx:1346
- This single global lock also suppresses clears for different servers. If server A's revocation is pending and the user switches to B or opens B's settings, clicking B's clear silently returns even though the underlying callback explicitly supports session switches. Key the lock by
server.id(for example, aSet) so duplicate clears for A are blocked without dropping B's action.
if (clearOAuthInFlightRef.current) return;
clearOAuthInFlightRef.current = true;
clients/web/src/hooks/useOAuthRecovery.ts:1308
- This now skips resume-snapshot cleanup when the target is active but
inspectorClientis currently null, which is reachable while a session is being created or torn down. The explicit clear then deletes storage but leaves stale OAuth recovery state behind. Include that case only while it still targets the same active session.
} else if (!isActive) {
clearOAuthResumeOnExplicitDisconnect();
clients/cli/src/clear-stored-auth-for-relogin.ts:130
- This branch is reachable: one plan can resolve just before the deadline and the next
Date.now()can run after it; an injectedbudgetMsof zero reaches it directly as well. Suppressing it as dead bypasses the repository's branch-coverage policy for behavior that decides whether a later grant is attempted. Remove the ignore and add a test for exhaustion before the second plan.
/* v8 ignore next 10 -- A bound, not a path: `withDeadline` fails any plan
that reaches the deadline, so a preceding plan cannot both succeed and
leave zero budget. This exists so a plan reached with nothing left is
reported rather than firing a request it would immediately abandon. */
if (needsNetwork && remainingMs <= 0) {
core/auth/revocation.ts:115
- RFC 8414 defines an omitted
revocation_endpoint_auth_methods_supportedasclient_secret_basic, but returning[]does not enforce that with this SDK:selectClientAuthMethodtreats an empty list as “metadata omitted” and may honor the registration'stoken_endpoint_auth_method. A DCR client registered withclient_secret_postwill therefore send body credentials to a revocation endpoint that omitted this field and only promised Basic, causing revocation to fail. Return['client_secret_basic']when the field is absent and cover the registered-post/omitted-list case.
Copilot reported "no new comments" again, with four real findings in its suppressed block. - The web in-flight lock was global, so a pending clear for server A silently swallowed a click on server B's — even though the callback explicitly supports clearing a non-active server. Keyed by server id now. - `else if (!isActive)` skipped the resume-snapshot cleanup when the target IS active but has no client yet, which is reachable while a session is being built or torn down. The clear then emptied storage and left stale OAuth recovery state pointing at credentials that no longer existed. - `revocationAuthMethods` returned `[]` for an omitted `revocation_endpoint_auth_methods_supported`, which does not enforce RFC 8414's default: an empty list means "the metadata said nothing" to `selectClientAuthMethod`, which then honors the registration's own `token_endpoint_auth_method`. The default is named literally now. - The `v8 ignore` on the CLI budget branch was wrong, and the challenge to it was right: an injected `budgetMs` of zero reaches it directly, through a seam I had added myself the round before. Removed, and covered by a test that asserts no request is made and the unattempted key is reported. 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>
Review round 20 — "no new comments", four in the suppressed blockSame pattern as round 16: the body reports none, the Suppressed comments (4) section carries four real ones. All addressed.
That last one is the one I got wrong twice in two rounds, and it is worth naming: I justified an ignore on reachability grounds while holding the very injection point that makes it reachable. The repo's branch-coverage policy caught what my reasoning did not. One nuance on the auth-methods fix:
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 55 out of 55 changed files in this pull request and generated no new comments.
Suppressed comments (2)
Previously missed (1) — in code that hasn't changed since the last review.
core/auth/storage.ts:251
listIssuersandgetIssuerTokensare now unused production API: the atomictakeRevocationSnapshotrefactor supplies all issuer slots directly, and repository search finds these methods only in their implementations and tests. Keeping them expands the exportedOAuthStoragecontract, forces every storage mock/implementation to carry dead methods, and preserves obsolete tests. Remove both methods and their associated implementation/test scaffolding.
core/auth/revocation.ts:376
- The PR body still contradicts this implementation in three places: it says revocation is sent “before wiping local state,” names the removed
planOAuthRevocationAPI, and promises a storage-read failure returns an outcome. This function intentionally performstakeRevocationSnapshot(including the clear) before sending, and rethrows when that take/clear fails. Update the body to describe the current contract so future callers do not reintroduce the race or rely on a nonexistent API.
…orage API (#2144) The CI coverage job failed with `ReferenceError: sessionStorage is not defined`. `revocation-e2e.test.ts` used `BrowserOAuthStorage`, but the `integration` vitest project runs in the node environment, not happy-dom. Node 22 gates Web Storage behind a flag while newer Node exposes it by default, so it passed locally and failed in CI. It uses `NodeOAuthStorage` with a temp dir now, matching the other OAuth integration tests, with a comment naming the trap. Also from Copilot review round 21: `listIssuers` and `getIssuerTokens` became dead production API when `takeRevocationSnapshot` took over — a grep finds them only in their own implementations and tests. Keeping them would widen the exported `OAuthStorage` contract and force every implementation and mock to carry methods nothing calls. Removed, along with their tests and the mock entries in four suites. 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>
…nto v2/feat/2144-oauth-revocation
Closes #2144
Clearing the Inspector's OAuth state deleted the local copy of the tokens and stopped there. The access token — and the refresh token, which is long-lived by design — stayed valid at the authorization server until they expired on their own. From the AS's side nothing happened; the client just went quiet, and a day of connect/disconnect iteration left it holding a pile of grants for sessions that ended hours ago. RFC 7009 §1 describes exactly that case, and nothing in the repo constructed a revocation request, so a server author implementing
revocation_endpointalso had no way to exercise it with the tool that exists to exercise this.The clear path now sends an RFC 7009 revocation before wiping local state.
What it does
core/auth/revocation.tsbuilds and sends the request; the three clear paths call it, so the behavior is written once:--reloginThe ordering is snapshot → clear → revoke, and both halves of it matter.
The snapshot has to come first, because the token, the client credentials and the discovered
revocation_endpointall live in the store the clear is about to empty. But the clear must not wait on the network: a five-second revocation would otherwise leave a window in which a fresh authorization completes and is then deleted by a clear still reasoning about the grant it replaced. That is reachable in all three clients — a same-server reconnect in the TUI, a stored-only web clear for a server that becomes active mid-flight, and another CLI/TUI process writing to the shared file-backed store.So
core/auth/revocation.tsexposes two halves,clearAndPlanRevocationandexecuteOAuthRevocation. The first takes the stored state and clears it in one atomic storage step (OAuthStorage.takeRevocationSnapshot); the second sends from what was taken. There is deliberately no single-call wrapper and no separatestorage.clear()in any caller, so the clear cannot be forgotten or reordered.Two consequences worth knowing. A failure of the take-and-clear rejects rather than returning an outcome — the state was not cleared, and every caller's contract is that it was; both clients have a rejection path for that. And cross-process atomicity is not claimed: nothing in the OAuth store takes a lock, every mutation is a read-modify-write over a loaded snapshot, and
clear()alone always had that property.The request names the refresh token when there is one. RFC 7009 §2.1 asks the AS to also invalidate the access tokens issued under the same grant, so one request covers both halves; naming the access token instead would leave the long-lived one alive.
Only grants the endpoint can be proved to belong to are sent: metadata is cached once per server rather than per issuer, so an issuer-bound grant must match the cached
issuer, and a document naming none is a mismatch rather than a free pass. A grant that cannot be matched is reported as dropped-unrevoked instead of being sent to an authorization server that may never have minted it.It is best-effort, and the local clear always finishes
Every path returns a
TokenRevocationOutcomerather than throwing:revocation_endpoint→ nothing is sent, and that authorization server behaves exactly as it did before this existed. This is the path that makes the change safe against every AS without RFC 7009 support.Forgetting the tokens is what the user asked for, so no failure on this leg stops it.
One path deliberately never revokes:
lost_authorization_staterecovery clears a half-finished flow in order to retry it, and an authorization that never completed has no grant to revoke.The setting
oauth.revokeOnClear(defaulttrue), surfaced as Revoke tokens on clear in Server Settings → Authorization, plus a per-run--no-revokeon the CLI. Onlyfalseis written to disk, matchingoauth.requestRefreshTokennext to it, so an entry that never touched the switch keeps a byte-stable round-trip.Unlike its neighbours it is read at clear time rather than connect time, so toggling it takes effect without reconnecting.
Turning it off is a testing affordance rather than only an escape hatch — a client that walks away still holding live tokens is a case a server author may want to reproduce on purpose.
Testing it in-repo
test-servers/src/test-server-oauth.tsnow advertisesrevocation_endpointand servesPOST /oauth/revoke, faithful on the two points the Inspector depends on: §2.2 (an unknown token is a success, so an already-expired token is not reported as a failure) and §2.1 (revoking a refresh token invalidates the access tokens under the same grant — a fixture without that linkage would let a regression through).Two showcase configs for driving it by hand:
oauth-revocation-http.jsonandoauth-no-revocation-http.json, identical but for the advertised endpoint.Proof
Web — Server Settings → OAuth Settings. The new control, on by default and opted out. Captured by driving the prod
--webbuild in headless Chromium.TUI — Auth tab, revocation failed. The frame
AuthTabrenders when the local clear succeeded but the RFC 7009 request did not. A terminal capture rather than a PNG:script(1)needs its stdin to be a terminal, so a driven TUI cannot also be fed keystrokes from a pipe here. This is the real component's output, not a mock's.It renders in yellow, not the cyan the informational channel uses: the local state really was cleared, so this is not an OAuth error, but the grant may still be live and cyan understated that.
Tests
core/auth/revocation.test.ts— token selection, RFC 8414 auth-method fallback, all three client-authentication shapes, and every outcome branch.revocation-e2e.test.ts— a real authorization-code exchange against the test server, then a revocation, then asserting the MCP endpoint rejects the bearer token. This is the half only a real AS can show: that the request is accepted, and that the §2.1 grant linkage holds.oauthManager.test.ts— the clear-then-revoke ordering, and that the clear still happens when the request fails.clearServerOAuthState,webProxiedFetch, the settings round-trip,/api/serversvalidation and write-through, and the two checkboxes.Note on the browser
The revocation POST goes through the backend-proxied fetch the OAuth flow already uses. Going direct is not an option: an authorization server serves no CORS headers for a page origin it has never heard of, so
globalThis.fetchwould fail on nearly every real deployment while working on a permissive one. On the stored-only path there is no live client to borrow one from, sogetWebProxiedFetchbuilds it; with no proxied fetch available the leg reportsskippedrather than sending a request that cannot work.🤖 Generated with Claude Code
https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh