Skip to content

[ai-assisted] Work around COOP issues with BroadcastChannel and local…#616

Merged
jpolitz merged 2 commits intohorizonfrom
coop-fix
Feb 2, 2026
Merged

[ai-assisted] Work around COOP issues with BroadcastChannel and local…#616
jpolitz merged 2 commits intohorizonfrom
coop-fix

Conversation

@jpolitz
Copy link
Member

@jpolitz jpolitz commented Jan 30, 2026

…Storage polling

@schanzer and I were on a call with a school that was getting bad login behavior (blank index page after successfully entering Google credentials, blank definitions area when logged in [but shared files would load; Google connection the issue]). It seems like GoGuardian is to blame.

In their dev tools we saw “Cross-Origin-Opener-Policy would block the window.opener call” in the gapi library. My guess/investigation is that some of the communication between the login popup and the home page was failing. I was able to partially reproduce this myself by setting Cross-Origin-Opener-Policy in Chrome devtools and by explicitly adding this to server.js:

app.use(function(req, res, next) {
res.setHeader("Cross-Origin-Opener-Policy", "noopener-allow-popups");
next();
});

(“Partially” because I got a spinning “waiting for login” with the failure rather than blank, but I'm making a [big?] assumption that these issues are similar enough that this is a good idea.)

I think the change here is a strict improvement anyway and could get around some annoying same-origin issues.

This says [ai-assisted] because I asked Claude for help, transcript here:

❯ OK here's a debugging issue. We have a school with GoGuardian installed trying to use Pyret (in
particular the signin from the index dashboard page in code.pyret.org/src/web/). The login popup
opens, Google Login apparently succeeds. However, the main page shows as blank. From debugging
(over Zoom, with a non-expert tech person navigating dev tools), it looks like something (I'm
tempted to blame GoGuardian), may be inserting Cross-Origin-Opener-Polic headers. We see the
error message "Cross-Origin-Opener-Policy would block the window.opener call", pointing to a
line number inside the Google Client library.

I have a few ideas, but I'd like to hear more and have mine evaluated

  • Try to do something other than window.opener, like setting something in localStorage and polling for it
  • Learn more about what GoGuardian is doing (this has been really hard to do). Is there a way to install GoGuardian in a test mode on a dev machine without installing it in some institution-wide way?
  • Try to use a different Google API for login – this is a bit fraught because we have 10s of thousands of existing users who are all happily logging in, so I'm very wary of breaking something else

I've been able to reproduce the same console error message by overriding headers with
Cross-Origin-Opener-Policy: noopener-allow-popups in Chrome devtools on the live
https://code.pyret.org site. However, the login apparently succeeds when I do that (I'm not sure
I'm exactly reproducing everything GoGuardian does).

I'd welcome ideas, successful searches of similar issues, feedback on my ideas, and so on

⏺ This is a well-documented class of problem. Let me do some research on both the current Pyret
implementation and known solutions for COOP + Google Sign-In conflicts.

⏺ 2 agents finished (ctrl+o to expand)
├─ Task (Research COOP Google Sign-In issues) · 12 tool uses · 22.6k tokens
│ ⎿  Done
└─ Explore (Explore Pyret login implementation) · 33 tool uses · 57.1k tokens
⎿  Done

⏺ This is very helpful research. Let me synthesize the findings and evaluate your ideas.

Current Implementation Analysis

Your auth flow in code.pyret.org/src/web/js/google-apis/api-wrapper.js:47-77 uses a popup that
communicates via postMessage:

// close.html does this:
window.opener.postMessage("done", document.location.origin);
window.close();

This is the exact line that breaks when COOP headers sever window.opener.


Evaluation of Your Ideas

  1. localStorage + polling (or BroadcastChannel)

Verdict: Good practical workaround

This is the right direction. BroadcastChannel is even better than polling:

// In close.html (popup)
localStorage.setItem('pyret_auth_complete', Date.now());
const channel = new BroadcastChannel('pyret_auth');
channel.postMessage({ type: 'auth_complete' });
window.close();

// In api-wrapper.js (opener)
const channel = new BroadcastChannel('pyret_auth');
channel.onmessage = (e) => {
if (e.data.type === 'auth_complete') {
resolve(reauth(true, useFullScopes));
}
};
// Fallback: also listen for storage event
window.addEventListener('storage', (e) => {
if (e.key === 'pyret_auth_complete') { ... }
});

Pros: Works regardless of COOP, same-origin so no security issues, minimal code change
Cons: Need to handle cleanup, slightly different timing characteristics

  1. Learn more about GoGuardian

Verdict: Low ROI

GoGuardian runs as a Chrome extension + proxy in managed Chromebook environments. You can't easily
install it for testing without institutional management. Their release notes mention they've been
working on header handling issues, but you can't control what they do.

