diff --git a/src/backend/controllers/auth/godmodeAppTokens.http.test.ts b/src/backend/controllers/auth/godmodeAppTokens.http.test.ts index 9c13ae1c0..62e6fe529 100644 --- a/src/backend/controllers/auth/godmodeAppTokens.http.test.ts +++ b/src/backend/controllers/auth/godmodeAppTokens.http.test.ts @@ -179,7 +179,8 @@ describe('godmode app tokens', () => { expect(decode(second.token!).token_uid).toBe( decode(first.token!).token_uid, ); - expect(second.expires_at).toBeGreaterThanOrEqual(first.expires_at!); + // Asked again within the hour: the row keeps the expiry it has. + expect(second.expires_at).toBe(first.expires_at); // A copy taken at launch keeps working after the desktop renews. expect(await live(first.token!)).toBe(true); @@ -193,6 +194,26 @@ describe('godmode app tokens', () => { }); }); + it('is extended once the desktop asks with under eleven hours left', async () => { + const desktop = await newDesktopSession(); + const first = await mint(desktop.gui_token); + const sessionUid = decode(first.token!).session_uid!; + await setRow(sessionUid, { + expires_at: Math.floor(Date.now() / 1000) + 2 * 60 * 60, + }); + + const before = Math.floor(Date.now() / 1000); + const renewed = await mint(desktop.gui_token); + expect(decode(renewed.token!).token_uid).toBe( + decode(first.token!).token_uid, + ); + expect(renewed.expires_at).toBeGreaterThanOrEqual( + before + TWELVE_HOURS, + ); + const row = await env.server.stores.session.getByUuidAny(sessionUid); + expect(row?.expires_at).toBe(renewed.expires_at); + }); + it('is never extended by being used', async () => { const desktop = await newDesktopSession(); const { token, expires_at } = await mint(desktop.gui_token); diff --git a/src/backend/services/auth/AuthService.ts b/src/backend/services/auth/AuthService.ts index a26d5869e..e3cc57c62 100644 --- a/src/backend/services/auth/AuthService.ts +++ b/src/backend/services/auth/AuthService.ts @@ -1544,7 +1544,7 @@ export class AuthService extends PuterService { * does. Its row hangs off that session, so signing out or revoking it takes * the token along. The JWT has no `exp` and stays the same across renewals, * so copies an app handed elsewhere keep working; the row's expiry is what - * lapses, 12h after the desktop stops asking. A lapsed row is never + * lapses, at most 12h after the desktop stops asking. A lapsed row is never * revived, and one past its max age is replaced and revoked. */ async getGodmodeAppToken( @@ -1565,13 +1565,12 @@ export class AuthService extends PuterService { } const authId = this.#authIdFor(actor.user as UserRow); - const expiresAt = nowSeconds() + GODMODE_TOKEN_WINDOW_SECONDS; const minted = await this.stores.session.getOrCreateGodmodeToken( actor.user.id, { parentSessionUid: actor.session.uid, appUid: app.uid, - expiresAt, + expiresAt: nowSeconds() + GODMODE_TOKEN_WINDOW_SECONDS, maxAgeSeconds: GODMODE_TOKEN_MAX_AGE_SECONDS, label: app.title || app.name || null, auth_id: authId, @@ -1595,7 +1594,7 @@ export class AuthService extends PuterService { full_access: true, godmode_app_uid: app.uid, }); - return { token, expiresAt }; + return { token, expiresAt: Number(row.expires_at) }; } /** diff --git a/src/backend/stores/session/SessionStore.js b/src/backend/stores/session/SessionStore.js index fb66f26d9..8b5fd9eb3 100644 --- a/src/backend/stores/session/SessionStore.js +++ b/src/backend/stores/session/SessionStore.js @@ -56,11 +56,14 @@ export const APP_WINDOW_SECONDS = 365 * 24 * 60 * 60; // 1y export const WORKER_WINDOW_SECONDS = 99 * 365 * 24 * 60 * 60; // 99y (virtually infinite); export const ASSET_WINDOW_SECONDS = 7 * 24 * 60 * 60; // 7 days -// A godmode app's token lapses this long after the desktop last renewed it, so -// a closed desktop's tokens die on their own. However often it is renewed, one -// token never outlives the max age; it is replaced and the old one revoked. +// A godmode app's token lapses at most this long after the desktop last asked +// for it, so a closed desktop's tokens die on their own. A token past the max +// age is never renewed again; the next ask replaces it and revokes the old one. export const GODMODE_TOKEN_WINDOW_SECONDS = 12 * 60 * 60; // 12h export const GODMODE_TOKEN_MAX_AGE_SECONDS = 7 * 24 * 60 * 60; // 7d +// A renewal that would move a godmode token's expiry by less than this is +// skipped, so repeat launches don't each write the row. +const GODMODE_TOKEN_RENEW_STEP_SECONDS = 60 * 60; // 1h const sqlTimestamp = (ms) => new Date(ms).toISOString().slice(0, 19).replace('T', ' '); @@ -328,7 +331,7 @@ export class SessionStore extends PuterStore { // Invalidate every cached view onto the row so the next read // (manage-sessions reload, /whoami, etc.) sees the new label. const rows = await this.clients.db.read( - 'SELECT `uuid`, `user_id`, `kind`, `app_uid`, `legacy_token_uid`, `meta`, `created_via`, `last_ip`, `last_user_agent` FROM `sessions` WHERE `uuid` = ? LIMIT 1', + 'SELECT `uuid`, `user_id`, `kind`, `app_uid`, `parent_session_id`, `legacy_token_uid`, `meta`, `created_via`, `last_ip`, `last_user_agent` FROM `sessions` WHERE `uuid` = ? LIMIT 1', [uuid], ); if (rows[0]) { @@ -366,7 +369,7 @@ export class SessionStore extends PuterStore { // replica miss here would return early and silently skip the // revoke. Revokes are rare, so the primary read is cheap. const rows = await this.clients.db.pread( - 'SELECT `uuid`, `user_id`, `kind`, `app_uid`, `legacy_token_uid`, `meta`, `created_via`, `last_ip`, `last_user_agent` FROM `sessions` WHERE `uuid` = ? AND `revoked_at` IS NULL LIMIT 1', + 'SELECT `uuid`, `user_id`, `kind`, `app_uid`, `parent_session_id`, `legacy_token_uid`, `meta`, `created_via`, `last_ip`, `last_user_agent` FROM `sessions` WHERE `uuid` = ? AND `revoked_at` IS NULL LIMIT 1', [uuid], ); if (rows.length === 0) return; @@ -429,7 +432,7 @@ export class SessionStore extends PuterStore { // be invalidated alongside the uuid key, otherwise a follow-up // `getOrCreateApp` would short-circuit to the freshly-revoked row. const rows = await this.clients.db.read( - 'SELECT `uuid`, `user_id`, `kind`, `app_uid`, `legacy_token_uid`, `meta`, `created_via`, `last_ip`, `last_user_agent` FROM `sessions` WHERE (`uuid` = ? OR `parent_session_id` = ?) AND `revoked_at` IS NULL', + 'SELECT `uuid`, `user_id`, `kind`, `app_uid`, `parent_session_id`, `legacy_token_uid`, `meta`, `created_via`, `last_ip`, `last_user_agent` FROM `sessions` WHERE (`uuid` = ? OR `parent_session_id` = ?) AND `revoked_at` IS NULL', [rootUuid, rootUuid], ); if (rows.length === 0) return null; @@ -497,10 +500,11 @@ export class SessionStore extends PuterStore { /** * The token row a godmode app runs on under one web session. A live row is * reused and its `expires_at` moved to `expiresAt`, so the token stays the - * same across renewals. A row that lapsed or is older than `maxAgeSeconds` - * is never extended: a fresh row is minted, and the old one comes back as - * `replaced` for the caller to revoke. Concurrent first launches can each - * insert a row; both stay valid. + * same across renewals; a move of under an hour is skipped and the row + * comes back with the expiry it has. A row that lapsed or is older than + * `maxAgeSeconds` is never extended: a fresh row is minted, and the old one + * comes back as `replaced` for the caller to revoke. Concurrent first + * launches can each insert a row; both stay valid. * * @param userId - User row id (numeric). * @param opts.parentSessionUid - The web session minting it; revoking that @@ -524,37 +528,52 @@ export class SessionStore extends PuterStore { ) { if (!userId || !parentSessionUid || !appUid) return null; + const now = nowSeconds(); + const renewable = (row) => + !!row && + row.revoked_at == null && + !isExpired(row, now) && + Number(row.created_at) > now - maxAgeSeconds; + + const pointerKey = this.#cacheKeyGodmode( + userId, + parentSessionUid, + appUid, + ); + const cached = await this.#cachedGodmodeRow(pointerKey, { + userId, + parentSessionUid, + appUid, + }); + if (renewable(cached)) { + const row = await this.#renewGodmodeRow(cached, expiresAt, now); + if (row) return { row, replaced: null }; + } + const rows = await this.clients.db.pread( "SELECT * FROM `sessions` WHERE `kind` = 'access_token' AND `user_id` = ? AND `parent_session_id` = ? AND `app_uid` = ? AND `revoked_at` IS NULL ORDER BY `id` DESC LIMIT 1", [userId, parentSessionUid, appUid], ); - const existing = this.#normalizeRow(rows[0]); - const now = nowSeconds(); - const renewable = - existing && - !isExpired(existing, now) && - Number(existing.created_at) > now - maxAgeSeconds; - if (!renewable) { - const row = await this.#insertSession(userId, { - kind: 'access_token', - label, - parent_session_id: parentSessionUid, - expires_at: expiresAt, - app_uid: appUid, - access_token_uid: uuidv4(), - auth_id, - }); - return { row, replaced: existing ?? null }; + const found = this.#normalizeRow(rows[0]); + if (renewable(found)) { + const row = await this.#renewGodmodeRow(found, expiresAt, now); + if (row) { + await this.#writeGodmodePointer(pointerKey, row.uuid); + return { row, replaced: null }; + } } - const keys = this.#allCacheKeysForRow(existing); - await this.publishCacheKeys({ keys, broadcast: true }); - await this.clients.db.write( - 'UPDATE `sessions` SET `expires_at` = ? WHERE `uuid` = ? AND `revoked_at` IS NULL', - [expiresAt, existing.uuid], - ); - await this.publishCacheKeys({ keys, broadcast: true }); - return { row: { ...existing, expires_at: expiresAt }, replaced: null }; + const row = await this.#insertSession(userId, { + kind: 'access_token', + label, + parent_session_id: parentSessionUid, + expires_at: expiresAt, + app_uid: appUid, + access_token_uid: uuidv4(), + auth_id, + }); + await this.#writeGodmodePointer(pointerKey, row.uuid); + return { row, replaced: found ?? null }; } /** @@ -961,6 +980,15 @@ export class SessionStore extends PuterStore { return `${CACHE_KEY_PREFIX}:worker:${userId}:${appUid ?? ''}:${workerName}`; } + /** + * Cache key naming the godmode token row for (user, web session, app). It + * holds only the row's uuid; the row is read through the uuid cache and + * checked on every use, and revoking the row drops the key. + */ + #cacheKeyGodmode(userId, parentSessionUid, appUid) { + return `${CACHE_KEY_PREFIX}:godmode:${userId}:${parentSessionUid}:${appUid}`; + } + /** * Cache key for the (legacy-web) backfill lookup. IP and UA are * percent-encoded into a single key segment so a UA containing `:` doesn't @@ -1019,6 +1047,20 @@ export class SessionStore extends PuterStore { ), ); } + if ( + row.kind === 'access_token' && + row.user_id && + row.parent_session_id && + row.app_uid + ) { + keys.push( + this.#cacheKeyGodmode( + row.user_id, + row.parent_session_id, + row.app_uid, + ), + ); + } if (row.legacy_token_uid) { keys.push(this.#cacheKeyLegacyAt(row.legacy_token_uid)); } @@ -1043,13 +1085,13 @@ export class SessionStore extends PuterStore { await this.#writeCacheKey(this.#cacheKey(session.uuid), session); } - async #writeCacheKey(key, value) { + async #writeCacheKey(key, value, ttlSeconds = CACHE_TTL_SECONDS) { try { await this.clients.redis.set( key, JSON.stringify(value), 'EX', - CACHE_TTL_SECONDS, + ttlSeconds, ); } catch { // Best-effort local backfill. @@ -1097,6 +1139,62 @@ export class SessionStore extends PuterStore { return this.#normalizeRow(rows[0]); } + /** + * The row a godmode pointer names, read the way token auth reads it. Null + * when there is no pointer or the row it names isn't this triple's. + */ + async #cachedGodmodeRow(key, { userId, parentSessionUid, appUid }) { + const pointer = await this.#readCacheKey(key); + if (typeof pointer?.uuid !== 'string') return null; + const row = await this.getByUuidAny(pointer.uuid); + if ( + row?.kind !== 'access_token' || + Number(row.user_id) !== Number(userId) || + row.parent_session_id !== parentSessionUid || + row.app_uid !== appUid + ) { + return null; + } + return row; + } + + /** + * Move a live godmode row's expiry up to `expiresAt`, or leave it when the + * move is under the renew step. Null when the row turned out to be revoked + * or lapsed by the time of the write. + */ + async #renewGodmodeRow(row, expiresAt, now) { + if ( + Number(row.expires_at) >= + expiresAt - GODMODE_TOKEN_RENEW_STEP_SECONDS + ) { + return row; + } + // The pointer stays: it names this same row after the write. + const keys = this.#allCacheKeysForRow(row).filter( + (key) => + key !== + this.#cacheKeyGodmode( + row.user_id, + row.parent_session_id, + row.app_uid, + ), + ); + await this.publishCacheKeys({ keys, broadcast: true }); + const result = await this.clients.db.write( + 'UPDATE `sessions` SET `expires_at` = ? WHERE `uuid` = ? AND `revoked_at` IS NULL AND (`expires_at` IS NULL OR `expires_at` > ?)', + [expiresAt, row.uuid, now], + ); + await this.publishCacheKeys({ keys, broadcast: true }); + return result?.anyRowsAffected + ? { ...row, expires_at: expiresAt } + : null; + } + + async #writeGodmodePointer(key, uuid) { + await this.#writeCacheKey(key, { uuid }, GODMODE_TOKEN_WINDOW_SECONDS); + } + #normalizeRow(row) { if (!row) return null; // Meta may be stored as JSON string (SQLite) or already parsed diff --git a/src/backend/stores/session/SessionStore.test.ts b/src/backend/stores/session/SessionStore.test.ts index 17ea8459f..7ed1379ec 100644 --- a/src/backend/stores/session/SessionStore.test.ts +++ b/src/backend/stores/session/SessionStore.test.ts @@ -17,12 +17,14 @@ * along with this program. If not, see . */ -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { v4 as uuidv4 } from 'uuid'; import { setupTestServer } from '../../testUtil.ts'; import { PuterServer } from '../../server.ts'; import { APP_WINDOW_SECONDS, + GODMODE_TOKEN_MAX_AGE_SECONDS, + GODMODE_TOKEN_WINDOW_SECONDS, WEB_WINDOW_SECONDS, WORKER_WINDOW_SECONDS, } from './SessionStore.js'; @@ -398,6 +400,142 @@ describe('SessionStore', () => { }); }); + describe('getOrCreateGodmodeToken', () => { + const nowS = () => Math.floor(Date.now() / 1000); + const mint = ( + userId: number, + parentSessionUid: string, + appUid = 'app-gm', + ) => + target.getOrCreateGodmodeToken(userId, { + parentSessionUid, + appUid, + expiresAt: nowS() + GODMODE_TOKEN_WINDOW_SECONDS, + maxAgeSeconds: GODMODE_TOKEN_MAX_AGE_SECONDS, + }); + const desktop = async () => { + const user = await makeUser(); + const web = await target.create(user.id); + return { userId: user.id as number, web: web.uuid as string }; + }; + + it('renews from the cache with no primary read and no write', async () => { + const { userId, web } = await desktop(); + const first = await mint(userId, web); + + const pread = vi.spyOn(server.clients.db, 'pread'); + const write = vi.spyOn(server.clients.db, 'write'); + try { + const second = await mint(userId, web); + expect(second.row.uuid).toBe(first.row.uuid); + expect(second.row.expires_at).toBe(first.row.expires_at); + expect(second.replaced).toBeNull(); + expect(pread).not.toHaveBeenCalled(); + expect(write).not.toHaveBeenCalled(); + } finally { + pread.mockRestore(); + write.mockRestore(); + } + }); + + it('extends the row once the move is an hour or more', async () => { + const { userId, web } = await desktop(); + const { row } = await mint(userId, web); + await server.clients.db.write( + 'UPDATE `sessions` SET `expires_at` = ? WHERE `uuid` = ?', + [nowS() + 2 * 60 * 60, row.uuid], + ); + await target.publishCacheKeys({ + keys: [`sessions:v2:uuid:${row.uuid}`], + }); + + const renewed = await mint(userId, web); + expect(renewed.row.uuid).toBe(row.uuid); + expect(renewed.row.expires_at).toBeGreaterThanOrEqual( + nowS() + GODMODE_TOKEN_WINDOW_SECONDS - 1, + ); + expect((await rawRow(row.uuid)).expires_at).toBe( + renewed.row.expires_at, + ); + }); + + it('mints a new row once the cached one is revoked', async () => { + const { userId, web } = await desktop(); + const first = await mint(userId, web); + await target.revokeCascade(first.row.uuid); + + const next = await mint(userId, web); + expect(next.row.uuid).not.toBe(first.row.uuid); + expect(next.replaced).toBeNull(); + + const second = await mint(userId, web); + await target.removeByUuid(second.row.uuid); + expect((await mint(userId, web)).row.uuid).not.toBe( + second.row.uuid, + ); + }); + + it('mints a new row when a lagging read re-cached the revoked one', async () => { + const { userId, web } = await desktop(); + const { row } = await mint(userId, web); + await target.revokeCascade(row.uuid); + // A replica that hadn't seen the revoke yet answered a read. + await server.clients.redis.set( + `sessions:v2:uuid:${row.uuid}`, + JSON.stringify(row), + ); + + const next = await mint(userId, web); + expect(next.row.uuid).not.toBe(row.uuid); + }); + + it('mints a new row when a renewal finds the cached one already revoked', async () => { + const { userId, web } = await desktop(); + const { row } = await mint(userId, web); + const soon = nowS() + 2 * 60 * 60; + // Revoked in the database, still live and due for renewal in the cache. + await server.clients.db.write( + 'UPDATE `sessions` SET `revoked_at` = ? WHERE `uuid` = ?', + [nowS(), row.uuid], + ); + await server.clients.redis.set( + `sessions:v2:uuid:${row.uuid}`, + JSON.stringify({ ...row, expires_at: soon }), + ); + + const next = await mint(userId, web); + expect(next.row.uuid).not.toBe(row.uuid); + expect((await rawRow(row.uuid)).expires_at).toBe(row.expires_at); + }); + + it('keeps one row per web session and app', async () => { + const { userId, web } = await desktop(); + const other = (await target.create(userId)).uuid; + const first = await mint(userId, web); + + expect((await mint(userId, other)).row.uuid).not.toBe( + first.row.uuid, + ); + expect((await mint(userId, web, 'app-gm-other')).row.uuid).not.toBe( + first.row.uuid, + ); + expect((await mint(userId, web)).row.uuid).toBe(first.row.uuid); + }); + + it('ignores a cached pointer to a row of another session', async () => { + const { userId, web } = await desktop(); + const other = (await target.create(userId)).uuid; + const mine = await mint(userId, web); + const theirs = await mint(userId, other); + await server.clients.redis.set( + `sessions:v2:godmode:${userId}:${web}:app-gm`, + JSON.stringify({ uuid: theirs.row.uuid }), + ); + + expect((await mint(userId, web)).row.uuid).toBe(mine.row.uuid); + }); + }); + describe('getOrCreateWorker', () => { it('creates a kind="worker" row tagged meta.worker / meta.worker_name', async () => { const user = await makeUser(); diff --git a/src/gui/src/UI/UIWindowManageSessions.js b/src/gui/src/UI/UIWindowManageSessions.js index 99765791e..d847e39f0 100644 --- a/src/gui/src/UI/UIWindowManageSessions.js +++ b/src/gui/src/UI/UIWindowManageSessions.js @@ -23,6 +23,7 @@ // window system and there is no cross-window z-index juggling. import { fetchAllEventsWorkers, eventsWorkerLabel } from '../helpers/eventsWorkers.js'; +import { forgetGodmodeTokens } from '../helpers/godmodeTokens.js'; // Hand-rolled UA → {browser, os} extractor. Covers Chrome/Edge/Firefox/ // Safari/Opera + Windows/macOS/iOS/Android/Linux. The backend already @@ -578,6 +579,7 @@ const UIWindowManageSessions = async function UIWindowManageSessions (options) { }, body: JSON.stringify({ uuid: session.uuid, anti_csrf }), }); + forgetGodmodeTokens(); if ( resp.ok ) { // Full reload — cascade may have killed children // we'd otherwise have to detach by hand. diff --git a/src/gui/src/helpers/godmodeTokens.js b/src/gui/src/helpers/godmodeTokens.js index bc667fa76..1341ffb2a 100644 --- a/src/gui/src/helpers/godmodeTokens.js +++ b/src/gui/src/helpers/godmodeTokens.js @@ -31,6 +31,9 @@ const RENEW_WHEN_LEFT_MS = 6 * 60 * 60 * 1000; const CHECK_INTERVAL_MS = 10 * 60 * 1000; // An app asking again this soon gets the token it was just sent. const RENEW_COOLDOWN_MS = 30 * 1000; +// A launch this soon after a mint reuses that token instead of asking again. +// Short, because a token revoked elsewhere goes unnoticed until the next ask. +const REUSE_WITHIN_MS = 10 * 60 * 1000; /** * @type {Map} + */ +const lastMinted = new Map(); +// Bumped by every forget, so a mint already in flight isn't remembered. +let mintGeneration = 0; + /** * Whether a token expiring at `expiresAt` (unix seconds) is due for renewal. * @@ -95,6 +113,7 @@ export const mintGodmodeToken = async (appUid, deps = {}) => { const fetchImpl = deps.fetchImpl ?? fetch; const apiOrigin = deps.apiOrigin ?? window.api_origin; const authToken = deps.authToken ?? window.auth_token; + const generation = mintGeneration; try { const response = await fetchImpl(`${apiOrigin}/auth/get-user-app-token`, { method: 'POST', @@ -107,7 +126,13 @@ export const mintGodmodeToken = async (appUid, deps = {}) => { if ( ! response.ok ) return { ok: false, status: response.status }; const body = await response.json().catch(() => null); if ( body?.godmode === true && typeof body.token === 'string' && body.token ) { - return { ok: true, token: body.token, expiresAt: Number(body.expires_at) }; + const expiresAt = Number(body.expires_at); + if ( generation === mintGeneration ) { + lastMinted.set(appUid, { token: body.token, expiresAt, mintedAt: Date.now(), authToken }); + } + const tokenRow = tokenRowOf(body.token); + if ( tokenRow ) tellPeers(appUid, tokenRow); + return { ok: true, token: body.token, expiresAt }; } return { ok: false, status: response.status }; } catch ( error ) { @@ -115,6 +140,74 @@ export const mintGodmodeToken = async (appUid, deps = {}) => { } }; +/** + * A token minted for `appUid` moments ago under the same desktop session, for + * a launch to use instead of asking again; null when there is none. + * + * @param {string} appUid + * @param {object} [deps] + * @param {string} [deps.authToken] + * @param {number} [deps.nowMs] + * @returns {{ ok: true; token: string; expiresAt: number; mintedAt: number } | null} + */ +export const reusableGodmodeToken = (appUid, deps = {}) => { + const authToken = deps.authToken ?? window.auth_token; + const nowMs = deps.nowMs ?? Date.now(); + const entry = lastMinted.get(appUid); + if ( !entry || !authToken || entry.authToken !== authToken ) return null; + const age = nowMs - entry.mintedAt; + if ( age < 0 || age >= REUSE_WITHIN_MS || shouldRenew(entry.expiresAt, nowMs) ) return null; + return { ok: true, token: entry.token, expiresAt: entry.expiresAt, mintedAt: entry.mintedAt }; +}; + +/** + * Stop reusing tokens minted for launches, for one app or all of them. Call + * after revoking sessions, so the next launch asks for a live token. + * + * @param {string} [appUid] + */ +export const forgetGodmodeTokens = (appUid) => { + forgetLocally(appUid); + tellPeers(appUid ?? null, null); +}; + +const forgetLocally = (appUid) => { + mintGeneration++; + if ( appUid === undefined ) lastMinted.clear(); + else lastMinted.delete(appUid); +}; + +// Other tabs of this desktop share its session and so its token rows. They are +// told which row an app is on now, or to forget it; never the token itself. +const peers = typeof BroadcastChannel === 'undefined' + ? null + : new BroadcastChannel('puter-godmode-tokens'); + +/** + * @param {string | null} appUid Null for every app + * @param {string | null} tokenRow The app's current row; null to forget it + */ +const tellPeers = (appUid, tokenRow) => { + try { + peers?.postMessage({ appUid, tokenRow }); + } catch { + // A closed channel only costs other tabs one extra mint. + } +}; + +peers?.addEventListener('message', ({ data }) => { + const appUid = data?.appUid; + const tokenRow = data?.tokenRow; + if ( appUid === null ) { + forgetLocally(); + return; + } + if ( typeof appUid !== 'string' ) return; + const entry = lastMinted.get(appUid); + if ( typeof tokenRow === 'string' && (!entry || tokenRowOf(entry.token) === tokenRow) ) return; + forgetLocally(appUid); +}); + // Pinned to the app's origin, so a frame that navigated elsewhere gets nothing. const postToken = (instanceId, token, origin) => { const iframe = window.iframe_for_app_instance?.(instanceId); @@ -149,6 +242,8 @@ const postToken = (instanceId, token, origin) => { export const renewGodmodeToken = async (instanceId, from = {}) => { const entry = tracked.get(instanceId); if ( ! entry ) return false; + // The app says its token stopped working; don't launch others with it. + if ( from.origin ) forgetGodmodeTokens(entry.appUid); if ( ! window.iframe_for_app_instance?.(instanceId) ) { tracked.delete(instanceId); return false; @@ -187,9 +282,10 @@ const renewDue = () => { * @param {string} appUid * @param {string} token * @param {number} expiresAt Unix seconds + * @param {number} [renewedAt] When the token was minted, if not just now */ -export const trackGodmodeToken = (instanceId, appUid, token, expiresAt) => { - tracked.set(instanceId, { appUid, token, expiresAt, renewedAt: Date.now() }); +export const trackGodmodeToken = (instanceId, appUid, token, expiresAt, renewedAt = Date.now()) => { + tracked.set(instanceId, { appUid, token, expiresAt, renewedAt }); if ( ! checkTimer ) { checkTimer = setInterval(renewDue, CHECK_INTERVAL_MS); } diff --git a/src/gui/src/helpers/godmodeTokens.test.js b/src/gui/src/helpers/godmodeTokens.test.js index e4b59f07f..6a29d38c4 100644 --- a/src/gui/src/helpers/godmodeTokens.test.js +++ b/src/gui/src/helpers/godmodeTokens.test.js @@ -17,10 +17,22 @@ * along with this program. If not, see . */ -import { describe, it, expect, vi } from 'vitest'; -import { mintGodmodeToken, sameTokenRow, shouldRenew } from './godmodeTokens.js'; +import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'; +import { + forgetGodmodeTokens, + mintGodmodeToken, + renewGodmodeToken, + reusableGodmodeToken, + sameTokenRow, + shouldRenew, + trackGodmodeToken, +} from './godmodeTokens.js'; const HOUR_MS = 60 * 60 * 1000; +const jwtFor = (tokenUid) => + `h.${Buffer.from(JSON.stringify({ token_uid: tokenUid })).toString('base64url')}.s`; +// Lets a message cross to another channel instance. +const settle = () => new Promise((resolve) => setTimeout(resolve, 20)); const reply = (status, body) => vi.fn(async () => ({ @@ -104,3 +116,176 @@ describe('sameTokenRow', () => { expect(sameTokenRow('garbage', 'garbage')).toBe(false); }); }); + +describe('reusableGodmodeToken', () => { + const MINUTE_MS = 60 * 1000; + const minted = (expiresInMs = 12 * HOUR_MS) => reply(200, { + token: 'jwt', + godmode: true, + expires_at: Math.floor((Date.now() + expiresInMs) / 1000), + }); + + beforeEach(() => forgetGodmodeTokens()); + + it('hands a launch the token minted moments ago under the same session', async () => { + await mintGodmodeToken('app-1', deps(minted())); + const reused = reusableGodmodeToken('app-1', { authToken: 'session' }); + expect(reused).toMatchObject({ ok: true, token: 'jwt' }); + expect(reused.mintedAt).toBeLessThanOrEqual(Date.now()); + }); + + it('never hands a token to another session or another app', async () => { + await mintGodmodeToken('app-1', deps(minted())); + expect(reusableGodmodeToken('app-1', { authToken: 'other-session' })).toBeNull(); + expect(reusableGodmodeToken('app-2', { authToken: 'session' })).toBeNull(); + }); + + it('asks again once the mint is ten minutes old or the token is due for renewal', async () => { + await mintGodmodeToken('app-1', deps(minted())); + const later = (ms) => ({ authToken: 'session', nowMs: Date.now() + ms }); + expect(reusableGodmodeToken('app-1', later(9 * MINUTE_MS))).not.toBeNull(); + expect(reusableGodmodeToken('app-1', later(10 * MINUTE_MS))).toBeNull(); + + await mintGodmodeToken('app-2', deps(minted(5 * HOUR_MS))); + expect(reusableGodmodeToken('app-2', { authToken: 'session' })).toBeNull(); + }); + + it('remembers nothing from a mint that was in flight when tokens were forgotten', async () => { + let answer; + const fetchImpl = vi.fn(() => new Promise((resolve) => { + answer = resolve; + })); + const pending = mintGodmodeToken('app-1', deps(fetchImpl)); + forgetGodmodeTokens('app-1'); + answer({ + ok: true, + status: 200, + json: async () => ({ token: 'jwt', godmode: true, expires_at: Math.floor(Date.now() / 1000) + 12 * 60 * 60 }), + }); + + expect((await pending).ok).toBe(true); + expect(reusableGodmodeToken('app-1', { authToken: 'session' })).toBeNull(); + }); + + it('remembers nothing from a failed mint', async () => { + await mintGodmodeToken('app-1', deps(reply(403, {}))); + expect(reusableGodmodeToken('app-1', { authToken: 'session' })).toBeNull(); + }); + + it('forgets one app, or every app, once sessions are revoked', async () => { + await mintGodmodeToken('app-1', deps(minted())); + await mintGodmodeToken('app-2', deps(minted())); + forgetGodmodeTokens('app-1'); + expect(reusableGodmodeToken('app-1', { authToken: 'session' })).toBeNull(); + expect(reusableGodmodeToken('app-2', { authToken: 'session' })).not.toBeNull(); + forgetGodmodeTokens(); + expect(reusableGodmodeToken('app-2', { authToken: 'session' })).toBeNull(); + }); +}); + +describe('renewGodmodeToken', () => { + const jwt = (tokenUid) => + `h.${Buffer.from(JSON.stringify({ token_uid: tokenUid })).toString('base64url')}.s`; + let postMessage; + + beforeEach(() => { + forgetGodmodeTokens(); + postMessage = vi.fn(); + vi.stubGlobal('window', { + api_origin: 'https://api.test', + auth_token: 'session', + iframe_for_app_instance: () => ({ + src: 'https://app.test/', + contentWindow: { postMessage }, + }), + }); + }); + + afterEach(() => vi.unstubAllGlobals()); + + it('stops reusing a token once an app reports it dead', async () => { + await mintGodmodeToken('app-1', deps(reply(200, { + token: jwt('t1'), + godmode: true, + expires_at: Math.floor(Date.now() / 1000) + 12 * 60 * 60, + }))); + const reused = reusableGodmodeToken('app-1'); + trackGodmodeToken('instance-dead', 'app-1', reused.token, reused.expiresAt, reused.mintedAt); + + // Inside the cooldown the app is sent the same token, but no launch + // reuses it any more. + await renewGodmodeToken('instance-dead', { origin: 'https://app.test' }); + expect(reusableGodmodeToken('app-1')).toBeNull(); + }); + + it('mints afresh for a reused token whose mint is past the cooldown', async () => { + const fetchImpl = reply(200, { + token: jwt('t2'), + godmode: true, + expires_at: Math.floor(Date.now() / 1000) + 12 * 60 * 60, + }); + vi.stubGlobal('fetch', fetchImpl); + // Reused from a mint a minute ago, not minted for this launch. + trackGodmodeToken('instance-reused', 'app-1', jwt('t1'), 0, Date.now() - 60 * 1000); + + expect(await renewGodmodeToken('instance-reused', { origin: 'https://app.test' })).toBe(true); + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(postMessage).toHaveBeenCalledWith({ msg: 'puter.token', token: jwt('t2') }, 'https://app.test'); + }); +}); + +describe('other tabs', () => { + const minted = (tokenUid) => reply(200, { + token: jwtFor(tokenUid), + godmode: true, + expires_at: Math.floor(Date.now() / 1000) + 12 * 60 * 60, + }); + let otherTab; + + beforeEach(() => { + forgetGodmodeTokens(); + otherTab = new BroadcastChannel('puter-godmode-tokens'); + }); + + afterEach(() => otherTab.close()); + + it('hear which row each mint landed on, and every forget', async () => { + const heard = []; + otherTab.onmessage = (event) => heard.push(event.data); + await mintGodmodeToken('app-1', deps(minted('t1'))); + forgetGodmodeTokens('app-1'); + forgetGodmodeTokens(); + await settle(); + + expect(heard).toEqual([ + { appUid: 'app-1', tokenRow: 't1' }, + { appUid: 'app-1', tokenRow: null }, + { appUid: null, tokenRow: null }, + ]); + // Tokens never cross between tabs. + expect(JSON.stringify(heard)).not.toContain(jwtFor('t1')); + }); + + it('stop this tab reusing a row another tab moved off or revoked', async () => { + const reusable = () => reusableGodmodeToken('app-1', { authToken: 'session' }); + await mintGodmodeToken('app-1', deps(minted('t1'))); + + otherTab.postMessage({ appUid: 'app-1', tokenRow: 't1' }); + await settle(); + expect(reusable()).not.toBeNull(); + + otherTab.postMessage({ appUid: 'app-1', tokenRow: 't2' }); + await settle(); + expect(reusable()).toBeNull(); + + await mintGodmodeToken('app-1', deps(minted('t2'))); + otherTab.postMessage({ appUid: 'app-1', tokenRow: null }); + await settle(); + expect(reusable()).toBeNull(); + + await mintGodmodeToken('app-1', deps(minted('t2'))); + otherTab.postMessage({ appUid: null, tokenRow: null }); + await settle(); + expect(reusable()).toBeNull(); + }); +}); diff --git a/src/gui/src/helpers/launchApp.js b/src/gui/src/helpers/launchApp.js index dcfa92a45..a89547610 100644 --- a/src/gui/src/helpers/launchApp.js +++ b/src/gui/src/helpers/launchApp.js @@ -24,7 +24,7 @@ import { starts_hidden } from './startsHidden.js'; import { append_signed_item_params } from './appendSignedItemParams.js'; import { expand_home_path } from './expandHomePath.js'; import { confirmUrlFileAccess } from './confirmUrlFileAccess.js'; -import { mintGodmodeToken, trackGodmodeToken } from './godmodeTokens.js'; +import { mintGodmodeToken, reusableGodmodeToken, trackGodmodeToken } from './godmodeTokens.js'; const normalizePrivateAccessDecision = (privateAccess) => { if ( !privateAccess || typeof privateAccess !== 'object' ) { @@ -520,10 +520,11 @@ const launch_app = async (options) => { let launch_token = null; let tokenResult = null; if ( is_godmode ) { - const godmode_token = await mintGodmodeToken(launch_app_uid); + const godmode_token = reusableGodmodeToken(launch_app_uid) + ?? await mintGodmodeToken(launch_app_uid); if ( godmode_token.ok ) { launch_token = godmode_token.token; - trackGodmodeToken(uuid, launch_app_uid, godmode_token.token, godmode_token.expiresAt); + trackGodmodeToken(uuid, launch_app_uid, godmode_token.token, godmode_token.expiresAt, godmode_token.mintedAt); } else { tokenResult = { token: null, reason: 'godmode-token-request-failed', ...godmode_token }; } diff --git a/src/gui/src/helpers/revokeAppSessions.js b/src/gui/src/helpers/revokeAppSessions.js index f848716c4..7e482b67f 100644 --- a/src/gui/src/helpers/revokeAppSessions.js +++ b/src/gui/src/helpers/revokeAppSessions.js @@ -17,6 +17,8 @@ * along with this program. If not, see . */ +import { forgetGodmodeTokens } from './godmodeTokens.js'; + /** * Pick the session rows that belong to one app, out of a /auth/list-sessions * response. @@ -74,35 +76,40 @@ export const revokeAppSessions = async ( } = {}, ) => { if (!appUid) return 0; - const token = authToken ?? (puter.authToken || window.auth_token); - const authHeaders = { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }; + try { + const token = authToken ?? (puter.authToken || window.auth_token); + const authHeaders = { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }; - const listResp = await fetchImpl(`${apiOrigin}/auth/list-sessions`, { - method: 'GET', - headers: { Authorization: `Bearer ${token}` }, - }); - if (!listResp.ok) { - throw new Error(`Failed to list sessions (${listResp.status})`); - } - const uuids = appSessionUuids(await listResp.json(), appUid); - if (uuids.length === 0) return 0; - - // One anti-CSRF token per row: the service issues single-use tokens, so a - // token reused across the loop would be rejected from the second row on. - let revoked = 0; - for (const uuid of uuids) { - const anti_csrf = await antiCsrfToken(); - const resp = await fetchImpl(`${apiOrigin}/auth/revoke-session`, { - method: 'POST', - headers: authHeaders, - body: JSON.stringify({ uuid, anti_csrf }), + const listResp = await fetchImpl(`${apiOrigin}/auth/list-sessions`, { + method: 'GET', + headers: { Authorization: `Bearer ${token}` }, }); - if (resp.ok || resp.status === 404) revoked++; + if (!listResp.ok) { + throw new Error(`Failed to list sessions (${listResp.status})`); + } + const uuids = appSessionUuids(await listResp.json(), appUid); + if (uuids.length === 0) return 0; + + // One anti-CSRF token per row: the service issues single-use tokens, so a + // token reused across the loop would be rejected from the second row on. + let revoked = 0; + for (const uuid of uuids) { + const anti_csrf = await antiCsrfToken(); + const resp = await fetchImpl(`${apiOrigin}/auth/revoke-session`, { + method: 'POST', + headers: authHeaders, + body: JSON.stringify({ uuid, anti_csrf }), + }); + if (resp.ok || resp.status === 404) revoked++; + } + return revoked; + } finally { + // After the revokes, so no launch reuses a token minted before them. + forgetGodmodeTokens(appUid); } - return revoked; }; export default revokeAppSessions; diff --git a/src/gui/src/helpers/revokeAppSessions.test.js b/src/gui/src/helpers/revokeAppSessions.test.js index 20ebd145f..8c909bf10 100644 --- a/src/gui/src/helpers/revokeAppSessions.test.js +++ b/src/gui/src/helpers/revokeAppSessions.test.js @@ -18,6 +18,7 @@ */ import { describe, it, expect } from 'vitest'; +import { mintGodmodeToken, reusableGodmodeToken } from './godmodeTokens.js'; import { appSessionUuids, revokeAppSessions } from './revokeAppSessions.js'; const APP = 'app-1111'; @@ -121,6 +122,22 @@ describe('revokeAppSessions', () => { ); }); + it('stops launches reusing the godmode token it just revoked', async () => { + const minted = async () => ({ + ok: true, + status: 200, + json: async () => ({ token: 'jwt', godmode: true, expires_at: Math.floor(Date.now() / 1000) + 12 * 60 * 60 }), + }); + await mintGodmodeToken(APP, { fetchImpl: minted, apiOrigin: 'https://api.test', authToken: 'auth-token' }); + expect(reusableGodmodeToken(APP, { authToken: 'auth-token' })).not.toBeNull(); + + const { fetchImpl } = makeFetch({ revokeStatus: () => { + throw new Error('offline'); + } }); + await expect(revokeAppSessions(APP, deps({ fetchImpl }))).rejects.toThrow('offline'); + expect(reusableGodmodeToken(APP, { authToken: 'auth-token' })).toBeNull(); + }); + it('does nothing without an app uid', async () => { const { fetchImpl, calls } = makeFetch(); expect(await revokeAppSessions('', deps({ fetchImpl }))).toBe(0);