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
3 changed files with 1400 additions and 9 deletions
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");
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue