Skip to content

Commit e978eef

Browse files
geneotechCopilot
andcommitted
Fix Discord OAuth in iframed game by removing COOP from redirect page
Root cause: discord_redirect.html was served with Cross-Origin-Opener-Policy: same-origin (from both nginx and coi-serviceworker.js). When the OAuth popup navigates from Discord (COOP: unsafe-none) back to the redirect page (COOP: same-origin), the COOP mismatch triggers a browsing context group switch that permanently severs window.opener. Since BroadcastChannel is also partitioned by top-level site, neither communication mechanism could deliver the token. Fix: Remove COOP from discord_redirect.html specifically. It doesn't need SharedArrayBuffer, so it doesn't need cross-origin isolation. With COOP: unsafe-none, window.opener survives the Discord->redirect navigation chain and postMessage works. Changes: - coi-serviceworker.js: Delete COOP header for discord_redirect.html navigations - nginx config: Add specific location block without COOP for the redirect page - discord_redirect.html: Add localStorage write as additional fallback - common.js: Add requestStorageAccess + localStorage polling as last-resort fallback (covers edge case where Discord itself sets COOP), plus dedup guard to prevent double-handling when multiple mechanisms succeed Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 16ae788 commit e978eef

File tree

4 files changed

+111
-8
lines changed

4 files changed

+111
-8
lines changed