Better approach: Your DevTools header override is actually a reasonable approximation for testing.
You could also test with a simple extension that injects COOP headers.

  1. Different Google API

Verdict: Actually the best long-term solution

You're currently using the deprecated gapi client library (apis.google.com/js/client.js). Google is
actively migrating to Google Identity Services (GIS) with FedCM support.

Key insight from research: GIS with FedCM enabled renders authentication UI directly in the
browser—no popup communication needed at all. This completely sidesteps the COOP problem.

Google's migration timeline: April 2025 onward, GIS is auto-migrating to FedCM.


Recommended Approach (Layered)

Short-term fix (days): BroadcastChannel fallback

Modify close.html and api-wrapper.js to use BroadcastChannel with postMessage as fallback:

// close.html - try both methods
if (window.opener) {
window.opener.postMessage("done", document.location.origin);
}
// Always also use BroadcastChannel (works even if opener is null)
const channel = new BroadcastChannel('pyret_auth');
channel.postMessage({ type: 'done' });
channel.close();
window.close();

Medium-term (weeks): Migrate to Google Identity Services

This is worth doing anyway since gapi is deprecated. GIS has specific COOP guidance:

  • With FedCM enabled: No COOP changes needed
  • Without FedCM: Requires Cross-Origin-Opener-Policy: same-origin-allow-popups

Also consider: Redirect flow option

For problematic environments, offer a redirect-based auth flow instead of popup. Your server-side
OAuth (/login, /oauth/callback) already supports this—you'd just change the redirect parameter to
return to the editor instead of close.html.


Testing GoGuardian-like behavior

Create a simple test extension:

// manifest.json
{
"manifest_version": 3, "name": "COOP Test", "version": "1.0", "permissions": ["declarativeNetRequest"], "declarative_net_request": { "rule_resources": [{ "id": "rules", "enabled": true, "path": "rules.json" }] } }

// rules.json
[{
"id": 1, "priority": 1, "action": { "type": "modifyHeaders", "responseHeaders": [{ "header": "Cross-Origin-Opener-Policy", "operation": "set", "value": "same-origin" }] }, "condition": { "urlFilter": "://code.pyret.org/", "resourceTypes": ["main_frame"] } }]

This will let you reliably reproduce the issue locally.


Key Files to Modify
File: code.pyret.org/src/web/close.html
Change: Add BroadcastChannel alongside postMessage
────────────────────────────────────────
File: code.pyret.org/src/web/js/google-apis/api-wrapper.js:47-77
Change: Listen on BroadcastChannel in reauth()
────────────────────────────────────────
File: code.pyret.org/src/web/editor.html
Change: Eventually: migrate from gapi to GIS
Want me to draft the BroadcastChannel changes as a concrete starting point?

✻ Crunched for 3m 10s

❯ yes

…Storage polling

@schanzer and I were on a call with a school that was getting bad login
behavior (blank index page after successfully entering Google credentials,
blank definitions area when logged in [but shared files would load; Google
connection the issue]). It seems like GoGuardian is to blame.

In their dev tools we saw “Cross-Origin-Opener-Policy would block the
window.opener call” in the gapi library. My guess/investigation is that some
of the communication between the login popup and the home page was failing. I
was able to partially reproduce this myself by setting
Cross-Origin-Opener-Policy in Chrome devtools and by explicitly adding this to
server.js:

  app.use(function(req, res, next) {
    res.setHeader("Cross-Origin-Opener-Policy", "noopener-allow-popups");
    next();
  });

(“Partially” because I got a spinning “waiting for login” with the failure
rather than blank, but I'm making a [big?] assumption that these issues are
similar enough that this is a good idea.)

I think the change here is a strict improvement anyway and could get around
some annoying same-origin issues.

This says [ai-assisted] because I asked Claude for help, transcript here:

❯ OK here's a debugging issue. We have a school with GoGuardian installed trying to use Pyret (in
  particular the signin from the index dashboard page in code.pyret.org/src/web/). The login popup
  opens, Google Login apparently succeeds. However, the main page shows as blank. From debugging
  (over Zoom, with a non-expert tech person navigating dev tools), it looks like something (I'm
  tempted to blame GoGuardian), may be inserting Cross-Origin-Opener-Polic headers. We see the
  error message "Cross-Origin-Opener-Policy would block the window.opener call", pointing to a
  line number inside the Google Client library.

  I have a few ideas, but I'd like to hear more and have mine evaluated

  - Try to do something other than window.opener, like setting something in localStorage and
  polling for it
  - Learn more about what GoGuardian is doing (this has been really hard to do). Is there a way to
  install GoGuardian in a test mode on a dev machine without installing it in some
  institution-wide way?
  - Try to use a different Google API for login – this is a bit fraught because we have 10s of
  thousands of existing users who are all happily logging in, so I'm very wary of breaking
  something else

  I've been able to reproduce the same console error message by overriding headers with
  Cross-Origin-Opener-Policy: noopener-allow-popups in Chrome devtools on the live
  https://code.pyret.org site. However, the login apparently succeeds when I do that (I'm not sure
  I'm exactly reproducing everything GoGuardian does).

  I'd welcome ideas, successful searches of similar issues, feedback on my ideas, and so on

