mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-20 18:42:24 +00:00
perf: cut godmode app launch latency (cached token renewal, launch reuse) (#4221)
* perf(auth): renew godmode app tokens from cache instead of the primary Every godmode launch read its token row from the primary and rewrote its expiry, a few cross-region round trips per launch far from the primary. - A cache pointer names the row per (user, web session, app). The row is read through the uuid cache token auth uses and re-checked (owner, session, app, revoked, expired, max age); anything else falls back to the primary read. Revoking the row drops the pointer. - The expiry write is skipped when it would move by under an hour. A renewal that hits a revoked or lapsed row mints a new one instead. - The response carries the row's actual expires_at. * perf(gui): reuse a just-minted godmode token for repeat launches A launch of a godmode app reuses the token this page minted for it within the last ten minutes under the same desktop session, instead of asking again. The cache is dropped when an app reports its token dead, after a session revoke from the session manager or an uninstall, and across tabs via a BroadcastChannel that carries token row ids, never tokens. A mint in flight during a drop is not remembered.
This commit is contained in:
1 parent
f37cb22d8a
commit
b52fc01712
10 files changed
+641
-77
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -17,12 +17,14 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<string, {
|
||||
@@ -44,6 +47,21 @@ const RENEW_COOLDOWN_MS = 30 * 1000;
|
||||
const tracked = new Map();
|
||||
let checkTimer = null;
|
||||
|
||||
/**
|
||||
* The last token minted for each godmode app, and the desktop session token it
|
||||
* was minted with.
|
||||
*
|
||||
* @type {Map<string, {
|
||||
* token: string;
|
||||
* expiresAt: number;
|
||||
* mintedAt: number;
|
||||
* authToken: string;
|
||||
* }>}
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -17,10 +17,22 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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;
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user