fix: key the OAuth clear's active-session check on the storage key - #2238
fix: key the OAuth clear's active-session check on the storage key#2238cliffhall wants to merge 6 commits into
Conversation
…2217) OAuth state is keyed by server URL, but `clearServerOAuthAndDisconnect` decided "is this the active connection" from the catalog entry id. Those are different identities and nothing keeps them in sync: `serverList` enforces no URL uniqueness, so two entries with distinct ids against one URL are a supported state — and a natural one, since separate entries are how a user keeps different names, headers or per-server settings against the same server. Clearing the inactive entry therefore deleted the shared URL-keyed blob — the active session's tokens, DCR client id and PKCE state — and, with #2144 in, revoked its grant at the authorization server, while the id check took the inactive branch: no live-client clear, no disconnect, no session cleanup. The active session was left connected on credentials that no longer existed, with nothing told to the user; the break then surfaced somewhere else entirely, on the next refresh, 401 or reload. The branch now compares the OAuth storage key of the cleared entry against the active entry's, and treats a match as affecting the active session — live-client clear, disconnect, session cleanup — exactly as clearing the active entry does. The existing id and client-identity checks stay on top: they guard the separate stale-session race from #2144, and the snapshotted id they revalidate is now the active one rather than the cleared entry's, which for a shared-key clear are not the same. The toast says why the session went away. The alternative, prohibiting duplicate URLs in `serverList`, would remove a legitimate workflow to fix a bug in the consumer and could not repair catalogs that already hold duplicates. 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>
There was a problem hiding this comment.
🟡 Changes recommended
The shared-URL toast can incorrectly claim that a session was disconnected when disconnect was skipped or failed.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Fixes OAuth clearing for catalog entries sharing the active server’s URL.
Changes:
- Compares OAuth storage keys instead of only entry IDs.
- Disconnects affected active sessions while preserving stale-session guards.
- Adds shared-URL and session-switch regression tests.
File summaries
| File | Description |
|---|---|
clients/web/src/hooks/useOAuthRecovery.ts |
Detects shared OAuth state and disconnects affected sessions. |
clients/web/src/hooks/useOAuthRecovery.test.tsx |
Covers shared URLs and in-flight session switching. |
Review details
- Files reviewed: 2/2 changed files
- Comments generated: 1
- Review effort level: Balanced
💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.
| ? `Stored tokens and client registration were removed. Reconnect to run a fresh authorization flow.${revocationSuffix(revocation)}` | ||
| : `Stored OAuth state was removed for "${server.name}". Connect to authorize again.${revocationSuffix(revocation)}`, | ||
| : sharesActiveOAuthKey | ||
| ? `Stored OAuth state was removed for "${server.name}". The active session authorizes against the same URL, so it shared that state and was disconnected too — reconnect to run a fresh authorization flow.${revocationSuffix(revocation)}` |
There was a problem hiding this comment.
Fixed in fcec17f..HEAD — good catch. The message now states the credential impact rather than the disconnect: The active session authorizes against the same URL, so its stored tokens went too — reconnect to run a fresh authorization flow. That holds whether the disconnect ran, was skipped by the stale-session guard, or rejected (which surfaces its own toast). Comment added at the source saying why.
…RL toast Copilot: the stale-session guard deliberately skips `disconnect()` after a switch, and `disconnect()` can also reject (which gets its own toast), so claiming the active session "was disconnected too" is not always true. What is always true is that its stored tokens went with the shared blob and it must reconnect, so the message says that instead. 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>
|
Copilot review round 1 — 1 comment, addressed.
A comment at the source records why it is worded that way. Test assertion updated to match; |
There was a problem hiding this comment.
🔵 Needs a closer look
The existing ID-keyed in-flight guard still permits concurrent clears of the same shared OAuth state.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
clients/web/src/hooks/useOAuthRecovery.ts:1408
affectsActiveSessionmakes distinct catalog IDs sharing a URL operate on the same OAuth blob and live client, butApp.tsx:1352-1383still deduplicates in-flight clears only byserver.id. Clearing both duplicate entries while revocation is pending can therefore run concurrent store/client clears and emit duplicate or conflicting teardown toasts—the exact race thatrunClearis intended to prevent. Key that guard by the OAuth storage key (while retaining an ID fallback for non-OAuth configs), or otherwise serialize clears that affect the same active session.
- Files reviewed: 2/2 changed files
- Comments generated: 0 new
- Review effort level: Balanced
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>
|
Copilot review round 2 — 1 suppressed comment, addressed.
Fixed as suggested, keyed by the OAuth storage key with an id fallback. The key derivation is a new pure helper,
Four unit tests cover those. |
There was a problem hiding this comment.
🟡 Changes recommended
The new App-level concurrent-clear suppression lacks required behavioral test coverage.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 5/5 changed files
- Comments generated: 1
- Review effort level: Balanced
| if (clearOAuthInFlightRef.current.has(inFlightKey)) return; | ||
| clearOAuthInFlightRef.current.add(inFlightKey); |
There was a problem hiding this comment.
Added, as suggested — App.test.tsx: suppresses a second clear for another entry with the same URL, and allows one after it settles. It connects entry A, holds its clear open on a deferred clearOAuthTokens, drives a clear for entry B (different catalog id, same URL) before the first settles, and asserts the underlying clear ran exactly once — then once more after settlement, so the key is proven released rather than leaking a permanently dead control. Verified to fail against the id-keyed guard.
Copilot: `oauthClearKey`'s unit tests prove the derivation, but a regression that dropped the `has` check, added or deleted the wrong key, or invoked the clear twice would still have passed. Adds an App.test.tsx case that connects entry A, holds its clear open on a deferred `clearOAuthTokens`, then drives a clear for entry B — a different catalog id against the same URL — and asserts the underlying clear ran exactly once, and once more after the first settles, so the key is proven released rather than leaking a permanently dead control. Verified to fail against the id-keyed guard. Two supporting changes it needs: - an `open-settings-b` control on the mocked InspectorView, the only way to reach a settings modal for a server other than the active one; - `@inspector/core/mcp/remote/index.js` becomes a partial mock. `getWebProxiedFetch` reaches for `createRemoteFetch` there, and the bare stub threw a missing-export error before the clear under test ever ran — the failure surfaced as the generic "Could not clear the stored OAuth state" toast. The case carries an explicit 20s timeout and `delay: null`: it drives three full modal interaction sequences against the whole App tree, which runs past the 5s default when the suite is under load. 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>
|
Copilot review round 3 — 1 comment, addressed.
Two supporting changes it needed, both worth flagging:
The case carries an explicit 20s timeout and
Unrelated, found while running the gate: |
There was a problem hiding this comment.
🟡 Changes recommended
The new App test contains two unjustified double casts that bypass required type contracts.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 6/6 changed files
- Comments generated: 2
- Review effort level: Balanced
| vi.mocked(useServers).mockReturnValue({ | ||
| servers: [sharedEntry("A", "PlotRocket"), sharedEntry("B", "Same URL")], | ||
| loading: false, | ||
| error: undefined, | ||
| refresh: vi.fn().mockResolvedValue(undefined), | ||
| addServer: addServerSpy, | ||
| updateServer: updateServerSpy, | ||
| updateServerSettings: updateServerSettingsSpy, | ||
| removeServer: vi.fn(), | ||
| } as unknown as ReturnType<typeof useServers>); |
There was a problem hiding this comment.
Fixed. Removing the cast immediately proved the point: tsc reported the mock was missing reorderServers and importSource, so App code calling either would have read undefined with this test still passing type-check. The full UseServersResult shape is supplied now, matching the mockServersWith helper above.
| const client = clientInstances[0] as unknown as { | ||
| clearOAuthTokens: ReturnType<typeof vi.fn>; | ||
| }; |
There was a problem hiding this comment.
Fixed — clientInstances[0] as EventTarget & { clearOAuthTokens: ReturnType<typeof vi.fn> }. Typechecks clean.
Copilot, and the repo's own rule against an unjustified `as unknown as`. The `useServers` cast was the more useful catch: removing it surfaced that the mock omitted `reorderServers` and `importSource`, so App code calling either would have read `undefined` with this test still passing type-check. The full result shape is supplied instead, matching the `mockServersWith` helper above. The client cast becomes an intersection — the instances are already typed `EventTarget`, so naming the test-only spy on top of that expresses what is needed without erasing the rest. 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>
|
Copilot review round 4 — 2 comments, both addressed. Both were correct and both are the repo's own rule (
One note on the gate, since it is relevant to reading CI here: three earlier runs of this branch went red on 5s test timeouts in files this PR does not touch ( |
There was a problem hiding this comment.
🟡 Changes recommended
Mutable catalog URLs can diverge from the live client’s OAuth key, reintroducing unsafe clearing and concurrency behavior.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 6/6 changed files
- Comments generated: 2
- Review effort level: Balanced
| // grant and one revocation, so an id-keyed guard lets exactly the race | ||
| // above through between them. `oauthClearKey` falls back to the id for a | ||
| // config with no OAuth URL, which has no shared state to collide over. | ||
| const inFlightKey = oauthClearKey(server.config, server.id); |
There was a problem hiding this comment.
Same root cause, same fix. Both consumers now call one exported resolveOAuthClearIdentity, which returns the inFlightKey alongside the affects-active-session answer, so an active-session clear locks on the client's key (url:X) rather than the edited entry's (url:Y) and collides with a concurrent clear of entry B at X, as it must. Two resolver tests cover it.
| const activeOAuthKey = (): string | undefined => { | ||
| const active = sessionRef.current.servers.find( | ||
| (s) => s.id === activeServerId, | ||
| ); | ||
| return active ? getOAuthServerUrl(active.config) : undefined; |
There was a problem hiding this comment.
Correct, and confirmed: onConfigSubmit (App.tsx) writes the catalog on an edit and does not rebuild the client, so an edited-while-connected entry reads a URL the session never authorized against. The comparison now resolves from InspectorClient.getTransportConfig() — the config the client was constructed with, which is what its credentials are keyed under — and falls back to the catalog entry only when there is no client. Covered by a hook test: A edited to Y while its client is still on X, clearing entry B at X disconnects.
…2217) Copilot: the catalog entry is mutable and the live client is not. A card can be edited while connected — `onConfigSubmit` writes the catalog and does not rebuild the client — so after A connects to X and is edited to Y, the entry reads Y while the session is still authorized against X. Both consumers read the entry, so both got it wrong in the same way: - the hook compared a cleared entry against A's *entry* URL, so clearing another entry still at X missed the match and deleted and revoked the live client's X-keyed credentials without disconnecting it — the exact failure this PR exists to prevent; - `runClear` locked on the entry's key, so a clear of edited-A (`url:Y`) and a clear of entry B at X (`url:X`) took different locks while both performed the same X-keyed operation through the same live client. `InspectorClient.getTransportConfig()` returns the config it was constructed with, which is what its credentials are keyed under, so both now resolve from that and fall back to the catalog entry only when there is no client. The rule moves into one exported function, `resolveOAuthClearIdentity`. Two copies of it that disagree is precisely the class of bug #2217 is, and the two consumers each re-deriving it is how this round's finding came to exist at all. Both fakes gain a `getTransportConfig`, and three tests cover the split: the resolver's own two (the client's URL wins; an active clear locks on the client's key) and a hook case asserting an edited active entry still disconnects when the entry sharing the client's real URL is cleared. 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>
|
Copilot review round 6 — 2 comments, one root cause, both addressed. (Round 5 was a zero-comment approval; this round was requested as a confirming pass and turned up something real, which is why we run two.) The premise checks out.
Fix. And the rule moved into one place. Tests — three, each verified to fail against the entry-only lookup:
Both fakes gained a
|
Closes #2217
The mismatch
OAuth state is keyed by server URL.
clearServerOAuthAndDisconnectdecided "is this the active connection" from the catalog entry id. Those are different identities and nothing keeps them in sync —core/mcp/serverList.tsenforces no URL uniqueness, so two entries with distinct ids against one URL are a supported state, and a natural one: separate entries are how a user keeps different names, custom headers or per-server settings against the same server.With entry a connected and entry b inactive, clearing b deleted the shared URL-keyed blob (a's tokens, DCR client id, PKCE state), revoked a's grant at the authorization server over RFC 7009 (#2144) — and then took the inactive branch, because
b.id !== activeServerId. No live-client clear, no disconnect, no session cleanup, no notice. Entry a was left connected on an in-memory access token whose persisted state was gone and whose grant may be dead, with the break surfacing later and somewhere else: the next refresh, the next 401, the next reload.What changed
The branch now compares the OAuth storage key of the cleared entry against the active entry's, and treats a match as affecting the active session — live-client clear, disconnect, session cleanup — exactly as clearing the active entry does. The toast says why the session went away.
The existing id and client-identity checks stay on top of it: they guard the separate stale-session race from #2144 (a suspended callback applying to a session the user switched to), which is a different axis and still real. One adjustment there — the id
stillTargetsActiveSessionrevalidates is now the active id snapshotted alongsideisActive, not the cleared entry's, since for a shared-key clear those differ and the session being protected is the active one.The alternative the issue raises — prohibiting duplicate URL keys in
serverList.ts— was not taken: it removes a legitimate workflow to fix a bug in the consumer, and it cannot repair catalogs that already hold duplicates.Tests
disconnects the active session when clearing an entry that shares its URL— two entries, same URL, one connected; asserts the active client is disconnected and the clear routed through it (isActiveConnection: true). Verified to fail against the old id-only check.does not disconnect a session switched to during a shared-URL clear— the new branch still snapshots the session it acted on, so a switch mid-flight must not drag the cleanup onto the new one.leaves a live session alone when clearing another servercase was fixed at the fixture: it usedentry("a")/entry("b"), which share a URL, so it was asserting the buggy behavior. It now clears an entry with its own URL, which is what "another server" was meant to mean.npm run local:gatepasses. (One integration test,matches responses to requests when a sibling listener fires a request inside the same connect event, flaked on the first run and passes in isolation and on re-run; it is unrelated to this diff.)Screenshots
None. The only visual surface is the "OAuth state cleared" toast string, which the tests assert directly; reproducing the shared-URL case in a live browser needs two catalog entries with a completed OAuth grant against one authorization server, which is a disproportionate setup for a one-sentence copy change. Everything else in the diff is behavior with no rendered surface.
🤖 Generated with Claude Code
https://claude.ai/code/session_01VGbw6TezNWnXZSCSp7sqb4