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

@ -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)}
/>