* feat(test-servers): stall an OAuth endpoint so #2319's timeouts can be driven #2319 put an `AbortSignal.timeout` on five OAuth-path calls that previously went out with no signal at all. Nothing in the repo could make any of them fire: the composable server's `oauth` block had no stall, delay or never-answer option, so all five were covered only by tests injecting a `fetch` stub. A stub settles on the client side. The state #2319 is actually about is an established, idle socket in the Inspector's own Node process — invisible in browser devtools, and held behind a connection status that deliberately stays at `connecting`. Only a server that accepts and withholds reproduces it. Adds `oauth.stallEndpoints` (any of `protected-resource-metadata`, `as-metadata`, `authorize`, `token`, `revoke`, `register`) plus an optional `stallMs` to answer late rather than never. Endpoints are named by the CALL, not the path: the exchange and the refresh share `/oauth/token`, and two of the documents sit at configurable paths, so a path-keyed option would be both ambiguous and wrong when `resourceMetadataPath` or `asMetadataPath` moves one. Per endpoint rather than global, so a test can tell "discovery timed out" from "token exchange timed out" — the distinction the five separate timeouts exist to make. An unrecognized name throws at start rather than being ignored, since a typo would otherwise yield a fixture that answers normally and a timeout test that fails pointing at the timeout instead of at the config. Two showcase configs and a `docs/test-servers.md` section covering both, what to do with them, and what the broken build did. Teardown needed no new machinery: `TestServerHttp.stop()` already calls `httpServer.closeAllConnections?.()`, which destroys an established socket whether or not a response was written. Measured at 0s with a withheld response in flight, and pinned by a test, because it is the property most likely to be broken by a future change to the stop path — and it would break as a hang in `afterEach`, pointing at the wrong test. Each guard was mutation-checked against the new suite: middleware never registered -> 7 of 8 failed match req.url instead of req.path -> 1 of 8 failed unknown-entry validation removed -> 1 of 8 failed stall calls next() anyway -> 6 of 8 failed (unmutated) -> 8 passed The `req.path` mutation initially escaped: every stallable endpoint but `authorize` is requested at a bare path, so matching on `req.url` passed the whole file. The query-string case exists to close that, and its comment says so. Closes #2382 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: cliffhall <cliff@futurescale.com> * fix(test-servers): key the OAuth stall by method, and cover every endpoint (#2383 review) Five findings from review, all defects in what this PR added. **Method is part of the endpoint identity.** The middleware collapsed each endpoint to a path, but two of the paths are caller-supplied: a valid `asMetadataPath: "/oauth/token"` made stalling `token` also stall the metadata GET, and vice versa — breaking the per-call contract the option exists to provide. Keyed on `${METHOD} ${path}` now, with `authorize` carrying both GET (consent page) and POST (submission). **Every advertised endpoint is driven, not three of six.** `as-metadata`, `revoke` and `register` were reachable in config but never requested, so a typo in any of their hardcoded entries would yield a fixture that quietly answers normally — the exact failure this option exists to prevent. The suite is now table-driven over `STALLABLE_OAUTH_ENDPOINTS`, so adding a name without covering it is impossible. **`stallMs` is tested.** It was advertised and every fixture used 0, leaving the delay branch and its timer cleanup able to regress unseen. Three cases now: answers late rather than never, times out when the caller's budget is shorter than the delay, and releases the timer when the client aborts first. **The teardown test waits for a real signal, not a fixed sleep.** A 250ms sleep that lost the race would have stopped the server before the request arrived; the fetch would reject because the server closed and the test would pass without ever exercising `closeAllConnections()` on an established request. It now waits until the request is actually parked. ⚠️ That signal started as a module-level counter and failed under Vitest, which can load the fixture module more than once — the middleware incremented one copy while the test polled another, and the wait timed out with the fixture working perfectly (verified standalone: parked after 100ms). It is per-server state read off the instance the test already holds, which makes module identity irrelevant and scopes the count to one fixture. **The docs section is top-level.** It had been inserted as `###` directly under `## Revoking tokens on clear`, so that section's prose became part of the stalled-fixture subsection. Re-mutated against the expanded suite; every guard is independently detected: middleware never registered -> 19 of 20 failed match req.url instead of req.path -> 2 of 20 failed ignore method (path-only key) -> 1 of 20 failed stallMs answers immediately -> 3 of 20 failed registry never decremented -> 1 of 20 failed as-metadata path typo -> 2 of 20 failed (unmutated) -> 20 passed Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: cliffhall <cliff@futurescale.com> * fix(test-servers): validate stallMs, cover both configurable paths and the timer (#2383 review round 2) Six more findings, all defects in what this PR added. **The timer-cleanup test did not test timer cleanup.** It observed `registry.parked`, which a separate `close` listener decrements, so it passed with the 10s timer still armed — a test whose name and comment claimed something it could not see. Split in two: the end-to-end case now says only what it proves (the fixture lets go), and a focused middleware test with fake timers and a `next` spy checks the actual claim. Removing `clearTimeout` now fails; before, it did not. **Both configurable metadata paths are now driven at non-default values.** `stallTargetsFor` reads `resourceMetadataPath` and `asMetadataPath`, and every test requested the defaults — so replacing either lookup with its default literal left the suite green while `stallEndpoints` silently stopped working for any config that moves the document. That is the exact typo class this option exists to prevent, and it was the one my own doc comment claimed to guard. **`authorize` is driven as a POST as well as a GET.** It is the only endpoint mapped to two methods, and only one was exercised, so dropping `"POST"` passed the suite while a consent submission stopped stalling. **`stallMs` is validated at setup**, like its sibling `stallEndpoints`. A JSON/YAML config is only *cast* to its interface, so anything can arrive: a negative or non-numeric value made `stallMs > 0` false and silently became a PERMANENT stall, and a value past the 32-bit timer range overflows to ~1ms and answers almost immediately. Both read as "the timeout behaved strangely" rather than "the config is wrong". `MAX_STALL_MS` mirrors `MAX_TIMER_DELAY_MS` in `core/auth/requestTimeout.ts`, which exists for the same reason. **The docs no longer claim a configurable budget.** They said the failure arrives "after roughly 30s unless a shorter one is configured". There is no such setting: `InspectorClient` passes `DEFAULT_OAUTH_REQUEST_TIMEOUT_MS` straight into the transport chain and passes no budget at all on the auth chain, so both take the default. Verified in `core/mcp/inspectorClient.ts:866-889` before changing the text — it now states the fixed 30 seconds. **`stop()`'s JSDoc is back above `stop()`.** The new accessor had been inserted between them, orphaning it. Each fix mutation-checked against the 26-test suite; every one is detected, and each by exactly one test: drop POST from authorize's methods -> 1 of 26 failed resourceMetadataPath -> default literal -> 1 of 26 failed asMetadataPath -> default literal -> 1 of 26 failed clearTimeout on close removed -> 1 of 26 failed stallMs validation removed -> 1 of 26 failed (unmutated) -> 26 passed Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: cliffhall <cliff@futurescale.com> * fix(test-servers): validate the stallEndpoints container, reject stallMs null, drop `any` (#2383 review round 3) Four findings — two inline, two suppressed. All valid. **`stallEndpoints` is validated as a container, not just by length.** A config file is cast rather than parsed, so the value can arrive as a string, `null`, or an object with a `length`. The old `.length === 0` check accepted `""` and `{ length: 0 }` and returned "no stalling configured" — silently disabling the capability this startup validation exists to protect — while a non-empty string failed later with an incidental `.filter is not a function`. **An explicit `stallMs: null` is rejected rather than defaulted.** `?? 0` turned it into a valid 0, which skipped every check below and silently produced a permanent stall. Only an omitted value takes the default now; `null` falls through to the finite-number check. **Both `as any` suppressions are gone.** This repo forbids `any` outright and forbids disabling the rule to satisfy the linter, and I did both — twice. The doubles are now bridged to `Request`/`Response` with a single justified `as unknown as` and a comment saying why no narrower option exists: the literal is missing ~50 properties so a single `as Request` is rejected, and the middleware reads only `method`, `path` and `res.on`. If it ever reads more, the double is wrong at runtime instead of being silently absorbed — which is what the `any` was doing. Mutation-checked, both new guards detected: stallEndpoints container check removed -> 1 of 28 failed stallMs back to `?? 0` (null coerced) -> 1 of 28 failed (unmutated) -> 28 passed ⚠️ The container mutation initially read as ESCAPED at 28 passed. It had not: `npm run format` had rewrapped the condition across lines, so the perl pattern matched nothing and the "mutation" was a no-op. Re-applied by line range it fails as expected. Worth recording — a mutation that silently fails to apply reads exactly like a test gap, and I nearly reported it as one. `npm run check` passes in both clients/cli and clients/web, so the `any` rule and the type-aware lint are clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: cliffhall <cliff@futurescale.com> * fix(test-servers): refuse to stall a route the config does not serve (#2383 review round 4) Three findings, all suppressed comments (no inline threads this round). **A stall is refused for an endpoint this config does not actually expose.** The middleware runs *before* Express routing, so it would happily hold a request for a route that would otherwise 404 — `supportDCR: false` with `stallEndpoints: ["register"]` produced a hanging registration endpoint instead of a configuration error, and a timeout test written against it would have passed for entirely the wrong reason. `servesEndpoint` mirrors the registration rules in `setupOAuthRoutes`/`setupMetadataEndpoints`: the protected-resource document is always served, the rest of the local AS exists only in `combined` mode, `revoke` carries `supportRevocation` and `register` carries `supportDCR`. It lives next to `stallTargetsFor` so the two stay in step — a new OAuth route needs an entry in both. This is the same class as the validation already here, and the reason is the one this PR keeps restating: a misconfiguration must fail loudly rather than produce a hang that reads as a timeout. **The error no longer misreports NaN and Infinity.** `JSON.stringify` returns the string `"null"` for both — the two values most likely to reach the `stallMs` check — so the message named a value the author never wrote. `describeValue` renders non-finite numbers with `String` and keeps quotes on strings, which is what makes `"600"` distinguishable from `600`. **The PR description's test count is corrected again** (26 → 30) along with the mutation table. Mutation-checked, both new guards detected: availability check removed -> 1 of 30 failed describeValue -> JSON.stringify -> 1 of 30 failed (unmutated) -> 30 passed The availability test asserts both directions: five contradictory configs throw, and the protected-resource document (served in every mode) plus the enabled forms are accepted, so the check cannot pass by over-rejecting. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Signed-off-by: cliffhall <cliff@futurescale.com> --------- Signed-off-by: cliffhall <cliff@futurescale.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
20e679bb1f
commit
de2cbaeeaf
8 changed files with 1168 additions and 3 deletions
|
|
@ -0,0 +1,745 @@
|
||||||
|
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
||||||
|
import path from "node:path";
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import {
|
||||||
|
withOAuthRequestTimeout,
|
||||||
|
OAuthRequestTimeoutError,
|
||||||
|
} from "@inspector/core/auth/requestTimeout.js";
|
||||||
|
import {
|
||||||
|
createTestServerHttp,
|
||||||
|
type TestServerHttp,
|
||||||
|
createTestServerInfo,
|
||||||
|
createEchoTool,
|
||||||
|
loadConfig,
|
||||||
|
resolveConfig,
|
||||||
|
STALLABLE_OAUTH_ENDPOINTS,
|
||||||
|
type StallableOAuthEndpoint,
|
||||||
|
createOAuthStallMiddleware,
|
||||||
|
createStallRegistry,
|
||||||
|
MAX_STALL_MS,
|
||||||
|
} from "@modelcontextprotocol/inspector-test-server";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The OAuth-path request timeouts (#2319), driven against a **real, established,
|
||||||
|
* idle socket** rather than a `fetch` stub (#2382).
|
||||||
|
*
|
||||||
|
* #2319 put an `AbortSignal.timeout` on five OAuth calls that previously went
|
||||||
|
* out with no signal at all. Every test of that work injected a stubbed
|
||||||
|
* `fetchFn`, which settles on the *client* side — so the state the issue
|
||||||
|
* actually describes was never reproduced:
|
||||||
|
*
|
||||||
|
* > a stall is invisible in browser devtools […] while a Node-side socket sat
|
||||||
|
* > established and idle.
|
||||||
|
*
|
||||||
|
* These tests use the `oauth.stallEndpoints` fixture capability to accept the
|
||||||
|
* request and withhold the response, so the deadline is the only thing that can
|
||||||
|
* end the call. A regression that dropped the wrapper would hang here until the
|
||||||
|
* suite's own budget killed it, rather than passing against an obliging stub.
|
||||||
|
*/
|
||||||
|
const configsDir = path.resolve(
|
||||||
|
path.dirname(fileURLToPath(import.meta.url)),
|
||||||
|
"../../../../../../test-servers/configs",
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Comfortably under the suite budget, comfortably over a LAN round trip. */
|
||||||
|
const BUDGET_MS = 1_500;
|
||||||
|
|
||||||
|
/** How each endpoint is requested, so every advertised name is really driven. */
|
||||||
|
const ENDPOINTS: Record<
|
||||||
|
StallableOAuthEndpoint,
|
||||||
|
{ pathname: string; init: RequestInit; query?: Record<string, string> }
|
||||||
|
> = {
|
||||||
|
"protected-resource-metadata": {
|
||||||
|
pathname: "/.well-known/oauth-protected-resource",
|
||||||
|
init: { method: "GET" },
|
||||||
|
},
|
||||||
|
"as-metadata": {
|
||||||
|
pathname: "/.well-known/oauth-authorization-server",
|
||||||
|
init: { method: "GET" },
|
||||||
|
},
|
||||||
|
authorize: {
|
||||||
|
pathname: "/oauth/authorize",
|
||||||
|
init: { method: "GET" },
|
||||||
|
// The one endpoint always called WITH a query string — which is what makes
|
||||||
|
// the middleware's `req.path` match load-bearing. Matching `req.url`
|
||||||
|
// instead passes every other case in this file and fails only here.
|
||||||
|
query: {
|
||||||
|
client_id: "test-client",
|
||||||
|
response_type: "code",
|
||||||
|
redirect_uri: "http://127.0.0.1:6274/oauth/callback",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
token: {
|
||||||
|
pathname: "/oauth/token",
|
||||||
|
init: {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||||
|
body: "grant_type=authorization_code&code=irrelevant",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
revoke: {
|
||||||
|
pathname: "/oauth/revoke",
|
||||||
|
init: {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||||
|
body: "token=irrelevant",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
register: {
|
||||||
|
pathname: "/oauth/register",
|
||||||
|
init: {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ redirect_uris: ["http://127.0.0.1:6274/cb"] }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Wait for `predicate`, rather than sleeping and hoping. */
|
||||||
|
async function until(
|
||||||
|
predicate: () => boolean,
|
||||||
|
what: string,
|
||||||
|
timeoutMs = 5_000,
|
||||||
|
): Promise<void> {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (predicate()) return;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
}
|
||||||
|
throw new Error(`timed out waiting for ${what}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("OAuth request timeouts against a stalled endpoint (#2382)", () => {
|
||||||
|
let server: TestServerHttp | null = null;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (server) {
|
||||||
|
try {
|
||||||
|
await server.stop();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
server = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function urlFor(base: string, endpoint: StallableOAuthEndpoint): string {
|
||||||
|
const { pathname, query } = ENDPOINTS[endpoint];
|
||||||
|
const url = new URL(pathname, base);
|
||||||
|
for (const [key, value] of Object.entries(query ?? {})) {
|
||||||
|
url.searchParams.set(key, value);
|
||||||
|
}
|
||||||
|
return url.href;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startStalling(
|
||||||
|
endpoints: StallableOAuthEndpoint[],
|
||||||
|
extra: { stallMs?: number } = {},
|
||||||
|
): Promise<TestServerHttp> {
|
||||||
|
const started = createTestServerHttp({
|
||||||
|
serverInfo: createTestServerInfo("oauth-stall", "1.0.0"),
|
||||||
|
tools: [createEchoTool()],
|
||||||
|
oauth: {
|
||||||
|
enabled: true,
|
||||||
|
mode: "combined",
|
||||||
|
requireAuth: true,
|
||||||
|
scopesSupported: ["mcp"],
|
||||||
|
supportDCR: true,
|
||||||
|
stallEndpoints: endpoints,
|
||||||
|
...extra,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await started.start();
|
||||||
|
server = started;
|
||||||
|
return started;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⚠️ Every advertised endpoint, not a sample. `stallTargetsFor` hardcodes a
|
||||||
|
// path and a method set per endpoint, so a typo in any entry produces a
|
||||||
|
// fixture that quietly answers normally — the precise failure this option
|
||||||
|
// exists to prevent, and one no other test would catch (Copilot).
|
||||||
|
for (const endpoint of STALLABLE_OAUTH_ENDPOINTS) {
|
||||||
|
it(`stalls the ${endpoint} endpoint until the deadline fires`, async () => {
|
||||||
|
const started = await startStalling([endpoint]);
|
||||||
|
const url = urlFor(started.url, endpoint);
|
||||||
|
const timedFetch = withOAuthRequestTimeout(fetch, BUDGET_MS);
|
||||||
|
const startedAt = Date.now();
|
||||||
|
|
||||||
|
await expect(timedFetch(url, ENDPOINTS[endpoint].init)).rejects.toThrow(
|
||||||
|
OAuthRequestTimeoutError,
|
||||||
|
);
|
||||||
|
|
||||||
|
// The deadline ended it, not an instant connection failure: a refused
|
||||||
|
// port rejects in single-digit ms and would satisfy a bare
|
||||||
|
// `rejects.toThrow` while proving nothing about the timer.
|
||||||
|
expect(Date.now() - startedAt).toBeGreaterThanOrEqual(BUDGET_MS - 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`names the ${endpoint} endpoint and the budget it gave up on`, async () => {
|
||||||
|
const started = await startStalling([endpoint]);
|
||||||
|
const url = urlFor(started.url, endpoint);
|
||||||
|
const timedFetch = withOAuthRequestTimeout(fetch, BUDGET_MS);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
timedFetch(url, ENDPOINTS[endpoint].init),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
timeoutMs: BUDGET_MS,
|
||||||
|
url: expect.stringContaining(ENDPOINTS[endpoint].pathname),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("stalls only the selected endpoint, leaving the others answering", async () => {
|
||||||
|
// The control. A fixture that stalled everything would make every test
|
||||||
|
// above pass while telling us nothing about which call was bounded.
|
||||||
|
const started = await startStalling(["token"]);
|
||||||
|
const metadata = await fetch(
|
||||||
|
urlFor(started.url, "protected-resource-metadata"),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(metadata.ok).toBe(true);
|
||||||
|
await expect(metadata.json()).resolves.toMatchObject({
|
||||||
|
authorization_servers: expect.any(Array),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("distinguishes two calls that share a path by their method", async () => {
|
||||||
|
// ⚠️ Both configurable document paths are caller-supplied, so a config may
|
||||||
|
// point one at a path another endpoint already serves. Keyed on path alone,
|
||||||
|
// stalling `token` would also stall this GET (Copilot).
|
||||||
|
const started = await startStalling(["token"]);
|
||||||
|
const collided = new URL("/oauth/token", started.url).href;
|
||||||
|
|
||||||
|
// The token POST is stalled…
|
||||||
|
await expect(
|
||||||
|
withOAuthRequestTimeout(fetch, BUDGET_MS)(collided, ENDPOINTS.token.init),
|
||||||
|
).rejects.toThrow(OAuthRequestTimeoutError);
|
||||||
|
|
||||||
|
// …while a GET to the very same path is not held by the stall middleware.
|
||||||
|
// Which status it gets does not matter; that a response arrives at all does.
|
||||||
|
const samePathGet = await fetch(collided, { method: "GET" });
|
||||||
|
expect(typeof samePathGet.status).toBe("number");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("answers late rather than never when stallMs is positive", async () => {
|
||||||
|
// `stallMs` is advertised and was previously untested: every fixture used
|
||||||
|
// 0, so the `setTimeout(() => next())` branch could regress unseen
|
||||||
|
// (Copilot).
|
||||||
|
const delayMs = 600;
|
||||||
|
const started = await startStalling(["protected-resource-metadata"], {
|
||||||
|
stallMs: delayMs,
|
||||||
|
});
|
||||||
|
const startedAt = Date.now();
|
||||||
|
|
||||||
|
const res = await fetch(urlFor(started.url, "protected-resource-metadata"));
|
||||||
|
const elapsed = Date.now() - startedAt;
|
||||||
|
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
// Late, but it did arrive — both halves matter.
|
||||||
|
expect(elapsed).toBeGreaterThanOrEqual(delayMs - 50);
|
||||||
|
await expect(res.json()).resolves.toMatchObject({
|
||||||
|
authorization_servers: expect.any(Array),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("times out when the caller's budget is shorter than stallMs", async () => {
|
||||||
|
const started = await startStalling(["token"], { stallMs: 5_000 });
|
||||||
|
const timedFetch = withOAuthRequestTimeout(fetch, 400);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
timedFetch(urlFor(started.url, "token"), ENDPOINTS.token.init),
|
||||||
|
).rejects.toThrow(OAuthRequestTimeoutError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops holding the request when the client aborts a delayed stall", async () => {
|
||||||
|
// End-to-end: the fixture lets go. This observes the REGISTRY, which is
|
||||||
|
// decremented by the `close` listener — so it does NOT prove the timer was
|
||||||
|
// cleared. That claim belongs to the focused test below, which can actually
|
||||||
|
// see the timer (Copilot).
|
||||||
|
const started = await startStalling(["token"], { stallMs: 10_000 });
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
const pending = fetch(urlFor(started.url, "token"), {
|
||||||
|
...ENDPOINTS.token.init,
|
||||||
|
signal: controller.signal,
|
||||||
|
}).catch(() => "aborted");
|
||||||
|
|
||||||
|
await until(
|
||||||
|
() => started.stalledRequestCount() >= 1,
|
||||||
|
"the request to be parked",
|
||||||
|
);
|
||||||
|
controller.abort();
|
||||||
|
|
||||||
|
await expect(pending).resolves.toBe("aborted");
|
||||||
|
await until(
|
||||||
|
() => started.stalledRequestCount() === 0,
|
||||||
|
"the stall to be released",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an unknown stallEndpoints entry instead of answering normally", async () => {
|
||||||
|
// A typo must fail loudly. Ignored, it would produce a fixture that answers
|
||||||
|
// promptly, and a timeout test written against it would fail pointing at
|
||||||
|
// the timeout rather than at the config.
|
||||||
|
//
|
||||||
|
// The throw lands on `start()`, not on the constructor: routes — and so the
|
||||||
|
// middleware — are built when the server is started. Asserting on the
|
||||||
|
// constructor would pass for the wrong reason, since it validates nothing.
|
||||||
|
const typo = createTestServerHttp({
|
||||||
|
serverInfo: createTestServerInfo("oauth-stall-typo", "1.0.0"),
|
||||||
|
tools: [createEchoTool()],
|
||||||
|
oauth: {
|
||||||
|
enabled: true,
|
||||||
|
mode: "combined",
|
||||||
|
// @ts-expect-error - deliberately not a StallableOAuthEndpoint
|
||||||
|
stallEndpoints: ["tokens"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(typo.start()).rejects.toThrow(
|
||||||
|
/Unknown oauth\.stallEndpoints entry.*"tokens"/s,
|
||||||
|
);
|
||||||
|
// It must also name what WAS valid, or the fixture author is left guessing.
|
||||||
|
await expect(typo.start()).rejects.toThrow(/Expected one of:.*token/s);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await typo.stop();
|
||||||
|
} catch {
|
||||||
|
// never started; nothing to stop
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops cleanly with a withheld response still in flight", async () => {
|
||||||
|
// ⚠️ The property that makes this fixture usable at all. A withheld
|
||||||
|
// response holds an established socket, and `stop()` relies on
|
||||||
|
// `httpServer.closeAllConnections?.()` to destroy it. Without that, every
|
||||||
|
// suite touching this fixture would hang at teardown instead of failing —
|
||||||
|
// and it would hang in `afterEach`, pointing at the wrong test.
|
||||||
|
const started = await startStalling(["token"]);
|
||||||
|
|
||||||
|
// Deliberately unawaited and unbounded: the point is that a request with no
|
||||||
|
// deadline of its own is in flight when the server goes down.
|
||||||
|
const pending = fetch(urlFor(started.url, "token"), {
|
||||||
|
...ENDPOINTS.token.init,
|
||||||
|
}).catch(() => "socket destroyed");
|
||||||
|
|
||||||
|
// ⚠️ Wait for the request to be ACCEPTED AND PARKED, never a fixed sleep.
|
||||||
|
// A sleep that lost the race would stop the server before the request
|
||||||
|
// arrived; the fetch would then reject because the server closed, and this
|
||||||
|
// test would pass without ever exercising `closeAllConnections()` on an
|
||||||
|
// established request (Copilot).
|
||||||
|
await until(
|
||||||
|
() => started.stalledRequestCount() >= 1,
|
||||||
|
"the request to be parked",
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(started.stop()).resolves.toBeUndefined();
|
||||||
|
server = null;
|
||||||
|
|
||||||
|
await expect(pending).resolves.toBe("socket destroyed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stalls the authorize POST, not only its GET", async () => {
|
||||||
|
// ⚠️ `authorize` is the only endpoint mapped to two methods, and the table
|
||||||
|
// above drives it as a GET. Without this, deleting `"POST"` from its method
|
||||||
|
// list passes the whole suite while a consent submission stops stalling
|
||||||
|
// (Copilot).
|
||||||
|
const started = await startStalling(["authorize"]);
|
||||||
|
const url = new URL("/oauth/authorize", started.url).href;
|
||||||
|
const timedFetch = withOAuthRequestTimeout(fetch, BUDGET_MS);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
timedFetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||||
|
body: "client_id=test-client&response_type=code&redirect_uri=http%3A%2F%2F127.0.0.1%3A6274%2Foauth%2Fcallback",
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(OAuthRequestTimeoutError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("follows resourceMetadataPath when the document is moved", async () => {
|
||||||
|
// ⚠️ Both metadata paths are configurable, and the table above only ever
|
||||||
|
// requests the DEFAULTS. A regression replacing either lookup in
|
||||||
|
// `stallTargetsFor` with its default literal would leave every other test
|
||||||
|
// green while `stallEndpoints` silently stopped working for any config that
|
||||||
|
// moves the document (Copilot).
|
||||||
|
const moved = "/custom/protected-resource.json";
|
||||||
|
const started = createTestServerHttp({
|
||||||
|
serverInfo: createTestServerInfo("oauth-stall-moved-prm", "1.0.0"),
|
||||||
|
tools: [createEchoTool()],
|
||||||
|
oauth: {
|
||||||
|
enabled: true,
|
||||||
|
mode: "combined",
|
||||||
|
requireAuth: true,
|
||||||
|
scopesSupported: ["mcp"],
|
||||||
|
resourceMetadataPath: moved,
|
||||||
|
stallEndpoints: ["protected-resource-metadata"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await started.start();
|
||||||
|
server = started;
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
withOAuthRequestTimeout(
|
||||||
|
fetch,
|
||||||
|
BUDGET_MS,
|
||||||
|
)(new URL(moved, started.url).href),
|
||||||
|
).rejects.toThrow(OAuthRequestTimeoutError);
|
||||||
|
|
||||||
|
// And the DEFAULT path is no longer the stalled one — it is not served at
|
||||||
|
// all once the document moves, so it answers (404) instead of hanging.
|
||||||
|
const atDefault = await fetch(
|
||||||
|
new URL("/.well-known/oauth-protected-resource", started.url).href,
|
||||||
|
);
|
||||||
|
expect(typeof atDefault.status).toBe("number");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("follows asMetadataPath when the AS document is moved", async () => {
|
||||||
|
const moved = "/custom/as-metadata.json";
|
||||||
|
const started = createTestServerHttp({
|
||||||
|
serverInfo: createTestServerInfo("oauth-stall-moved-as", "1.0.0"),
|
||||||
|
tools: [createEchoTool()],
|
||||||
|
oauth: {
|
||||||
|
enabled: true,
|
||||||
|
mode: "combined",
|
||||||
|
requireAuth: true,
|
||||||
|
scopesSupported: ["mcp"],
|
||||||
|
asMetadataPath: moved,
|
||||||
|
stallEndpoints: ["as-metadata"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await started.start();
|
||||||
|
server = started;
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
withOAuthRequestTimeout(
|
||||||
|
fetch,
|
||||||
|
BUDGET_MS,
|
||||||
|
)(new URL(moved, started.url).href),
|
||||||
|
).rejects.toThrow(OAuthRequestTimeoutError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drives the two checked-in showcase configs, not just inline ones", async () => {
|
||||||
|
// The JSON-to-ServerConfig mapping is its own failure surface: a misspelled
|
||||||
|
// key in either file, or a `resolveConfig` that stopped threading
|
||||||
|
// `stallEndpoints`, would leave the documented manual fixtures quietly
|
||||||
|
// permissive while every inline test above stayed green.
|
||||||
|
for (const file of [
|
||||||
|
"oauth-stalled-token-http.json",
|
||||||
|
"oauth-stalled-discovery-http.json",
|
||||||
|
]) {
|
||||||
|
const resolved = resolveConfig(loadConfig(path.join(configsDir, file)));
|
||||||
|
const started = createTestServerHttp(resolved);
|
||||||
|
await started.start();
|
||||||
|
server = started;
|
||||||
|
|
||||||
|
const endpoint: StallableOAuthEndpoint = file.includes("token")
|
||||||
|
? "token"
|
||||||
|
: "protected-resource-metadata";
|
||||||
|
const timedFetch = withOAuthRequestTimeout(fetch, BUDGET_MS);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
timedFetch(urlFor(started.url, endpoint), ENDPOINTS[endpoint].init),
|
||||||
|
).rejects.toThrow(OAuthRequestTimeoutError);
|
||||||
|
|
||||||
|
await started.stop();
|
||||||
|
server = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The middleware on its own, with no server, so the timer itself is observable.
|
||||||
|
*
|
||||||
|
* The end-to-end abort test can only watch the registry, which a separate
|
||||||
|
* `close` listener decrements — so it passes with the timer still armed and
|
||||||
|
* cannot support a claim about `clearTimeout` (Copilot). Here `next` is a spy
|
||||||
|
* and time is fake, so "the delayed answer never fires after a close" is
|
||||||
|
* directly checkable.
|
||||||
|
*/
|
||||||
|
describe("createOAuthStallMiddleware timer cleanup (#2382)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal Express doubles, bridged to the real types.
|
||||||
|
*
|
||||||
|
* The middleware touches exactly three things — `req.method`, `req.path` and
|
||||||
|
* `res.on("close", …)` — so a full `Request`/`Response` is unnecessary and a
|
||||||
|
* real server would defeat the purpose (the whole point here is to control
|
||||||
|
* time and observe `next`).
|
||||||
|
*
|
||||||
|
* ⚠️ The bridge is a justified double cast, NOT an `any`: this repo forbids
|
||||||
|
* `any` outright and forbids disabling the rule to satisfy the linter
|
||||||
|
* (`AGENTS.md`, Typescript instructions). A single `as Request` cannot work —
|
||||||
|
* the literal is missing ~50 properties, so TS rejects the conversion — and
|
||||||
|
* these doubles are structurally correct for every field the code under test
|
||||||
|
* reads. If the middleware ever reads more, the double will be wrong at
|
||||||
|
* runtime rather than silently absorbing it, which is what an `any` would do
|
||||||
|
* (Copilot).
|
||||||
|
*/
|
||||||
|
function fakeRes(): { res: Response; close: () => void } {
|
||||||
|
const listeners: Array<() => void> = [];
|
||||||
|
const double = {
|
||||||
|
on(event: string, fn: () => void) {
|
||||||
|
if (event === "close") listeners.push(fn);
|
||||||
|
return double;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
res: double as unknown as Response,
|
||||||
|
close: () => listeners.forEach((fn) => fn()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeReq(method: string, path: string): Request {
|
||||||
|
return { method, path } as unknown as Request;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenReq = fakeReq("POST", "/oauth/token");
|
||||||
|
|
||||||
|
it("does not answer a delayed stall after the client closed", () => {
|
||||||
|
const middleware = createOAuthStallMiddleware(
|
||||||
|
{
|
||||||
|
enabled: true,
|
||||||
|
mode: "combined",
|
||||||
|
stallEndpoints: ["token"],
|
||||||
|
stallMs: 10_000,
|
||||||
|
},
|
||||||
|
createStallRegistry(),
|
||||||
|
);
|
||||||
|
expect(middleware).not.toBeNull();
|
||||||
|
|
||||||
|
const next = vi.fn();
|
||||||
|
const { res, close } = fakeRes();
|
||||||
|
middleware!(tokenReq, res, next);
|
||||||
|
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
close();
|
||||||
|
|
||||||
|
// Past the delay, with the timer cleared: nothing should fire. Without
|
||||||
|
// `clearTimeout` this advances into `next()` and the spy is called.
|
||||||
|
vi.advanceTimersByTime(20_000);
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still answers a delayed stall that is not closed", () => {
|
||||||
|
// The complement — otherwise a middleware that never calls `next` at all
|
||||||
|
// would satisfy the test above.
|
||||||
|
const middleware = createOAuthStallMiddleware(
|
||||||
|
{
|
||||||
|
enabled: true,
|
||||||
|
mode: "combined",
|
||||||
|
stallEndpoints: ["token"],
|
||||||
|
stallMs: 10_000,
|
||||||
|
},
|
||||||
|
createStallRegistry(),
|
||||||
|
);
|
||||||
|
const next = vi.fn();
|
||||||
|
const { res } = fakeRes();
|
||||||
|
middleware!(tokenReq, res, next);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(9_999);
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
vi.advanceTimersByTime(2);
|
||||||
|
expect(next).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a malformed stallEndpoints container instead of disabling silently", () => {
|
||||||
|
// ⚠️ The container, not just its entries. A config file is cast, so
|
||||||
|
// `stallEndpoints` can arrive as a string or an object with a `length`. A
|
||||||
|
// bare `.length === 0` check accepts `""` and `{ length: 0 }` and returns
|
||||||
|
// "no stalling configured" — silently disabling the very capability this
|
||||||
|
// validation exists to protect (Copilot).
|
||||||
|
for (const stallEndpoints of ["", "token", null, { length: 0 }, 3]) {
|
||||||
|
expect(() =>
|
||||||
|
createOAuthStallMiddleware({
|
||||||
|
enabled: true,
|
||||||
|
mode: "combined",
|
||||||
|
// @ts-expect-error - a JSON config is cast, so this really can arrive
|
||||||
|
stallEndpoints,
|
||||||
|
}),
|
||||||
|
).toThrow(/oauth\.stallEndpoints must be an array/);
|
||||||
|
}
|
||||||
|
|
||||||
|
// An omitted value is not malformed — it means "no stalling".
|
||||||
|
expect(
|
||||||
|
createOAuthStallMiddleware({ enabled: true, mode: "combined" }),
|
||||||
|
).toBeNull();
|
||||||
|
// Nor is an explicitly empty array.
|
||||||
|
expect(
|
||||||
|
createOAuthStallMiddleware({
|
||||||
|
enabled: true,
|
||||||
|
mode: "combined",
|
||||||
|
stallEndpoints: [],
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to stall a route this config does not serve", () => {
|
||||||
|
// ⚠️ The middleware runs BEFORE Express routing, so without this it will
|
||||||
|
// hold a request for an endpoint that would otherwise 404 — turning a
|
||||||
|
// contradictory fixture into a hang that reads as a timeout, and inviting a
|
||||||
|
// test that passes for entirely the wrong reason (Copilot).
|
||||||
|
const cases: Array<[Record<string, unknown>, StallableOAuthEndpoint]> = [
|
||||||
|
// DCR off, but asked to stall the registration endpoint.
|
||||||
|
[{ mode: "combined", supportDCR: false }, "register"],
|
||||||
|
// Revocation explicitly disabled.
|
||||||
|
[{ mode: "combined", supportRevocation: false }, "revoke"],
|
||||||
|
// protected-resource mode serves no local AS routes at all.
|
||||||
|
[
|
||||||
|
{
|
||||||
|
mode: "protected-resource",
|
||||||
|
authorizationServers: ["https://as.example"],
|
||||||
|
},
|
||||||
|
"token",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
mode: "protected-resource",
|
||||||
|
authorizationServers: ["https://as.example"],
|
||||||
|
},
|
||||||
|
"authorize",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
mode: "protected-resource",
|
||||||
|
authorizationServers: ["https://as.example"],
|
||||||
|
},
|
||||||
|
"as-metadata",
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [oauth, endpoint] of cases) {
|
||||||
|
expect(() =>
|
||||||
|
createOAuthStallMiddleware({
|
||||||
|
enabled: true,
|
||||||
|
...oauth,
|
||||||
|
stallEndpoints: [endpoint],
|
||||||
|
}),
|
||||||
|
).toThrow(/does not serve/);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The protected-resource document is served in every mode, so stalling it
|
||||||
|
// is always legitimate — the check must not over-reject.
|
||||||
|
expect(
|
||||||
|
createOAuthStallMiddleware({
|
||||||
|
enabled: true,
|
||||||
|
mode: "protected-resource",
|
||||||
|
authorizationServers: ["https://as.example"],
|
||||||
|
stallEndpoints: ["protected-resource-metadata"],
|
||||||
|
}),
|
||||||
|
).not.toBeNull();
|
||||||
|
// And the enabled forms are accepted.
|
||||||
|
expect(
|
||||||
|
createOAuthStallMiddleware({
|
||||||
|
enabled: true,
|
||||||
|
mode: "combined",
|
||||||
|
supportDCR: true,
|
||||||
|
stallEndpoints: ["register", "revoke", "token"],
|
||||||
|
}),
|
||||||
|
).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names the offending value honestly, including NaN and Infinity", () => {
|
||||||
|
// ⚠️ `JSON.stringify(NaN)` is the string "null", so an error built with it
|
||||||
|
// reports a value the author never wrote (Copilot).
|
||||||
|
expect(() =>
|
||||||
|
createOAuthStallMiddleware({
|
||||||
|
enabled: true,
|
||||||
|
mode: "combined",
|
||||||
|
stallEndpoints: ["token"],
|
||||||
|
stallMs: Number.NaN,
|
||||||
|
}),
|
||||||
|
).toThrow(/got NaN/);
|
||||||
|
expect(() =>
|
||||||
|
createOAuthStallMiddleware({
|
||||||
|
enabled: true,
|
||||||
|
mode: "combined",
|
||||||
|
stallEndpoints: ["token"],
|
||||||
|
stallMs: Infinity,
|
||||||
|
}),
|
||||||
|
).toThrow(/got Infinity/);
|
||||||
|
// A string keeps its quotes, so "600" stays distinguishable from 600.
|
||||||
|
expect(() =>
|
||||||
|
createOAuthStallMiddleware({
|
||||||
|
enabled: true,
|
||||||
|
mode: "combined",
|
||||||
|
stallEndpoints: ["token"],
|
||||||
|
// @ts-expect-error - a JSON config is cast, so this really can arrive
|
||||||
|
stallMs: "600",
|
||||||
|
}),
|
||||||
|
).toThrow(/got "600"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an explicit stallMs: null rather than defaulting it to 0", () => {
|
||||||
|
// ⚠️ `?? 0` would turn `null` into a valid 0 and skip every check, silently
|
||||||
|
// producing a permanent stall. Only an OMITTED value gets the default
|
||||||
|
// (Copilot).
|
||||||
|
expect(() =>
|
||||||
|
createOAuthStallMiddleware({
|
||||||
|
enabled: true,
|
||||||
|
mode: "combined",
|
||||||
|
stallEndpoints: ["token"],
|
||||||
|
// @ts-expect-error - a JSON config is cast, so this really can arrive
|
||||||
|
stallMs: null,
|
||||||
|
}),
|
||||||
|
).toThrow(/oauth\.stallMs must be a finite number/);
|
||||||
|
|
||||||
|
// Omitted still means 0 (stall forever), which is the documented default.
|
||||||
|
expect(
|
||||||
|
createOAuthStallMiddleware({
|
||||||
|
enabled: true,
|
||||||
|
mode: "combined",
|
||||||
|
stallEndpoints: ["token"],
|
||||||
|
}),
|
||||||
|
).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a stallMs that is negative, non-finite or past the timer range", () => {
|
||||||
|
// ⚠️ A JSON/YAML config is only CAST to its interface, so anything can
|
||||||
|
// arrive here. Unvalidated, a negative value makes `stallMs > 0` false and
|
||||||
|
// silently becomes a PERMANENT stall, and a value past the 32-bit timer
|
||||||
|
// range overflows to ~1ms and answers almost immediately — both read as
|
||||||
|
// "the timeout behaved strangely" rather than "the config is wrong"
|
||||||
|
// (Copilot).
|
||||||
|
for (const stallMs of [-1, Number.NaN, Infinity, MAX_STALL_MS + 1]) {
|
||||||
|
expect(() =>
|
||||||
|
createOAuthStallMiddleware({
|
||||||
|
enabled: true,
|
||||||
|
mode: "combined",
|
||||||
|
stallEndpoints: ["token"],
|
||||||
|
stallMs,
|
||||||
|
}),
|
||||||
|
).toThrow(/oauth\.stallMs must be a finite number/);
|
||||||
|
}
|
||||||
|
// A non-numeric value from an unvalidated config file.
|
||||||
|
expect(() =>
|
||||||
|
createOAuthStallMiddleware({
|
||||||
|
enabled: true,
|
||||||
|
mode: "combined",
|
||||||
|
stallEndpoints: ["token"],
|
||||||
|
// @ts-expect-error - a JSON config is cast, so this really can arrive
|
||||||
|
stallMs: "600",
|
||||||
|
}),
|
||||||
|
).toThrow(/oauth\.stallMs must be a finite number/);
|
||||||
|
|
||||||
|
// The boundaries themselves are valid.
|
||||||
|
for (const stallMs of [0, MAX_STALL_MS]) {
|
||||||
|
expect(() =>
|
||||||
|
createOAuthStallMiddleware({
|
||||||
|
enabled: true,
|
||||||
|
mode: "combined",
|
||||||
|
stallEndpoints: ["token"],
|
||||||
|
stallMs,
|
||||||
|
}),
|
||||||
|
).not.toThrow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -56,6 +56,7 @@ as a missing capability rather than an error.
|
||||||
| `oauth-rfc8414-at-oidc-path-http.json` **(legacy era)** | Plain OAuth 2.0 AS metadata served at the OIDC well-known path | [#2172](https://github.com/modelcontextprotocol/inspector/issues/2172) |
|
| `oauth-rfc8414-at-oidc-path-http.json` **(legacy era)** | Plain OAuth 2.0 AS metadata served at the OIDC well-known path | [#2172](https://github.com/modelcontextprotocol/inspector/issues/2172) |
|
||||||
| `oauth-insecure-token-endpoint-http.json` **(legacy era)** | A token endpoint the SDK refuses to post credentials to | [#2280](https://github.com/modelcontextprotocol/inspector/issues/2280) |
|
| `oauth-insecure-token-endpoint-http.json` **(legacy era)** | A token endpoint the SDK refuses to post credentials to | [#2280](https://github.com/modelcontextprotocol/inspector/issues/2280) |
|
||||||
| `oauth-cimd-http.json` **(legacy era)** | URL-based client IDs (CIMD / SEP-991), DCR deliberately off | [#2242](https://github.com/modelcontextprotocol/inspector/issues/2242) |
|
| `oauth-cimd-http.json` **(legacy era)** | URL-based client IDs (CIMD / SEP-991), DCR deliberately off | [#2242](https://github.com/modelcontextprotocol/inspector/issues/2242) |
|
||||||
|
| `oauth-stalled-{token,discovery}-http.json` **(legacy era)** | An OAuth endpoint that accepts the request and never answers, so the OAuth-path request timeouts fire against a real idle socket | [#2382](https://github.com/modelcontextprotocol/inspector/issues/2382) |
|
||||||
| `logging-{legacy,modern}-http.json` **(era per file)** | Logging, both eras | [#1629](https://github.com/modelcontextprotocol/inspector/issues/1629) |
|
| `logging-{legacy,modern}-http.json` **(era per file)** | Logging, both eras | [#1629](https://github.com/modelcontextprotocol/inspector/issues/1629) |
|
||||||
| `subscriptions-{legacy,modern}-http.json` **(era per file)** | Resource subscriptions, both eras | [#1630](https://github.com/modelcontextprotocol/inspector/issues/1630) |
|
| `subscriptions-{legacy,modern}-http.json` **(era per file)** | Resource subscriptions, both eras | [#1630](https://github.com/modelcontextprotocol/inspector/issues/1630) |
|
||||||
| `subscriptions-never-acknowledged-http.json` **(modern era)** | A `subscriptions/listen` answered with a bare result | [#2097](https://github.com/modelcontextprotocol/inspector/issues/2097) |
|
| `subscriptions-never-acknowledged-http.json` **(modern era)** | A `subscriptions/listen` answered with a bare result | [#2097](https://github.com/modelcontextprotocol/inspector/issues/2097) |
|
||||||
|
|
@ -648,6 +649,21 @@ string included — so a server that walked to another port on `EADDRINUSE` stil
|
||||||
`client_id` equal to the URL you fetched, and the integration test drives it on a harness-chosen port
|
`client_id` equal to the URL you fetched, and the integration test drives it on a harness-chosen port
|
||||||
for exactly that reason. The fixed-port dependency that *does* bite is `redirect_uris`, above.
|
for exactly that reason. The fixed-port dependency that *does* bite is `redirect_uris`, above.
|
||||||
|
|
||||||
|
## An OAuth endpoint that never answers
|
||||||
|
|
||||||
|
Two OAuth-protected servers (combined AS + resource, DCR, refresh tokens) that are ordinary in every respect but one: a single endpoint **accepts the request and withholds the response forever**. Plain streamable-HTTP — connect with the **default (legacy)** protocol era.
|
||||||
|
|
||||||
|
- `oauth-stalled-token-http.json` stalls `POST /oauth/token`, so the token exchange and the refresh — which share that endpoint — both hang.
|
||||||
|
- `oauth-stalled-discovery-http.json` stalls `GET /.well-known/oauth-protected-resource`, so the stall happens before a client has any authorization server to talk to.
|
||||||
|
|
||||||
|
**What to do.** Connect to either from the Inspector and start an OAuth flow. Each should fail with an `OAuthRequestTimeoutError` naming the endpoint it gave up on and the budget it used, after **30 seconds** — `DEFAULT_OAUTH_REQUEST_TIMEOUT_MS`. That wait is not configurable from the UI or from a config file: `InspectorClient` passes the constant straight into its transport chain and passes no budget at all on the auth chain, so both take the default. Budget it for a full 30s of waiting per attempt. Every *other* endpoint on the same server answers normally, so the failure points at one call rather than at the server.
|
||||||
|
|
||||||
|
**What the broken build did.** Before [#2319](https://github.com/modelcontextprotocol/inspector/issues/2319), every OAuth-path call went out with no `AbortSignal` at all and these fixtures produced an unbounded spinner rather than an error — and a silent one, because a connect-time auth error deliberately holds the connection status at `connecting` on the theory that a redirect is about to end the attempt. The stall is server-side, in the Inspector's own Node process, so browser devtools showed no pending request while a socket sat established and idle.
|
||||||
|
|
||||||
|
**Why the fixture exists at all.** #2319's own tests inject a `fetch` stub, which settles on the client side and cannot produce an established idle socket — the precise state the issue is about. Until [#2382](https://github.com/modelcontextprotocol/inspector/issues/2382) added `oauth.stallEndpoints`, nothing in the repo could make those five timeouts fire.
|
||||||
|
|
||||||
|
**Configuring your own.** Any config's `oauth` block takes `stallEndpoints` — any of `protected-resource-metadata`, `as-metadata`, `authorize`, `token`, `revoke`, `register` — plus an optional `stallMs` to answer *late* instead of never. Endpoints are named by the **call**, not the path, because the token exchange and the refresh share `/oauth/token` and two of the documents sit at configurable paths. An unrecognized name throws when the server starts rather than being ignored, so a typo cannot quietly produce a fixture that answers normally.
|
||||||
|
|
||||||
## Revoking tokens on clear (RFC 7009)
|
## Revoking tokens on clear (RFC 7009)
|
||||||
|
|
||||||
`oauth-revocation-http.json` and `oauth-no-revocation-http.json` are the same OAuth-protected server (combined AS + resource, DCR, refresh tokens) differing in one thing: the first advertises a `revocation_endpoint`, the second advertises none. Plain streamable-HTTP — connect with the **default (legacy)** protocol era.
|
`oauth-revocation-http.json` and `oauth-no-revocation-http.json` are the same OAuth-protected server (combined AS + resource, DCR, refresh tokens) differing in one thing: the first advertises a `revocation_endpoint`, the second advertises none. Plain streamable-HTTP — connect with the **default (legacy)** protocol era.
|
||||||
|
|
|
||||||
25
test-servers/configs/oauth-stalled-discovery-http.json
Normal file
25
test-servers/configs/oauth-stalled-discovery-http.json
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
{
|
||||||
|
"serverInfo": {
|
||||||
|
"name": "oauth-stalled-discovery",
|
||||||
|
"version": "1.0.0"
|
||||||
|
},
|
||||||
|
"tools": [
|
||||||
|
{
|
||||||
|
"preset": "echo"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"resources": [],
|
||||||
|
"oauth": {
|
||||||
|
"enabled": true,
|
||||||
|
"mode": "combined",
|
||||||
|
"requireAuth": true,
|
||||||
|
"scopesSupported": ["mcp"],
|
||||||
|
"supportDCR": true,
|
||||||
|
"stallEndpoints": ["protected-resource-metadata"],
|
||||||
|
"stallMs": 0
|
||||||
|
},
|
||||||
|
"transport": {
|
||||||
|
"type": "streamable-http",
|
||||||
|
"port": 8094
|
||||||
|
}
|
||||||
|
}
|
||||||
26
test-servers/configs/oauth-stalled-token-http.json
Normal file
26
test-servers/configs/oauth-stalled-token-http.json
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
"serverInfo": {
|
||||||
|
"name": "oauth-stalled-token",
|
||||||
|
"version": "1.0.0"
|
||||||
|
},
|
||||||
|
"tools": [
|
||||||
|
{
|
||||||
|
"preset": "echo"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"resources": [],
|
||||||
|
"oauth": {
|
||||||
|
"enabled": true,
|
||||||
|
"mode": "combined",
|
||||||
|
"requireAuth": true,
|
||||||
|
"scopesSupported": ["mcp"],
|
||||||
|
"supportDCR": true,
|
||||||
|
"supportRefreshTokens": true,
|
||||||
|
"stallEndpoints": ["token"],
|
||||||
|
"stallMs": 0
|
||||||
|
},
|
||||||
|
"transport": {
|
||||||
|
"type": "streamable-http",
|
||||||
|
"port": 8093
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -50,6 +50,7 @@ import {
|
||||||
wireModernTaskHandlers,
|
wireModernTaskHandlers,
|
||||||
} from "./modern-tasks.js";
|
} from "./modern-tasks.js";
|
||||||
import { SKILLS_EXTENSION_KEY, wireSkillsHandlers } from "./skills.js";
|
import { SKILLS_EXTENSION_KEY, wireSkillsHandlers } from "./skills.js";
|
||||||
|
import type { StallableOAuthEndpoint } from "./test-server-oauth.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MCP Apps extension id. Hardcoded for the same reason the Inspector's
|
* MCP Apps extension id. Hardcoded for the same reason the Inspector's
|
||||||
|
|
@ -792,6 +793,29 @@ export interface ServerConfig {
|
||||||
*/
|
*/
|
||||||
supportRefreshTokens?: boolean;
|
supportRefreshTokens?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Endpoints that accept the request and then withhold the response, so the
|
||||||
|
* `AbortSignal.timeout` #2319 put on the OAuth path can be driven against a
|
||||||
|
* real, established, idle socket (#2382).
|
||||||
|
*
|
||||||
|
* A `fetch` stub cannot reproduce that state — it settles on the client
|
||||||
|
* side — which is why the five timeouts shipped covered only by unit tests.
|
||||||
|
* Name the *call*, not the path: exchange and refresh share `/oauth/token`,
|
||||||
|
* and two of the documents sit at configurable paths.
|
||||||
|
*
|
||||||
|
* Per endpoint rather than global, so a test can tell "discovery timed out"
|
||||||
|
* from "token exchange timed out" — the distinction the five separate
|
||||||
|
* timeouts exist to make.
|
||||||
|
*/
|
||||||
|
stallEndpoints?: StallableOAuthEndpoint[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Answer a stalled endpoint after this many ms instead of never
|
||||||
|
* (default: 0, never). Lets one fixture cover both "slower than the
|
||||||
|
* client's budget" and "no answer at all".
|
||||||
|
*/
|
||||||
|
stallMs?: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether to advertise and serve the RFC 7009 `revocation_endpoint`
|
* Whether to advertise and serve the RFC 7009 `revocation_endpoint`
|
||||||
* (default: true). Set to `false` to reproduce an authorization server that
|
* (default: true). Set to `false` to reproduce an authorization server that
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,10 @@
|
||||||
import { readFileSync } from "fs";
|
import { readFileSync } from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import YAML from "yaml";
|
import YAML from "yaml";
|
||||||
import { isOriginRelativePath } from "./test-server-oauth.js";
|
import {
|
||||||
|
isOriginRelativePath,
|
||||||
|
type StallableOAuthEndpoint,
|
||||||
|
} from "./test-server-oauth.js";
|
||||||
|
|
||||||
export interface PresetRef {
|
export interface PresetRef {
|
||||||
preset: string;
|
preset: string;
|
||||||
|
|
@ -58,6 +61,18 @@ export interface ConfigFileOAuth {
|
||||||
supportRefreshTokens?: boolean;
|
supportRefreshTokens?: boolean;
|
||||||
/** RFC 7009 revocation endpoint; default true (#2144). */
|
/** RFC 7009 revocation endpoint; default true (#2144). */
|
||||||
supportRevocation?: boolean;
|
supportRevocation?: boolean;
|
||||||
|
/**
|
||||||
|
* Endpoints that accept the request and withhold the response, to drive the
|
||||||
|
* OAuth-path request timeouts against a real socket (#2382). One of
|
||||||
|
* `protected-resource-metadata`, `as-metadata`, `authorize`, `token`,
|
||||||
|
* `revoke`, `register`. An unrecognized name throws at setup.
|
||||||
|
*/
|
||||||
|
stallEndpoints?: StallableOAuthEndpoint[];
|
||||||
|
/**
|
||||||
|
* Answer a stalled endpoint after this many ms instead of never (default 0 =
|
||||||
|
* never). Use it for "slower than the budget"; leave it out for "no answer".
|
||||||
|
*/
|
||||||
|
stallMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConfigFile {
|
export interface ConfigFile {
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ import * as crypto from "node:crypto";
|
||||||
import type { ServerConfig } from "./test-server-fixtures.js";
|
import type { ServerConfig } from "./test-server-fixtures.js";
|
||||||
import {
|
import {
|
||||||
setupOAuthRoutes,
|
setupOAuthRoutes,
|
||||||
|
createStallRegistry,
|
||||||
|
type StallRegistry,
|
||||||
createBearerTokenMiddleware,
|
createBearerTokenMiddleware,
|
||||||
buildScopeRequirementRegistry,
|
buildScopeRequirementRegistry,
|
||||||
scopeRequirementRegistryHasEntries,
|
scopeRequirementRegistryHasEntries,
|
||||||
|
|
@ -307,6 +309,13 @@ export class TestServerHttp {
|
||||||
private readonly configWithCallback: ServerConfig;
|
private readonly configWithCallback: ServerConfig;
|
||||||
private readonly serverControl: ServerControl;
|
private readonly serverControl: ServerControl;
|
||||||
private _closing = false;
|
private _closing = false;
|
||||||
|
/**
|
||||||
|
* Requests currently parked by the OAuth stall middleware (#2382).
|
||||||
|
*
|
||||||
|
* Read through `stalledRequestCount()` so a test can wait for a request to be
|
||||||
|
* accepted and parked rather than sleeping — see `StallRegistry`.
|
||||||
|
*/
|
||||||
|
private readonly stallRegistry: StallRegistry = createStallRegistry();
|
||||||
private recordedRequests: RecordedRequest[] = [];
|
private recordedRequests: RecordedRequest[] = [];
|
||||||
private httpServer?: HttpServer;
|
private httpServer?: HttpServer;
|
||||||
private transport?:
|
private transport?:
|
||||||
|
|
@ -519,7 +528,7 @@ export class TestServerHttp {
|
||||||
// Set up OAuth if enabled (BEFORE MCP routes)
|
// Set up OAuth if enabled (BEFORE MCP routes)
|
||||||
if (this.config.oauth?.enabled) {
|
if (this.config.oauth?.enabled) {
|
||||||
// We need baseUrl, but it's not set yet - we'll set it after server starts
|
// We need baseUrl, but it's not set yet - we'll set it after server starts
|
||||||
setupOAuthRoutes(app, this.config.oauth);
|
setupOAuthRoutes(app, this.config.oauth, this.stallRegistry);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bearer token middleware for MCP routes if requireAuth
|
// Bearer token middleware for MCP routes if requireAuth
|
||||||
|
|
@ -668,7 +677,7 @@ export class TestServerHttp {
|
||||||
// But the routes use relative paths, so they should work regardless
|
// But the routes use relative paths, so they should work regardless
|
||||||
if (this.config.oauth?.enabled) {
|
if (this.config.oauth?.enabled) {
|
||||||
// Use placeholder URL - actual baseUrl will be set after server starts
|
// Use placeholder URL - actual baseUrl will be set after server starts
|
||||||
setupOAuthRoutes(app, this.config.oauth);
|
setupOAuthRoutes(app, this.config.oauth, this.stallRegistry);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bearer token middleware for SSE routes if requireAuth
|
// Bearer token middleware for SSE routes if requireAuth
|
||||||
|
|
@ -740,6 +749,17 @@ export class TestServerHttp {
|
||||||
return this.listen(port);
|
return this.listen(port);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many requests this server is currently holding in an OAuth stall.
|
||||||
|
*
|
||||||
|
* On the instance rather than a module export: Vitest can load the fixture
|
||||||
|
* module more than once, and a module-level counter left the middleware
|
||||||
|
* incrementing one copy while the test polled another (#2382).
|
||||||
|
*/
|
||||||
|
stalledRequestCount(): number {
|
||||||
|
return this.stallRegistry.parked;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stop the server. Set closing before closing transport so in-flight tools can skip sending.
|
* Stop the server. Set closing before closing transport so in-flight tools can skip sending.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -139,6 +139,293 @@ function bearerChallenge(config: OAuthConfig, req: Request): string {
|
||||||
return `Bearer resource_metadata="${new URL(path, requestBaseUrl).href}"`;
|
return `Bearer resource_metadata="${new URL(path, requestBaseUrl).href}"`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The OAuth endpoints a fixture can be told to stall.
|
||||||
|
*
|
||||||
|
* One name per call that #2319 put an `AbortSignal.timeout` on, plus
|
||||||
|
* `authorize` for completeness. They are named for the *call* rather than the
|
||||||
|
* path because two of them share `/oauth/token` (exchange and refresh are the
|
||||||
|
* same endpoint with a different `grant_type`), and because the path of the
|
||||||
|
* protected-resource document is itself configurable.
|
||||||
|
*/
|
||||||
|
export const STALLABLE_OAUTH_ENDPOINTS = [
|
||||||
|
"protected-resource-metadata",
|
||||||
|
"as-metadata",
|
||||||
|
"authorize",
|
||||||
|
"token",
|
||||||
|
"revoke",
|
||||||
|
"register",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type StallableOAuthEndpoint = (typeof STALLABLE_OAUTH_ENDPOINTS)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The largest delay `setTimeout` can actually schedule. Past `2 ** 31 - 1` the
|
||||||
|
* delay overflows a 32-bit signed integer and Node falls back to **1ms**, so an
|
||||||
|
* over-large `stallMs` would produce an *immediate* answer — the opposite of
|
||||||
|
* what the fixture author asked for. Mirrors `MAX_TIMER_DELAY_MS` in
|
||||||
|
* `core/auth/requestTimeout.ts`, which exists for the same reason.
|
||||||
|
*/
|
||||||
|
export const MAX_STALL_MS = 2_147_483_647;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a rejected config value for an error message.
|
||||||
|
*
|
||||||
|
* ⚠️ `JSON.stringify` returns the string `"null"` for `NaN` and `Infinity` — the
|
||||||
|
* two values most likely to reach the `stallMs` check — so an error built with
|
||||||
|
* it names the wrong offending value and sends the reader looking for a `null`
|
||||||
|
* they did not write (Copilot). Strings keep their quotes, which is what makes
|
||||||
|
* `"600"` distinguishable from `600` in the message.
|
||||||
|
*/
|
||||||
|
function describeValue(value: unknown): string {
|
||||||
|
if (typeof value === "number" && !Number.isFinite(value))
|
||||||
|
return String(value);
|
||||||
|
return JSON.stringify(value) ?? String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isStallableOAuthEndpoint(
|
||||||
|
value: unknown,
|
||||||
|
): value is StallableOAuthEndpoint {
|
||||||
|
return (STALLABLE_OAUTH_ENDPOINTS as readonly unknown[]).includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How a stallable endpoint is addressed: its path, and the methods it serves. */
|
||||||
|
export interface StallTarget {
|
||||||
|
path: string;
|
||||||
|
methods: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where each stallable endpoint lives, for this config.
|
||||||
|
*
|
||||||
|
* Two of these are configurable, so the map is built per config rather than
|
||||||
|
* hardcoded: the protected-resource document moves with `resourceMetadataPath`
|
||||||
|
* and the AS metadata with `asMetadataPath`. Getting either wrong would make
|
||||||
|
* the stall silently never match, which is the one failure this fixture must
|
||||||
|
* not have — a test would then read as "the timeout did not fire". Every entry
|
||||||
|
* is covered by a real-request test for exactly that reason.
|
||||||
|
*
|
||||||
|
* ⚠️ **The method is part of the identity, not decoration.** Both configurable
|
||||||
|
* paths are caller-supplied, so a config may legitimately point one of them at
|
||||||
|
* a path another endpoint already uses — `asMetadataPath: "/oauth/token"` is
|
||||||
|
* valid. Keyed on path alone, selecting `token` would then also stall the
|
||||||
|
* metadata GET and selecting `as-metadata` would stall the token POST, which
|
||||||
|
* breaks the per-call contract this option exists to provide (Copilot).
|
||||||
|
*
|
||||||
|
* `authorize` serves both GET (the consent page) and POST (the submission), so
|
||||||
|
* it carries both: stalling "the authorize call" means either direction.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Does this config actually serve `endpoint`?
|
||||||
|
*
|
||||||
|
* Mirrors the route registration in `setupOAuthRoutes` / `setupMetadataEndpoints`
|
||||||
|
* exactly — the protected-resource document is always served, everything else
|
||||||
|
* on the local AS exists only in `combined` mode, and two routes carry their own
|
||||||
|
* feature flags. Kept adjacent to `stallTargetsFor` so the two stay in step: a
|
||||||
|
* new OAuth route needs an entry in both.
|
||||||
|
*/
|
||||||
|
export function servesEndpoint(
|
||||||
|
config: OAuthConfig,
|
||||||
|
endpoint: StallableOAuthEndpoint,
|
||||||
|
): boolean {
|
||||||
|
const combined = getOAuthMode(config) === "combined";
|
||||||
|
switch (endpoint) {
|
||||||
|
case "protected-resource-metadata":
|
||||||
|
return true;
|
||||||
|
case "as-metadata":
|
||||||
|
case "authorize":
|
||||||
|
case "token":
|
||||||
|
return combined;
|
||||||
|
case "revoke":
|
||||||
|
return combined && config.supportRevocation !== false;
|
||||||
|
case "register":
|
||||||
|
return combined && config.supportDCR === true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stallTargetsFor(
|
||||||
|
config: OAuthConfig,
|
||||||
|
): Record<StallableOAuthEndpoint, StallTarget> {
|
||||||
|
return {
|
||||||
|
"protected-resource-metadata": {
|
||||||
|
path:
|
||||||
|
resourceMetadataPath(config) ?? "/.well-known/oauth-protected-resource",
|
||||||
|
methods: ["GET"],
|
||||||
|
},
|
||||||
|
"as-metadata": { path: asMetadataPath(config), methods: ["GET"] },
|
||||||
|
authorize: { path: "/oauth/authorize", methods: ["GET", "POST"] },
|
||||||
|
token: { path: "/oauth/token", methods: ["POST"] },
|
||||||
|
revoke: { path: "/oauth/revoke", methods: ["POST"] },
|
||||||
|
register: { path: "/oauth/register", methods: ["POST"] },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many requests are currently parked in a stall on one server.
|
||||||
|
*
|
||||||
|
* A test waits on this to know a request was actually **accepted and parked**,
|
||||||
|
* instead of sleeping and hoping. That distinction is the whole point of the
|
||||||
|
* teardown test: a fixed sleep that lost the race would stop the server before
|
||||||
|
* the request arrived, the fetch would then reject because the server closed,
|
||||||
|
* and the test would pass without ever exercising `closeAllConnections()` on an
|
||||||
|
* established request (Copilot).
|
||||||
|
*
|
||||||
|
* ⚠️ **Per server, deliberately not a module-level counter.** A module global
|
||||||
|
* was tried first and failed under Vitest, which can load this module more than
|
||||||
|
* once: the middleware incremented one copy while the test polled another, and
|
||||||
|
* the wait timed out with the fixture working perfectly. Hanging the state off
|
||||||
|
* the server instance the test already holds makes module identity irrelevant —
|
||||||
|
* and scopes the count to one fixture, which is what a caller means anyway.
|
||||||
|
*/
|
||||||
|
export interface StallRegistry {
|
||||||
|
parked: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createStallRegistry(): StallRegistry {
|
||||||
|
return { parked: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accept a request on a configured endpoint and withhold its response.
|
||||||
|
*
|
||||||
|
* ⚠️ **This deliberately calls neither `next()` nor any `res` method.** That is
|
||||||
|
* the whole point: the socket is accepted and established, the client's fetch
|
||||||
|
* is pending, and nothing ever answers — which is the state #2319 describes and
|
||||||
|
* the one a `fetch` stub cannot reproduce. A stub rejects or resolves on the
|
||||||
|
* client side; only a real server holding a real socket exercises the
|
||||||
|
* `AbortSignal.timeout` that #2319 added.
|
||||||
|
*
|
||||||
|
* With `stallMs > 0` it answers late instead of never, by handing control back
|
||||||
|
* to the real route after the delay — so one fixture covers both "slower than
|
||||||
|
* the budget" and "never".
|
||||||
|
*
|
||||||
|
* **Teardown is already safe and this relies on it rather than re-implementing
|
||||||
|
* it.** `TestServerHttp.stop()` calls `httpServer.closeAllConnections?.()`,
|
||||||
|
* which destroys an established socket whether or not a response was ever
|
||||||
|
* written. A withheld response therefore cannot hang a suite at teardown —
|
||||||
|
* `stalls the token endpoint and still stops cleanly` pins that, because it is
|
||||||
|
* the property most likely to be broken by a future change to the stop path.
|
||||||
|
*/
|
||||||
|
export function createOAuthStallMiddleware(
|
||||||
|
config: OAuthConfig,
|
||||||
|
registry?: StallRegistry,
|
||||||
|
): express.RequestHandler | null {
|
||||||
|
// ⚠️ Validate the CONTAINER before its contents. A JSON/YAML config is only
|
||||||
|
// cast, so `stallEndpoints` can arrive as a string, `null`, or an object with
|
||||||
|
// a `length`. A bare `.length === 0` check accepts `""` and `{ length: 0 }`
|
||||||
|
// and silently returns "no stalling configured" — the precise misconfiguration
|
||||||
|
// this startup validation exists to catch — while a non-empty string fails
|
||||||
|
// later with an incidental `.filter is not a function` (Copilot).
|
||||||
|
if (
|
||||||
|
config.stallEndpoints !== undefined &&
|
||||||
|
!Array.isArray(config.stallEndpoints)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`oauth.stallEndpoints must be an array (got ${JSON.stringify(config.stallEndpoints)}).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const requested = config.stallEndpoints ?? [];
|
||||||
|
if (requested.length === 0) return null;
|
||||||
|
|
||||||
|
const unknown = requested.filter((e) => !isStallableOAuthEndpoint(e));
|
||||||
|
if (unknown.length > 0) {
|
||||||
|
// Loud, not ignored: a typo would otherwise produce a fixture that answers
|
||||||
|
// normally, and a test asserting a timeout would fail pointing at the
|
||||||
|
// timeout rather than at the config.
|
||||||
|
throw new Error(
|
||||||
|
`Unknown oauth.stallEndpoints entry: ${unknown.map((e) => JSON.stringify(e)).join(", ")}. ` +
|
||||||
|
`Expected one of: ${STALLABLE_OAUTH_ENDPOINTS.join(", ")}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⚠️ `stallMs` needs validating for the same reason `stallEndpoints` does, and
|
||||||
|
// more urgently: a JSON/YAML config is only *cast* to its interface, so
|
||||||
|
// anything can arrive here. Unchecked, a negative or non-numeric value makes
|
||||||
|
// `stallMs > 0` false and silently becomes a PERMANENT stall, and a value past
|
||||||
|
// the 32-bit timer range overflows and fires almost immediately — both of
|
||||||
|
// which read as "the timeout behaved strangely" rather than "the config is
|
||||||
|
// wrong" (Copilot).
|
||||||
|
// ⚠️ `?? 0` would turn an explicit `stallMs: null` from a config file into a
|
||||||
|
// valid 0 and skip every check below, silently producing a permanent stall.
|
||||||
|
// Only an OMITTED value gets the default; `null` falls through to the
|
||||||
|
// finite-number check and is rejected (Copilot).
|
||||||
|
const rawStallMs = config.stallMs === undefined ? 0 : config.stallMs;
|
||||||
|
if (
|
||||||
|
typeof rawStallMs !== "number" ||
|
||||||
|
!Number.isFinite(rawStallMs) ||
|
||||||
|
rawStallMs < 0 ||
|
||||||
|
rawStallMs > MAX_STALL_MS
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`oauth.stallMs must be a finite number between 0 and ${MAX_STALL_MS} (got ${describeValue(config.stallMs)}).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⚠️ Refuse to stall a route this config does not actually serve. The stall
|
||||||
|
// middleware runs BEFORE Express routing, so it will happily hold a request
|
||||||
|
// for an endpoint that would otherwise 404 — turning a contradictory fixture
|
||||||
|
// (`supportDCR: false` with `stallEndpoints: ["register"]`) into a hanging
|
||||||
|
// registration endpoint rather than a configuration error, and inviting a
|
||||||
|
// timeout test that passes for entirely the wrong reason (Copilot).
|
||||||
|
const unavailable = requested.filter(
|
||||||
|
(endpoint) => !servesEndpoint(config, endpoint),
|
||||||
|
);
|
||||||
|
if (unavailable.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`oauth.stallEndpoints names ${unavailable.map((e) => JSON.stringify(e)).join(", ")}, ` +
|
||||||
|
`which this config does not serve (mode=${getOAuthMode(config)}, ` +
|
||||||
|
`supportDCR=${String(config.supportDCR ?? false)}, ` +
|
||||||
|
`supportRevocation=${String(config.supportRevocation !== false)}). ` +
|
||||||
|
"Stalling a route that would otherwise 404 produces a hang that reads as a timeout.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targets = stallTargetsFor(config);
|
||||||
|
// `${METHOD} ${path}` rather than a path set — see `stallTargetsFor`.
|
||||||
|
const stalled = new Set(
|
||||||
|
requested.flatMap((endpoint) => {
|
||||||
|
const { path, methods } = targets[endpoint];
|
||||||
|
return methods.map((method) => `${method} ${path}`);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const stallMs = rawStallMs;
|
||||||
|
|
||||||
|
return (req: Request, res: Response, next: express.NextFunction) => {
|
||||||
|
// `req.path`, never `req.url`: the latter carries the query string, which
|
||||||
|
// every authorize request has, so matching on it would silently stop
|
||||||
|
// hitting `authorize` while every bare-path endpoint kept working.
|
||||||
|
if (!stalled.has(`${req.method} ${req.path}`)) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registry) registry.parked += 1;
|
||||||
|
// One decrement per request, whichever way it ends: answered late, or the
|
||||||
|
// socket destroyed under it. Without this the counter only ever rises and
|
||||||
|
// a later "wait until parked" would pass instantly on a stale count.
|
||||||
|
let released = false;
|
||||||
|
const release = () => {
|
||||||
|
if (released) return;
|
||||||
|
released = true;
|
||||||
|
if (registry) registry.parked -= 1;
|
||||||
|
};
|
||||||
|
res.on("close", release);
|
||||||
|
|
||||||
|
if (stallMs > 0) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
release();
|
||||||
|
next();
|
||||||
|
}, stallMs);
|
||||||
|
// Release the timer if the client gives up first, so a stalled fixture
|
||||||
|
// cannot keep the event loop alive past the test that used it.
|
||||||
|
res.on("close", () => clearTimeout(timer));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Answer never. The socket stays established and idle until the client's
|
||||||
|
// own timeout fires or the server destroys it on stop.
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set up OAuth routes on an Express application
|
* Set up OAuth routes on an Express application
|
||||||
* This adds all OAuth endpoints (authorization, token, metadata, etc.)
|
* This adds all OAuth endpoints (authorization, token, metadata, etc.)
|
||||||
|
|
@ -149,7 +436,14 @@ function bearerChallenge(config: OAuthConfig, req: Request): string {
|
||||||
export function setupOAuthRoutes(
|
export function setupOAuthRoutes(
|
||||||
app: express.Application,
|
app: express.Application,
|
||||||
config: OAuthConfig,
|
config: OAuthConfig,
|
||||||
|
stallRegistry?: StallRegistry,
|
||||||
): void {
|
): void {
|
||||||
|
// Ahead of every OAuth route, so a stalled endpoint is withheld before any
|
||||||
|
// handler can answer it — including the metadata documents, which are
|
||||||
|
// registered first.
|
||||||
|
const stall = createOAuthStallMiddleware(config, stallRegistry);
|
||||||
|
if (stall) app.use(stall);
|
||||||
|
|
||||||
setupMetadataEndpoints(app, config);
|
setupMetadataEndpoints(app, config);
|
||||||
|
|
||||||
if (getOAuthMode(config) === "combined") {
|
if (getOAuthMode(config) === "combined") {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue