* 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
|
|
@ -197,12 +197,15 @@ import { clearServerOAuthState } from "./lib/clearServerOAuthState";
|
|||
import {
|
||||
authRecoveryRestoredMessage,
|
||||
isReAuthBannerReason,
|
||||
issuerBindingFailureCopy,
|
||||
lostAuthorizationStateActionLabel,
|
||||
oauthPreRedirectToastCopy,
|
||||
oauthResumeAbandonedMessage,
|
||||
reAuthBannerMessage,
|
||||
type OAuthPreRedirectContext,
|
||||
type OAuthRecoverySource,
|
||||
} from "./utils/oauthUx";
|
||||
import { findIssuerBindingFailure } from "@inspector/core/auth/issuerBinding.js";
|
||||
import {
|
||||
isBrowserTabVisible,
|
||||
onBrowserTabVisible,
|
||||
|
|
@ -879,6 +882,20 @@ function App() {
|
|||
const [reAuthBanner, setReAuthBanner] = useState<{
|
||||
serverId: string;
|
||||
message: string;
|
||||
/**
|
||||
* `lost_authorization_state` marks the SEP-2352 recovery case (#1808): the
|
||||
* callback arrived with no recorded discovery state, so the banner offers
|
||||
* "Authorize again", which drops the stale/partial OAuth state before
|
||||
* starting a fresh authorization.
|
||||
*/
|
||||
kind?: "lost_authorization_state";
|
||||
/**
|
||||
* Resolved at the point the banner is raised so `issuerBindingFailureCopy`
|
||||
* stays the single source of the `kind → copy` mapping; both fall back to
|
||||
* `ReAuthBanner`'s own defaults when absent.
|
||||
*/
|
||||
title?: string;
|
||||
actionLabel?: string;
|
||||
} | null>(null);
|
||||
const [pendingReauth, setPendingReauth] = useState<PendingReauth | null>(
|
||||
null,
|
||||
|
|
@ -2559,6 +2576,37 @@ function App() {
|
|||
});
|
||||
return;
|
||||
}
|
||||
// SEP-2352 issuer binding (#1808). Two very different failures share
|
||||
// one SDK error class, so classify before falling through to the
|
||||
// generic re-auth banner (whose detail line would otherwise be the raw
|
||||
// "AuthorizationServerMismatchError" text):
|
||||
// - no discovery state was recorded → recoverable bookkeeping loss,
|
||||
// surface the "Authorize again" affordance;
|
||||
// - a *different* issuer answered the callback → security signal, so
|
||||
// no one-click recovery is offered.
|
||||
const issuerBindingFailure = findIssuerBindingFailure(err);
|
||||
if (issuerBindingFailure) {
|
||||
const copy = issuerBindingFailureCopy(issuerBindingFailure, {
|
||||
serverName: server.name,
|
||||
});
|
||||
if (issuerBindingFailure.kind === "lost_authorization_state") {
|
||||
setReAuthBanner({
|
||||
serverId: server.id,
|
||||
message: copy.message,
|
||||
kind: "lost_authorization_state",
|
||||
title: copy.title,
|
||||
actionLabel: lostAuthorizationStateActionLabel(),
|
||||
});
|
||||
} else {
|
||||
notifications.show({
|
||||
title: copy.title,
|
||||
message: copy.message,
|
||||
color: "red",
|
||||
autoClose: false,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
queueMicrotask(() => {
|
||||
showReAuthBanner(server.id, err instanceof Error ? err : String(err));
|
||||
});
|
||||
|
|
@ -2839,8 +2887,45 @@ function App() {
|
|||
const onReauthenticateFromBanner = useCallback(() => {
|
||||
if (!reAuthBanner) return;
|
||||
const serverId = reAuthBanner.serverId;
|
||||
const bannerKind = reAuthBanner.kind;
|
||||
setReAuthBanner(null);
|
||||
|
||||
// Lost-authorization-state recovery (#1808). The stale half of the flow
|
||||
// (code verifier without discovery state, possibly a stale registration)
|
||||
// would make a plain retry fail the same way, so drop the persisted OAuth
|
||||
// state for this server first, then start a fresh authorization. This
|
||||
// banner is only raised from the `/oauth/callback` failure path, where the
|
||||
// session never reached "connected", so connecting is always the right
|
||||
// toggle direction here.
|
||||
if (bannerKind === "lost_authorization_state") {
|
||||
void (async () => {
|
||||
const server = serversRef.current.find((s) => s.id === serverId);
|
||||
if (server) {
|
||||
try {
|
||||
await clearServerOAuthState({
|
||||
config: server.config,
|
||||
inspectorClient:
|
||||
serverId === activeServerId ? inspectorClient : null,
|
||||
isActiveConnection: serverId === activeServerId,
|
||||
oauthStorage: webOAuthStorage,
|
||||
});
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
title: "Could not clear the stored authorization state",
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
color: "red",
|
||||
// The banner is already dismissed and the flow is dead, so this
|
||||
// is the only remaining explanation — don't time it out.
|
||||
autoClose: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
await onToggleConnection(serverId);
|
||||
})();
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
serverId === activeServerId &&
|
||||
connectionStatus === "connected" &&
|
||||
|
|
@ -2888,6 +2973,7 @@ function App() {
|
|||
servers,
|
||||
prepareOAuthRedirect,
|
||||
onToggleConnection,
|
||||
webOAuthStorage,
|
||||
]);
|
||||
|
||||
// --- Action handlers that route directly to the InspectorClient. ---
|
||||
|
|
@ -4259,6 +4345,8 @@ function App() {
|
|||
<ReAuthBannerBar>
|
||||
<ReAuthBanner
|
||||
message={reAuthBanner.message}
|
||||
title={reAuthBanner.title}
|
||||
actionLabel={reAuthBanner.actionLabel}
|
||||
onReauthenticate={onReauthenticateFromBanner}
|
||||
onDismiss={() => setReAuthBanner(null)}
|
||||
/>
|
||||
|
|
|
|||
Loading…
Reference in a new issue