cmake/web/assets/coi-serviceworker.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,14 @@ self.addEventListener("fetch", function (e) {
1010
if (r.status === 0) return r;
1111
const headers = new Headers(r.headers);
1212
if (e.request.mode === "navigate") {
13-
headers.set("Cross-Origin-Opener-Policy", "same-origin");
13+
const url = new URL(e.request.url);
14+
if (url.pathname.endsWith('/discord_redirect.html')) {
15+
// OAuth redirect page must NOT have COOP so window.opener survives
16+
// the cross-origin navigation chain (game iframe -> Discord -> redirect)
17+
headers.delete("Cross-Origin-Opener-Policy");
18+
} else {
19+
headers.set("Cross-Origin-Opener-Policy", "same-origin");
20+
}
1421
}
1522
headers.set("Cross-Origin-Embedder-Policy", "credentialless");
1623
headers.set("Cross-Origin-Resource-Policy", "cross-origin");

cmake/web/assets/common.js

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -426,15 +426,65 @@ function revokeDiscord(accessToken) {
426426
}
427427

428428
function loginDiscord() {
429-
const site_origin = window.location.origin; // Dynamically get the origin of the current site
429+
const site_origin = window.location.origin;
430430
const redirect = site_origin + '/assets/discord_redirect.html';
431431
const redirectUri = encodeURIComponent(redirect);
432432

433433
const scope = 'identify';
434434
const authUrl = `https://discord.com/oauth2/authorize?response_type=token&client_id=${clientIdDiscord}&redirect_uri=${redirectUri}&scope=${scope}`;
435435

436+
// Request unpartitioned storage access (for iframe scenarios where
437+
// localStorage polling is needed as a last-resort fallback).
438+
// Must be called within user gesture, before window.open.
439+
if (document.requestStorageAccess) {
440+
document.requestStorageAccess().then(() => {
441+
console.log("loginDiscord: storage access granted");
442+
}).catch(() => {});
443+
}
444+
436445
try {
437-
window.open(authUrl, '_blank');
446+
const popup = window.open(authUrl, '_blank');
447+
448+
// Poll localStorage as a last-resort fallback for when both
449+
// BroadcastChannel and postMessage fail (e.g. Discord itself sets COOP)
450+
if (popup) {
451+
const pollInterval = setInterval(() => {
452+
try {
453+
const result = localStorage.getItem('discord_auth_result');
454+
if (result) {
455+
const data = JSON.parse(result);
456+
if (data && data.access_token) {
457+
clearInterval(pollInterval);
458+
localStorage.removeItem('discord_auth_result');
459+
fetchUserProfile(data.access_token, data.expires_in);
460+
try { popup.close(); } catch (e) {}
461+
}
462+
}
463+
} catch (e) {}
464+
465+
try {
466+
if (popup.closed) {
467+
// Final check after popup closes
468+
setTimeout(() => {
469+
try {
470+
const result = localStorage.getItem('discord_auth_result');
471+
if (result) {
472+
const data = JSON.parse(result);
473+
if (data && data.access_token) {
474+
localStorage.removeItem('discord_auth_result');
475+
fetchUserProfile(data.access_token, data.expires_in);
476+
}
477+
}
478+
} catch (e) {}
479+
}, 500);
480+
clearInterval(pollInterval);
481+
}
482+
} catch (e) {}
483+
}, 1000);
484+
485+
// Stop polling after 10 minutes
486+
setTimeout(() => clearInterval(pollInterval), 600000);
487+
}
438488
} catch (e) {
439489
console.warn("Could not open Discord login (may be blocked by iframe restrictions or popup blocker):", e);
440490
}
@@ -582,12 +632,22 @@ function create_module(for_cg) {
582632
Module['preRun'].push(pre_run);
583633

584634
const channel = new BroadcastChannel('token_bridge');
635+
let discordAuthHandled = false;
585636

586637
function handleDiscordAuth(data) {
638+
if (discordAuthHandled) return;
639+
discordAuthHandled = true;
640+
587641
console.log('Expires in:', data.expires_in);
588642
console.log('Token type:', data.token_type);
589643

644+
// Clean up localStorage fallback token if present
645+
try { localStorage.removeItem('discord_auth_result'); } catch (e) {}
646+
590647
fetchUserProfile(data.access_token, data.expires_in);
648+
649+
// Reset after a delay so user can re-login later
650+
setTimeout(() => { discordAuthHandled = false; }, 5000);
591651
}
592652

593653
channel.addEventListener('message', event => {
@@ -602,6 +662,21 @@ function create_module(for_cg) {
602662
}
603663
});
604664

665+
// Listen for localStorage changes (fallback for edge cases where both
666+
// BroadcastChannel and postMessage fail, requires requestStorageAccess)
667+
window.addEventListener('storage', event => {
668+
if (event.key === 'discord_auth_result' && event.newValue) {
669+
try {
670+
const data = JSON.parse(event.newValue);
671+
if (data && data.type === 'authentication' && data.access_token) {
672+
handleDiscordAuth(data);
673+
}
674+
} catch (e) {
675+
console.warn("storage event handling failed:", e);
676+
}
677+
}
678+
});
679+
605680
Module.channel = channel;
606681

607682
Module.loginDiscord = loginDiscord;

cmake/web/assets/discord_redirect.html

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,23 +46,36 @@
4646
token_type: token_type
4747
};
4848

49-
// BroadcastChannel works when the game is not iframed (same top-level origin)
49+
// Method 1: BroadcastChannel (works when game is same top-level origin)
5050
try {
5151
const channel = new BroadcastChannel('token_bridge');
5252
channel.postMessage(message);
5353
channel.close();
54+
console.log("discord_redirect: sent via BroadcastChannel");
5455
} catch (e) {
55-
console.warn("BroadcastChannel failed:", e);
56+
console.warn("discord_redirect: BroadcastChannel failed:", e);
5657
}
5758

58-
// window.opener.postMessage works when the game is iframed on a third-party domain
59-
// (BroadcastChannel is partitioned by top-level site in modern browsers)
59+
// Method 2: window.opener.postMessage (works when game is iframed,
60+
// requires COOP: unsafe-none on this page so opener survives the redirect chain)
6061
if (window.opener) {
6162
try {
6263
window.opener.postMessage(message, window.location.origin);
64+
console.log("discord_redirect: sent via window.opener.postMessage");
6365
} catch (e) {
64-
console.warn("window.opener.postMessage failed:", e);
66+
console.warn("discord_redirect: window.opener.postMessage failed:", e);
6567
}
68+
} else {
69+
console.warn("discord_redirect: window.opener is null (COOP may have severed it)");
70+
}
71+
72+
// Method 3: localStorage (fallback for when both above fail;
73+
// requires iframe to have called requestStorageAccess)
74+
try {
75+
localStorage.setItem('discord_auth_result', JSON.stringify(message));
76+
console.log("discord_redirect: wrote token to localStorage");
77+
} catch (e) {
78+
console.warn("discord_redirect: localStorage write failed:", e);
6679
}
6780
}
6881
};

cmake/web/play.hypersomnia.io

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,14 @@ server {
1717
add_header Cross-Origin-Embedder-Policy "credentialless" always;
1818
}
1919

20+
# OAuth redirect page must NOT have COOP so window.opener survives
21+
# the cross-origin navigation chain (game iframe -> Discord -> redirect).
22+
location = /assets/discord_redirect.html {
23+
alias /var/www/html/assets/discord_redirect.html;
24+
add_header Cross-Origin-Resource-Policy "cross-origin" always;
25+
add_header Cross-Origin-Embedder-Policy "credentialless" always;
26+
}
27+
2028
location /assets/ {
2129
alias /var/www/html/assets/;
2230
autoindex on; # This is optional; it allows directory listing if no index file is found

0 commit comments

Comments
 (0)