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);