fix(auth): explicit re-authorize when the OAuth callback state was lost (#1808) (#1810)

* fix(auth): offer explicit re-authorize when callback state is lost (#1808)

A `/oauth/callback` that arrives with no recorded SEP-2352 discovery state
made the SDK throw `AuthorizationServerMismatchError` and the Inspector
dead-ended, surfacing the raw SDK text with no path forward.

The SDK folds two very different failures into that one error class, so
`core/auth/issuerBinding.ts` classifies them structurally: a recoverable
"lost authorization state" (the SDK puts prose, not an issuer URL, in the
`recordedIssuer` slot) versus a genuine cross-AS `issuer_mismatch`. Only
the first gets a recovery affordance.

Web: the callback failure now raises a dedicated banner ("Authorization
state was lost" / "Authorize again") whose action clears the stale OAuth
state for that server and starts a fresh authorization. A genuine
mismatch stays a red, non-dismissible security notification with both
issuers named and no one-click retry.

Node runners (CLI/TUI): the same classification rewrites the callback
rejection into the shared actionable copy instead of the SDK text.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016S7h83UKNQ7kvSiFDNksCt

* fix(auth): detect the SDK mismatch error via isInstance (#1808)

The callback-leg classifier gated on `err.mcpBrand`, but the SDK declares
that brand in a `static {}` block, so it lives on the constructor and is
never reachable from an instance. The predicate always returned false, so
`findIssuerBindingFailure()` never matched and the #1808 recovery banner
was inert in production — the callback dead-ended exactly as before.

Use the SDK's own `AuthorizationServerMismatchError.isInstance()`, which is
cross-copy safe by construction (it consults a `Symbol.for()`-keyed brand
set), with a `name` fallback for a serialization boundary. Correct the
module doc, which cited cross-bundle safety as the reason to avoid
`instanceof` — the very thing the SDK's branded `hasInstance` provides.

Every fixture now constructs a real `AuthorizationServerMismatchError`
instead of a hand-rolled object with an own `mcpBrand`, which is why a
green `npm run ci` hid this. Adds a test pinning the brand's placement so
the feature cannot silently go inert again.

Review follow-ups: drop the unused `isLostAuthorizationStateError`, remove
the duplicated banner title default, resolve the banner title/action label
where the banner is raised, keep the clear-failure notification open, and
bound remote-supplied issuers in user-facing copy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016S7h83UKNQ7kvSiFDNksCt

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cliff Hall 2026-07-27 18:08:31 -04:00 • committed by GitHub
commit 7784553674
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -1,5 +1,9 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { ProtocolErrorCode, ProtocolError } from "@modelcontextprotocol/client";
import {
ProtocolErrorCode,
ProtocolError,
AuthorizationServerMismatchError,
} from "@modelcontextprotocol/client";
import { UrlElicitationLoopError } from "@inspector/core/mcp/urlElicitation.js";
import { ToolCallCancelledError } from "@inspector/core/mcp/toolCallCancelledError.js";
import {
@ -47,6 +51,9 @@ vi.mock("@inspector/core/mcp/index.js", async (importOriginal) => {
// When set, the next `connect()` rejects with this value (one-shot), so a test
// can exercise the handshake-failure path. Cleared after it fires.
let nextConnectRejection: unknown = null;
// Same one-shot arming for the `/oauth/callback` token exchange, so a test can
// exercise the callback-leg failure paths (#1808).
let nextResumeRejection: unknown = null;
class FakeInspectorClient extends EventTarget {
connect = vi.fn(() => {
if (nextConnectRejection !== null) {
@ -93,8 +100,16 @@ vi.mock("@inspector/core/mcp/index.js", async (importOriginal) => {
getRoots = vi.fn().mockReturnValue([]);
setRoots = vi.fn().mockResolvedValue(undefined);
setServerSettings = vi.fn();
resumeAfterOAuth = vi.fn().mockResolvedValue(undefined);
resumeAfterOAuth = vi.fn(() => {
if (nextResumeRejection !== null) {
const err = nextResumeRejection;
nextResumeRejection = null;
return Promise.reject(err);
}
return Promise.resolve(undefined);
});
checkAuthChallengeSatisfied = vi.fn().mockResolvedValue(true);
clearOAuthTokens = vi.fn().mockResolvedValue(undefined);
}
const instances: FakeInspectorClient[] = [];
return {
@ -110,6 +125,10 @@ vi.mock("@inspector/core/mcp/index.js", async (importOriginal) => {
__rejectNextConnect: (err: unknown) => {
nextConnectRejection = err;
},
// Test-only: arm the next resumeAfterOAuth() to reject (callback-leg failure).
__rejectNextResumeAfterOAuth: (err: unknown) => {
nextResumeRejection = err;
},
};
});
@ -563,9 +582,11 @@ import { useManagedRequestorTasks } from "@inspector/core/react/useManagedReques
import { useMessageLog } from "@inspector/core/react/useMessageLog.js";
import { useInspectorClient } from "@inspector/core/react/useInspectorClient.js";
import { useSettingsDraft } from "@inspector/core/react/useSettingsDraft.js";
import { useServers } from "@inspector/core/react/useServers.js";
import type {
InspectorServerSettings,
MessageEntry,
ServerEntry,
} from "@inspector/core/mcp/types.js";
// Default useInspectorClient return — capabilities empty (no task tool calls).
@ -590,6 +611,12 @@ const rejectNextConnect = (
McpIndex as unknown as { __rejectNextConnect: (err: unknown) => void }
).__rejectNextConnect;
const rejectNextResumeAfterOAuth = (
McpIndex as unknown as {
__rejectNextResumeAfterOAuth: (err: unknown) => void;
}
).__rejectNextResumeAfterOAuth;
const fetchLogInstances = (
FetchLogModule as unknown as { __fetchLogInstances: EventTarget[] }
).__fetchLogInstances;
@ -2154,6 +2181,159 @@ describe("App OAuth resume lifecycle", () => {
});
});
// A callback that lands without recorded SEP-2352 discovery state used to
// dead-end with the SDK's raw `AuthorizationServerMismatchError` text. It now
// raises an explicit recovery affordance — but only for that case; a genuine
// cross-authorization-server mismatch stays a security error. See #1808.
describe("App OAuth callback issuer-binding failures (#1808)", () => {
const storage = new Map<string, string>();
const HTTP_SERVER: ServerEntry = {
id: "A",
name: "PlotRocket",
config: { type: "streamable-http", url: "https://mcp.example.com/mcp" },
connection: { status: "disconnected" },
};
const STDIO_SERVER: ServerEntry = {
id: "A",
name: "PlotRocket",
config: { type: "stdio", command: "node" },
connection: { status: "disconnected" },
};
/** Full `useServers` shape so the override typechecks like the real hook. */
const useServersResult = (
servers: ServerEntry[],
): ReturnType<typeof useServers> => ({
servers,
loading: false,
error: undefined,
refresh: vi.fn().mockResolvedValue(undefined),
addServer: vi.fn().mockResolvedValue(undefined),
updateServer: vi.fn().mockResolvedValue(undefined),
updateServerSettings: updateServerSettingsSpy,
removeServer: vi.fn().mockResolvedValue(undefined),
reorderServers: vi.fn().mockResolvedValue(undefined),
importSource: vi.fn().mockResolvedValue({ servers: {} }),
});
// A real SDK error, not a look-alike: the SDK's `mcpBrand` is static, so a
// fabricated instance-branded object would exercise a shape that cannot occur.
const mismatchError = (recordedIssuer: string): Error =>
new AuthorizationServerMismatchError(
recordedIssuer,
"https://as.example.com",
);
const renderCallbackWithFailure = (err: Error) => {
writeOAuthResumeSnapshot({
version: 1,
serverId: "A",
activeTab: "Tools",
authKind: "reauth",
tabUi: {},
});
window.history.replaceState({}, "", `${OAUTH_CALLBACK_PATH}?code=test`);
rejectNextResumeAfterOAuth(err);
renderWithMantine(<App />);
};
beforeEach(() => {
clientInstances.length = 0;
storage.clear();
notificationsMock.show.mockClear();
vi.mocked(useInspectorClient).mockReturnValue({
...DEFAULT_USE_INSPECTOR_CLIENT,
status: "disconnected",
});
vi.mocked(useServers).mockReturnValue(useServersResult([HTTP_SERVER]));
vi.stubGlobal("sessionStorage", {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => {
storage.set(key, value);
},
removeItem: (key: string) => {
storage.delete(key);
},
});
window.history.replaceState({}, "", "/");
});
afterEach(() => {
vi.unstubAllGlobals();
vi.mocked(useInspectorClient).mockReturnValue(DEFAULT_USE_INSPECTOR_CLIENT);
vi.mocked(useServers).mockReturnValue(useServersResult([STDIO_SERVER]));
window.history.replaceState({}, "", "/");
});
it("offers an explicit re-authorize affordance when the recorded state was lost", async () => {
renderCallbackWithFailure(
mismatchError(
"discoveryState was not available on the callback leg; ensure your provider persists discoveryState alongside codeVerifier",
),
);
expect(
await screen.findByText("Authorization state was lost"),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Authorize again" }),
).toBeInTheDocument();
// The raw SDK wording never reaches the user.
expect(screen.queryByText(/discoveryState/)).not.toBeInTheDocument();
expect(screen.queryByText("Re-authentication required")).toBeNull();
});
it("clears the stale OAuth state and reconnects when the affordance is used", async () => {
const user = userEvent.setup();
renderCallbackWithFailure(
mismatchError("discoveryState was not available on the callback leg"),
);
const button = await screen.findByRole("button", {
name: "Authorize again",
});
await waitFor(() => expect(clientInstances).toHaveLength(1));
const callbackClient = clientInstances[0] as unknown as {
clearOAuthTokens: ReturnType<typeof vi.fn>;
};
await user.click(button);
await waitFor(() =>
expect(callbackClient.clearOAuthTokens).toHaveBeenCalled(),
);
// A fresh client is built for the retry connect.
await waitFor(() => expect(clientInstances.length).toBeGreaterThan(1));
expect(screen.queryByText("Authorization state was lost")).toBeNull();
});
it("treats a genuine issuer mismatch as a security error with no recovery button", async () => {
renderCallbackWithFailure(mismatchError("https://old.example.com"));
await waitFor(() =>
expect(notificationsMock.show).toHaveBeenCalledWith(
expect.objectContaining({
title: "Authorization server mismatch",
color: "red",
}),
),
);
expect(screen.queryByText("Authorization state was lost")).toBeNull();
expect(
screen.queryByRole("button", { name: "Authorize again" }),
).toBeNull();
});
it("falls back to the generic re-auth banner for an unrelated callback failure", async () => {
renderCallbackWithFailure(new Error("token endpoint exploded"));
expect(
await screen.findByText("Re-authentication required"),
).toBeInTheDocument();
expect(screen.queryByText("Authorization state was lost")).toBeNull();
});
});
describe("App paginated list pagination toggle (#1721)", () => {
beforeEach(() => {
clientInstances.length = 0;