diff --git a/src/gui/src/UI/UIWindowLogin.js b/src/gui/src/UI/UIWindowLogin.js index 2ca338cc0..179c95acd 100644 --- a/src/gui/src/UI/UIWindowLogin.js +++ b/src/gui/src/UI/UIWindowLogin.js @@ -191,8 +191,11 @@ async function UIWindowLogin (options) { // returns it as a plain sign-in popup, losing whatever the popup // was opened to do — and, for a permission prompt, handing the // opener a token instead of a decision. Don't offer it there. + // A cross-origin-isolated opener can only get the token through + // the session relay, which the provider's return doesn't keep. if ( window.embedded_in_popup - && ! offersFederatedSignInInPopup(window.gui_action) ) { + && ( ! offersFederatedSignInInPopup(window.gui_action) + || window.url_query_params?.get('cross_origin_isolated') === 'true' ) ) { return; } const res = await fetch(`${window.api_origin}/auth/oidc/providers`); diff --git a/src/gui/src/UI/UIWindowLogin.oidcPopup.test.js b/src/gui/src/UI/UIWindowLogin.oidcPopup.test.js new file mode 100644 index 000000000..a7c0d4b71 --- /dev/null +++ b/src/gui/src/UI/UIWindowLogin.oidcPopup.test.js @@ -0,0 +1,118 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import jQuery from '../lib/jquery-3.6.1/jquery-3.6.1.min.js'; + +const state = vi.hoisted(() => ({ el: null })); +vi.mock('./UIWindowRecoverPassword.js', () => ({ default: vi.fn() })); +vi.mock('./UIWindowEmailConfirmationRequired.js', () => ({ default: vi.fn() })); +vi.mock('./UIWindowPhoneVerificationRequired.js', () => ({ default: vi.fn() })); +vi.mock('./UIWindowCardVerificationRequired.js', () => ({ default: vi.fn() })); +vi.mock('../helpers/authLogoHeader.js', () => ({ + authLogoHeader: () => '', + wireAuthLogoHeader: vi.fn(), +})); +vi.mock('./UIWindow.js', () => ({ + default: async (options) => { + state.el = document.createElement('div'); + state.el.innerHTML = options.body_content; + document.body.append(state.el); + options.onAppend?.(state.el); + return state.el; + }, +})); +globalThis.def = globalThis.def ?? ((value) => value); +const { default: UIWindowLogin } = await import('./UIWindowLogin.js'); +const { default: UIWindowSignup } = await import('./UIWindowSignup.js'); + +const PROVIDERS_URL = 'https://api.puter.test/auth/oidc/providers'; +const RELAY_POPUP = + 'embedded_in_popup=true&msg_id=1&cross_origin_isolated=true&signin_session=s'; +const PLAIN_POPUP = 'embedded_in_popup=true&msg_id=1'; + +const asPopup = (query) => { + window.embedded_in_popup = true; + window.gui_action = 'sign-in'; + window.url_query_params = new URLSearchParams(query); +}; + +const askedForProviders = () => + fetch.mock.calls.some(([url]) => url === PROVIDERS_URL); + +beforeEach(() => { + document.body.innerHTML = ''; + globalThis.$ = jQuery; + jQuery.fn.close = vi.fn(); + globalThis.i18n = (key) => key; + globalThis.html_encode = (value) => jQuery('
').text(value).html(); + globalThis.fetch = vi.fn( + async () => new Response(JSON.stringify({ providers: ['google'] })), + ); + window.gui_origin = 'https://puter.test'; + window.api_origin = 'https://api.puter.test'; + window.gui_params = { turnstileSiteKey: 'site-key' }; + window.turnstile = { render: vi.fn() }; + window.icons = {}; + window.uuidv4 = () => 'oidc-popup-test'; + window.embedded_in_popup = false; + window.gui_action = undefined; + window.url_query_params = new URLSearchParams(''); + state.el = null; +}); +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe.each([ + [ + 'login', + () => UIWindowLogin({ reload_on_success: false, redirect_url: '/' }), + '.login-btn', + ], + [ + 'signup', + () => UIWindowSignup({ reload_on_success: false, redirect_url: '/' }), + '.signup-btn', + ], +])('OIDC buttons in the %s window', (_name, open, formButton) => { + const rendered = async () => { + void open(); + await vi.waitFor(() => + expect(jQuery(state.el).find(formButton)).toHaveLength(1), + ); + }; + + // The provider's return drops the relay that hands the token to an + // isolated opener, so only password sign-in can finish there. + it('hides them in a cross-origin-isolated relay popup', async () => { + asPopup(RELAY_POPUP); + await rendered(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(askedForProviders()).toBe(false); + expect(jQuery(state.el).find('.oidc-google-btn').css('display')).toBe( + 'none', + ); + }); + + it('keeps them in a regular sign-in popup', async () => { + asPopup(PLAIN_POPUP); + await rendered(); + + await vi.waitFor(() => + expect( + jQuery(state.el).find('.oidc-google-btn').css('display'), + ).toBe('flex'), + ); + }); + + it('keeps them outside a popup', async () => { + await rendered(); + + await vi.waitFor(() => + expect( + jQuery(state.el).find('.oidc-google-btn').css('display'), + ).toBe('flex'), + ); + expect(askedForProviders()).toBe(true); + }); +}); diff --git a/src/gui/src/UI/UIWindowSignup.js b/src/gui/src/UI/UIWindowSignup.js index 671c9cea4..660393b4b 100644 --- a/src/gui/src/UI/UIWindowSignup.js +++ b/src/gui/src/UI/UIWindowSignup.js @@ -285,10 +285,16 @@ function UIWindowSignup(options) { // provider returns it as a plain sign-in popup, losing // whatever the popup was opened to do — and, for a // permission prompt, handing the opener a token instead - // of a decision. Don't offer it there. + // of a decision. Don't offer it there. A + // cross-origin-isolated opener can only get the token + // through the session relay, which the provider's + // return doesn't keep. if ( window.embedded_in_popup && - !offersFederatedSignInInPopup(window.gui_action) + (!offersFederatedSignInInPopup(window.gui_action) || + window.url_query_params?.get( + 'cross_origin_isolated', + ) === 'true') ) { return; } diff --git a/src/puter-js/src/index.authFlow.test.js b/src/puter-js/src/index.authFlow.test.js new file mode 100644 index 000000000..72ba77840 --- /dev/null +++ b/src/puter-js/src/index.authFlow.test.js @@ -0,0 +1,134 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('socket.io-client', () => ({ + io: () => ({ + on: vi.fn(), + off: vi.fn(), + emit: vi.fn(), + disconnect: vi.fn(), + }), +})); +vi.mock('./lib/polyfills/localStorage.js', () => ({ default: {} })); +vi.mock('./lib/polyfills/xhrshim.js', () => ({ default: class {} })); + +const origDefine = customElements.define.bind(customElements); +customElements.define = (name, cls) => + customElements.get(name) || origDefine(name, cls); + +const origParent = Object.getOwnPropertyDescriptor(globalThis, 'parent'); +const origXHR = globalThis.XMLHttpRequest; +const urls = []; +class CountingXHR { + _listeners = {}; + status = 200; + readyState = 0; + responseType = ''; + upload = { addEventListener() {} }; + open(method, url) { + this.url = url; + urls.push(`${method} ${url}`); + } + setRequestHeader() {} + addEventListener(type, fn) { + (this._listeners[type] ??= []).push(fn); + } + getResponseHeader(n) { + return n.toLowerCase() === 'content-type' ? 'application/json' : null; + } + abort() {} + send() { + queueMicrotask(() => { + this.responseText = JSON.stringify({ username: 'u' }); + this.response = this.responseText; + this.readyState = 4; + for (const fn of this._listeners.readystatechange ?? []) + fn.call(this); + for (const fn of this._listeners.load ?? []) + fn.call(this, { target: this }); + }); + } +} + +beforeEach(() => { + localStorage.clear(); + urls.length = 0; + Object.defineProperty(globalThis, 'parent', { + value: globalThis, + configurable: true, + }); + globalThis.XMLHttpRequest = CountingXHR; +}); +afterEach(() => { + if (origParent) Object.defineProperty(globalThis, 'parent', origParent); + globalThis.XMLHttpRequest = origXHR; + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +const flush = async () => { + for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0)); +}; + +describe('implicit sign-in on a web page', () => { + it('runs onAuth twice for one sign-in', async () => { + history.replaceState(null, '', '/site/index.html'); + vi.resetModules(); + // Hide `process` from the SDK's environment probe only, so the + // module's own instance boots as a top-level web page. + const realProcess = globalThis.process; + Object.defineProperty(globalThis, 'process', { + configurable: true, + get() { + const stack = new Error().stack ?? ''; + return /src\/index\.js:\d+/.test(stack.split('\n')[2] ?? '') + ? undefined + : realProcess; + }, + }); + let puter; + try { + ({ puter } = await import('./index.js')); + } finally { + Object.defineProperty(globalThis, 'process', { + value: realProcess, + configurable: true, + writable: true, + }); + } + expect(puter.env).toBe('web'); + expect(puter.authToken).toBeFalsy(); + + const popup = { closed: false }; + vi.spyOn(window, 'open').mockReturnValue(popup); + Object.defineProperty(navigator, 'userActivation', { + value: { hasBeenActive: true, isActive: true }, + configurable: true, + }); + vi.spyOn(crypto, 'randomUUID').mockReturnValue('sess-1'); + + const onAuth = vi.fn(); + puter.onAuth = onAuth; + + const signedIn = puter.ui.authenticateWithPuter(); + window.dispatchEvent( + new MessageEvent('message', { + origin: puter.defaultGUIOrigin, + source: popup, + data: { + msg: 'puter.token', + msg_id: 'sess-1', + success: true, + token: 'tok', + }, + }), + ); + await signedIn; + await flush(); + + expect(puter.authToken).toBe('tok'); + expect(onAuth).toHaveBeenCalledTimes(1); + const whoami = urls.filter((u) => u.includes('/whoami')); + expect(whoami.length).toBe(1); + }); +}); diff --git a/src/puter-js/src/index.js b/src/puter-js/src/index.js index 197f643a4..e9b8932fb 100644 --- a/src/puter-js/src/index.js +++ b/src/puter-js/src/index.js @@ -722,8 +722,6 @@ export class Puter { // When SDK is loaded in GUI the initiation process should start when the DOM is ready. This is because // the SDK needs to show a dialog to the user to ask for permission to access their Puter account. else if (this.env === 'web') { - // initialize submodules - this.initSubmodules(); try { const storedToken = this.normalizeAuthTokenCandidate( localStorage.getItem(STORAGE_KEY_V2), @@ -754,6 +752,8 @@ export class Puter { console.error('Error accessing localStorage:', error); } + this.initSubmodules(); + // Print a CTA for developers to publish their app on the Puter App Store this.printDevCTA(); @@ -2015,9 +2015,14 @@ globalThis.addEventListener && '*', ); } else if (event.data.msg === 'puter.token') { + if (puter.auth?.pendingSignInMessageIds_?.has(event.data.msg_id)) + return; if (!puter.tokenSourceAllowed_(event.source)) { return; } + // Could be a late reply from an old popup; the flow that owns the + // prompt settles its own failures. + if (event.data.success === false) return; // Set the authToken property puter.setAuthToken(event.data.token); // update appID only when token does not include app identity @@ -2038,7 +2043,8 @@ globalThis.addEventListener && // Not awaited: the waiting sign-ins below settle first. (async () => { try { - puter.onAuth(await puter.getUser()); + const user = await puter.whoamiCache_; + puter.onAuth(user ?? (await puter.getUser())); } catch (e) { console.error(e); } diff --git a/src/puter-js/src/index.test.js b/src/puter-js/src/index.test.js index aa1ac1572..678504e91 100644 --- a/src/puter-js/src/index.test.js +++ b/src/puter-js/src/index.test.js @@ -328,6 +328,29 @@ describe('GUI message listener', () => { ).resolves.toBeUndefined(); }); + it('ignores a late failure message without touching a pending sign-in', async () => { + const { puter, handler } = await bootWithListener(); + const previous = puter.authToken; + const waiter = { resolve: vi.fn(), reject: vi.fn() }; + puter.puterAuthState.isPromptOpen = true; + puter.puterAuthState.resolvers.push(waiter); + await handler({ + origin: puter.defaultGUIOrigin, + source: globalThis.parent, + data: { + msg: 'puter.token', + success: false, + token: 'invalid', + error: 'auth_canceled', + }, + }); + expect(puter.authToken).toBe(previous); + expect(waiter.reject).not.toHaveBeenCalled(); + expect(waiter.resolve).not.toHaveBeenCalled(); + expect(puter.puterAuthState.resolvers).toEqual([waiter]); + expect(puter.puterAuthState.isPromptOpen).toBe(true); + }); + it('reports a failed user lookup after sign-in', async () => { const { puter, handler } = await bootWithListener(); const error = vi.spyOn(console, 'error').mockImplementation(() => {}); diff --git a/src/puter-js/src/index.webBoot.test.js b/src/puter-js/src/index.webBoot.test.js new file mode 100644 index 000000000..89c3805e0 --- /dev/null +++ b/src/puter-js/src/index.webBoot.test.js @@ -0,0 +1,87 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const ioCalls = []; +vi.mock('socket.io-client', () => ({ + io: (origin, opts) => { + ioCalls.push({ origin, token: opts?.auth?.auth_token ?? null }); + return { + on: vi.fn(), + off: vi.fn(), + emit: vi.fn(), + disconnect: vi.fn(), + }; + }, +})); +vi.mock('./lib/polyfills/localStorage.js', () => ({ default: {} })); +vi.mock('./lib/polyfills/xhrshim.js', () => ({ default: class {} })); + +const STORAGE_KEY = 'puter.auth.token.v2'; +const ORIGIN_KEY = 'puter.auth.token.origin.v2'; +const b64url = (value) => Buffer.from(value).toString('base64url'); +const APP_TOKEN = `${b64url('{"alg":"HS256"}')}.${b64url(JSON.stringify({ t: 'au', v: '2', au: Buffer.alloc(16, 1).toString('base64') }))}.sig`; + +const origDefine = customElements.define.bind(customElements); +customElements.define = (name, cls) => + customElements.get(name) || origDefine(name, cls); + +const origParent = Object.getOwnPropertyDescriptor(globalThis, 'parent'); +const origXHR = globalThis.XMLHttpRequest; +class StubXHR { + open() {} + setRequestHeader() {} + addEventListener() {} + send() {} +} + +beforeEach(() => { + localStorage.clear(); + ioCalls.length = 0; + Object.defineProperty(globalThis, 'parent', { + value: globalThis, + configurable: true, + }); + globalThis.XMLHttpRequest = StubXHR; +}); +afterEach(() => { + if (origParent) Object.defineProperty(globalThis, 'parent', origParent); + globalThis.XMLHttpRequest = origXHR; +}); + +describe('web-env boot with a stored token', () => { + it('opens only one authenticated socket', async () => { + localStorage.setItem(STORAGE_KEY, APP_TOKEN); + localStorage.setItem(ORIGIN_KEY, 'https://api.puter.com'); + history.replaceState(null, '', '/site/index.html'); + vi.resetModules(); + const { Puter } = await import('./index.js'); + ioCalls.length = 0; + + const realProcess = globalThis.process; + Object.defineProperty(globalThis, 'process', { + configurable: true, + get() { + const caller = (new Error().stack ?? '').split('\n')[2] ?? ''; + return /src\/index\.js:\d+/.test(caller) + ? undefined + : realProcess; + }, + }); + let puter; + try { + puter = new Puter(); + } finally { + Object.defineProperty(globalThis, 'process', { + value: realProcess, + configurable: true, + writable: true, + }); + } + + expect(puter.env).toBe('web'); + expect(puter.authToken).toBe(APP_TOKEN); + expect(ioCalls).toEqual([ + { origin: 'https://api.puter.com', token: APP_TOKEN }, + ]); + }); +}); diff --git a/src/puter-js/src/modules/Auth.js b/src/puter-js/src/modules/Auth.js index 1b93e330d..faa0431c3 100644 --- a/src/puter-js/src/modules/Auth.js +++ b/src/puter-js/src/modules/Auth.js @@ -116,6 +116,9 @@ import { * are for apps that drive the sign-in flow themselves. */ export class AuthModule extends PuterModule { + /** @internal */ + pendingSignInMessageIds_ = new Set(); + /** * Signs the user in, opening a popup with the appropriate authentication * method. Must be called from a user gesture (such as a click) — without @@ -164,6 +167,7 @@ export class AuthModule extends PuterModule { return new Promise((resolve, reject) => { const signinsession = crypto.randomUUID(); const msg_id = signinsession; + this.pendingSignInMessageIds_.add(msg_id); const popupName = `Puter-sign-in-${signinsession}`; const url = `${puter.defaultGUIOrigin}/action/sign-in?embedded_in_popup=true&msg_id=${msg_id}${window.crossOriginIsolated ? `&cross_origin_isolated=true&signin_session=${signinsession}` : ''}${options.attempt_temp_user_creation ? '&attempt_temp_user_creation=true' : ''}${options.request_auth ? '&request_auth=true' : ''}`; @@ -177,6 +181,7 @@ export class AuthModule extends PuterModule { let popupWindow = null; const cleanup = () => { + this.pendingSignInMessageIds_.delete(msg_id); if (checkClosed) { clearInterval(checkClosed); checkClosed = null; @@ -184,6 +189,18 @@ export class AuthModule extends PuterModule { window.removeEventListener('message', messageHandler); }; + const notifyAuth = () => { + if (typeof puter.onAuth !== 'function') return; + void (async () => { + try { + const user = await puter.whoamiCache_; + puter.onAuth(user ?? (await puter.getUser())); + } catch (error) { + console.error(error); + } + })(); + }; + if (window.crossOriginIsolated) { (async () => { // Bounded too: isolated with a gesture, nothing watches. @@ -223,6 +240,7 @@ export class AuthModule extends PuterModule { settled = true; cleanup(); puter.setAuthToken(auth_token); + notifyAuth(); resolve({ success: true, token: auth_token }); return ''; } @@ -262,6 +280,7 @@ export class AuthModule extends PuterModule { if (e.data.success) { // set the auth token puter.setAuthToken(e.data.token); + notifyAuth(); resolve(e.data); } else { reject(e.data); diff --git a/src/puter-js/src/modules/Auth.signIn.test.js b/src/puter-js/src/modules/Auth.signIn.test.js index cf04a3c0e..89cbcb1f0 100644 --- a/src/puter-js/src/modules/Auth.signIn.test.js +++ b/src/puter-js/src/modules/Auth.signIn.test.js @@ -131,6 +131,18 @@ describe('signIn popup binding', () => { }, ); + it('notifies explicit sign-in once using the cached user lookup', async () => { + const user = { username: 'alice' }; + puter.whoamiCache_ = Promise.resolve(user); + puter.onAuth = vi.fn(); + puter.getUser = vi.fn(); + const signedIn = auth.signIn(); + respond(popups[0], 'token'); + await signedIn; + expect(puter.onAuth).toHaveBeenCalledExactlyOnceWith(user); + expect(puter.getUser).not.toHaveBeenCalled(); + }); + it('keeps the isolated-page relay independent of popup messages', async () => { vi.stubGlobal('crossOriginIsolated', true); let answer; diff --git a/src/puter-js/src/modules/FileSystem/index.js b/src/puter-js/src/modules/FileSystem/index.js index d0cd541b5..4e13e4202 100644 --- a/src/puter-js/src/modules/FileSystem/index.js +++ b/src/puter-js/src/modules/FileSystem/index.js @@ -103,6 +103,10 @@ export class PuterJSFileSystemModule extends PuterModule { this.socket.disconnect(); } + this.socket = null; + if (!this.authToken) return; + this.socketToken_ = this.authToken; + this.socketOrigin_ = this.APIOrigin; this.socket = io(this.APIOrigin, { auth: { auth_token: this.authToken, @@ -212,6 +216,9 @@ export class PuterJSFileSystemModule extends PuterModule { } if ( this.puter.socketEnabled ) { + if (this.socket?.active && + this.socketToken_ === this.authToken && + this.socketOrigin_ === this.APIOrigin) return; this.initializeSocket(); } } diff --git a/src/puter-js/src/modules/FileSystem/index.test.js b/src/puter-js/src/modules/FileSystem/index.test.js index dc6021c00..8a06844ab 100644 --- a/src/puter-js/src/modules/FileSystem/index.test.js +++ b/src/puter-js/src/modules/FileSystem/index.test.js @@ -137,6 +137,43 @@ describe('auth state', () => { expect(fs.socket).not.toBe(previousSocket); }); + it('keeps an active socket when credentials and origin have not changed', () => { + const fs = makeModule('web'); + const previous = fs.socket; + previous.active = true; + fs.onAuthStateChanged(); + expect(fs.socket).toBe(previous); + expect(previous.disconnect).not.toHaveBeenCalled(); + }); + + it('replaces an inactive socket even when credentials are unchanged', () => { + const fs = makeModule('web'); + const previous = fs.socket; + previous.active = false; + fs.onAuthStateChanged(); + expect(fs.socket).not.toBe(previous); + expect(previous.disconnect).toHaveBeenCalledOnce(); + }); + + it('closes the socket on sign-out without opening an anonymous replacement', () => { + const fs = makeModule('web'); + const previous = fs.socket; + fs.puter.authToken = null; + fs.onAuthStateChanged(); + expect(previous.disconnect).toHaveBeenCalledOnce(); + expect(fs.socket).toBeNull(); + }); + + it('replaces an active socket when the API origin changes', () => { + const fs = makeModule('web'); + const previous = fs.socket; + previous.active = true; + fs.puter.APIOrigin = 'https://new-api.test'; + fs.onAuthStateChanged(); + expect(fs.socket).not.toBe(previous); + expect(previous.disconnect).toHaveBeenCalledOnce(); + }); + it('never opens a socket when the client opted out', () => { const fs = makeModule('web', false); diff --git a/src/puter-js/src/modules/UI.js b/src/puter-js/src/modules/UI.js index 52f1fdc0a..44c2239a8 100644 --- a/src/puter-js/src/modules/UI.js +++ b/src/puter-js/src/modules/UI.js @@ -2615,11 +2615,6 @@ export class UIModule extends EventListener { return puter.auth.signIn({ request_auth: true }).then( () => { settle(true); - if ( puter.onAuth && typeof puter.onAuth === 'function' ) { - puter.getUser().then((user) => { - puter.onAuth(user); - }); - } }, (err) => { settle(false);