fix(web): scope the OAuth recovery restores to the attempt that made them

Copilot round 2, both findings valid:

- `resumePendingReauth`'s restore could resurrect a challenge whose
  session ended during the awaits — a server switch or disconnect clears
  the slot on purpose. It is now gated on the attempt still targeting
  the live session, client identity included (reconnecting to the same
  server builds a replacement client an id-only check would admit), and
  uses a functional update so a newer deferral queued in the same batch
  wins rather than being read past.
- `prepareOAuthRedirect` cleared whichever resume snapshot was current,
  not the one it wrote. Nothing single-flights that callback, so a
  redirect failing before navigation could delete a later attempt's
  callback-routing state. `writeOAuthResumeSnapshot` now returns the
  serialization it stored, and `clearOwnOAuthResumeSnapshot` removes it
  only while it is still the current one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Quk1hBUtyhkXowwMXzAnAY
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-29 02:12:47 -04:00
commit 76b75d3759
2 changed files with 166 additions and 12 deletions

View file

@ -11,6 +11,7 @@ import {
readOAuthResumeSnapshot,
restoreTabUiFromSnapshot,
writeOAuthResumeSnapshot,
clearOwnOAuthResumeSnapshot,
type OAuthResumeSnapshot,
} from "./oauthResume.js";
import {
@ -349,6 +350,43 @@ describe("oauthResume", () => {
}
});
it("clearOwnOAuthResumeSnapshot removes only the snapshot it was given", () => {
const first = writeOAuthResumeSnapshot({
version: 1,
serverId: "a",
activeTab: "tools",
authKind: "reauth",
tabUi: {},
});
// A later attempt replaces it before the first one's failure lands.
writeOAuthResumeSnapshot({
version: 1,
serverId: "b",
activeTab: "prompts",
authKind: "reauth",
tabUi: {},
});
expect(clearOwnOAuthResumeSnapshot(first)).toBe(false);
expect(readOAuthResumeSnapshot()?.serverId).toBe("b");
const second = window.sessionStorage.getItem(OAUTH_RESUME_KEY)!;
expect(clearOwnOAuthResumeSnapshot(second)).toBe(true);
expect(window.sessionStorage.getItem(OAUTH_RESUME_KEY)).toBeNull();
});
it("clearOwnOAuthResumeSnapshot is a no-op without a token", () => {
writeOAuthResumeSnapshot({
version: 1,
serverId: "a",
activeTab: "tools",
authKind: "reauth",
tabUi: {},
});
expect(clearOwnOAuthResumeSnapshot(undefined)).toBe(false);
expect(readOAuthResumeSnapshot()?.serverId).toBe("a");
});
it("writeOAuthResumeSnapshot swallows setItem failures", () => {
vi.stubGlobal("sessionStorage", {
getItem: () => null,
@ -368,6 +406,17 @@ describe("oauthResume", () => {
).not.toThrow();
});
it("clearOwnOAuthResumeSnapshot swallows storage failures", () => {
vi.stubGlobal("sessionStorage", {
getItem: () => {
throw new Error("blocked");
},
setItem: () => {},
removeItem: () => {},
});
expect(clearOwnOAuthResumeSnapshot("{}")).toBe(false);
});
it("clearOAuthResumeSnapshot swallows removeItem failures", () => {
vi.stubGlobal("sessionStorage", {
getItem: () => null,
@ -422,6 +471,10 @@ describe("oauthResume", () => {
it("clearOAuthResumeSnapshot is a no-op", () => {
expect(() => clearOAuthResumeSnapshot()).not.toThrow();
});
it("clearOwnOAuthResumeSnapshot is a no-op", () => {
expect(clearOwnOAuthResumeSnapshot("{}")).toBe(false);
});
});
it("restoreTabUiFromSnapshot returns early when tabUi is undefined", () => {

View file

@ -168,14 +168,26 @@ export function restoreTabUiFromSnapshot(
}
}
export function writeOAuthResumeSnapshot(snapshot: OAuthResumeSnapshot): void {
/**
* Persist the snapshot, returning the exact serialization stored — the
* "attempt token" {@link clearOwnOAuthResumeSnapshot} matches against (#2165).
*
* `undefined` when nothing was stored (no `window`, privacy mode, quota), so a
* caller holding one knows a clear is meaningful.
*/
export function writeOAuthResumeSnapshot(
snapshot: OAuthResumeSnapshot,
): string | undefined {
if (typeof window === "undefined") {
return;
return undefined;
}
const serialized = JSON.stringify(snapshot);
try {
window.sessionStorage.setItem(OAUTH_RESUME_KEY, JSON.stringify(snapshot));
window.sessionStorage.setItem(OAUTH_RESUME_KEY, serialized);
return serialized;
} catch {
// Best-effort — privacy mode / quota.
return undefined;
}
}
@ -231,6 +243,39 @@ function readLegacyPendingServerSnapshot(): OAuthResumeSnapshot | undefined {
}
}
/**
* Clear the snapshot **only if it is still the one this attempt wrote** (#2165).
*
* `prepareOAuthRedirect` has no single-flight guard, so a redirect that fails
* before navigating can reject after a *later* attempt has already written its
* own snapshot. An unconditional clear there would delete the newer attempt's
* callback-routing state, which is a worse failure than the one it is trying
* to avoid.
*
* The token is the stored serialization, which makes two byte-identical
* snapshots indistinguishable — deliberately: identical bytes describe the
* same redirect, for the same server, with the same shell state, so clearing
* "the other one" reaches the same end state.
*
* Returns whether anything was removed.
*/
export function clearOwnOAuthResumeSnapshot(
token: string | undefined,
): boolean {
if (typeof window === "undefined" || token === undefined) {
return false;
}
try {
if (window.sessionStorage.getItem(OAUTH_RESUME_KEY) !== token) {
return false;
}
window.sessionStorage.removeItem(OAUTH_RESUME_KEY);
return true;
} catch {
return false;
}
}
export function clearOAuthResumeSnapshot(): void {
if (typeof window === "undefined") {
return;