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:
parent
ecad587afe
commit
76b75d3759
2 changed files with 166 additions and 12 deletions
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue