fix: cancellable compat probes and a validated budget (#2319 round 16)
Two real bugs and two stale rationales.
The OIDC fallback probes were issued with `{ headers }` and no signal,
so a caller that aborted after the initial 404 got a timeout reported
in place of its own cancellation, while the probe ran on to its own
deadline. The probes now carry the caller's signal — via the shared
`callerSignalOf`, so the precedence rule has one definition — a
pre-aborted caller is not probed at all, and an abort is rethrown
rather than folded back into the preceding response.
`Math.max(0, Math.round(timeoutMs))` let NaN and Infinity through.
`setTimeout(fn, NaN)` fires immediately, and a delay above 2**31-1
overflows a 32-bit signed int and falls back to 1ms — so a caller
asking for a very large budget, or one whose arithmetic produced NaN,
got an immediate OAuth timeout. `normalizeBudget` falls back to the
default on non-finite and clamps to the timer's range; the route's
envelope-supplied deadline gets the same clamp, but ignores a
non-finite value outright since a malformed deadline reads safest as
no deadline.
The wire-marker docblock and the matching route test still said a
backend deadline is the only one on the transport-internal discovery
path, which round 11 changed. Both now give the real reason the marker
matters: both ends run the same budget, and it is the backend winning
the race — a backgrounded tab throttling setTimeout — that must not
decide whether the error names its endpoint.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YFnM6RqB4nXqNysgUYmjmq
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
93965936ba
commit
e234343702
3 changed files with 185 additions and 13 deletions
|
|
@ -439,3 +439,82 @@ describe("withRfc8414OidcCompat", () => {
|
|||
expect(base).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("probe cancellation (#2319)", () => {
|
||||
const RFC8414 =
|
||||
"https://as.example.com/.well-known/oauth-authorization-server/tenant";
|
||||
|
||||
/** 404 on the RFC 8414 leg, then never answers the OIDC probe. */
|
||||
function stallingProbeFetch() {
|
||||
return vi.fn<typeof fetch>((input, init) => {
|
||||
if (String(input) === RFC8414) {
|
||||
return Promise.resolve(new Response(null, { status: 404 }));
|
||||
}
|
||||
return new Promise<Response>((_resolve, reject) => {
|
||||
// A real `fetch` rejects immediately on a signal that is already
|
||||
// aborted, so the double is only useful if it does the same.
|
||||
if (init?.signal?.aborted) {
|
||||
reject(init.signal.reason as Error);
|
||||
return;
|
||||
}
|
||||
init?.signal?.addEventListener("abort", () =>
|
||||
reject(init.signal?.reason as Error),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
it("carries the caller's signal into the probe", async () => {
|
||||
const inner = stallingProbeFetch();
|
||||
const wrapped = withRfc8414OidcCompat(inner);
|
||||
const caller = new AbortController();
|
||||
|
||||
const settled = wrapped(RFC8414, { signal: caller.signal }).then(
|
||||
(r) => r,
|
||||
(e: unknown) => e,
|
||||
);
|
||||
// Wait until the probe is actually in flight, so the abort exercises
|
||||
// cancellation rather than the pre-aborted short-circuit.
|
||||
await vi.waitFor(() => {
|
||||
expect(inner).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
const reason = new Error("caller gave up");
|
||||
caller.abort(reason);
|
||||
|
||||
// Not the preceding 404: substituting it would report a discovery failure
|
||||
// for a request the caller deliberately cancelled.
|
||||
expect(await settled).toBe(reason);
|
||||
expect((inner.mock.calls[1][1] as RequestInit).signal).toBe(caller.signal);
|
||||
});
|
||||
|
||||
it("does not issue a probe the caller already cancelled", async () => {
|
||||
const inner = stallingProbeFetch();
|
||||
const wrapped = withRfc8414OidcCompat(inner);
|
||||
const reason = new Error("already gone");
|
||||
|
||||
const settled = wrapped(RFC8414, {
|
||||
signal: AbortSignal.abort(reason),
|
||||
}).then(
|
||||
(r) => r,
|
||||
(e: unknown) => e,
|
||||
);
|
||||
|
||||
expect(await settled).toBe(reason);
|
||||
// Only the RFC 8414 leg, which the caller did make.
|
||||
expect(inner).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("still falls back to the original response on an ordinary probe failure", async () => {
|
||||
const inner = vi.fn<typeof fetch>((input) => {
|
||||
if (String(input) === RFC8414) {
|
||||
return Promise.resolve(new Response(null, { status: 404 }));
|
||||
}
|
||||
return Promise.reject(new TypeError("network error"));
|
||||
});
|
||||
const wrapped = withRfc8414OidcCompat(inner);
|
||||
|
||||
const response = await wrapped(RFC8414);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -387,6 +387,46 @@ describe("withOAuthRequestTimeout", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("falls back to the default on a non-finite budget", async () => {
|
||||
vi.useFakeTimers();
|
||||
// `NaN` survives `Math.max(0, Math.round(NaN))` and `setTimeout(fn, NaN)`
|
||||
// fires immediately, so without this every OAuth request under a budget
|
||||
// that came out of bad arithmetic would fail at once with "timed out after
|
||||
// NaNms".
|
||||
for (const bad of [NaN, Infinity, -Infinity]) {
|
||||
const wrapped = withOAuthRequestTimeout(neverSettles, bad);
|
||||
const assertion = wrapped(URL_UNDER_TEST).catch((err: unknown) => err);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(DEFAULT_OAUTH_REQUEST_TIMEOUT_MS - 1);
|
||||
expect(await Promise.race([assertion, Promise.resolve("pending")])).toBe(
|
||||
"pending",
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect((await assertion) as OAuthRequestTimeoutError).toMatchObject({
|
||||
timeoutMs: DEFAULT_OAUTH_REQUEST_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("clamps an over-large budget to what setTimeout can schedule", async () => {
|
||||
vi.useFakeTimers();
|
||||
// Past 2**31-1 the delay overflows a 32-bit signed int and Node falls back
|
||||
// to 1ms — an immediate timeout, the opposite of what was asked for.
|
||||
const wrapped = withOAuthRequestTimeout(neverSettles, 2 ** 40);
|
||||
|
||||
const assertion = wrapped(URL_UNDER_TEST).catch((err: unknown) => err);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(await Promise.race([assertion, Promise.resolve("pending")])).toBe(
|
||||
"pending",
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2_147_483_647);
|
||||
expect((await assertion) as OAuthRequestTimeoutError).toMatchObject({
|
||||
timeoutMs: 2_147_483_647,
|
||||
});
|
||||
});
|
||||
|
||||
it("clamps a negative budget to zero rather than throwing", async () => {
|
||||
vi.useFakeTimers();
|
||||
const wrapped = withOAuthRequestTimeout(neverSettles, -1);
|
||||
|
|
|
|||
|
|
@ -473,10 +473,12 @@ describe("server.ts supplemental coverage", () => {
|
|||
|
||||
it("answers 504 with a typed marker when the request's deadline fires (#2319)", async () => {
|
||||
// The caller's budget travels in the envelope, so this costs milliseconds
|
||||
// rather than the production thirty seconds. The marker matters because
|
||||
// a backend-enforced deadline is the only one on the transport-internal
|
||||
// discovery path, and its error has to survive the hop as something an
|
||||
// `instanceof OAuthRequestTimeoutError` check can still recognize.
|
||||
// rather than the production thirty seconds. What it pins is typed-marker
|
||||
// preservation when the *backend* wins the client-side race — both ends
|
||||
// run the same budget, and a backgrounded tab throttling `setTimeout` is
|
||||
// the plausible way the server's fires first. The error has to survive
|
||||
// the hop as something an `instanceof OAuthRequestTimeoutError` check can
|
||||
// still recognize, whichever end produced it.
|
||||
const res = await fetch(`${h.baseUrl}/api/fetch`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
|
|
|
|||
Loading…
Reference in a new issue