* 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
3 changed files with 1003 additions and 7 deletions
|
|
@ -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" }),
|
||||
},
|
||||
};
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue