* 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
6 changed files with 1168 additions and 3 deletions
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,
|
||||
} from "./modern-tasks.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
|
||||
|
|
@ -792,6 +793,29 @@ export interface ServerConfig {
|
|||
*/
|
||||
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`
|
||||
* (default: true). Set to `false` to reproduce an authorization server that
|
||||
|
|
|
|||
|
|
@ -6,7 +6,10 @@
|
|||
import { readFileSync } from "fs";
|
||||
import path from "path";
|
||||
import YAML from "yaml";
|
||||
import { isOriginRelativePath } from "./test-server-oauth.js";
|
||||
import {
|
||||
isOriginRelativePath,
|
||||
type StallableOAuthEndpoint,
|
||||
} from "./test-server-oauth.js";
|
||||
|
||||
export interface PresetRef {
|
||||
preset: string;
|
||||
|
|
@ -58,6 +61,18 @@ export interface ConfigFileOAuth {
|
|||
supportRefreshTokens?: boolean;
|
||||
/** RFC 7009 revocation endpoint; default true (#2144). */
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ import * as crypto from "node:crypto";
|
|||
import type { ServerConfig } from "./test-server-fixtures.js";
|
||||
import {
|
||||
setupOAuthRoutes,
|
||||
createStallRegistry,
|
||||
type StallRegistry,
|
||||
createBearerTokenMiddleware,
|
||||
buildScopeRequirementRegistry,
|
||||
scopeRequirementRegistryHasEntries,
|
||||
|
|
@ -307,6 +309,13 @@ export class TestServerHttp {
|
|||
private readonly configWithCallback: ServerConfig;
|
||||
private readonly serverControl: ServerControl;
|
||||
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 httpServer?: HttpServer;
|
||||
private transport?:
|
||||
|
|
@ -519,7 +528,7 @@ export class TestServerHttp {
|
|||
// Set up OAuth if enabled (BEFORE MCP routes)
|
||||
if (this.config.oauth?.enabled) {
|
||||
// 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
|
||||
|
|
@ -668,7 +677,7 @@ export class TestServerHttp {
|
|||
// But the routes use relative paths, so they should work regardless
|
||||
if (this.config.oauth?.enabled) {
|
||||
// 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
|
||||
|
|
@ -740,6 +749,17 @@ export class TestServerHttp {
|
|||
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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -139,6 +139,293 @@ function bearerChallenge(config: OAuthConfig, req: Request): string {
|
|||
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
|
||||
* This adds all OAuth endpoints (authorization, token, metadata, etc.)
|
||||
|
|
@ -149,7 +436,14 @@ function bearerChallenge(config: OAuthConfig, req: Request): string {
|
|||
export function setupOAuthRoutes(
|
||||
app: express.Application,
|
||||
config: OAuthConfig,
|
||||
stallRegistry?: StallRegistry,
|
||||
): 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);
|
||||
|
||||
if (getOAuthMode(config) === "combined") {
|
||||
|
|
|
|||
Loading…
Reference in a new issue