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
3 changed files with 1003 additions and 7 deletions

View file

@ -0,0 +1,44 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { fn } from "storybook/test";
import { ReAuthBanner } from "./ReAuthBanner";
import {
lostAuthorizationStateActionLabel,
lostAuthorizationStateMessage,
lostAuthorizationStateTitle,
reAuthBannerMessage,
} from "../../../utils/oauthUx";
const meta: Meta<typeof ReAuthBanner> = {
title: "Groups/ReAuthBanner",
component: ReAuthBanner,
args: {
onReauthenticate: fn(),
onDismiss: fn(),
},
};
export default meta;
type Story = StoryObj<typeof ReAuthBanner>;
/** Degraded session: the stored token no longer works. */
export const SessionNeedsAttention: Story = {
args: {
message: reAuthBannerMessage({
serverName: "PlotRocket",
detail: "Token expired.",
}),
},
};
/**
* SEP-2352 callback leg arrived with no recorded discovery state (#1808).
* Explains the loss in plain language and offers a one-click fresh
* authorization (which clears the stale state first).
*/
export const AuthorizationStateLost: Story = {
args: {
title: lostAuthorizationStateTitle(),
actionLabel: lostAuthorizationStateActionLabel(),
message: lostAuthorizationStateMessage({ serverName: "PlotRocket" }),
},
};

View file

@ -27,4 +27,27 @@ describe("ReAuthBanner", () => {
await user.click(closeButton);
expect(onDismiss).toHaveBeenCalledOnce();
});
it("renders a custom title and action label (lost-state recovery, #1808)", async () => {
const user = userEvent.setup();
const onReauthenticate = vi.fn();
renderWithMantine(
<ReAuthBanner
message="The stored authorization state was lost."
title="Authorization state was lost"
actionLabel="Authorize again"
onReauthenticate={onReauthenticate}
onDismiss={vi.fn()}
/>,
);
expect(
screen.getByText("Authorization state was lost"),
).toBeInTheDocument();
expect(screen.queryByText("Re-authentication required")).toBeNull();
await user.click(screen.getByRole("button", { name: "Authorize again" }));
expect(onReauthenticate).toHaveBeenCalledOnce();
});
});

View file

@ -1,16 +1,27 @@
import { Alert, Button, Group, Text } from "@mantine/core";
export const DEFAULT_REAUTH_BANNER_TITLE = "Re-authentication required";
export const DEFAULT_REAUTH_BANNER_ACTION_LABEL = "Re-authenticate";
export interface ReAuthBannerProps {
message: string;
/** Heading; defaults to {@link DEFAULT_REAUTH_BANNER_TITLE}. */
title?: string;
/** Action button label; defaults to {@link DEFAULT_REAUTH_BANNER_ACTION_LABEL}. */
actionLabel?: string;
onReauthenticate: () => void;
onDismiss: () => void;
}
// `title` is intentionally not baked in here: the component always passes a
// resolved `title`, whose parameter default is the single source of truth.
const ReAuthAlert = Alert.withProps({
color: "red",
variant: "reauth",
title: "Re-authentication required",
withCloseButton: true,
// Mantine's close button renders icon-only; without a label it has no
// accessible name (axe `button-name`).
closeButtonLabel: "Dismiss",
});
const BannerRow = Group.withProps({
@ -32,14 +43,16 @@ const ReAuthButton = Button.withProps({
export function ReAuthBanner({
message,
title = DEFAULT_REAUTH_BANNER_TITLE,
actionLabel = DEFAULT_REAUTH_BANNER_ACTION_LABEL,
onReauthenticate,
onDismiss,
}: ReAuthBannerProps) {
return (
<ReAuthAlert onClose={onDismiss}>
<ReAuthAlert title={title} onClose={onDismiss}>
<BannerRow>
<MessageText>{message}</MessageText>
<ReAuthButton onClick={onReauthenticate}>Re-authenticate</ReAuthButton>
<ReAuthButton onClick={onReauthenticate}>{actionLabel}</ReAuthButton>
</BannerRow>
</ReAuthAlert>
);