⏺ This is a well-documented class of problem. Let me do some research on both the current Pyret
  implementation and known solutions for COOP + Google Sign-In conflicts.

⏺ 2 agents finished (ctrl+o to expand)
   ├─ Task (Research COOP Google Sign-In issues) · 12 tool uses · 22.6k tokens
   │  ⎿  Done
   └─ Explore (Explore Pyret login implementation) · 33 tool uses · 57.1k tokens
      ⎿  Done

⏺ This is very helpful research. Let me synthesize the findings and evaluate your ideas.

  Current Implementation Analysis

  Your auth flow in code.pyret.org/src/web/js/google-apis/api-wrapper.js:47-77 uses a popup that
  communicates via postMessage:

  // close.html does this:
  window.opener.postMessage("done", document.location.origin);
  window.close();

  This is the exact line that breaks when COOP headers sever window.opener.

  ---
  Evaluation of Your Ideas

  1. localStorage + polling (or BroadcastChannel)

  Verdict: Good practical workaround

  This is the right direction. BroadcastChannel is even better than polling:

  // In close.html (popup)
  localStorage.setItem('pyret_auth_complete', Date.now());
  const channel = new BroadcastChannel('pyret_auth');
  channel.postMessage({ type: 'auth_complete' });
  window.close();

  // In api-wrapper.js (opener)
  const channel = new BroadcastChannel('pyret_auth');
  channel.onmessage = (e) => {
    if (e.data.type === 'auth_complete') {
      resolve(reauth(true, useFullScopes));
    }
  };
  // Fallback: also listen for storage event
  window.addEventListener('storage', (e) => {
    if (e.key === 'pyret_auth_complete') { ... }
  });

  Pros: Works regardless of COOP, same-origin so no security issues, minimal code change
  Cons: Need to handle cleanup, slightly different timing characteristics

  2. Learn more about GoGuardian

  Verdict: Low ROI

  GoGuardian runs as a Chrome extension + proxy in managed Chromebook environments. You can't easily
  install it for testing without institutional management. Their release notes mention they've been
  working on header handling issues, but you can't control what they do.

  Better approach: Your DevTools header override is actually a reasonable approximation for testing.
  You could also test with a simple extension that injects COOP headers.

  3. Different Google API

  Verdict: Actually the best long-term solution

  You're currently using the deprecated gapi client library (apis.google.com/js/client.js). Google is
  actively migrating to Google Identity Services (GIS) with FedCM support.

  Key insight from research: GIS with FedCM enabled renders authentication UI directly in the
  browser—no popup communication needed at all. This completely sidesteps the COOP problem.

  Google's migration timeline: April 2025 onward, GIS is auto-migrating to FedCM.

  ---
  Recommended Approach (Layered)

  Short-term fix (days): BroadcastChannel fallback

  Modify close.html and api-wrapper.js to use BroadcastChannel with postMessage as fallback:

  // close.html - try both methods
  if (window.opener) {
    window.opener.postMessage("done", document.location.origin);
  }
  // Always also use BroadcastChannel (works even if opener is null)
  const channel = new BroadcastChannel('pyret_auth');
  channel.postMessage({ type: 'done' });
  channel.close();
  window.close();

  Medium-term (weeks): Migrate to Google Identity Services

  This is worth doing anyway since gapi is deprecated. GIS has specific COOP guidance:
  - With FedCM enabled: No COOP changes needed
  - Without FedCM: Requires Cross-Origin-Opener-Policy: same-origin-allow-popups

  Also consider: Redirect flow option

  For problematic environments, offer a redirect-based auth flow instead of popup. Your server-side
  OAuth (/login, /oauth/callback) already supports this—you'd just change the redirect parameter to
  return to the editor instead of close.html.

  ---
  Testing GoGuardian-like behavior

  Create a simple test extension:

  // manifest.json
  {
    "manifest_version": 3,
    "name": "COOP Test",
    "version": "1.0",
    "permissions": ["declarativeNetRequest"],
    "declarative_net_request": {
      "rule_resources": [{
        "id": "rules",
        "enabled": true,
        "path": "rules.json"
      }]
    }
  }

  // rules.json
  [{
    "id": 1,
    "priority": 1,
    "action": {
      "type": "modifyHeaders",
      "responseHeaders": [{
        "header": "Cross-Origin-Opener-Policy",
        "operation": "set",
        "value": "same-origin"
      }]
    },
    "condition": {
      "urlFilter": "*://code.pyret.org/*",
      "resourceTypes": ["main_frame"]
    }
  }]

  This will let you reliably reproduce the issue locally.

  ---
  Key Files to Modify
  File: code.pyret.org/src/web/close.html
  Change: Add BroadcastChannel alongside postMessage
  ────────────────────────────────────────
  File: code.pyret.org/src/web/js/google-apis/api-wrapper.js:47-77
  Change: Listen on BroadcastChannel in reauth()
  ────────────────────────────────────────
  File: code.pyret.org/src/web/editor.html
  Change: Eventually: migrate from gapi to GIS
  Want me to draft the BroadcastChannel changes as a concrete starting point?

