Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/oauth-popup-localstorage-residue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"executor": patch
---

**The OAuth popup clears its result out of `localStorage` after handing it over**

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.

The entry is now cleared once the handover has had time to land. This cannot cost a listener the result: a `storage` event captures `newValue` at dispatch, so an opener that has been notified already holds it.
74 changes: 74 additions & 0 deletions packages/core/api/src/oauth-popup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,80 @@ describe("popupDocument", () => {
expect(script).toContain("channel\\u003c/script\\u003e");
});

// The assertions below RUN the generated script against stub globals rather
// than matching its source text. A string check would pass on a script that
// never executes — and the property at stake here is what the browser is left
// holding, which only running it can show.
const runPopupScript = (html: string) => {
const script = /<script>\n?([\s\S]*?)<\/script>/.exec(html)?.[1];
expect(script).toBeDefined();
const store = new Map<string, string>();
const timers: { readonly fn: () => void; readonly ms: number }[] = [];
let closed = false;
const win = {
opener: null,
location: { origin: "https://app.example" },
close: () => {
closed = true;
},
};
const fn = new Function(
"window",
"localStorage",
"setTimeout",
"BroadcastChannel",
script ?? "",
) as (w: unknown, ls: unknown, st: unknown, bc: unknown) => void;
fn(
win,
{
setItem: (k: string, v: string) => store.set(k, v),
removeItem: (k: string) => store.delete(k),
},
(cb: () => void, ms: number) => {
timers.push({ fn: cb, ms });
return timers.length;
},
undefined,
);
return {
store,
isClosed: () => closed,
runTimers: () => {
for (const t of [...timers]) t.fn();
},
};
};

it("clears the stored result after handing it over on success", () => {
const html = popupDocument(successPayload, "chan-1");
const run = runPopupScript(html);
// Written first, so a listening opener gets its `storage` event.
expect(run.store.get("chan-1")).toContain("session-abc");

run.runTimers();

// ...and not left in the profile afterwards. The payload carries an identity
// label; nobody listening must not mean it sits there forever.
expect(run.store.has("chan-1")).toBe(false);
expect(run.isClosed()).toBe(true);
});

it("clears the stored result on failure too, without closing the window", () => {
const html = popupDocument(
{ type: OAUTH_POPUP_MESSAGE_TYPE, ok: false, sessionId: null, error: "nope" },
"chan-2",
);
const run = runPopupScript(html);
expect(run.store.get("chan-2")).toContain("nope");

run.runTimers();

expect(run.store.has("chan-2")).toBe(false);
// A failed flow keeps the window up so the user can read the error.
expect(run.isClosed()).toBe(false);
});

it("posts to window.opener AND falls back to BroadcastChannel with the given channel name", () => {
const html = popupDocument(successPayload, "executor:openapi-oauth-result");
expect(html).toContain("window.opener.postMessage(p,window.location.origin)");
Expand Down
9 changes: 8 additions & 1 deletion packages/core/api/src/oauth-popup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,14 @@ ${detailsHtml}
try{if(window.opener)window.opener.postMessage(p,window.location.origin)}catch(e){}
try{if("BroadcastChannel"in window){const c=new BroadcastChannel(${serializedChannel});c.postMessage(p);setTimeout(()=>c.close(),100)}}catch(e){}
try{localStorage.setItem(${serializedChannel},JSON.stringify(p))}catch(e){}
if(p.ok)setTimeout(()=>window.close(),400);})();
// The payload carries the identity label — an email — and, on failure, the
// error preview, so it must not outlive the handover. Clearing it cannot cost a
// listener the result: a 'storage' event captures newValue at dispatch, so an
// opener that has been notified already holds it. Leaving it would park that
// data in the user's browser profile indefinitely whenever nobody is listening,
// which is every abandoned or opener-less flow.
const clear=()=>{try{localStorage.removeItem(${serializedChannel})}catch(e){}};
if(p.ok)setTimeout(()=>{clear();window.close()},400);else setTimeout(clear,5000);})();
</script>
</body></html>`;
};
Expand Down