Fix OAuth popup flow for COOP-enforcing providers#1996
Conversation
Microsoft now enforces Cross-Origin-Opener-Policy on its login pages, which severs the popup handle: closed reads true mid-login and window.opener is nulled, so the response never reached the dashboard and re-auth always failed with "Pop-up was closed before completing authentication". * Sending the response over a BroadcastChannel as well as the opener postMessage, and only processing the first delivery * Verifying state on the opener side since COOP can drop the popup's copy of sessionStorage * Treating a closed reading as a grace timer instead of an instant error - a blocked popup (null handle) still errors right away * Closing the popup from its own side since a severed opener cannot * Keeping the OauthWindowOpenerMissing dialog, now shown only when the result is genuinely undeliverable (no opener and no BroadcastChannel)
There was a problem hiding this comment.
I haven't tested this myself yet, but I (and Claude) looked over the changes and found a couple things mostly around the OAuth popup lifecycle. They aren't massive blockers, but would be nice to properly handle things like navigating away from the OAuth button in the main window, closing the popup not hanging the main window, multiple dashboard windows stepping on eachother's toes, etc.
| if (popupRef.current.window.closed && !timeoutRef.current) { | ||
| // Stop polling: every read of a COOP-severed handle logs a | ||
| // console warning, and closed will never read false again. | ||
| clearInterval(intervalRef.current); | ||
| removeState(); | ||
| window.removeEventListener( | ||
| MESSAGE_KEY, | ||
| handleMessageListener | ||
| ); | ||
|
|
||
| timeoutRef.current = setTimeout(async () => { | ||
| setLoading(false); | ||
| await onError( | ||
| 'We did not receive a response from the authentication pop-up. If you closed it before finishing, or the provider could not complete your login, please try authenticating again.' | ||
| ); | ||
| cleanup(); | ||
| }, POPUP_RESULT_TIMEOUT); | ||
| } |
There was a problem hiding this comment.
Closing the popup to cancel gets loading=true for the full 180s: the FullPageSpinner backdrop covers the form, the Authenticate button is disabled, and useBeforeUnload(loading) blocks navigation with a confirm dialog, with no cancel path. The poller now responds to closed by arming the POPUP_RESULT_TIMEOUT grace timer rather than failing fast, for every provider, including non-COOP ones like Google where closed is still a reliable signal. Options: a cancel affordance, a shorter timeout, or only entering grace mode when closed reads true implausibly soon after open (the COOP signature; a real user close mid-login happens many seconds in).
| try { | ||
| channelRef.current = new BroadcastChannel( | ||
| OAUTH_BROADCAST_CHANNEL | ||
| ); | ||
| channelRef.current.onmessage = handleMessageListener; | ||
| } catch { | ||
| // No BroadcastChannel support - the opener path below can | ||
| // still deliver for providers that do not enforce COOP | ||
| channelRef.current = null; | ||
| } |
There was a problem hiding this comment.
Every popup broadcasts on the single OAUTH_BROADCAST_CHANNEL name, so each tab's getAuth listener receives the other tab's result: it sets responseHandled (so the tab's own result is ignored when it arrives), fails the state check, shows OAuth error: State mismatch., and cleanup() closes its own popup while the user is still logging in. Error results are worse: message.data?.error is handled before any state check, so a denied consent in one tab aborts every in-flight flow on the origin. Fix: key the channel name by state (e.g. `${OAUTH_BROADCAST_CHANNEL}.${state}`, with the popup deriving it from the state it received back), scoping delivery to the attempt.
| return () => { | ||
| window.removeEventListener(MESSAGE_KEY, handleMessageListener); | ||
| listenerRef.current = null; | ||
| if (intervalRef.current) clearInterval(intervalRef.current); | ||
| if (timeoutRef.current) clearTimeout(timeoutRef.current); | ||
| channelRef.current?.close(); | ||
| channelRef.current = null; | ||
| }; |
There was a problem hiding this comment.
getAuth returns a teardown function, but useOauthHandler.openPopUp discards it and the hook has no useEffect teardown, so unmounting the connector config form mid-flow (navigating back, switching routes) leaks everything getAuth set up: the popup.closed poller keeps polling, the message listener and BroadcastChannel stay subscribed, and the popup stays open with no owner.
If the user then finishes logging in inside that popup, the leaked listener still runs the token exchange and successHandler for a form that no longer exists. The teardown logic is also hand-copied in three places (the init reset, cleanup(), the discarded return) and has drifted: the init reset doesn't closePopup() before overwriting popupRef.current, so re-clicking Authenticate orphans the first window with no handle to close it. Fix: one teardown function stored in a ref, invoked from the init path, from cleanup(), and from a useEffect(() => () => teardownRef.current?.(), []).
Issues
N/A
Changes
OAuth popup broken for Microsoft logins
Microsoft now sends
Cross-Origin-Opener-Policy: same-originon its login pages (February 2026 Microsoft Q&A, and Microsoft has acknowledged the behavior there but not published a changelog entry). Once the popup hits their login page,popup.closedreadstruewhile the window is still open andwindow.openeris permanently null, so the auth result could never reach the dashboard. Every Microsoft re-auth failed with "Pop-up was closed before completing authentication"./oauthnow sends the result over aBroadcastChannelas well aswindow.opener.postMessage, and the hook only processes the first deliverystateitself, since COOP can drop the popup's copy ofsessionStorage(the popup-side check andOAUTH_STATE_KEYare gone)OauthWindowOpenerMissingdialog, now shown only when the result is genuinely undeliverable (no opener and noBroadcastChannel)Tests
Manually tested
Automated tests
Playwright tests ran locally
Screenshots
N/A. The only visible change is a short "Authentication finished. You can close this window." note if the popup cannot close itself.