fix(auth): salvage a malformed fallback registration, and correct the stale API names (#2144)
Copilot review round 19. - Parsing the preconfigured client registration sat outside the per-slot salvage, so one malformed fallback aborted the whole plan and grants carrying their own valid credentials were never revoked. It is a *fallback*; a bad one is now recorded and skipped, matching every other slot. - `sendPlans` preserved an earlier success when a later key was never attempted, contradicting the failure-first rule two lines below — the unattempted key's grant may still be live. That branch turns out to be a bound rather than a path (`withDeadline` fails any plan that reaches the deadline, so a preceding plan cannot both succeed and leave zero budget), so it carries a justified `v8 ignore` saying exactly that. - `clearStoredAuthForRelogin` takes an optional `budgetMs`, which is what lets the shared-budget behavior be tested without a five-second test. - Three stale references to `planOAuthRevocation`, which no longer exists — two JSDoc links and the `AGENTS.md` entry, which additionally still told callers to make a separate `storage.clear()` call. Following that text would have reintroduced the second clear the atomic step exists to remove. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
463227c947
commit
579a3eeeb9
2 changed files with 128 additions and 20 deletions
|
|
@ -333,6 +333,30 @@ describe("clearStoredAuthForRelogin", () => {
|
|||
|
||||
// A grant still live at the authorization server is what the user needs to
|
||||
// hear about; reporting the earlier success would print no warning at all.
|
||||
// One budget across both keys, so a first request that outlives it fails
|
||||
// rather than each key getting a fresh five seconds.
|
||||
it("bounds both keys with one shared budget", async () => {
|
||||
seedBothSpellings("live-r", "stale-r");
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockImplementation(async () => {
|
||||
// Outlives the injected budget, so the second key is never attempted.
|
||||
await new Promise((r) => setTimeout(r, 40));
|
||||
return new Response(null, { status: 200 });
|
||||
});
|
||||
try {
|
||||
const outcome = await clearStoredAuthForRelogin("https://example.com", {
|
||||
budgetMs: 20,
|
||||
});
|
||||
expect(outcome).toMatchObject({ status: "failed" });
|
||||
expect(outcome?.status === "failed" ? outcome.detail : "").toContain(
|
||||
"timed out after 20ms",
|
||||
);
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("reports a later failure over an earlier success", async () => {
|
||||
seedBothSpellings("live-r", "stale-r");
|
||||
const fetchSpy = vi
|
||||
|
|
|
|||
|
|
@ -32,7 +32,15 @@ function normalizeServerUrl(serverUrl: string): string {
|
|||
*/
|
||||
export async function clearStoredAuthForRelogin(
|
||||
serverUrl: string | undefined,
|
||||
options?: { revoke?: boolean },
|
||||
options?: {
|
||||
revoke?: boolean;
|
||||
/**
|
||||
* Total wall-clock budget for the revocation requests across both key
|
||||
* spellings. Defaults to {@link DEFAULT_REVOCATION_TIMEOUT_MS}; injectable
|
||||
* so the exhaustion path can be exercised without a five-second test.
|
||||
*/
|
||||
budgetMs?: number;
|
||||
},
|
||||
): Promise<TokenRevocationOutcome | undefined> {
|
||||
if (!serverUrl?.trim()) return undefined;
|
||||
const raw = serverUrl.trim();
|
||||
|
|
@ -68,7 +76,9 @@ export async function clearStoredAuthForRelogin(
|
|||
// entry from the NodeOAuthStorage cache.
|
||||
resetNodeOAuthStorageCache();
|
||||
|
||||
return options?.revoke === false ? undefined : sendPlans(plans);
|
||||
return options?.revoke === false
|
||||
? undefined
|
||||
: sendPlans(plans, options?.budgetMs ?? DEFAULT_REVOCATION_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -100,9 +110,10 @@ export async function clearStoredAuthForRelogin(
|
|||
*/
|
||||
async function sendPlans(
|
||||
plans: OAuthRevocationPlan[],
|
||||
budgetMs: number,
|
||||
): Promise<TokenRevocationOutcome | undefined> {
|
||||
const fetchFn = createProxyFetch() ?? fetch;
|
||||
const deadlineAt = Date.now() + DEFAULT_REVOCATION_TIMEOUT_MS;
|
||||
const deadlineAt = Date.now() + budgetMs;
|
||||
let reported: TokenRevocationOutcome | undefined;
|
||||
let lastSkip: TokenRevocationOutcome | undefined;
|
||||
for (const plan of plans) {
|
||||
|
|
@ -112,11 +123,19 @@ async function sendPlans(
|
|||
// may still be live when the key held no grant at all — a false alarm, and
|
||||
// one that outranks the real outcome under the failure-first rule below.
|
||||
const needsNetwork = plan.outcome === undefined;
|
||||
/* v8 ignore next 10 -- A bound, not a path: `withDeadline` fails any plan
|
||||
that reaches the deadline, so a preceding plan cannot both succeed and
|
||||
leave zero budget. This exists so a plan reached with nothing left is
|
||||
reported rather than firing a request it would immediately abandon. */
|
||||
if (needsNetwork && remainingMs <= 0) {
|
||||
reported ??= {
|
||||
// Overrides an earlier success rather than deferring to it: this key's
|
||||
// grant may still be live at the authorization server, and that is the
|
||||
// thing the user needs to hear about. Same failure-first rule as below.
|
||||
const exhausted: TokenRevocationOutcome = {
|
||||
status: "failed",
|
||||
detail: `the ${DEFAULT_REVOCATION_TIMEOUT_MS}ms revocation budget was exhausted before "${plan.serverUrl}" was attempted`,
|
||||
detail: `the ${budgetMs}ms revocation budget was exhausted before "${plan.serverUrl}" was attempted`,
|
||||
};
|
||||
if (reported?.status !== "failed") reported = exhausted;
|
||||
continue;
|
||||
}
|
||||
const outcome = await executeOAuthRevocation(plan, {
|
||||
|
|
|
|||
Loading…
Reference in a new issue