* 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:
parent
01df9d81bb
commit
7784553674
1 changed files with 1003 additions and 7 deletions
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue