Conversation
…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
|
@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 |
There was a problem hiding this comment.
This appears to have different semantics than the if we had before, of d.resolve(reauth(...)) or d.result(null)
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
Does this code set up three methods in all circumstances? If so, does cleanup actually clean them all up?
There was a problem hiding this comment.
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.
src/web/close.html
Outdated
| // This is the fallback for environments like GoGuardian that inject COOP headers | ||
| if (typeof BroadcastChannel !== 'undefined') { | ||
| try { | ||
| var channel = new BroadcastChannel('pyret_auth'); |
There was a problem hiding this comment.
This channel is nicely scoped and goes away cleanly; the other channel doesn't seem to.
There was a problem hiding this comment.
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
|
I think I addressed all! |
|
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. |
|
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. |
|
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). |
…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
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
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
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.
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:
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