Skip to content

Commit e66a3d8

Browse files
Clear the OAuth popup result from localStorage after handover (#1570)
* Clear the OAuth popup result from localStorage after handing it over The callback page writes its result to localStorage as the reliable same-origin completion channel, and the opener removes it on pickup. When nothing is listening — an abandoned flow, a reloaded opener, any caller not using the React helper — nobody ever removes it, and the payload carries the identity label (an email) and, on failure, the error preview. It sat in the user's browser profile indefinitely. The page now clears its own entry: on success just before the existing auto-close, and on failure after a delay, since a failed flow deliberately keeps the window up. This cannot cost a listener the result — a `storage` event captures `newValue` at dispatch, so an opener that was notified already holds it, and the only other reader polls `popup.closed`, not storage. The tests RUN the generated script against stub globals rather than matching its source, because a string assertion passes just as well on a script that never executes, and what is at stake here is what the browser is left holding. * Add a changeset for the popup storage-residue fix * fix(api): don't strand the OAuth error payload when the user closes the failure popup The failure page never auto-closes so the user can read the error, and its localStorage clear rode solely on a 5s timer. Closing the window earlier killed the pending timer, and with no opener listening (COOP severs window.opener; a reloaded app tab has no storage listener) nothing else removed the key — the error payload sat in the browser profile forever, exactly the residue this branch exists to eliminate. A pagehide listener now clears the entry when the document dies; the timers stay for the page merely sitting open. Clearing on pagehide cannot cost a listener the result: a storage event captures newValue at dispatch, and the opener ignores the null-newValue removal event. --------- Co-authored-by: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com>
1 parent c8bb857 commit e66a3d8

3 files changed

Lines changed: 121 additions & 1 deletion

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"executor": patch
3+
---
4+
5+
**The OAuth popup clears its result out of `localStorage` after handing it over**
6+
7+
The popup writes its result to `localStorage` as the fallback completion channel, because `postMessage` is severed when a provider's consent page sets COOP and `BroadcastChannel` can be partitioned or raced by the auto-close. Nothing removed that entry afterwards, so the payload — which carries the identity label, an email, and on failure the error preview — stayed parked in the user's browser profile.
8+
9+
The entry is now cleared once the handover has had time to land, and on `pagehide` as a backstop — the failure page never auto-closes so the user can read the error, and closing it by hand would otherwise cancel the pending timer and strand the entry. This cannot cost a listener the result: a `storage` event captures `newValue` at dispatch, so an opener that has been notified already holds it.

‎packages/core/api/src/oauth-popup.test.ts‎

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,106 @@ describe("popupDocument", () => {
149149
expect(script).toContain("channel\\u003c/script\\u003e");
150150
});
151151

152+
// The assertions below RUN the generated script against stub globals rather
153+
// than matching its source text. A string check would pass on a script that
154+
// never executes — and the property at stake here is what the browser is left
155+
// holding, which only running it can show.
156+
const runPopupScript = (html: string) => {
157+
const script = /<script>\n?([\s\S]*?)<\/script>/.exec(html)?.[1];
158+
expect(script).toBeDefined();
159+
const store = new Map<string, string>();
160+
const timers: { readonly fn: () => void; readonly ms: number }[] = [];
161+
const pagehideListeners: (() => void)[] = [];
162+
let closed = false;
163+
const win = {
164+
opener: null,
165+
location: { origin: "https://app.example" },
166+
close: () => {
167+
closed = true;
168+
},
169+
addEventListener: (type: string, listener: () => void) => {
170+
if (type === "pagehide") pagehideListeners.push(listener);
171+
},
172+
};
173+
const fn = new Function(
174+
"window",
175+
"localStorage",
176+
"setTimeout",
177+
"BroadcastChannel",
178+
script ?? "",
179+
) as (w: unknown, ls: unknown, st: unknown, bc: unknown) => void;
180+
fn(
181+
win,
182+
{
183+
setItem: (k: string, v: string) => store.set(k, v),
184+
removeItem: (k: string) => store.delete(k),
185+
},
186+
(cb: () => void, ms: number) => {
187+
timers.push({ fn: cb, ms });
188+
return timers.length;
189+
},
190+
undefined,
191+
);
192+
return {
193+
store,
194+
isClosed: () => closed,
195+
runTimers: () => {
196+
for (const t of [...timers]) t.fn();
197+
},
198+
// Simulates the document dying (user closes the window): pagehide fires,
199+
// pending timers never do.
200+
firePagehide: () => {
201+
for (const listener of [...pagehideListeners]) listener();
202+
},
203+
};
204+
};
205+
206+
it("clears the stored result after handing it over on success", () => {
207+
const html = popupDocument(successPayload, "chan-1");
208+
const run = runPopupScript(html);
209+
// Written first, so a listening opener gets its `storage` event.
210+
expect(run.store.get("chan-1")).toContain("session-abc");
211+
212+
run.runTimers();
213+
214+
// ...and not left in the profile afterwards. The payload carries an identity
215+
// label; nobody listening must not mean it sits there forever.
216+
expect(run.store.has("chan-1")).toBe(false);
217+
expect(run.isClosed()).toBe(true);
218+
});
219+
220+
it("clears the stored result on failure too, without closing the window", () => {
221+
const html = popupDocument(
222+
{ type: OAUTH_POPUP_MESSAGE_TYPE, ok: false, sessionId: null, error: "nope" },
223+
"chan-2",
224+
);
225+
const run = runPopupScript(html);
226+
expect(run.store.get("chan-2")).toContain("nope");
227+
228+
run.runTimers();
229+
230+
expect(run.store.has("chan-2")).toBe(false);
231+
// A failed flow keeps the window up so the user can read the error.
232+
expect(run.isClosed()).toBe(false);
233+
});
234+
235+
it("clears the stored result when the user closes the failure window before the timer", () => {
236+
const html = popupDocument(
237+
{ type: OAUTH_POPUP_MESSAGE_TYPE, ok: false, sessionId: null, error: "nope" },
238+
"chan-3",
239+
);
240+
const run = runPopupScript(html);
241+
expect(run.store.get("chan-3")).toContain("nope");
242+
243+
// The failure page never auto-closes; the user reads the error and closes
244+
// the window before the 5s timer fires. The document dies — pending timers
245+
// never run — so pagehide is the only thing standing between the payload
246+
// and living in the browser profile forever.
247+
run.firePagehide();
248+
249+
expect(run.store.has("chan-3")).toBe(false);
250+
});
251+
152252
it("posts to window.opener AND falls back to BroadcastChannel with the given channel name", () => {
153253
const html = popupDocument(successPayload, "executor:openapi-oauth-result");
154254
expect(html).toContain("window.opener.postMessage(p,window.location.origin)");

‎packages/core/api/src/oauth-popup.ts‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,18 @@ ${detailsHtml}
119119
try{if(window.opener)window.opener.postMessage(p,window.location.origin)}catch(e){}
120120
try{if("BroadcastChannel"in window){const c=new BroadcastChannel(${serializedChannel});c.postMessage(p);setTimeout(()=>c.close(),100)}}catch(e){}
121121
try{localStorage.setItem(${serializedChannel},JSON.stringify(p))}catch(e){}
122-
if(p.ok)setTimeout(()=>window.close(),400);})();
122+
// The payload carries the identity label — an email — and, on failure, the
123+
// error preview, so it must not outlive the handover. Clearing it cannot cost a
124+
// listener the result: a 'storage' event captures newValue at dispatch, so an
125+
// opener that has been notified already holds it. Leaving it would park that
126+
// data in the user's browser profile indefinitely whenever nobody is listening,
127+
// which is every abandoned or opener-less flow. pagehide backs the timers up:
128+
// the failure page never auto-closes (the user must be able to read the error),
129+
// and closing it kills any pending timer — without pagehide the entry would
130+
// outlive the document after all.
131+
const clear=()=>{try{localStorage.removeItem(${serializedChannel})}catch(e){}};
132+
window.addEventListener("pagehide",clear);
133+
if(p.ok)setTimeout(()=>{clear();window.close()},400);else setTimeout(clear,5000);})();
123134
</script>
124135
</body></html>`;
125136
};

0 commit comments

Comments
 (0)