diff --git a/doc/embedding-app-browser.md b/doc/embedding-app-browser.md index a2a834b28..e1aee0286 100644 --- a/doc/embedding-app-browser.md +++ b/doc/embedding-app-browser.md @@ -20,11 +20,16 @@ the desktop and dashboard send `utm_campaign=desktop` and `utm_campaign=dashboar ## Session and framing Only show the iframe after the user has signed in. The embed reads the existing -GUI session from local storage on its own origin. It does not create accounts, -show a login dialog, or accept a token from its parent. A missing session or -failed load displays an unavailable/error state. Being signed in on a subdomain -alone does not populate the main GUI origin's storage; the main GUI session must -already exist. +GUI session from local storage on its own origin. If that storage holds no +session, the embed gets the session from the GUI origin's session cookie through +`/get-gui-token`. This matters in Safari, which gives a framed origin separate +local storage under each top-level origin, so even a sibling subdomain's iframe +can't see the GUI's stored token. The session cookie is still sent, because the +host is on the same site. The embed doesn't write that token back to storage. It +does not create accounts, show a login dialog, or accept a token from its parent. +A missing session or failed load displays an unavailable/error state. Being +signed in on a subdomain alone does not create a session on the main GUI origin; +the main GUI session must already exist. The route's `Content-Security-Policy: frame-ancestors` allows the GUI origin and the embedding page's origin when it is exactly one label under `config.domain` diff --git a/src/gui/src/UI/Dashboard/AppBrowser.browser.test.js b/src/gui/src/UI/Dashboard/AppBrowser.browser.test.js index f0d4a35ce..9c01fa970 100644 --- a/src/gui/src/UI/Dashboard/AppBrowser.browser.test.js +++ b/src/gui/src/UI/Dashboard/AppBrowser.browser.test.js @@ -15,6 +15,7 @@ let port; const requests = []; const guiRoot = fileURLToPath(new URL('../../../', import.meta.url)); const sessionToken = 'app-browser-test-session'; +const sessionCookie = 'app-browser-test-cookie'; const appRows = Array.from({ length: 105 }, (_, i) => ({ name: `app-${i}`, title: `App ${i}`, icon: null, })); @@ -55,6 +56,10 @@ beforeAll(async () => { const offset = (Number(req.query.page) - 1) * 100; res.json(appRows.slice(offset, offset + 100)); }); + app.get('/get-gui-token', (req, res) => { + if ( ! req.headers.cookie?.includes(`puter_auth_token=${sessionCookie}`) ) return res.sendStatus(401); + res.json({ token: sessionToken }); + }); app.get('/get-launch-apps', (_req, res) => res.json({ recommended: [{ name: 'removed', title: 'Removed' }] })); app.get('/cache/last-change-timestamp', (_req, res) => res.json({ timestamp: 0 })); app.post('/drivers/call', (req, res) => { @@ -163,6 +168,27 @@ describe('embedded app browser', () => { }, 30000, ); + it('falls back to the session cookie when its local storage holds no session', async () => { + // Safari gives the frame its own empty storage under a sibling subdomain. + const context = await browser.newContext({ storageState: { + cookies: [{ + name: 'puter_auth_token', value: sessionCookie, domain: 'puter.localhost', path: '/', + expires: -1, httpOnly: true, secure: false, sameSite: 'Lax', + }], + origins: [], + } }); + try { + const page = await context.newPage(); + page.setDefaultTimeout(6000); + await page.goto(`http://spreadsheet.puter.localhost:${port}/host`); + const frame = page.frameLocator('iframe'); + await frame.locator('.myapps-group-tile').waitFor(); + expect(await frame.locator('[data-app-name="app-104"]').count()).toBe(1); + } finally { + await context.close(); + } + }); + it('opens the desktop landing, cut off from the embed, for an app it can\'t launch itself', async () => { const context = await browser.newContext({ storageState: { cookies: [], origins: [{ origin, localStorage: [{ name: 'auth_token_v2', value: sessionToken }] }], diff --git a/src/gui/src/appsEmbed.js b/src/gui/src/appsEmbed.js index d4bd5ad40..68440cc26 100644 --- a/src/gui/src/appsEmbed.js +++ b/src/gui/src/appsEmbed.js @@ -85,8 +85,21 @@ function launchTile ({ appName, targetLink }) { .finally(() => launching.delete(appName)); } +/** + * The GUI session, from local storage or else the session cookie. Safari keys a + * frame's local storage by the top-level origin, so under a sibling subdomain + * the GUI's stored token is out of reach while its cookie is still sent. + */ +async function readSession () { + const stored = localStorage.getItem('auth_token_v2'); + if ( stored ) return stored; + const response = await fetch('/get-gui-token', { credentials: 'include' }); + if ( ! response.ok ) return null; + return (await response.json())?.token || null; +} + async function mount () { - window.auth_token = localStorage.getItem('auth_token_v2'); + window.auth_token = await readSession(); if ( ! window.auth_token ) throw new Error('Missing session'); await Promise.all([new Promise((resolve, reject) => {