From 2275f6516943385a081bb0800f1f43d93dea39a4 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Thu, 21 May 2026 12:06:47 -0700 Subject: [PATCH] Centralize auth popup handling and consent dialog --- src/puter-js/src/lib/auth-popup.js | 67 +++++++++++ src/puter-js/src/modules/Auth.js | 120 +++++++++++++------- src/puter-js/src/modules/PuterDialog.js | 142 ++++++++++-------------- 3 files changed, 210 insertions(+), 119 deletions(-) create mode 100644 src/puter-js/src/lib/auth-popup.js diff --git a/src/puter-js/src/lib/auth-popup.js b/src/puter-js/src/lib/auth-popup.js new file mode 100644 index 000000000..911ba5f74 --- /dev/null +++ b/src/puter-js/src/lib/auth-popup.js @@ -0,0 +1,67 @@ +/** + * Shared helpers for opening Puter authentication popup windows. + * + * Browsers only allow `window.open()` to spawn a real popup (instead of + * silently blocking it) while the document has user activation — i.e. + * during or shortly after a user gesture such as a click. Every Puter auth + * flow needs the same activation check and the same popup geometry, so this + * module is the single source of truth for both. + * + * Keeping this in one place is what prevents the flows from drifting apart: + * previously the implicit-auth flow had an activation check + consent-dialog + * fallback while `puter.auth.signIn()` opened the popup unconditionally and + * got popup-blocked when called without a user gesture. + */ + +// Auth popup window dimensions. +const POPUP_WIDTH = 600; +const POPUP_HEIGHT = 700; + +/** + * Detects whether the document currently has user activation, which the + * browser requires in order to open a popup without blocking it. + * + * @returns {boolean} True if a popup can be opened right now. + */ +export const hasUserActivation = () => { + // Modern browsers expose the User Activation API. + if ( navigator.userActivation ) { + return navigator.userActivation.hasBeenActive && navigator.userActivation.isActive; + } + + // Fallback for browsers without the API: probe by attempting to open a + // tiny off-screen popup. If it succeeds, a user gesture is active; close + // it immediately. This is hacky, but it is the only signal available. + try { + const testPopup = window.open('', '_blank', 'width=1,height=1,left=-1000,top=-1000'); + if ( testPopup ) { + testPopup.close(); + return true; + } + return false; + } catch (e) { + return false; + } +}; + +/** + * Opens a centered Puter authentication popup window. + * + * This must be called synchronously from within a user gesture (e.g. a click + * handler), or the browser will block the popup. Callers must gate any + * non-gesture invocation behind `hasUserActivation()` and fall back to a + * consent dialog (which collects a gesture) when there is no activation. + * + * @param {string} url - The full URL (including query string) to load. + * @param {string} [title='Puter'] - The popup window name. + * @returns {Window|null} The popup window, or null if the browser blocked it. + */ +export const openAuthPopup = (url, title = 'Puter') => { + const left = (screen.width / 2) - (POPUP_WIDTH / 2); + const top = (screen.height / 2) - (POPUP_HEIGHT / 2); + return window.open( + url, + title, + `toolbar=no, location=no, directories=no, status=no, menubar=no, scrollbars=no, resizable=no, copyhistory=no, width=${POPUP_WIDTH}, height=${POPUP_HEIGHT}, top=${top}, left=${left}`, + ); +}; diff --git a/src/puter-js/src/modules/Auth.js b/src/puter-js/src/modules/Auth.js index cf1bdda6c..26cad9d71 100644 --- a/src/puter-js/src/modules/Auth.js +++ b/src/puter-js/src/modules/Auth.js @@ -1,4 +1,6 @@ import * as utils from '../lib/utils.js'; +import PuterDialog from './PuterDialog.js'; +import { hasUserActivation, openAuthPopup } from '../lib/auth-popup.js'; class Auth { // Used to generate a unique message id for each message sent to the host environment @@ -46,55 +48,97 @@ class Auth { options = options || {}; return new Promise((resolve, reject) => { - let msg_id = this.#messageID++; - let w = 600; - let h = 700; - let title = 'Puter'; - var left = (screen.width / 2) - (w / 2); - var top = (screen.height / 2) - (h / 2); + const msg_id = this.#messageID++; + const url = `${puter.defaultGUIOrigin}/action/sign-in?embedded_in_popup=true&msg_id=${msg_id}${window.crossOriginIsolated ? '&cross_origin_isolated=true' : ''}${options.attempt_temp_user_creation ? '&attempt_temp_user_creation=true' : ''}`; - // Store reference to the popup window - const popup = window.open( - `${puter.defaultGUIOrigin}/action/sign-in?embedded_in_popup=true&msg_id=${msg_id}${window.crossOriginIsolated ? '&cross_origin_isolated=true' : ''}${options.attempt_temp_user_creation ? '&attempt_temp_user_creation=true' : ''}`, - title, - `toolbar=no, location=no, directories=no, status=no, menubar=no, scrollbars=no, resizable=no, copyhistory=no, width=${w}, height=${h}, top=${top}, left=${left}`, - ); + // Guards against settling the promise more than once across the + // message, popup-closed, and dialog-cancel code paths. + let settled = false; + // Interval id for polling whether the user closed the popup. + let checkClosed = null; - // Set up interval to check if popup was closed - const checkClosed = setInterval(() => { - if ( popup.closed ) { + const cleanup = () => { + if ( checkClosed ) { clearInterval(checkClosed); - // Remove the message listener - window.removeEventListener('message', messageHandler); - reject({ error: 'auth_window_closed', msg: 'Authentication window was closed by the user without completing the process.' }); + checkClosed = null; } - }, 100); + window.removeEventListener('message', messageHandler); + }; function messageHandler (e) { - if ( e.data.msg_id == msg_id ) { - // Clear the interval since we got a response - clearInterval(checkClosed); + if ( e.data?.msg_id != msg_id ) { + return; + } + if ( settled ) { + return; + } + settled = true; + cleanup(); - // remove redundant attributes - delete e.data.msg_id; - delete e.data.msg; + // remove redundant attributes + delete e.data.msg_id; + delete e.data.msg; - if ( e.data.success ) { - // set the auth token - puter.setAuthToken(e.data.token); - - resolve(e.data); - } else - { - reject(e.data); - } - - // delete the listener - window.removeEventListener('message', messageHandler); + if ( e.data.success ) { + // set the auth token + puter.setAuthToken(e.data.token); + resolve(e.data); + } else { + reject(e.data); } } - window.addEventListener('message', messageHandler); + + // Once the popup exists, watch for the user closing it without + // completing sign-in. `popup` is null if the browser blocked it. + const watchPopup = (popup) => { + if ( settled ) { + return; + } + if ( ! popup ) { + settled = true; + cleanup(); + reject({ error: 'popup_blocked', msg: 'The sign-in popup was blocked by the browser.' }); + return; + } + checkClosed = setInterval(() => { + if ( ! popup.closed ) { + return; + } + clearInterval(checkClosed); + checkClosed = null; + if ( settled ) { + return; + } + settled = true; + cleanup(); + reject({ error: 'auth_window_closed', msg: 'Authentication window was closed by the user without completing the process.' }); + }, 100); + }; + + if ( hasUserActivation() ) { + // A user gesture is active — open the popup immediately. + watchPopup(openAuthPopup(url)); + } else { + // No user gesture: a popup opened now would be blocked by the + // browser. Show a consent dialog first; the popup is then + // opened from the user's click on that dialog, which provides + // the gesture the browser requires. + const dialog = new PuterDialog(() => {}, () => {}, { + popupURL: url, + onLaunch: (popup) => watchPopup(popup), + onCancel: () => { + if ( settled ) { + return; + } + settled = true; + cleanup(); + reject({ error: 'auth_window_closed', msg: 'Authentication window was closed by the user without completing the process.' }); + }, + }); + document.body.appendChild(dialog); + dialog.open(); + } }); }; diff --git a/src/puter-js/src/modules/PuterDialog.js b/src/puter-js/src/modules/PuterDialog.js index 4613078fc..bedf2e029 100644 --- a/src/puter-js/src/modules/PuterDialog.js +++ b/src/puter-js/src/modules/PuterDialog.js @@ -1,72 +1,33 @@ +import { hasUserActivation, openAuthPopup } from '../lib/auth-popup.js'; + class PuterDialog extends (globalThis.HTMLElement || Object) { // It will fall back to only extending Object in environments without a DOM // Similar to `#messageID` in Auth.js. We start at an arbitrary high number to avoid // collisions. static messageID = Math.floor(Number.MAX_SAFE_INTEGER / 2); - /** - * Detects if the current page is loaded using the file:// protocol. - * @returns {boolean} True if using file:// protocol, false otherwise. - */ - isUsingFileProtocol = () => { - return window.location.protocol === 'file:'; - }; - #messageID; - constructor (resolve, reject) { + /** + * @param {Function} resolve - Resolves the implicit-auth promise. + * @param {Function} reject - Rejects the implicit-auth promise. + * @param {Object} [options] - Optional configuration. + * @param {string} [options.popupURL] - When set, the dialog acts as a + * generic popup launcher: it opens this URL (instead of the default + * implicit-auth URL), skips the `puter.token` message handling and + * `puterAuthState` bookkeeping (the caller owns the auth result), and + * reports cancellation through `options.onCancel`. + * @param {Function} [options.onLaunch] - Called with the opened popup + * window (or null if the browser blocked it) right after launch. + * @param {Function} [options.onCancel] - Called when the user dismisses + * the dialog without completing authentication. + */ + constructor (resolve, reject, options = {}) { super(); this.reject = reject; this.resolve = resolve; - this.popupLaunched = false; // Track if popup was successfully launched + this.options = options; this.#messageID = this.constructor.messageID++; - /** - * Detects if there's a recent user activation that would allow popup opening - * @returns {boolean} True if user activation is available, false otherwise. - */ - this.hasUserActivation = () => { - // Modern browsers support navigator.userActivation - if ( navigator.userActivation ) { - return navigator.userActivation.hasBeenActive && navigator.userActivation.isActive; - } - - // Fallback: try to detect user activation by attempting to open a popup - // This is a bit hacky but works as a fallback - try { - const testPopup = window.open('', '_blank', 'width=1,height=1,left=-1000,top=-1000'); - if ( testPopup ) { - testPopup.close(); - return true; - } - return false; - } catch (e) { - return false; - } - }; - - /** - * Launches the authentication popup window - * @returns {Window|null} The popup window reference or null if failed - */ - this.launchPopup = () => { - try { - let w = 600; - let h = 700; - let title = 'Puter'; - var left = (screen.width / 2) - (w / 2); - var top = (screen.height / 2) - (h / 2); - const popup = window.open( - `${puter.defaultGUIOrigin }/?embedded_in_popup=true&request_auth=true${ window.crossOriginIsolated ? '&cross_origin_isolated=true' : ''}`, - title, - `toolbar=no, location=no, directories=no, status=no, menubar=no, scrollbars=no, resizable=no, copyhistory=no, width=${ w }, height=${ h }, top=${ top }, left=${ left}`, - ); - return popup; - } catch (e) { - console.error('Failed to open popup:', e); - return null; - } - }; - this.attachShadow({ mode: 'open' }); let h; @@ -496,10 +457,31 @@ class PuterDialog extends (globalThis.HTMLElement || Object) { // It will fall b } + /** + * Returns the URL to open in the auth popup. In launcher mode this is the + * caller-supplied URL; otherwise it is the default implicit-auth URL. + * @returns {string} + */ + #popupURL () { + if ( this.options.popupURL ) { + return this.options.popupURL; + } + return `${puter.defaultGUIOrigin}/?embedded_in_popup=true&request_auth=true&msg_id=${this.#messageID}${window.crossOriginIsolated ? '&cross_origin_isolated=true' : ''}`; + } + // Optional: Handle dialog cancellation as rejection cancelListener = () => { this.close(); window.removeEventListener('message', this.messageListener); + + // Launcher mode: the caller owns the auth promise and any state. + if ( this.options.popupURL ) { + if ( typeof this.options.onCancel === 'function' ) { + this.options.onCancel(); + } + return; + } + puter.puterAuthState.authGranted = false; puter.puterAuthState.isPromptOpen = false; @@ -515,22 +497,26 @@ class PuterDialog extends (globalThis.HTMLElement || Object) { // It will fall b }; connectedCallback () { - // Add event listener to the button + // Wire the "Continue" button to open the auth popup. Opening here is + // safe from being popup-blocked because it happens inside a click. this.shadowRoot.querySelector('#launch-auth-popup')?.addEventListener('click', () => { - let w = 600; - let h = 700; - let title = 'Puter'; - var left = (screen.width / 2) - (w / 2); - var top = (screen.height / 2) - (h / 2); - window.open( - `${puter.defaultGUIOrigin }/?embedded_in_popup=true&request_auth=true&msg_id=${this.#messageID}${ window.crossOriginIsolated ? '&cross_origin_isolated=true' : ''}`, - title, - `toolbar=no, location=no, directories=no, status=no, menubar=no, scrollbars=no, resizable=no, copyhistory=no, width=${ w }, height=${ h }, top=${ top }, left=${ left}`, - ); + const popup = openAuthPopup(this.#popupURL()); + + // Launcher mode: hand the popup back to the caller and close the + // consent dialog — its only job was to provide the user gesture. + if ( this.options.popupURL ) { + if ( typeof this.options.onLaunch === 'function' ) { + this.options.onLaunch(popup); + } + this.close(); + } }); - // Add the event listener to the window object - window.addEventListener('message', this.messageListener); + // The implicit-auth flow listens for the token message from the popup. + // In launcher mode the caller registers its own message handler. + if ( ! this.options.popupURL ) { + window.addEventListener('message', this.messageListener); + } // Add event listeners for cancel and close buttons this.shadowRoot.querySelector('#launch-auth-popup-cancel')?.addEventListener('click', this.cancelListener); @@ -538,17 +524,11 @@ class PuterDialog extends (globalThis.HTMLElement || Object) { // It will fall b } open () { - if ( this.hasUserActivation() ) { - let w = 600; - let h = 700; - let title = 'Puter'; - var left = (screen.width / 2) - (w / 2); - var top = (screen.height / 2) - (h / 2); - window.open( - `${puter.defaultGUIOrigin }/?embedded_in_popup=true&request_auth=true&msg_id=${this.#messageID}${ window.crossOriginIsolated ? '&cross_origin_isolated=true' : ''}`, - title, - `toolbar=no, location=no, directories=no, status=no, menubar=no, scrollbars=no, resizable=no, copyhistory=no, width=${ w }, height=${ h }, top=${ top }, left=${ left}`, - ); + if ( hasUserActivation() ) { + const popup = openAuthPopup(this.#popupURL()); + if ( this.options.popupURL && typeof this.options.onLaunch === 'function' ) { + this.options.onLaunch(popup); + } } else { this.shadowRoot.querySelector('dialog').showModal();