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
10 changed files with 1400 additions and 9 deletions
|
|
@ -11,6 +11,7 @@ import type { ClientConfig } from "@inspector/core/client/types.js";
|
|||
import type { RemoteInspectorClientStorage } from "@inspector/core/mcp/remote/index.js";
|
||||
import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js";
|
||||
import { EmaClientNotConfiguredError } from "@inspector/core/auth/ema/clientConfigError.js";
|
||||
import { InsecureTokenEndpointError } from "@modelcontextprotocol/client";
|
||||
import { renderWithMantine, act, waitFor } from "../test/renderWithMantine";
|
||||
import { EMPTY_SETTINGS } from "../utils/serverSettingsDefaults";
|
||||
import { DEEP_LINK_SERVER_ID } from "../utils/deepLink";
|
||||
|
|
@ -258,6 +259,27 @@ const lastClient = (h: Harness): InspectorClient => {
|
|||
return client;
|
||||
};
|
||||
|
||||
/**
|
||||
* The last updater handed to `setReAuthBanner`, applied to a banner.
|
||||
*
|
||||
* Every terminal SEP-2207 arm clears the banner with a **functional** update
|
||||
* guarded on `serverId`, because these paths are asynchronous and a late
|
||||
* continuation for one server must not erase a banner another raised in the
|
||||
* meantime. The harness's setter is a spy, so the updater is never invoked for
|
||||
* us — asserting it directly is what actually exercises the guard rather than
|
||||
* merely reaching the line.
|
||||
*/
|
||||
const applyBannerUpdate = (
|
||||
spy: ReturnType<typeof vi.fn>,
|
||||
banner: { serverId: string; message: string } | null,
|
||||
) => {
|
||||
const updater = spy.mock.calls.at(-1)?.[0] as unknown;
|
||||
if (typeof updater !== "function") {
|
||||
throw new Error("expected a functional setReAuthBanner update");
|
||||
}
|
||||
return (updater as (prev: unknown) => unknown)(banner);
|
||||
};
|
||||
|
||||
const toastTitles = (): string[] =>
|
||||
notificationsMock.show.mock.calls.map((c) => String(c[0]?.title));
|
||||
|
||||
|
|
@ -601,6 +623,128 @@ describe("useConnectionLifecycle", () => {
|
|||
expect(h.spies.setFailedServerId).not.toHaveBeenCalledWith("a");
|
||||
});
|
||||
|
||||
it("reports an insecure token endpoint as terminal, without flagging the card", async () => {
|
||||
// SEP-2207 (#2280). Asserted on the hook, not just the notice helper,
|
||||
// because what makes this arm correct is its *position*: above
|
||||
// `setFailedServerId` and above the generic toast. A helper-only test
|
||||
// cannot see either of those go wrong.
|
||||
connectSpy.mockRejectedValueOnce(
|
||||
new InsecureTokenEndpointError(
|
||||
"http://tenant.app.localhost:3300/token",
|
||||
),
|
||||
);
|
||||
const h = harness({ servers: [entry("a")] });
|
||||
|
||||
await act(async () => {
|
||||
await h.api().onToggleConnection("a");
|
||||
});
|
||||
|
||||
expect(toastTitles()).toContain("Token endpoint is not secure");
|
||||
// The generic arm must not also fire — two notifications for one failure
|
||||
// is how the raw SDK text would creep back in beside the good copy.
|
||||
expect(toastTitles()).not.toContain('Failed to connect to "Server a"');
|
||||
expect(h.spies.setFailedServerId).not.toHaveBeenCalledWith("a");
|
||||
// The clear is scoped: it drops this server's banner and spares another's.
|
||||
expect(
|
||||
applyBannerUpdate(h.spies.setReAuthBanner, {
|
||||
serverId: "a",
|
||||
message: "x",
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
applyBannerUpdate(h.spies.setReAuthBanner, {
|
||||
serverId: "other",
|
||||
message: "x",
|
||||
}),
|
||||
).toMatchObject({ serverId: "other" });
|
||||
// The real `connect()` sets status `"error"` and dispatches
|
||||
// `statusChange` before rethrowing, which paints the card red and pins
|
||||
// the monitoring sidebar open — presenting this as the failed connect
|
||||
// attempt the notice says it is not. `connect` is mocked here, so the
|
||||
// teardown is what this asserts; without it the client is left in that
|
||||
// state.
|
||||
expect(disconnectSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("finds an insecure token endpoint wrapped under `cause` on the connect path", async () => {
|
||||
// Era negotiation and the transport wrappers bury the rejection, so the
|
||||
// shallow check this replaced would have missed exactly this shape.
|
||||
connectSpy.mockRejectedValueOnce(
|
||||
new Error("connect failed", {
|
||||
cause: new InsecureTokenEndpointError("http://localhost.:8091/token"),
|
||||
}),
|
||||
);
|
||||
const h = harness({ servers: [entry("a")] });
|
||||
|
||||
await act(async () => {
|
||||
await h.api().onToggleConnection("a");
|
||||
});
|
||||
|
||||
expect(toastTitles()).toContain("Token endpoint is not secure");
|
||||
expect(h.spies.setFailedServerId).not.toHaveBeenCalledWith("a");
|
||||
expect(disconnectSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports an insecure token endpoint raised by the 401 authorization attempt", async () => {
|
||||
// The second of the two arms: `authenticate()` rejects rather than the
|
||||
// opening handshake, which is the path a refresh takes.
|
||||
connectSpy.mockRejectedValueOnce(unauthorized());
|
||||
authenticateSpy.mockRejectedValueOnce(
|
||||
new InsecureTokenEndpointError("http://localhost.:8091/token"),
|
||||
);
|
||||
const h = harness({ servers: [entry("a")] });
|
||||
|
||||
await act(async () => {
|
||||
await h.api().onToggleConnection("a");
|
||||
});
|
||||
|
||||
expect(toastTitles()).toContain("Token endpoint is not secure");
|
||||
expect(toastTitles()).not.toContain(
|
||||
'OAuth authorization failed for "Server a"',
|
||||
);
|
||||
expect(h.spies.setFailedServerId).not.toHaveBeenCalledWith("a");
|
||||
// The generic arm also records this as the connect error banner text;
|
||||
// the terminal arm returns before that, so it must stay unset.
|
||||
expect(h.api().connectErrorMessage).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports a terminal refusal raised by the retried connect, without flagging the card", async () => {
|
||||
// The satisfied-challenge retry still ends in a token exchange, so it can
|
||||
// raise this on its own. Reporting it as a failed connect is doubly wrong
|
||||
// here: the Inspector has just told the user the authorization worked
|
||||
// (#2280).
|
||||
connectSpy
|
||||
.mockRejectedValueOnce(
|
||||
new AuthRecoveryRequiredError(
|
||||
new URL("https://as.example/authorize"),
|
||||
{ reason: "unauthorized" },
|
||||
),
|
||||
)
|
||||
.mockRejectedValueOnce(
|
||||
new InsecureTokenEndpointError("http://localhost.:8091/token"),
|
||||
);
|
||||
checkSpy.mockResolvedValueOnce(true);
|
||||
const h = harness({ servers: [entry("a")] });
|
||||
|
||||
await act(async () => {
|
||||
await h.api().onToggleConnection("a");
|
||||
});
|
||||
|
||||
expect(connectSpy).toHaveBeenCalledTimes(2);
|
||||
expect(toastTitles()).toContain("Token endpoint is not secure");
|
||||
expect(toastTitles()).not.toContain('Failed to connect to "Server a"');
|
||||
expect(h.spies.setFailedServerId).not.toHaveBeenCalledWith("a");
|
||||
expect(
|
||||
applyBannerUpdate(h.spies.setReAuthBanner, {
|
||||
serverId: "other",
|
||||
message: "x",
|
||||
}),
|
||||
).toMatchObject({ serverId: "other" });
|
||||
expect(h.api().connectErrorMessage).toBeUndefined();
|
||||
// The teardown the generic arm does is still required on this one.
|
||||
expect(disconnectSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retries the connect when the auth challenge is already satisfied", async () => {
|
||||
connectSpy.mockRejectedValueOnce(
|
||||
new AuthRecoveryRequiredError(new URL("https://as.example/authorize"), {
|
||||
|
|
@ -1211,6 +1355,39 @@ describe("useConnectionLifecycle", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("reports a terminal token-endpoint refusal from the banner action", async () => {
|
||||
// The path a user reaches by *acting*: an ordinary re-auth banner, they
|
||||
// click Re-authenticate, and the exchange is refused. The worst place to
|
||||
// fall back to the raw SDK text, since they have just been told that
|
||||
// retrying is the fix (#2280).
|
||||
const h = harness({ servers: [entry("a")] });
|
||||
await act(async () => {
|
||||
await h.api().onToggleConnection("a");
|
||||
});
|
||||
const client = lastClient(h);
|
||||
authenticateSpy.mockRejectedValue(
|
||||
new InsecureTokenEndpointError("http://localhost.:8091/token"),
|
||||
);
|
||||
h.rerender({
|
||||
servers: [entry("a")],
|
||||
activeServerId: "a",
|
||||
connectionStatus: "connected",
|
||||
client,
|
||||
reAuthBanner: { serverId: "a", message: "lapsed" },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
h.api().onReauthenticateFromBanner();
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(toastTitles()).toContain("Token endpoint is not secure"),
|
||||
);
|
||||
expect(toastTitles()).not.toContain(
|
||||
'OAuth authorization failed for "Server a"',
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to an unnamed failure toast for an unknown server", async () => {
|
||||
const h = harness({ servers: [entry("a")] });
|
||||
await act(async () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import type {
|
|||
import type { AuthChallenge } from "@inspector/core/auth/challenge.js";
|
||||
import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js";
|
||||
import { EmaClientNotConfiguredError } from "@inspector/core/auth/ema/clientConfigError.js";
|
||||
import { InsecureTokenEndpointError } from "@modelcontextprotocol/client";
|
||||
import { useEffect, useLayoutEffect, useRef } from "react";
|
||||
import { renderWithMantine, act, waitFor } from "../test/renderWithMantine";
|
||||
import {
|
||||
|
|
@ -979,6 +980,137 @@ describe("useOAuthRecovery", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("claims an insecure token endpoint on the command path instead of rethrowing", async () => {
|
||||
// SEP-2207 (#2280). A mid-session silent refresh rejects here rather than
|
||||
// as an AuthRecoveryRequiredError, so before this it was rethrown into
|
||||
// the generic reporting below.
|
||||
const client = fakeClient();
|
||||
const h = harness({ servers: [entry("a")], activeServerId: "a", client });
|
||||
await act(async () => {
|
||||
await expect(
|
||||
h
|
||||
.api()
|
||||
.runWithCommandAuthRecovery(
|
||||
() =>
|
||||
Promise.reject(
|
||||
new InsecureTokenEndpointError(
|
||||
"http://localhost.:8091/token",
|
||||
),
|
||||
),
|
||||
"tool",
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
expect(toastTitles()).toContain("Token endpoint is not secure");
|
||||
});
|
||||
|
||||
it("does not clear a banner belonging to a different server", async () => {
|
||||
// The paths are asynchronous: server A can reject long after the user
|
||||
// switched away and server B raised its own banner. An unconditional
|
||||
// clear would erase B's, which is still valid and still actionable.
|
||||
const client = fakeClient();
|
||||
const h = harness({
|
||||
servers: [entry("a"), entry("b")],
|
||||
activeServerId: "b",
|
||||
client,
|
||||
});
|
||||
await act(async () => {
|
||||
client.emit("oauthError", { error: new Error("session expired") });
|
||||
});
|
||||
await waitFor(() => expect(h.api().reAuthBanner?.serverId).toBe("b"));
|
||||
|
||||
// The user switches to "a"; a stale continuation for it now rejects.
|
||||
h.rerender({
|
||||
servers: [entry("a"), entry("b")],
|
||||
activeServerId: "a",
|
||||
client,
|
||||
});
|
||||
await act(async () => {
|
||||
await h
|
||||
.api()
|
||||
.runWithCommandAuthRecovery(
|
||||
() =>
|
||||
Promise.reject(
|
||||
new InsecureTokenEndpointError("http://localhost.:8091/token"),
|
||||
),
|
||||
"tool",
|
||||
);
|
||||
});
|
||||
|
||||
expect(toastTitles()).toContain("Token endpoint is not secure");
|
||||
// B's banner survives — it is still valid and still actionable.
|
||||
expect(h.api().reAuthBanner?.serverId).toBe("b");
|
||||
});
|
||||
|
||||
it("clears a stale banner when a command-path failure is terminal", async () => {
|
||||
// Every terminal arm goes through one wrapper for this reason: a banner
|
||||
// left by an earlier failure carries a Re-authenticate button just as
|
||||
// dead as the one this arm declines to offer, and the user cannot tell
|
||||
// which failure it belongs to.
|
||||
const client = fakeClient();
|
||||
const h = harness({ servers: [entry("a")], activeServerId: "a", client });
|
||||
await act(async () => {
|
||||
client.emit("oauthError", { error: new Error("session expired") });
|
||||
});
|
||||
await waitFor(() => expect(h.api().reAuthBanner?.serverId).toBe("a"));
|
||||
|
||||
await act(async () => {
|
||||
await h
|
||||
.api()
|
||||
.runWithCommandAuthRecovery(
|
||||
() =>
|
||||
Promise.reject(
|
||||
new InsecureTokenEndpointError("http://localhost.:8091/token"),
|
||||
),
|
||||
"tool",
|
||||
);
|
||||
});
|
||||
expect(toastTitles()).toContain("Token endpoint is not secure");
|
||||
expect(h.api().reAuthBanner).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the terminal notice instead of the generic title in the background form", async () => {
|
||||
// The `errorTitle` call sites would otherwise render the raw SDK text
|
||||
// under a generic heading.
|
||||
const client = fakeClient();
|
||||
const h = harness({ servers: [entry("a")], activeServerId: "a", client });
|
||||
await act(async () => {
|
||||
h.api().runCommandInBackground(
|
||||
() =>
|
||||
Promise.reject(
|
||||
new InsecureTokenEndpointError("http://localhost.:8091/token"),
|
||||
),
|
||||
"ambient",
|
||||
"Refresh failed",
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(toastTitles()).toContain("Token endpoint is not secure"),
|
||||
);
|
||||
expect(toastTitles()).not.toContain("Refresh failed");
|
||||
});
|
||||
|
||||
it("still reports it at a call site whose panel owns reporting", async () => {
|
||||
// The worse half of the old behavior: with no `errorTitle` the rejection
|
||||
// was swallowed outright and the command just appeared to do nothing.
|
||||
const client = fakeClient();
|
||||
const h = harness({ servers: [entry("a")], activeServerId: "a", client });
|
||||
await act(async () => {
|
||||
h.api().runCommandInBackground(
|
||||
() =>
|
||||
Promise.reject(
|
||||
new InsecureTokenEndpointError("http://localhost.:8091/token"),
|
||||
),
|
||||
"ambient",
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(toastTitles()).toContain("Token endpoint is not secure"),
|
||||
);
|
||||
});
|
||||
|
||||
it("toasts a background failure only when given a title", async () => {
|
||||
const client = fakeClient();
|
||||
const h = harness({ servers: [entry("a")], activeServerId: "a", client });
|
||||
|
|
@ -1181,6 +1313,28 @@ describe("useOAuthRecovery", () => {
|
|||
await waitFor(() => expect(h.api().reAuthBanner?.serverId).toBe("a"));
|
||||
});
|
||||
|
||||
it("clears a banner already on screen when a later oauthError is terminal", async () => {
|
||||
// Otherwise the stale Re-authenticate button sits beside the terminal
|
||||
// notice — the affordance this change removes, sourced from an earlier
|
||||
// failure rather than this one.
|
||||
const client = fakeClient();
|
||||
const h = harness({ servers: [entry("a")], activeServerId: "a", client });
|
||||
await act(async () => {
|
||||
client.emit("oauthError", { error: new Error("token endpoint 500") });
|
||||
});
|
||||
await waitFor(() => expect(h.api().reAuthBanner?.serverId).toBe("a"));
|
||||
|
||||
await act(async () => {
|
||||
client.emit("oauthError", {
|
||||
error: new InsecureTokenEndpointError("http://localhost.:8091/token"),
|
||||
});
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(toastTitles()).toContain("Token endpoint is not secure"),
|
||||
);
|
||||
expect(h.api().reAuthBanner).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores an oauthError with no active server", async () => {
|
||||
const client = fakeClient();
|
||||
const h = harness({ servers: [], activeServerId: undefined, client });
|
||||
|
|
@ -1344,6 +1498,41 @@ describe("useOAuthRecovery", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("does not re-arm a deferred recovery whose failure is terminal", async () => {
|
||||
// The restore's premise is that the recovery is still owed and a later
|
||||
// trigger should retry it. For a refusal that can only fail the same way,
|
||||
// re-arming means every future tab focus replays it under a toast
|
||||
// promising a retry that cannot succeed — an unbounded loop on a terminal
|
||||
// error (#2280).
|
||||
const client = fakeClient({
|
||||
handleAuthChallenge: vi
|
||||
.fn()
|
||||
.mockRejectedValue(
|
||||
new InsecureTokenEndpointError("http://localhost.:8091/token"),
|
||||
),
|
||||
});
|
||||
const h = harness({ servers: [entry("a")], activeServerId: "a", client });
|
||||
await defer(client, h);
|
||||
await act(async () => {
|
||||
becomeVisible();
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(toastTitles()).toContain("Token endpoint is not secure"),
|
||||
);
|
||||
// Neither the retry promise nor the slot that would make good on it.
|
||||
// `pendingReauth` is not on the hook's public surface, so it is read back
|
||||
// through the commit probe, as the step-up tests above do.
|
||||
expect(toastTitles()).not.toContain("Could not continue authorization");
|
||||
expect(h.commits[h.commits.length - 1]?.reauthServerId).toBeUndefined();
|
||||
|
||||
// The load-bearing assertion: coming back to the tab does not replay it.
|
||||
await act(async () => {
|
||||
becomeVisible();
|
||||
});
|
||||
expect(client.handleAuthChallenge).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not put a stale challenge back over a newer deferral", async () => {
|
||||
// The tab can go hidden mid-resume and a newer challenge defer itself
|
||||
// into the slot; that one describes the session as it is now, so the
|
||||
|
|
@ -1686,6 +1875,45 @@ describe("useOAuthRecovery", () => {
|
|||
expect(h.spies.setFailedServerId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports an insecure token endpoint terminally, with no banner and no red card", async () => {
|
||||
// SEP-2207 (#2280). The three assertions are the whole point of the arm's
|
||||
// position: the banner would carry a Re-authenticate button that cannot
|
||||
// work, and flagging the card would present a configuration error as a
|
||||
// failed connect attempt.
|
||||
snapshot();
|
||||
const client = fakeClient({
|
||||
resumeAfterOAuth: vi
|
||||
.fn()
|
||||
.mockRejectedValue(
|
||||
new InsecureTokenEndpointError("http://localhost.:8091/token"),
|
||||
),
|
||||
});
|
||||
const h = callbackHarness(`?code=abc&state=${AUTH_ID}`, {}, client);
|
||||
await waitFor(() =>
|
||||
expect(toastTitles()).toContain("Token endpoint is not secure"),
|
||||
);
|
||||
expect(h.api().reAuthBanner).toBeNull();
|
||||
expect(h.spies.setFailedServerId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("finds an insecure token endpoint wrapped under `cause` on the callback leg", async () => {
|
||||
snapshot();
|
||||
const client = fakeClient({
|
||||
resumeAfterOAuth: vi.fn().mockRejectedValue(
|
||||
new Error("resume failed", {
|
||||
cause: new InsecureTokenEndpointError(
|
||||
"http://localhost.:8091/token",
|
||||
),
|
||||
}),
|
||||
),
|
||||
});
|
||||
const h = callbackHarness(`?code=abc&state=${AUTH_ID}`, {}, client);
|
||||
await waitFor(() =>
|
||||
expect(toastTitles()).toContain("Token endpoint is not secure"),
|
||||
);
|
||||
expect(h.api().reAuthBanner).toBeNull();
|
||||
});
|
||||
|
||||
it("offers one-click recovery when the authorization state was lost", async () => {
|
||||
snapshot();
|
||||
const client = fakeClient({
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { RefObject } from "react";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
|
|
@ -30,6 +31,7 @@ import {
|
|||
emaStepUpSuccessMessage,
|
||||
} from "@inspector/core/auth/oauthUx.js";
|
||||
import { isEmaClientNotConfiguredError } from "@inspector/core/auth/ema/clientConfigError.js";
|
||||
import { showInsecureTokenEndpointNotice } from "../lib/insecureTokenEndpointNotice";
|
||||
import type { OAuthDetails } from "../components/groups/ConnectionInfoContent/ConnectionInfoContent";
|
||||
import { oauthDetailsFromConnectionState } from "../components/groups/ConnectionInfoContent/oauthDetailsFromConnectionState";
|
||||
import { getWebRemoteOAuthStorage } from "../lib/remoteOAuthStorage";
|
||||
|
|
@ -210,7 +212,13 @@ export interface OAuthRecovery {
|
|||
onBeforeOAuthRedirect: (authorizationUrl: URL) => void;
|
||||
prepareOAuthRedirect: (args: PrepareOAuthRedirectArgs) => void;
|
||||
reAuthBanner: ReAuthBannerState | null;
|
||||
setReAuthBanner: (next: ReAuthBannerState | null) => void;
|
||||
/**
|
||||
* The raw state setter, functional form included. Consumers need the updater
|
||||
* to clear a banner **only when it belongs to the server they are reporting
|
||||
* on** — these paths are asynchronous, so a late continuation for server A
|
||||
* must not erase a banner server B raised in the meantime.
|
||||
*/
|
||||
setReAuthBanner: Dispatch<SetStateAction<ReAuthBannerState | null>>;
|
||||
/**
|
||||
* Drops the banner and both pending-OAuth slots. Called from the session
|
||||
* reset, which runs on every disconnect: an unanswered step-up prompt or a
|
||||
|
|
@ -383,6 +391,41 @@ export function useOAuthRecovery({
|
|||
[sessionRef],
|
||||
);
|
||||
|
||||
/**
|
||||
* Report a terminal SEP-2207 refusal (#2280) and clear any re-auth banner.
|
||||
*
|
||||
* Every arm goes through this rather than calling the notice helper directly.
|
||||
* The banner clear is not incidental: a banner left from an *earlier* failure
|
||||
* carries a Re-authenticate button that is just as dead as the one this
|
||||
* change removes, and the user cannot tell which failure it belongs to. Round
|
||||
* 4 fixed that for one arm by hand; wrapping it is what stops the next arm
|
||||
* from omitting it.
|
||||
*
|
||||
* Returns whether the error was claimed, so callers keep their fall-through.
|
||||
*/
|
||||
const reportTerminalInsecureTokenEndpoint = useCallback(
|
||||
(
|
||||
err: unknown,
|
||||
serverId: string | undefined,
|
||||
serverName?: string,
|
||||
): boolean => {
|
||||
if (!showInsecureTokenEndpointNotice(err, serverName)) {
|
||||
return false;
|
||||
}
|
||||
// Clear only *this* server's banner. The command and deferred-resume
|
||||
// paths are asynchronous, so server A can reject long after the user
|
||||
// switched away and server B raised a banner of its own; an unconditional
|
||||
// clear would then erase B's, which is still valid and still actionable.
|
||||
// Functional so it sees the queued state rather than the render-time
|
||||
// value, matching how `setPendingReauth` guards its own late restore.
|
||||
setReAuthBanner((prev) =>
|
||||
prev && prev.serverId === serverId ? null : prev,
|
||||
);
|
||||
return true;
|
||||
},
|
||||
[setReAuthBanner],
|
||||
);
|
||||
|
||||
const showReAuthBanner = useCallback(
|
||||
(
|
||||
serverId: string,
|
||||
|
|
@ -390,6 +433,14 @@ export function useOAuthRecovery({
|
|||
options?: { reason?: AuthChallengeReason },
|
||||
) => {
|
||||
const server = sessionRef.current.servers.find((s) => s.id === serverId);
|
||||
// SEP-2207 (#2280). The SDK rethrows `InsecureTokenEndpointError` instead
|
||||
// of retrying, so the banner's "Re-authenticate" could only fail the same
|
||||
// way. Claimed here, at the single funnel every re-auth banner goes
|
||||
// through, rather than at each of its call sites — a new caller then gets
|
||||
// the right behavior by default instead of by remembering.
|
||||
if (reportTerminalInsecureTokenEndpoint(detail, serverId, server?.name)) {
|
||||
return;
|
||||
}
|
||||
const message = reAuthBannerMessage({
|
||||
serverName: server?.name,
|
||||
detail:
|
||||
|
|
@ -410,7 +461,7 @@ export function useOAuthRecovery({
|
|||
message,
|
||||
});
|
||||
},
|
||||
[sessionRef],
|
||||
[sessionRef, reportTerminalInsecureTokenEndpoint],
|
||||
);
|
||||
|
||||
/** Clears pending OAuth resume state — explicit user disconnect only. */
|
||||
|
|
@ -811,10 +862,35 @@ export function useOAuthRecovery({
|
|||
}
|
||||
return undefined;
|
||||
}
|
||||
// SEP-2207 (#2280), on the command path. A mid-session silent refresh
|
||||
// against an unusable token endpoint rejects here rather than as an
|
||||
// `AuthRecoveryRequiredError`, so without this it is rethrown and lands
|
||||
// in `runCommandInBackground` — which either shows the raw SDK text
|
||||
// under a generic title or, at a call site whose panel owns reporting,
|
||||
// swallows it and leaves the command looking like it did nothing.
|
||||
//
|
||||
// Claimed rather than rethrown, taking the same `undefined` exit the
|
||||
// unsatisfied-recovery branch above already uses: the failure is
|
||||
// terminal and now fully reported, so an awaited caller should stop
|
||||
// rather than render it a second time.
|
||||
const server = sessionRef.current.servers.find(
|
||||
(s) => s.id === activeServerId,
|
||||
);
|
||||
if (
|
||||
reportTerminalInsecureTokenEndpoint(err, activeServerId, server?.name)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[inspectorClient, activeServerId, handleCommandScopedAuthRecovery],
|
||||
[
|
||||
inspectorClient,
|
||||
activeServerId,
|
||||
handleCommandScopedAuthRecovery,
|
||||
sessionRef,
|
||||
reportTerminalInsecureTokenEndpoint,
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
|
|
@ -924,6 +1000,25 @@ export function useOAuthRecovery({
|
|||
});
|
||||
}
|
||||
} catch (err) {
|
||||
// SEP-2207 (#2280) first, and specifically BEFORE the restore below.
|
||||
// `handleAuthChallenge` runs the same SDK auth flow, so it can raise
|
||||
// this terminal error — and the restore's whole premise is that the
|
||||
// recovery is still owed and a later trigger should retry it. For a
|
||||
// refusal that can only fail the same way, re-arming the slot means
|
||||
// every future tab focus and reconnect replays it, under a toast
|
||||
// promising a retry that cannot succeed. Report it and let it go.
|
||||
const failedServer = sessionRef.current.servers.find(
|
||||
(s) => s.id === pending.serverId,
|
||||
);
|
||||
if (
|
||||
reportTerminalInsecureTokenEndpoint(
|
||||
err,
|
||||
pending.serverId,
|
||||
failedServer?.name,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// The slot was cleared above only to keep a tab-visible event and a
|
||||
// reconnect from starting the same authorization twice — not because
|
||||
// the recovery was delivered. It still is owed, so restore it and let
|
||||
|
|
@ -969,6 +1064,7 @@ export function useOAuthRecovery({
|
|||
}
|
||||
},
|
||||
[
|
||||
reportTerminalInsecureTokenEndpoint,
|
||||
sessionRef,
|
||||
inspectorClient,
|
||||
connectionStatus,
|
||||
|
|
@ -1320,6 +1416,12 @@ export function useOAuthRecovery({
|
|||
});
|
||||
return;
|
||||
}
|
||||
// Above `setFailedServerId` for the same reason the EMA arm is: this is
|
||||
// a configuration error, not a failed attempt, so it should not flag
|
||||
// the card red or pull the monitoring sidebar open.
|
||||
if (reportTerminalInsecureTokenEndpoint(err, server.id, server.name)) {
|
||||
return;
|
||||
}
|
||||
// The token exchange (or the re-handshake behind it) failed. Flag the
|
||||
// server (#1621) so the monitoring sidebar opens onto the OAuth
|
||||
// requests that explain it (#2108) — the rebuilt client restored the
|
||||
|
|
@ -1424,6 +1526,7 @@ export function useOAuthRecovery({
|
|||
initialConfigSettledRef,
|
||||
clearResultPanels,
|
||||
showReAuthBanner,
|
||||
reportTerminalInsecureTokenEndpoint,
|
||||
webOAuthStorage,
|
||||
setUi,
|
||||
setActiveTab,
|
||||
|
|
|
|||
57
clients/web/src/lib/insecureTokenEndpointNotice.test.ts
Normal file
57
clients/web/src/lib/insecureTokenEndpointNotice.test.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { InsecureTokenEndpointError } from "@modelcontextprotocol/client";
|
||||
import {
|
||||
insecureTokenEndpointMessage,
|
||||
insecureTokenEndpointTitle,
|
||||
} from "../utils/oauthUx";
|
||||
|
||||
const show = vi.fn();
|
||||
vi.mock("@mantine/notifications", () => ({
|
||||
notifications: { show: (...args: unknown[]) => show(...args) },
|
||||
}));
|
||||
|
||||
const { showInsecureTokenEndpointNotice } =
|
||||
await import("./insecureTokenEndpointNotice");
|
||||
|
||||
const ENDPOINT = "http://tenant.example.localhost:3300/api/oauth/token";
|
||||
|
||||
beforeEach(() => {
|
||||
show.mockClear();
|
||||
});
|
||||
|
||||
describe("showInsecureTokenEndpointNotice", () => {
|
||||
it("claims the SDK error and shows the terminal notice", () => {
|
||||
const handled = showInsecureTokenEndpointNotice(
|
||||
new InsecureTokenEndpointError(ENDPOINT),
|
||||
"Acme",
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(show).toHaveBeenCalledTimes(1);
|
||||
expect(show).toHaveBeenCalledWith({
|
||||
title: insecureTokenEndpointTitle(),
|
||||
message: insecureTokenEndpointMessage({
|
||||
tokenEndpoint: ENDPOINT,
|
||||
serverName: "Acme",
|
||||
}),
|
||||
color: "red",
|
||||
// Non-recoverable, so the explanation must not vanish on a timer — there
|
||||
// is no second chance to read it.
|
||||
autoClose: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("works without a server name", () => {
|
||||
expect(
|
||||
showInsecureTokenEndpointNotice(new InsecureTokenEndpointError(ENDPOINT)),
|
||||
).toBe(true);
|
||||
expect(show.mock.calls[0][0].message).toContain("this server");
|
||||
});
|
||||
|
||||
it("declines any other error, leaving the caller's handling in place", () => {
|
||||
expect(showInsecureTokenEndpointNotice(new Error("boom"), "Acme")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(show).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
59
clients/web/src/lib/insecureTokenEndpointNotice.ts
Normal file
59
clients/web/src/lib/insecureTokenEndpointNotice.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
/**
|
||||
* Surfaces the SDK's SEP-2207 refusal to post credentials to a non-TLS token
|
||||
* endpoint as the terminal configuration error it is (#2280).
|
||||
*
|
||||
* Lives in `lib/` rather than `utils/` because showing a notification is a side
|
||||
* effect; the copy it renders is pure and lives in `@inspector/core/auth`.
|
||||
*
|
||||
* Shaped as a claim-or-decline predicate rather than a plain `show(...)` so the
|
||||
* three OAuth failure paths that need it — the connect handshake, the post-
|
||||
* redirect callback, and the re-auth banner funnel — can each spend one line on
|
||||
* it and keep their existing fall-through intact:
|
||||
*
|
||||
* ```ts
|
||||
* if (showInsecureTokenEndpointNotice(err, server.name)) return;
|
||||
* ```
|
||||
*
|
||||
* `autoClose: false` matches the other non-recoverable OAuth notices (issuer
|
||||
* mismatch, unconfigured enterprise IdP): nothing the user does next will make
|
||||
* this reappear, so a toast that vanishes takes the only explanation with it.
|
||||
* It stops the notice **expiring**, not the user dismissing it — Mantine's close
|
||||
* control still works, which is correct for a message someone has finished
|
||||
* reading. Don't describe this as non-dismissible.
|
||||
*/
|
||||
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { findInsecureTokenEndpoint } from "@inspector/core/auth/insecureTokenEndpoint.js";
|
||||
import {
|
||||
insecureTokenEndpointMessage,
|
||||
insecureTokenEndpointTitle,
|
||||
} from "../utils/oauthUx";
|
||||
|
||||
/**
|
||||
* Show the terminal notice when `err` is the SDK's `InsecureTokenEndpointError`.
|
||||
*
|
||||
* @returns `true` when it was handled (the caller should stop), `false` when
|
||||
* `err` is some other failure and the caller's normal handling applies.
|
||||
*/
|
||||
export function showInsecureTokenEndpointNotice(
|
||||
err: unknown,
|
||||
serverName?: string,
|
||||
): boolean {
|
||||
// Searched rather than type-tested: era negotiation and the transport
|
||||
// wrappers bury the rejection under `cause` / `data.cause`, so the connect and
|
||||
// refresh paths hand us a wrapper rather than the error itself.
|
||||
const found = findInsecureTokenEndpoint(err);
|
||||
if (!found) {
|
||||
return false;
|
||||
}
|
||||
notifications.show({
|
||||
title: insecureTokenEndpointTitle(),
|
||||
message: insecureTokenEndpointMessage({
|
||||
tokenEndpoint: found.tokenEndpoint,
|
||||
serverName,
|
||||
}),
|
||||
color: "red",
|
||||
autoClose: false,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
124
clients/web/src/test/core/auth/insecureTokenEndpoint.test.ts
Normal file
124
clients/web/src/test/core/auth/insecureTokenEndpoint.test.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { InsecureTokenEndpointError } from "@modelcontextprotocol/client";
|
||||
import { findInsecureTokenEndpoint } from "@inspector/core/auth/insecureTokenEndpoint.js";
|
||||
|
||||
const ENDPOINT = "http://tenant.example.localhost:3300/api/oauth/token";
|
||||
|
||||
/**
|
||||
* Documents the assumption the classifier is built on, the same way
|
||||
* `issuerBinding.test.ts` does for its sibling: the SDK declares `mcpBrand` in
|
||||
* a `static {}` block, so it lives on the constructor and instances never carry
|
||||
* it. A classifier that read `err.mcpBrand` would match no real thrown error.
|
||||
*/
|
||||
describe("SDK brand placement", () => {
|
||||
it("keeps `mcpBrand` on the class, not the instance", () => {
|
||||
expect("mcpBrand" in new InsecureTokenEndpointError(ENDPOINT)).toBe(false);
|
||||
});
|
||||
|
||||
it("is not an OAuthError, which is why the retry path must not claim it", () => {
|
||||
const err = new InsecureTokenEndpointError(ENDPOINT);
|
||||
// The SDK deliberately keeps this off the `OAuthError` hierarchy so hosts
|
||||
// do not treat it as a transient authorization failure. If a future SDK
|
||||
// changes that, the #2280 handling should be revisited rather than silently
|
||||
// keeping a now-wrong justification.
|
||||
expect(err.name).toBe("InsecureTokenEndpointError");
|
||||
expect(typeof err.tokenEndpoint).toBe("string");
|
||||
});
|
||||
});
|
||||
|
||||
describe("findInsecureTokenEndpoint", () => {
|
||||
it("recognizes a real SDK error and returns its endpoint", () => {
|
||||
expect(
|
||||
findInsecureTokenEndpoint(new InsecureTokenEndpointError(ENDPOINT)),
|
||||
).toMatchObject({ tokenEndpoint: ENDPOINT });
|
||||
});
|
||||
|
||||
it("recognizes a serialized copy by `name`, where the prototype is gone", () => {
|
||||
// The fallback arm: a structured clone or JSON hop drops the prototype and
|
||||
// the brand set but keeps `name`.
|
||||
expect(
|
||||
findInsecureTokenEndpoint({
|
||||
name: "InsecureTokenEndpointError",
|
||||
message: "Refusing to send credentials…",
|
||||
tokenEndpoint: ENDPOINT,
|
||||
}),
|
||||
).toMatchObject({ tokenEndpoint: ENDPOINT });
|
||||
});
|
||||
|
||||
it("rejects a look-alike carrying the endpoint but not the identity", () => {
|
||||
// Neither the brand nor the name: some other error that happens to have a
|
||||
// `tokenEndpoint` field must not be swallowed by the terminal arm.
|
||||
expect(
|
||||
findInsecureTokenEndpoint({ tokenEndpoint: ENDPOINT, name: "Error" }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects the right identity with no endpoint to report", () => {
|
||||
// The copy names the endpoint, so a value that cannot supply one is not
|
||||
// usable by this path and falls through to the generic handling.
|
||||
expect(
|
||||
findInsecureTokenEndpoint({ name: "InsecureTokenEndpointError" }),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
findInsecureTokenEndpoint({
|
||||
name: "InsecureTokenEndpointError",
|
||||
tokenEndpoint: 42,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([null, undefined, "InsecureTokenEndpointError", 0, new Error("x")])(
|
||||
"rejects %j",
|
||||
(value) => {
|
||||
expect(findInsecureTokenEndpoint(value)).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
describe("cause chains", () => {
|
||||
// Era negotiation and the transport wrappers bury the rejection, so a
|
||||
// top-level-only check would miss the connect and refresh paths outright
|
||||
// and let the retryable UI render anyway.
|
||||
it("finds it under `cause`", () => {
|
||||
const wrapped = new Error("connect failed", {
|
||||
cause: new InsecureTokenEndpointError(ENDPOINT),
|
||||
});
|
||||
expect(findInsecureTokenEndpoint(wrapped)).toMatchObject({
|
||||
tokenEndpoint: ENDPOINT,
|
||||
});
|
||||
});
|
||||
|
||||
it("finds it under `data.cause`", () => {
|
||||
const wrapped = Object.assign(new Error("negotiation failed"), {
|
||||
data: { cause: new InsecureTokenEndpointError(ENDPOINT) },
|
||||
});
|
||||
expect(findInsecureTokenEndpoint(wrapped)).toMatchObject({
|
||||
tokenEndpoint: ENDPOINT,
|
||||
});
|
||||
});
|
||||
|
||||
it("finds it several links down", () => {
|
||||
const wrapped = new Error("outer", {
|
||||
cause: new Error("middle", {
|
||||
cause: new InsecureTokenEndpointError(ENDPOINT),
|
||||
}),
|
||||
});
|
||||
expect(findInsecureTokenEndpoint(wrapped)).toMatchObject({
|
||||
tokenEndpoint: ENDPOINT,
|
||||
});
|
||||
});
|
||||
|
||||
it("terminates on a self-referential cause instead of looping", () => {
|
||||
const loop: { cause?: unknown; name: string } = { name: "Loop" };
|
||||
loop.cause = loop;
|
||||
expect(findInsecureTokenEndpoint(loop)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for a chain that never contains one", () => {
|
||||
expect(
|
||||
findInsecureTokenEndpoint(
|
||||
new Error("outer", { cause: new Error("inner") }),
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -6,6 +6,8 @@ import {
|
|||
emaStepUpFailureMessage,
|
||||
emaStepUpInProgressMessage,
|
||||
emaStepUpSuccessMessage,
|
||||
insecureTokenEndpointMessage,
|
||||
insecureTokenEndpointTitle,
|
||||
isActionTriggeredOAuthRecovery,
|
||||
isEmaStepUp,
|
||||
isReAuthBannerReason,
|
||||
|
|
@ -437,3 +439,57 @@ describe("oauthUx issuer-binding copy", () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("insecureTokenEndpoint copy", () => {
|
||||
const ENDPOINT = "http://tenant.example.localhost:3300/api/oauth/token";
|
||||
|
||||
it("names the failure as a configuration problem, not an auth failure", () => {
|
||||
expect(insecureTokenEndpointTitle()).toBe("Token endpoint is not secure");
|
||||
});
|
||||
|
||||
it("describes the scheme as not-HTTPS rather than as plain HTTP", () => {
|
||||
// The SDK's check is `protocol !== "https:"`, so a mistyped `ftp:` or `ws:`
|
||||
// endpoint lands here too; naming the wrong scheme would send the reader
|
||||
// hunting for a problem they do not have.
|
||||
const message = insecureTokenEndpointMessage({
|
||||
tokenEndpoint: "ftp://as.example.com/token",
|
||||
});
|
||||
expect(message).toContain("not HTTPS");
|
||||
expect(message).not.toContain("plain HTTP");
|
||||
});
|
||||
|
||||
it("names the endpoint, the server, and both ways out", () => {
|
||||
const message = insecureTokenEndpointMessage({
|
||||
tokenEndpoint: ENDPOINT,
|
||||
serverName: "Acme",
|
||||
});
|
||||
expect(message).toContain('"Acme"');
|
||||
expect(message).toContain(ENDPOINT);
|
||||
expect(message).toContain("HTTPS");
|
||||
expect(message).toContain("127.0.0.1");
|
||||
expect(message).toContain("Token URL");
|
||||
});
|
||||
|
||||
it("says a retry cannot help, which is the whole point of the message", () => {
|
||||
// The bug this copy fixes (#2280) was a Re-authenticate button that could
|
||||
// never succeed. If this sentence goes, the copy stops doing its job.
|
||||
expect(insecureTokenEndpointMessage({ tokenEndpoint: ENDPOINT })).toContain(
|
||||
"Re-authenticating cannot change this",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to a generic subject with no server name", () => {
|
||||
const message = insecureTokenEndpointMessage({ tokenEndpoint: ENDPOINT });
|
||||
expect(message).toContain("this server");
|
||||
expect(message).not.toContain('""');
|
||||
});
|
||||
|
||||
it("bounds a hostile-length endpoint for display", () => {
|
||||
// The endpoint is remote-supplied (it comes from the server's AS metadata),
|
||||
// so an overlong value must not be echoed back whole into the layout.
|
||||
const long = `https://example.com/${"a".repeat(500)}`;
|
||||
const message = insecureTokenEndpointMessage({ tokenEndpoint: long });
|
||||
expect(message).not.toContain(long);
|
||||
expect(message).toContain("…");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
228
clients/web/src/test/integration/mcp/strict-port.test.ts
Normal file
228
clients/web/src/test/integration/mcp/strict-port.test.ts
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { createServer, type Server } from "node:http";
|
||||
import { writeFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import {
|
||||
createTestServerHttp,
|
||||
type TestServerHttp,
|
||||
createTestServerInfo,
|
||||
loadConfig,
|
||||
resolveConfig,
|
||||
} from "@modelcontextprotocol/inspector-test-server";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
/**
|
||||
* Live coverage of `ServerConfig.strictPort` (#2280).
|
||||
*
|
||||
* Every other fixture walks to the next free port on `EADDRINUSE`, which is
|
||||
* right for them and wrong for one: `oauth-insecure-token-endpoint-http.json`
|
||||
* hard-codes its port inside an OAuth issuer string, so a relocated server would
|
||||
* announce 8092 while all its metadata still pointed at whatever unrelated
|
||||
* process holds 8091 — and would silently stop reproducing the refusal it
|
||||
* exists for. This asserts the walk still happens by default and does not
|
||||
* happen for that fixture, because "fails loudly" is only a safety property if
|
||||
* it actually fails.
|
||||
*/
|
||||
const configsDir = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"../../../../../../test-servers/configs",
|
||||
);
|
||||
|
||||
describe("strictPort (#2280)", () => {
|
||||
let squatter: Server | null = null;
|
||||
let server: TestServerHttp | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (server) {
|
||||
try {
|
||||
await server.stop();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
server = null;
|
||||
}
|
||||
if (squatter) {
|
||||
await new Promise<void>((resolve) => squatter!.close(() => resolve()));
|
||||
squatter = null;
|
||||
}
|
||||
});
|
||||
|
||||
/** Hold a port so the next bind has to decide whether to walk. */
|
||||
const squat = async (): Promise<number> => {
|
||||
squatter = createServer((_req, res) => res.end());
|
||||
await new Promise<void>((resolve) =>
|
||||
squatter!.listen(0, "127.0.0.1", () => resolve()),
|
||||
);
|
||||
const address = squatter.address();
|
||||
if (typeof address !== "object" || address === null) {
|
||||
throw new Error("no port");
|
||||
}
|
||||
return address.port;
|
||||
};
|
||||
|
||||
it("walks to another port by default", async () => {
|
||||
const taken = await squat();
|
||||
server = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo("walks", "1.0.0"),
|
||||
serverType: "streamable-http",
|
||||
port: taken,
|
||||
});
|
||||
const bound = await server.start();
|
||||
expect(bound).not.toBe(taken);
|
||||
});
|
||||
|
||||
it("refuses to relocate when strictPort is set", async () => {
|
||||
const taken = await squat();
|
||||
server = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo("strict", "1.0.0"),
|
||||
serverType: "streamable-http",
|
||||
port: taken,
|
||||
strictPort: true,
|
||||
});
|
||||
await expect(server.start()).rejects.toMatchObject({
|
||||
code: "EADDRINUSE",
|
||||
});
|
||||
// Deliberately NOT nulled: `start()` installs the process-global test-server
|
||||
// control before it binds, and only `stop()` clears it. Dropping the
|
||||
// reference here would skip teardown and leave that global pointing at a
|
||||
// dead server for the rest of the worker.
|
||||
});
|
||||
|
||||
it.each([undefined, 0])(
|
||||
"refuses to start with strictPort and port %j",
|
||||
async (port) => {
|
||||
// Nothing to be strict about. Falling through to an OS-assigned port
|
||||
// would let a misconfigured fixture look strict while relocating every
|
||||
// run — the failure the flag exists to prevent, now silent.
|
||||
server = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo("misconfigured", "1.0.0"),
|
||||
serverType: "streamable-http",
|
||||
port,
|
||||
strictPort: true,
|
||||
});
|
||||
await expect(server.start()).rejects.toThrow(/integer in 1-65535/);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["false", "true", 1, null])(
|
||||
"rejects a non-boolean strictPort in a config file: %j",
|
||||
(value) => {
|
||||
// Consumed as a plain truthiness check at bind time, so the string
|
||||
// "false" would read as *enabled* and silently disable the port walk —
|
||||
// the opposite of what the author wrote.
|
||||
const file = path.join(
|
||||
tmpdir(),
|
||||
`strict-port-${Date.now()}-${Math.random()}.json`,
|
||||
);
|
||||
writeFileSync(
|
||||
file,
|
||||
JSON.stringify({
|
||||
serverInfo: { name: "x", version: "1.0.0" },
|
||||
transport: { type: "streamable-http", port: 8099, strictPort: value },
|
||||
}),
|
||||
);
|
||||
try {
|
||||
expect(() => loadConfig(file)).toThrow(
|
||||
/transport.strictPort must be a boolean/,
|
||||
);
|
||||
} finally {
|
||||
rmSync(file, { force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
// Each of these is truthy or type-valid enough to pass a naive check, and
|
||||
// each fails SILENTLY: the fixture looks strict and relocates anyway.
|
||||
[
|
||||
{ type: "streamable-http", port: "0", strictPort: true },
|
||||
/integer in 1-65535/,
|
||||
],
|
||||
[
|
||||
{ type: "streamable-http", port: "8091", strictPort: true },
|
||||
/integer in 1-65535/,
|
||||
],
|
||||
[
|
||||
{ type: "streamable-http", port: 0, strictPort: true },
|
||||
/integer in 1-65535/,
|
||||
],
|
||||
[
|
||||
{ type: "streamable-http", port: 8091.5, strictPort: true },
|
||||
/integer in 1-65535/,
|
||||
],
|
||||
[
|
||||
{ type: "streamable-http", port: 70000, strictPort: true },
|
||||
/integer in 1-65535/,
|
||||
],
|
||||
[{ type: "streamable-http", strictPort: true }, /integer in 1-65535/],
|
||||
// No listener at all, and `resolveConfig` drops the flag.
|
||||
[{ type: "stdio", strictPort: true }, /requires an HTTP transport/],
|
||||
])("rejects the unhonorable strictPort config %j", (transport, message) => {
|
||||
const file = path.join(
|
||||
tmpdir(),
|
||||
`strict-port-combo-${Date.now()}-${Math.random()}.json`,
|
||||
);
|
||||
writeFileSync(
|
||||
file,
|
||||
JSON.stringify({
|
||||
serverInfo: { name: "x", version: "1.0.0" },
|
||||
transport,
|
||||
}),
|
||||
);
|
||||
try {
|
||||
expect(() => loadConfig(file)).toThrow(message);
|
||||
} finally {
|
||||
rmSync(file, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("still accepts the honorable combination", () => {
|
||||
const file = path.join(
|
||||
tmpdir(),
|
||||
`strict-port-ok-${Date.now()}-${Math.random()}.json`,
|
||||
);
|
||||
writeFileSync(
|
||||
file,
|
||||
JSON.stringify({
|
||||
serverInfo: { name: "x", version: "1.0.0" },
|
||||
transport: { type: "streamable-http", port: 8091, strictPort: true },
|
||||
}),
|
||||
);
|
||||
try {
|
||||
expect(resolveConfig(loadConfig(file)).strictPort).toBe(true);
|
||||
} finally {
|
||||
rmSync(file, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a truthy-but-unbindable port at bind time too", async () => {
|
||||
// Defense in depth for a programmatic caller that bypasses `loadConfig`.
|
||||
// A string "0" is truthy, so a bare falsiness guard would pass it through
|
||||
// and Node would coerce it to the dynamic port 0.
|
||||
server = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo("stringy", "1.0.0"),
|
||||
serverType: "streamable-http",
|
||||
port: "0" as unknown as number,
|
||||
strictPort: true,
|
||||
});
|
||||
await expect(server.start()).rejects.toThrow(/integer in 1-65535/);
|
||||
// Deliberately NOT nulled, for the same reason as the EADDRINUSE case
|
||||
// above: `start()` installs the process-global test-server control before
|
||||
// it validates, so dropping the reference would skip teardown and leave
|
||||
// that global pointing at a dead server.
|
||||
});
|
||||
|
||||
it("is carried from the fixture's config file to the resolved server config", async () => {
|
||||
// The plumbing half: a flag the loader drops would leave the fixture
|
||||
// relocating again with nothing to show for it.
|
||||
const resolved = resolveConfig(
|
||||
loadConfig(
|
||||
path.join(configsDir, "oauth-insecure-token-endpoint-http.json"),
|
||||
),
|
||||
);
|
||||
expect(resolved.strictPort).toBe(true);
|
||||
expect(resolved.port).toBe(8091);
|
||||
expect(resolved.oauth?.issuerUrl?.href).toContain("localhost.:8091");
|
||||
});
|
||||
});
|
||||
|
|
@ -14,6 +14,8 @@ export {
|
|||
lostAuthorizationStateTitle,
|
||||
issuerMismatchMessage,
|
||||
issuerMismatchTitle,
|
||||
insecureTokenEndpointMessage,
|
||||
insecureTokenEndpointTitle,
|
||||
type OAuthInteractiveAuthKind,
|
||||
type OAuthPreRedirectContext,
|
||||
type OAuthRecoverySource,
|
||||
|
|
|
|||
Loading…
Reference in a new issue