Skip to content

Fix OAuth popup flow for COOP-enforcing providers#1996

Open
SeanWhelan wants to merge 1 commit into
mainfrom
sean/fix-oauth-popup-coop
Open

Fix OAuth popup flow for COOP-enforcing providers#1996
SeanWhelan wants to merge 1 commit into
mainfrom
sean/fix-oauth-popup-coop

Conversation

@SeanWhelan

@SeanWhelan SeanWhelan commented Jun 11, 2026

Copy link
Copy Markdown

Issues

N/A

Changes

OAuth popup broken for Microsoft logins

Microsoft now sends Cross-Origin-Opener-Policy: same-origin on 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.closed reads true while the window is still open and window.opener is permanently null, so the auth result could never reach the dashboard. Every Microsoft re-auth failed with "Pop-up was closed before completing authentication".

  • /oauth now sends the result over a BroadcastChannel as well as window.opener.postMessage, and the hook only processes the first delivery
  • The hook verifies OAuth state itself, since COOP can drop the popup's copy of sessionStorage (the popup-side check and OAUTH_STATE_KEY are gone)
  • A closed reading from the poller starts a 3 minute grace timer instead of erroring right away. We cannot tell a severed handle from a closed window, so the user closing the popup mid-login now errors at the timer instead of instantly. A blocked popup (null handle) still errors immediately
  • The popup closes itself after reporting, since a severed opener cannot close it, and shows a "you can close this window" note if the browser refuses
  • Kept the OauthWindowOpenerMissing dialog, now shown only when the result is genuinely undeliverable (no opener and no BroadcastChannel)

Tests

Manually tested

  • Reproduced the original failure in prod against Bing Ads (popup opens, closes, "Pop-up was closed" error, COOP warnings in console)
  • Not yet manually verified against live Microsoft enforcement, since they flight it per session.

Automated tests

  • New unit tests for the hook: channel-only delivery (severed opener), double-delivery dedupe, state mismatch rejection, blocked popup fails fast, grace timer expiry, response during grace period, retry detaching the stale listener

Playwright tests ran locally

  • Admin
  • Captures
  • Collections
  • HomePage
  • Login
  • Materialization

Screenshots

N/A. The only visible change is a short "Authentication finished. You can close this window." note if the popup cannot close itself.

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)
@SeanWhelan SeanWhelan requested a review from a team as a code owner June 11, 2026 10:37

@jshearer jshearer left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +165 to 177
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);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Comment on lines +136 to +145
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;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines 181 to 188
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;
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?.(), []).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants