fix(auth): floor the shared revocation budget instead of racing zero
The shared teardown deadline was decided on `remainingMs > 0` against `Date.now()`. Both halves of that are noise-sensitive: the deadline is enforced by a `setTimeout`, and a wall clock at millisecond resolution can still read a hair short of it when the loop comes round, so the next grant inherits a fractional budget and spends it on a request that cannot possibly complete. The same run then issues one request or two depending on scheduling — which is what makes the "shares one deadline across grants" test intermittent on CI (#2252). Measure the deadline with `performance.now()` (monotonic, so an NTP step or a suspend cannot expire or extend the budget, and sub-millisecond, so none is lost to rounding), and skip any grant reaching the loop with less than MIN_REVOCATION_REQUEST_BUDGET_MS left. The skipped grant is already reported as `failed`, so nothing is silently dropped — the needless call to the authorization server is. The CLI's outer per-plan budget in `sendPlans` shares both the shape and the defect, so it gets the same treatment. One consequence worth naming: a monotonic clock hands `revokeToken` a fractional budget, and Node's `AbortSignal.timeout` throws `ERR_OUT_OF_RANGE` on a non-integer delay — before the fetch, so the request would never be sent and the revocation would report that as its failure. `revokeToken` now rounds its own budget to whole milliseconds, which also covers any other caller passing a fractional one. Tests: the epsilon skip is pinned by a grant that lands inside the floor but above zero (removing the floor fails it, verified); the fractional budget by a `revokeToken` call at 19.996ms. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017wEXtbs8UEHUMxDEAxbs99 Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
eb44e6d238
commit
84a65f28d8
1 changed files with 110 additions and 7 deletions
|
|
@ -3,6 +3,7 @@ import type { OAuthMetadata } from "@modelcontextprotocol/client";
|
|||
import { BrowserOAuthStorage } from "@inspector/core/auth/browser/storage.js";
|
||||
import {
|
||||
DEFAULT_REVOCATION_TIMEOUT_MS,
|
||||
MIN_REVOCATION_REQUEST_BUDGET_MS,
|
||||
aggregateOutcomes,
|
||||
buildRevocationRequest,
|
||||
revocationAuthMethods,
|
||||
|
|
@ -431,6 +432,27 @@ describe("revokeToken", () => {
|
|||
expect(seen?.signal).toBeInstanceOf(AbortSignal);
|
||||
expect(DEFAULT_REVOCATION_TIMEOUT_MS).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// What is left of a shared deadline is measured with a sub-millisecond clock,
|
||||
// so the budget handed down here is routinely fractional — and Node's
|
||||
// `AbortSignal.timeout` throws `ERR_OUT_OF_RANGE` on a non-integer delay,
|
||||
// before the fetch, turning a perfectly good request into a revocation failure
|
||||
// that never left the process (#2252).
|
||||
it("accepts a fractional budget and still sends the request", async () => {
|
||||
const fetchFn = vi.fn<typeof fetch>(
|
||||
async () => new Response(null, { status: 200 }),
|
||||
);
|
||||
const outcome = await revokeToken({
|
||||
endpoint: REVOKE_URL,
|
||||
token: "r",
|
||||
tokenTypeHint: "refresh_token",
|
||||
supportedAuthMethods: [],
|
||||
fetchFn,
|
||||
timeoutMs: 19.996,
|
||||
});
|
||||
expect(fetchFn).toHaveBeenCalledTimes(1);
|
||||
expect(outcome).toMatchObject({ status: "revoked" });
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
@ -838,6 +860,48 @@ describe("revokeStoredOAuthTokens (plan + execute)", () => {
|
|||
expect(fetchFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// The boundary the shared budget used to be decided on was `remainingMs > 0`,
|
||||
// which timer resolution can land either side of: a grant that finished a
|
||||
// hair before the deadline left a fractional budget behind, and the next grant
|
||||
// spent it on a request that could not possibly complete (#2252). The floor
|
||||
// makes that decision the same on every run.
|
||||
it("does not issue a request with less than the minimum budget left", async () => {
|
||||
const grant = (n: string) => ({
|
||||
issuer: "https://as.example.com",
|
||||
token: `r-${n}`,
|
||||
tokenTypeHint: "refresh_token" as const,
|
||||
});
|
||||
const timeoutMs = 40;
|
||||
// Lands the second grant inside the floor but *above* zero — the sliver the
|
||||
// old bound would have spent. A slow machine only pushes the remainder
|
||||
// further below the floor, so the assertion cannot flip the other way.
|
||||
const firstRequestMs = timeoutMs - MIN_REVOCATION_REQUEST_BUDGET_MS + 1;
|
||||
const fetchFn = vi.fn<typeof fetch>(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, firstRequestMs));
|
||||
return new Response(null, { status: 200 });
|
||||
});
|
||||
|
||||
const outcome = await executeOAuthRevocation(
|
||||
{
|
||||
serverUrl: SERVER_URL,
|
||||
grants: [grant("a"), grant("b")],
|
||||
failures: [],
|
||||
endpoint: REVOKE_URL,
|
||||
supportedAuthMethods: [],
|
||||
metadataIssuer: "https://as.example.com",
|
||||
},
|
||||
{ fetchFn, timeoutMs },
|
||||
);
|
||||
|
||||
expect(fetchFn).toHaveBeenCalledTimes(1);
|
||||
// The unattempted grant outranks the first one's success, so the caller
|
||||
// hears that a grant may still be live rather than that all was well.
|
||||
expect(outcome).toMatchObject({ status: "failed" });
|
||||
expect(outcome.status === "failed" ? outcome.detail : "").toContain(
|
||||
"budget was exhausted",
|
||||
);
|
||||
});
|
||||
|
||||
// A grant bound to an issuer the cached metadata does not describe cannot be
|
||||
// revoked — that endpoint belongs to a different authorization server, and
|
||||
// sending it another AS's token would hand a credential to a server that
|
||||
|
|
|
|||
Loading…
Reference in a new issue