fix(auth): bound the whole teardown, and scope the TUI clear to its server (#2144)

Copilot review round 7.

- The five-second timeout was per request while grants are revoked
  sequentially, so a server with N issuer slots could block the disconnect for
  N x 5s — the "short timeout" bounded a request rather than anything the user
  feels. It is now one budget for the whole call: each grant gets the remaining
  time, and a grant reached after it is exhausted is reported as never
  attempted rather than starting a fresh five seconds. `--relogin` shares one
  budget across its two key spellings for the same reason.
- `AuthTab` called the same settle handler on rejection, so a failed local
  clear or disconnect announced "OAuth state cleared". A rejection is not a
  revocation failure — those come back as outcomes — so it now has its own
  path and reports the real error. The completion is also scoped: a clear
  carries the server it started on and an attempt number, and a switch retires
  it, so server A's confirmation cannot appear under B.
- `clearOAuthAttemptRef` is now retired by the server-switch effect alongside
  `disconnectAttemptRef`; without that an A -> B -> A round trip left both the
  token and the captured name matching again. `handleClearOAuth` also
  revalidates after `await disconnectInspector()`, since a switch during that
  second await would have put the revision bump on the new selection.

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:
cliffhall 2026-08-28 03:55:37 -04:00
commit 43c817c8e9

View file

@ -36,6 +36,11 @@ import type { OAuthStorage } from "./storage.js";
* committed to, so an unreachable or wedged authorization server must not hold
* the teardown open — five seconds is long enough for a real endpoint on a slow
* link and short enough that a dead one is not felt as a hang.
*
* It is a budget for the **whole** call, not per request: a server with several
* issuer-bound grants revokes them sequentially against one shared deadline, so
* the bound the user feels is this number regardless of how many grants the
* clear is about to delete.
*/
export const DEFAULT_REVOCATION_TIMEOUT_MS = 5000;
@ -525,6 +530,14 @@ async function computeOutcome(
const supportedAuthMethods = revocationAuthMethods(metadata);
const outcomes: TokenRevocationOutcome[] = [...failures];
// ONE deadline for the whole teardown, not one per grant. `clear` deletes
// every issuer slot, so a server with N of them would otherwise block the
// disconnect for N × the timeout — at which point the "short timeout"
// bounds a single request and nothing the user experiences. Grants are
// revoked sequentially on purpose (a burst of parallel requests to one
// authorization server is not a kindness), so the budget is shared instead.
const timeoutMs = params.timeoutMs ?? DEFAULT_REVOCATION_TIMEOUT_MS;
const deadlineAt = Date.now() + timeoutMs;
for (const grant of grants) {
// Metadata is cached once per server, not per issuer, so it describes
// whichever authorization server was discovered last. Sending another
@ -542,6 +555,17 @@ async function computeOutcome(
});
continue;
}
const remainingMs = deadlineAt - Date.now();
if (remainingMs <= 0) {
outcomes.push({
status: "failed",
endpoint,
detail: `the ${timeoutMs}ms revocation budget was exhausted before this grant was attempted${
grant.issuer === undefined ? "" : ` (issuer ${grant.issuer})`
}`,
});
continue;
}
outcomes.push(
await revokeToken({
endpoint,
@ -550,7 +574,7 @@ async function computeOutcome(
clientInformation: grant.clientInformation,
supportedAuthMethods,
fetchFn,
timeoutMs: params.timeoutMs,
timeoutMs: remainingMs,
}),
);
}