fix(auth): close the fail-open issuer check and the remaining clear races (#2144)

Copilot review rounds 8 and 9.

- A cached metadata document with no `issuer` was letting every issuer-bound
  token go to its single revocation endpoint. Absence establishes nothing, so
  that is now a mismatch: an issuer-bound grant must match, and only a legacy
  unkeyed grant — which nothing binds to a different authorization server —
  proceeds without the comparison. This was a credential-disclosure risk, not
  just a correctness one.
- The ctx-less grant read was skipped whenever it carried an issuer stamp, on
  the assumption that slot had already been enumerated. If `listIssuers` fails
  there is no enumerated slot behind the stamp, so the one readable grant went
  unrevoked while `clear` deleted it. Both reads now share one issuer+token
  key, and the stamped issuer is retained rather than discarded.
- `AuthTab`'s repeat guard read `clearState`, which is not updated until React
  re-renders, so two `s` events in one input turn both started a clear against
  the same store entry. It is a ref now, set before the call.
- A rejected clear left `ownClearRef` set — `handleClearOAuth` never reached
  its `oauthRevision` bump — so the next unrelated revision was swallowed and
  the failure banner stranded. Cleared on that path.
- The web `clearServerOAuthAndDisconnect` had the staleness the TUI path was
  already fixed for: `isActive`/`inspectorClient` are captured before a
  five-second await, so a switch during it disconnected the session the user
  had just moved to and ran the session-wide cleanup against it. Revalidated
  against `sessionRef` after each await.

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 04:38:38 -04:00
commit 5170cc484b
3 changed files with 250 additions and 40 deletions

View file

@ -1368,6 +1368,47 @@ describe("useOAuthRecovery", () => {
);
});
// #2144 — the RFC 7009 leg is a bounded network request, so this callback
// can stay suspended for seconds. `isActive`/`inspectorClient` are captured
// before it, so without revalidating, a switch during the wait would
// disconnect the session the user just moved to and run the session-wide
// cleanup against it.
it("does not disconnect a session switched to while a clear is in flight", async () => {
let settle: (r: { cleared: boolean }) => void = () => {};
clearServerOAuthStateMock.mockImplementation(
() =>
new Promise((resolve) => {
settle = resolve as typeof settle;
}),
);
const client = fakeClient();
const h = harness({
servers: [entry("a"), entry("b")],
activeServerId: "a",
client,
});
let done: Promise<void>;
await act(async () => {
done = h.api().clearServerOAuthAndDisconnect(entry("a"));
await Promise.resolve();
});
// The user switches away while the clear is still running.
h.rerender({
servers: [entry("a"), entry("b")],
activeServerId: "b",
client,
});
await act(async () => {
settle({ cleared: true });
await done;
});
expect(client.disconnect).not.toHaveBeenCalled();
});
it("clears the resume snapshot on an explicit disconnect", () => {
writeOAuthResumeSnapshot({
version: 1,

View file

@ -1254,9 +1254,23 @@ export function useOAuthRecovery({
const clearServerOAuthAndDisconnect = useCallback(
async (server: ClearableServer) => {
const isActive = server.id === activeServerId;
const client = isActive ? inspectorClient : null;
// The RFC 7009 leg is a bounded network request (#2144), so this callback
// can stay suspended for seconds — long enough for the user to close the
// modal and switch servers. `isActive` and `inspectorClient` were
// snapshotted before it, so everything below would otherwise apply to
// whatever session is active *now*: disconnecting a client the user just
// switched to, and running the session-wide UI cleanup against it.
//
// The session ref is the live answer. Only the parts that touch the
// *session* are gated on it; the store write already happened and the
// toast still belongs to the server the user asked about.
const stillTargetsActiveSession = (): boolean =>
isActive && sessionRef.current.activeServerId === server.id;
const { cleared, revocation } = await clearServerOAuthState({
config: server.config,
inspectorClient: isActive ? inspectorClient : null,
inspectorClient: client,
isActiveConnection: isActive,
oauthStorage: webOAuthStorage,
revoke: server.settings?.oauthRevokeOnClear !== false,
@ -1264,14 +1278,17 @@ export function useOAuthRecovery({
});
if (!cleared) return;
if (isActive && inspectorClient) {
if (client && stillTargetsActiveSession()) {
try {
await inspectorClient.disconnect();
await client.disconnect();
} finally {
setConnectionInfoOAuthWhenConnected(undefined);
finalizeExplicitDisconnect();
// Revalidate after the second await for the same reason.
if (stillTargetsActiveSession()) {
setConnectionInfoOAuthWhenConnected(undefined);
finalizeExplicitDisconnect();
}
}
} else {
} else if (!isActive) {
clearOAuthResumeOnExplicitDisconnect();
}
@ -1286,6 +1303,7 @@ export function useOAuthRecovery({
[
activeServerId,
inspectorClient,
sessionRef,
webOAuthStorage,
finalizeExplicitDisconnect,
clearOAuthResumeOnExplicitDisconnect,

View file

@ -476,9 +476,11 @@ describe("revokeStoredOAuthTokens", () => {
// Suppressing on the value would drop it unrevoked and unreported, which is
// why the suppression keys off the store's issuer stamp instead.
it("keeps the legacy grant when another issuer holds the same token value", async () => {
// The metadata names the same issuer as the slot below, so BOTH grants are
// genuinely revocable — otherwise this would pass for the wrong reason.
await storage.saveServerMetadata(
SERVER_URL,
metadata({ issuer: undefined }),
metadata({ issuer: "https://as-a.example.com" }),
);
// A legacy unkeyed grant...
await storage.saveTokens(SERVER_URL, {
@ -507,15 +509,96 @@ describe("revokeStoredOAuthTokens", () => {
expect(fetchFn).toHaveBeenCalledTimes(2);
});
// Absence establishes nothing: a metadata document with no `issuer` cannot
// show that its revocation endpoint belongs to the grant's authorization
// server, so sending the token anyway would disclose a bearer credential to
// a server that may never have minted it.
it("refuses an issuer-bound grant when the cached metadata names no issuer", async () => {
await storage.saveServerMetadata(
SERVER_URL,
metadata({ issuer: undefined }),
);
await storage.saveTokens(
SERVER_URL,
{ access_token: "a", token_type: "Bearer", refresh_token: "r" },
{ issuer: "https://as-a.example.com" },
);
const fetchFn = vi.fn<typeof fetch>();
const outcome = await revokeStoredOAuthTokens({
serverUrl: SERVER_URL,
storage,
fetchFn,
});
expect(fetchFn).not.toHaveBeenCalled();
expect(outcome).toMatchObject({ status: "failed" });
expect(outcome.status === "failed" ? outcome.detail : "").toContain(
"names no issuer",
);
});
// A legacy unkeyed grant is not bound to any authorization server, so there
// is nothing to contradict — it proceeds without the comparison.
it("still revokes a legacy grant when the metadata names no issuer", async () => {
await storage.saveServerMetadata(
SERVER_URL,
metadata({ issuer: undefined }),
);
await storage.saveTokens(SERVER_URL, {
access_token: "a",
token_type: "Bearer",
refresh_token: "r",
});
const fetchFn = vi.fn<typeof fetch>(
async () => new Response(null, { status: 200 }),
);
await expect(
revokeStoredOAuthTokens({ serverUrl: SERVER_URL, storage, fetchFn }),
).resolves.toMatchObject({ status: "revoked" });
expect(fetchFn).toHaveBeenCalledTimes(1);
});
// If `listIssuers` fails there is no enumerated slot behind the stamp, so
// skipping a stamped ctx-less read would leave the one grant the store can
// still produce unrevoked — while `clear` deleted it anyway.
it("revokes the active grant even when the issuer list cannot be read", async () => {
await storage.saveServerMetadata(
SERVER_URL,
metadata({ issuer: "https://as-a.example.com" }),
);
await storage.saveTokens(
SERVER_URL,
{ access_token: "a", token_type: "Bearer", refresh_token: "r-active" },
{ issuer: "https://as-a.example.com" },
);
vi.spyOn(storage, "listIssuers").mockRejectedValue(new Error("no list"));
const fetchFn = vi.fn<typeof fetch>(
async () => new Response(null, { status: 200 }),
);
const outcome = await revokeStoredOAuthTokens({
serverUrl: SERVER_URL,
storage,
fetchFn,
});
expect(
new URLSearchParams(String(fetchFn.mock.calls[0]![1]!.body)).get("token"),
).toBe("r-active");
// The listing failure is still surfaced — it outranks the success.
expect(outcome).toMatchObject({ status: "failed" });
});
// 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 }),
);
await storage.saveServerMetadata(SERVER_URL, metadata({ issuer: "a" }));
// All three bound to the issuer the cached metadata describes, so all three
// are genuinely revocable and the budget is what stops them.
vi.spyOn(storage, "listIssuers").mockResolvedValue(["a", "b", "c"]);
vi.spyOn(storage, "getIssuerTokens").mockImplementation(
async (_url: string, issuer: string) => ({