✻ Crunched for 3m 10s

❯ yes
@jpolitz
Copy link
Member Author

jpolitz commented Jan 30, 2026

@blerner I had AI generate some slop for us if you want to have a look.

(I actually think this is a real fix to a real issue, to be clear)

// Need to do a login to get a cookie for this user; do it in a popup
window.addEventListener('message', function(e) {

// Track whether we've already resolved to avoid double-resolution
Copy link
Member

Choose a reason for hiding this comment

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

This appears to have different semantics than the if we had before, of d.resolve(reauth(...)) or d.result(null)

Copy link
Member Author

Choose a reason for hiding this comment

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

Yeah, it's true. But the resolving-to-null case essentially never happened (that would happen if someone managed to postMessage to the page from some non-Pyret origin).

And resolving to null now makes less sense because the page is waiting for any of the mechanisms to send the response back.

A separate cancellable timer that resolves null would be the right thing. I'd be happy to add that in a separate pass (we don't have anything like that right now)

}

// Cleanup function to remove all listeners
var channel = null;
Copy link
Member

Choose a reason for hiding this comment

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

This cleanup function doesn't fully cleanup the channel (setting it back to null)

// Clear any stale auth flag before opening popup
try { localStorage.removeItem('pyret_auth_complete'); } catch (e) {}

// Need to do a login to get a cookie for this user; do it in a popup
Copy link
Member

Choose a reason for hiding this comment

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

Does this code set up three methods in all circumstances? If so, does cleanup actually clean them all up?

Copy link
Member Author

Choose a reason for hiding this comment

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

Nearly!

You were right that it needed to set channel to null. It also ought to remove the localStorage key. I'll make those changes.

// This is the fallback for environments like GoGuardian that inject COOP headers
if (typeof BroadcastChannel !== 'undefined') {
try {
var channel = new BroadcastChannel('pyret_auth');
Copy link
Member

Choose a reason for hiding this comment

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

This channel is nicely scoped and goes away cleanly; the other channel doesn't seem to.

Copy link
Member Author

Choose a reason for hiding this comment

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

Well, it's not strict mode or using let so I think this is a global actually? I'll change to let

- Reorder removing a key with setting a handler to avoid a spurious event
- Make sure cleanup clears localStorage and nulls out the channel
- Add some useful debugging info we can look for in console.logs

I tested this by commenting out cleanup and checking that all 3 modes triggered.

Then I added

+++ b/src/server.js
@@ -45,6 +45,11 @@ function start(config, onServerReady) {
   app = express();
   app.use(bodyParser.urlencoded({ extended: false }))
   app.use(bodyParser.json())
+  app.use(function(req, res, next) {
+    res.setHeader("Cross-Origin-Opener-Policy", "noopener-allow-popups");
+    next();
+  });
+

And verified that the postMessage failed, but the other two mechanisms succeed
@jpolitz
Copy link
Member Author

jpolitz commented Jan 31, 2026

I think I addressed all!

@blerner
Copy link
Member

blerner commented Jan 31, 2026

Does it make sense to replace the Q.defer() with Promise.withResolvers(), so that we don't have to even worry about the double-resolve case? Or do you want to track double-resolves so that we can track any logical errors?

@jpolitz
Copy link
Member Author

jpolitz commented Feb 2, 2026

Does it make sense to replace the Q.defer() with Promise.withResolvers(), so that we don't have to even worry about the double-resolve case? Or do you want to track double-resolves so that we can track any logical errors?

I don't want to grow the commit here to worry about promise library versions. I do think it's useful to log which of these come in, regardless of promise tech.

@jpolitz jpolitz merged commit e9c5429 into horizon Feb 2, 2026
2 checks passed
@blerner
Copy link
Member

blerner commented Feb 2, 2026

Fair, though Promise.withResolvers would actually shrink the library usage, since it's built in and broadly available; that's the main reason I suggested it. It's not critical, though.

@jpolitz
Copy link
Member Author

jpolitz commented Feb 2, 2026

Not arguing that – it's just Q is used in other places in the codebase. So it's a whole thing to remove it (e.g. I don't know that the clients of this library don't rely on something Q related, or some method named one way in Q with a different eventual Promise method name).

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