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:
parent
56ac83051b
commit
43c817c8e9
1 changed files with 198 additions and 13 deletions
|
|
@ -507,6 +507,43 @@ describe("revokeStoredOAuthTokens", () => {
|
|||
expect(fetchFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
// The timeout is a budget for the WHOLE teardown, not per request. `clear`
|
||||
// deletes every issuer slot, so a per-request bound would let a server with
|
||||
// N of them block the disconnect for N × the timeout — at which point the
|
||||
// "short timeout" bounds a single request and nothing the user feels.
|
||||
it("shares one deadline across grants instead of one per grant", async () => {
|
||||
await storage.saveServerMetadata(
|
||||
SERVER_URL,
|
||||
metadata({ issuer: undefined }),
|
||||
);
|
||||
vi.spyOn(storage, "listIssuers").mockResolvedValue(["a", "b", "c"]);
|
||||
vi.spyOn(storage, "getIssuerTokens").mockImplementation(
|
||||
async (_url: string, issuer: string) => ({
|
||||
access_token: `a-${issuer}`,
|
||||
token_type: "Bearer",
|
||||
refresh_token: `r-${issuer}`,
|
||||
}),
|
||||
);
|
||||
// Every request hangs, so each one burns the whole remaining budget.
|
||||
const fetchFn = vi.fn<typeof fetch>(() => new Promise<Response>(() => {}));
|
||||
|
||||
const started = Date.now();
|
||||
const outcome = await revokeStoredOAuthTokens({
|
||||
serverUrl: SERVER_URL,
|
||||
storage,
|
||||
fetchFn,
|
||||
timeoutMs: 30,
|
||||
});
|
||||
const elapsed = Date.now() - started;
|
||||
|
||||
expect(outcome).toMatchObject({ status: "failed" });
|
||||
// Three grants: with a per-grant bound this would be ~90ms. Generous upper
|
||||
// bound so the assertion is about the shape, not the machine.
|
||||
expect(elapsed).toBeLessThan(70);
|
||||
// The first burned the budget; the rest are reported as never attempted.
|
||||
expect(fetchFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// 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