fix: resolve the clear's identity from the live client, in one place (#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>
This commit is contained in:
parent
be86791900
commit
f78a00ea6c
1 changed files with 255 additions and 24 deletions
|
|
@ -16,7 +16,6 @@ import {
|
|||
parseOAuthCallbackParams,
|
||||
parseOAuthState,
|
||||
} from "@inspector/core/auth/index.js";
|
||||
import { getOAuthServerUrl } from "@inspector/core/mcp/config.js";
|
||||
import { RemoteInspectorClientStorage } from "@inspector/core/mcp/remote/index.js";
|
||||
import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js";
|
||||
import type { TokenRevocationOutcome } from "@inspector/core/auth/revocation.js";
|
||||
|
|
@ -36,6 +35,7 @@ import { oauthDetailsFromConnectionState } from "../components/groups/Connection
|
|||
import { getWebRemoteOAuthStorage } from "../lib/remoteOAuthStorage";
|
||||
import { getWebProxiedFetch } from "../lib/webProxiedFetch";
|
||||
import { clearServerOAuthState } from "../lib/clearServerOAuthState";
|
||||
import { resolveOAuthClearIdentity } from "../utils/oauthClearKey";
|
||||
import { getAuthToken } from "../lib/authToken";
|
||||
import {
|
||||
isBrowserTabVisible,
|
||||
|
|
@ -1380,7 +1380,6 @@ export function useOAuthRecovery({
|
|||
|
||||
const clearServerOAuthAndDisconnect = useCallback(
|
||||
async (server: ClearableServer) => {
|
||||
const isActive = server.id === activeServerId;
|
||||
// OAuth state is keyed by the server URL, but "is this the active
|
||||
// connection" was keyed by catalog entry id — and `serverList` enforces
|
||||
// no URL uniqueness, so two entries with different ids and the same URL
|
||||
|
|
@ -1391,20 +1390,20 @@ export function useOAuthRecovery({
|
|||
// session connected on credentials that no longer exist, silently
|
||||
// (#2217). Comparing the storage keys is what sees it; the id check
|
||||
// cannot, by construction.
|
||||
const activeOAuthKey = (): string | undefined => {
|
||||
const active = sessionRef.current.servers.find(
|
||||
(s) => s.id === activeServerId,
|
||||
);
|
||||
return active ? getOAuthServerUrl(active.config) : undefined;
|
||||
};
|
||||
const clearedOAuthKey = getOAuthServerUrl(server.config);
|
||||
const sharesActiveOAuthKey =
|
||||
!isActive &&
|
||||
clearedOAuthKey !== undefined &&
|
||||
clearedOAuthKey === activeOAuthKey();
|
||||
// Either way the active session's credentials are being destroyed, so it
|
||||
// takes the live-client path, disconnects, and runs the session cleanup.
|
||||
const affectsActiveSession = isActive || sharesActiveOAuthKey;
|
||||
//
|
||||
// The live client's own config is what the comparison uses, because a
|
||||
// card can be edited while connected and the catalog write does not
|
||||
// rebuild the client (Copilot). `resolveOAuthClearIdentity` owns the
|
||||
// rule; `App`'s in-flight guard reads the same answer from it.
|
||||
const { isActive, sharesActiveOAuthKey, affectsActiveSession } =
|
||||
resolveOAuthClearIdentity({
|
||||
server,
|
||||
activeServerId,
|
||||
activeClientConfig: inspectorClient?.getTransportConfig(),
|
||||
activeEntryConfig: sessionRef.current.servers.find(
|
||||
(s) => s.id === activeServerId,
|
||||
)?.config,
|
||||
});
|
||||
const client = affectsActiveSession ? inspectorClient : null;
|
||||
// The RFC 7009 leg is a bounded network request (#2144), so this callback
|
||||
// can stay suspended for seconds — long enough for the user to close the
|
||||
|
|
|
|||
Loading…
Reference in a new issue