fix: report a terminal OAuth token-endpoint refusal instead of a dead retry
Closes #2280 InsecureTokenEndpointError appeared nowhere in our source, so it fell through to the generic auth-failure path and rendered a "Re-authentication required" banner with a Re-authenticate button. That button can never work: the SDK's assertSecureTokenEndpoint runs inside executeTokenRequest, the error does not extend OAuthError, and auth() special-cases it to rethrow rather than start a fresh /authorize redirect. Clicking re-ran the same flow to the same refusal, under the raw SDK message, which names the three exempt host literals and nothing actionable. A terminal configuration error was being presented as a retryable auth error. Not specific to *.localhost: it fires for any endpoint outside the SDK's exemption — host.docker.internal (#1911), a LAN hostname, a reverse-proxy name, a mistyped scheme. It is now recognized (core/auth/insecureTokenEndpoint.ts, mirroring issuerBinding.ts's brand-plus-name classifier and walking cause / data.cause, since era negotiation and the transport wrappers bury the rejection) and surfaced as the configuration error it is, naming the endpoint and both ways out, with no action affordance. Six paths reach the refusal and all six classify it — the connect handshake, authenticate() during connect, the satisfied-challenge connect retry, the post-redirect callback, the command/background path, the deferred tab-visible resume (which re-armed on every focus, an unbounded loop on a terminal error), and the banner action. They share one wrapper so a seventh cannot omit the banner clear, and every clear is scoped to its own serverId: these paths are asynchronous, so a late continuation for one server must not erase a banner another raised in the meantime. Adds oauth-insecure-token-endpoint-http.json to reproduce it, whose issuer is http://localhost.:8091 — the root-anchored spelling every resolver sends to loopback but which is none of the SDK's exempt literals. transport.strictPort keeps that fixture honest, since its port is hard-coded inside the issuer. Making such an endpoint work has to land in the SDK (typescript-sdk#2591); this changes the reporting, not the outcome. Split out of #2282, where it was tangled with the *.localhost origin work for #1944 — a separate question the reporter is still clarifying. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018MvNbmAtQuWhDvzi4yXDju Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
98911df74d
commit
b4b82493b1
1 changed files with 1400 additions and 9 deletions
|
|
@ -30,8 +30,11 @@ import {
|
|||
getActiveEnterpriseManagedAuthIdp,
|
||||
} from "@inspector/core/client/types.js";
|
||||
import { isEmaClientNotConfiguredError } from "@inspector/core/auth/ema/clientConfigError.js";
|
||||
import { showInsecureTokenEndpointNotice } from "../lib/insecureTokenEndpointNotice";
|
||||
import { findInsecureTokenEndpoint } from "@inspector/core/auth/insecureTokenEndpoint.js";
|
||||
import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js";
|
||||
import type { RemoteInspectorClientStorage } from "@inspector/core/mcp/remote/index.js";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import type { SessionRef } from "./useSessionRef";
|
||||
import type { FetchLogOptions } from "./useInspectorStores";
|
||||
import type { LastPersistedSettings } from "./useLastPersistedSettings";
|
||||
|
|
@ -172,7 +175,7 @@ export interface UseConnectionLifecycleOptions {
|
|||
prepareOAuthRedirect: (args: PrepareOAuthRedirectArgs) => void;
|
||||
finalizeExplicitDisconnect: () => void;
|
||||
reAuthBanner: ReAuthBannerState | null;
|
||||
setReAuthBanner: (next: ReAuthBannerState | null) => void;
|
||||
setReAuthBanner: Dispatch<SetStateAction<ReAuthBannerState | null>>;
|
||||
|
||||
/** See `SessionResetSurface`. */
|
||||
sessionReset: SessionResetSurface;
|
||||
|
|
@ -637,6 +640,30 @@ export function useConnectionLifecycle({
|
|||
});
|
||||
return;
|
||||
}
|
||||
// SEP-2207 (#2280): a token endpoint the SDK will not post credentials
|
||||
// to. Terminal, so it gets a notice of its own rather than the generic
|
||||
// "Failed to connect" toast, whose detail line would be the raw SDK
|
||||
// text.
|
||||
//
|
||||
// The teardown is load-bearing, not tidiness. `connect()` sets its
|
||||
// status to `"error"` and dispatches `statusChange` *before* rethrowing
|
||||
// (it is not a connect-auth-recovery error), and `InspectorView` pins
|
||||
// the monitoring sidebar open on that transition and paints the card
|
||||
// red. Returning without it would present this as the failed connect
|
||||
// attempt the notice explicitly says it is not. The `authenticate()`
|
||||
// arm below already disconnects for the same reason.
|
||||
if (findInsecureTokenEndpoint(err)) {
|
||||
await client.disconnect().catch(() => {});
|
||||
showInsecureTokenEndpointNotice(err, target.name);
|
||||
// Clear a banner left by an earlier failure: its Re-authenticate
|
||||
// button is just as dead as the one this arm declines to offer, and
|
||||
// the user cannot tell which failure it belongs to. Scoped to this
|
||||
// server, so an async continuation cannot erase another's.
|
||||
setReAuthBanner((prev) =>
|
||||
prev && prev.serverId === id ? null : prev,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// A 401 from an OAuth-protected server means we have no (valid) token
|
||||
// yet. Kick off the authorization-code flow: `authenticate()` runs
|
||||
|
|
@ -671,6 +698,22 @@ export function useConnectionLifecycle({
|
|||
// held. The fetch log survives a disconnect, so the Network
|
||||
// diagnostics this issue is about are unaffected.
|
||||
await client.disconnect().catch(() => {});
|
||||
// SEP-2207 (#2280). The retried `connect()` above can raise the
|
||||
// terminal refusal on its own — a satisfied challenge still ends in
|
||||
// a token exchange — and reporting that as a failed connect attempt
|
||||
// is doubly wrong here: the card goes red and the message is the
|
||||
// raw SDK text, on the one path where the Inspector had just told
|
||||
// the user the authorization *worked*. Placed after the teardown
|
||||
// above, which this arm needs for the same reason the generic one
|
||||
// does, and before the flag it must not set.
|
||||
if (showInsecureTokenEndpointNotice(recoveryErr, target.name)) {
|
||||
// Only this server's banner — an async continuation must not
|
||||
// erase one raised for a server the user has since switched to.
|
||||
setReAuthBanner((prev) =>
|
||||
prev && prev.serverId === id ? null : prev,
|
||||
);
|
||||
return;
|
||||
}
|
||||
setFailedServerId(id);
|
||||
const message =
|
||||
recoveryErr instanceof Error
|
||||
|
|
@ -722,6 +765,14 @@ export function useConnectionLifecycle({
|
|||
});
|
||||
return;
|
||||
}
|
||||
// See the SEP-2207 note on the handshake arm above (#2280). The
|
||||
// disconnect already happened at the top of this catch.
|
||||
if (showInsecureTokenEndpointNotice(authErr, target.name)) {
|
||||
setReAuthBanner((prev) =>
|
||||
prev && prev.serverId === id ? null : prev,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// The connect attempt failed, same as any other handshake error —
|
||||
// flag the card (#1621) and, with it, open the monitoring sidebar
|
||||
// onto the OAuth requests that explain the failure (#2108). This
|
||||
|
|
@ -766,6 +817,7 @@ export function useConnectionLifecycle({
|
|||
setFailedServerId,
|
||||
prepareOAuthRedirect,
|
||||
finalizeExplicitDisconnect,
|
||||
setReAuthBanner,
|
||||
],
|
||||
);
|
||||
|
||||
|
|
@ -962,6 +1014,16 @@ export function useConnectionLifecycle({
|
|||
authorizationUrl: authUrl,
|
||||
});
|
||||
} catch (err) {
|
||||
// SEP-2207 (#2280), and this is the path a user reaches by *acting*:
|
||||
// a connected session raises an ordinary re-auth banner, they click
|
||||
// Re-authenticate, and the token exchange is refused. Without this
|
||||
// the generic toast below reports it with the raw SDK text — the
|
||||
// worst place to lose the guidance, since they have just been told
|
||||
// retrying is the fix. No banner clear is needed: this callback
|
||||
// already cleared it before starting.
|
||||
if (showInsecureTokenEndpointNotice(err, server?.name)) {
|
||||
return;
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
notifications.show({
|
||||
title: server
|
||||
|
|
|
|||
Loading…
Reference in a new issue