fix(embed): read the app browser's session from the cookie when its storage is empty (#4516)

Safari keys a framed origin's local storage by the top-level origin, so the
app browser embedded on a sibling subdomain finds no auth_token_v2 and shows
"Apps are unavailable". The session cookie is still sent there, so the embed
now falls back to /get-gui-token and keeps the token in memory.
This commit is contained in:
Nariman Jelveh authored and GitHub committed 2026-11-05 23:41:38 -07:00
1 parent 7f0cd1689b
commit 763742dbc0
3 files changed
+50 -6

No files matched your search

+10 -5
View File
@@ -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`
@@ -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 }] }],
+14 -1
View File
@@ -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) => {