mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-05 21:46:51 +00:00
* 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.
427 lines
16 KiB
TypeScript
427 lines
16 KiB
TypeScript
/*
|
|
* Copyright (C) 2024-present Puter Technologies Inc.
|
|
*
|
|
* This file is part of Puter.
|
|
*
|
|
* Puter is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License as published
|
|
* by the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
import { isAccountContext } from '../../core/actor.js';
|
|
import type { AccessTokenPayload } from '../../services/auth/types.js';
|
|
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
|
|
|
|
// Seeded with godmode on.
|
|
const DEV_CENTER = 'app-0b37f054-07d4-4627-8765-11bd23e889d4';
|
|
const TWELVE_HOURS = 12 * 60 * 60;
|
|
|
|
/**
|
|
* A godmode app launched from the desktop runs on a full-access token of its
|
|
* own: tied to the desktop session that asked for it, carrying the app for
|
|
* attribution, and expiring unless the desktop asks again.
|
|
*/
|
|
describe('godmode app tokens', () => {
|
|
let env: PuterTestEnv;
|
|
let userId = 0;
|
|
|
|
beforeAll(async () => {
|
|
env = await setupPuterTestEnv();
|
|
const row = await env.server.stores.user.getByUsername(
|
|
env.users.user.username,
|
|
);
|
|
userId = row!.id;
|
|
}, 120_000);
|
|
|
|
afterAll(async () => {
|
|
await env?.shutdown();
|
|
});
|
|
|
|
const call = (
|
|
method: string,
|
|
path: string,
|
|
token: string,
|
|
body?: unknown,
|
|
) =>
|
|
fetch(new URL(path, env.apiOrigin), {
|
|
method,
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
authorization: `Bearer ${token}`,
|
|
},
|
|
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
});
|
|
|
|
const mint = async (sessionToken: string, appUid = DEV_CENTER) => {
|
|
const res = await call(
|
|
'POST',
|
|
'/auth/get-user-app-token',
|
|
sessionToken,
|
|
{ app_uid: appUid },
|
|
);
|
|
expect(res.status).toBe(200);
|
|
return (await res.json()) as {
|
|
token: string | null;
|
|
app_uid: string;
|
|
godmode?: boolean;
|
|
expires_at?: number;
|
|
};
|
|
};
|
|
|
|
const decode = (token: string) =>
|
|
env.server.services.token.verify<AccessTokenPayload>('auth', token);
|
|
|
|
const live = async (token: string) =>
|
|
(await env.server.services.auth.authenticate(token)).actor !== undefined;
|
|
|
|
/** A read token minted the way `getReadURL()` does, by `minter`. */
|
|
const mintScoped = async (minter: string) => {
|
|
const res = await call('POST', '/auth/create-access-token', minter, {
|
|
permissions: ['service:foo:ii:read'],
|
|
expiresIn: '7d',
|
|
});
|
|
expect(res.status).toBe(200);
|
|
return ((await res.json()) as { token: string }).token;
|
|
};
|
|
|
|
/** Rewrite a session row in place, as time passing would. */
|
|
const setRow = async (uuid: string, fields: Record<string, number>) => {
|
|
const cols = Object.keys(fields);
|
|
await env.server.clients.db.write(
|
|
`UPDATE \`sessions\` SET ${cols.map((c) => `\`${c}\` = ?`).join(', ')} WHERE \`uuid\` = ?`,
|
|
[...cols.map((c) => fields[c]), uuid],
|
|
);
|
|
await env.server.stores.session.publishCacheKeys({
|
|
keys: [`sessions:v2:uuid:${uuid}`],
|
|
});
|
|
};
|
|
|
|
/** A desktop session of its own, so revoking it leaves the shared one up. */
|
|
const newDesktopSession = async () => {
|
|
const user = await env.server.stores.user.getById(userId);
|
|
return env.server.services.auth.createSessionToken(user!);
|
|
};
|
|
|
|
/** An app row of its own, so demoting it disturbs nothing else. */
|
|
const makeApp = async ({
|
|
name = `gm-${crypto.randomUUID().slice(0, 8)}`,
|
|
godmode = true,
|
|
} = {}) => {
|
|
const uid = `app-${crypto.randomUUID()}`;
|
|
await env.server.clients.db.write(
|
|
'INSERT INTO `apps` (`uid`, `owner_user_id`, `name`, `title`, `index_url`, `godmode`, `timestamp`) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)',
|
|
[uid, userId, name, 'Test App', `https://${name}.test/`, godmode ? 1 : 0],
|
|
);
|
|
return { uid, name };
|
|
};
|
|
|
|
it('issues a full-access token that carries the app', async () => {
|
|
const before = Math.floor(Date.now() / 1000);
|
|
const body = await mint(env.users.user.token);
|
|
|
|
expect(body).toMatchObject({ app_uid: DEV_CENTER, godmode: true });
|
|
expect(body.expires_at).toBeGreaterThanOrEqual(before + TWELVE_HOURS);
|
|
|
|
const claims = decode(body.token!);
|
|
expect(claims).toMatchObject({
|
|
type: 'access-token',
|
|
full_access: true,
|
|
godmode_app_uid: DEV_CENTER,
|
|
});
|
|
expect(claims.app_uid).toBeUndefined();
|
|
// The row's expiry is what lapses; the JWT itself never does.
|
|
expect(claims.exp).toBeUndefined();
|
|
|
|
const { actor } = await env.server.services.auth.authenticate(
|
|
body.token!,
|
|
);
|
|
expect(actor).toBeDefined();
|
|
expect(isAccountContext(actor!)).toBe(true);
|
|
expect(actor!.effectiveApp).toBeNull();
|
|
expect(actor!.accessToken).toMatchObject({
|
|
fullAccess: true,
|
|
godmodeApp: { uid: DEV_CENTER },
|
|
});
|
|
});
|
|
|
|
it('reaches the account surface but not account management', async () => {
|
|
const { token } = await mint(env.users.user.token);
|
|
|
|
expect((await call('GET', '/whoami', token!)).status).toBe(200);
|
|
expect((await call('GET', '/get-dev-profile', token!)).status).toBe(
|
|
200,
|
|
);
|
|
expect((await call('GET', '/auth/list-sessions', token!)).status).toBe(
|
|
403,
|
|
);
|
|
// It can't mint its own replacement; only the desktop can.
|
|
const self = await call('POST', '/auth/get-user-app-token', token!, {
|
|
app_uid: DEV_CENTER,
|
|
});
|
|
expect(self.status).toBe(403);
|
|
});
|
|
|
|
it('keeps one token per desktop session across renewals', async () => {
|
|
const desktop = await newDesktopSession();
|
|
const first = await mint(desktop.gui_token);
|
|
const second = await mint(desktop.gui_token);
|
|
|
|
expect(decode(second.token!).token_uid).toBe(
|
|
decode(first.token!).token_uid,
|
|
);
|
|
// 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);
|
|
|
|
const row = await env.server.stores.session.getByUuidAny(
|
|
decode(second.token!).session_uid!,
|
|
);
|
|
expect(row).toMatchObject({
|
|
kind: 'access_token',
|
|
app_uid: DEV_CENTER,
|
|
expires_at: second.expires_at,
|
|
});
|
|
});
|
|
|
|
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);
|
|
expect((await call('GET', '/whoami', token!)).status).toBe(200);
|
|
const row = await env.server.stores.session.getByUuidAny(
|
|
decode(token!).session_uid!,
|
|
);
|
|
expect(row?.expires_at).toBe(expires_at);
|
|
});
|
|
|
|
it('lists the token under its app for the account', async () => {
|
|
await mint(env.users.user.token);
|
|
const res = await call(
|
|
'GET',
|
|
'/auth/list-sessions',
|
|
env.users.user.token,
|
|
);
|
|
const sessions = (await res.json()) as Array<{
|
|
kind: string;
|
|
app_uid: string | null;
|
|
app: { name?: string } | null;
|
|
}>;
|
|
const row = sessions.find(
|
|
(s) => s.kind === 'access_token' && s.app_uid === DEV_CENTER,
|
|
);
|
|
expect(row?.app?.name).toBe('dev-center');
|
|
});
|
|
|
|
it('installs the app like any other launch, and uninstalls the same way', async () => {
|
|
const { uid } = await makeApp();
|
|
const app = await env.server.stores.app.getByUid(uid);
|
|
const installed = () =>
|
|
env.server.stores.permission.hasUserAppPerm(
|
|
userId,
|
|
app!.id,
|
|
'flag:app-is-authenticated',
|
|
);
|
|
|
|
const { token } = await mint(env.users.user.token, uid);
|
|
expect(await installed()).toBe(true);
|
|
expect(
|
|
await env.server.stores.fsEntry.getEntryByPath(
|
|
`/${env.users.user.username}/AppData/${uid}`,
|
|
),
|
|
).toBeTruthy();
|
|
|
|
// What the desktop's uninstall does: drop the grants, then the app's sessions.
|
|
const revoked = await call(
|
|
'POST',
|
|
'/auth/revoke-user-app',
|
|
env.users.user.token,
|
|
{ app_uid: uid, permission: '*' },
|
|
);
|
|
expect(revoked.status).toBe(200);
|
|
expect(await installed()).toBe(false);
|
|
|
|
await env.server.services.auth.revokeSession(decode(token!).session_uid!);
|
|
expect(await live(token!)).toBe(false);
|
|
});
|
|
|
|
it('dies with the desktop session that minted it', async () => {
|
|
const desktop = await newDesktopSession();
|
|
const { token } = await mint(desktop.gui_token);
|
|
expect((await call('GET', '/whoami', token!)).status).toBe(200);
|
|
|
|
await env.server.services.auth.revokeSession(
|
|
desktop.session.uuid as string,
|
|
);
|
|
|
|
const res = await call('GET', '/whoami', token!);
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it('stops working once its app is no longer godmode', async () => {
|
|
const app = await makeApp();
|
|
const { token } = await mint(env.users.user.token, app.uid);
|
|
expect((await call('GET', '/whoami', token!)).status).toBe(200);
|
|
|
|
const row = await env.server.stores.app.getByUid(app.uid);
|
|
await env.server.clients.db.write(
|
|
'UPDATE `apps` SET `godmode` = 0 WHERE `uid` = ?',
|
|
[app.uid],
|
|
);
|
|
await env.server.stores.app.invalidate(row);
|
|
|
|
expect((await call('GET', '/whoami', token!)).status).toBe(401);
|
|
});
|
|
|
|
it('still gives an ordinary app an ordinary app token', async () => {
|
|
const app = await makeApp({ godmode: false });
|
|
const body = await mint(env.users.user.token, app.uid);
|
|
expect(body.godmode).toBeUndefined();
|
|
expect(decode(body.token!).type).toBe('app-under-user');
|
|
});
|
|
|
|
it('mints scoped tokens that are revoked along with it', async () => {
|
|
const { token } = await mint(env.users.user.token);
|
|
const child = await call(
|
|
'POST',
|
|
'/auth/create-access-token',
|
|
token!,
|
|
{ permissions: ['service:foo:ii:read'], expiresIn: '1h' },
|
|
);
|
|
expect(child.status).toBe(200);
|
|
const childToken = ((await child.json()) as { token: string }).token;
|
|
expect(
|
|
(await env.server.services.auth.authenticate(childToken)).actor,
|
|
).toBeDefined();
|
|
|
|
const full = await call('POST', '/auth/create-access-token', token!, {
|
|
permissions: ['full-api-access'],
|
|
});
|
|
expect(full.status).toBe(403);
|
|
|
|
const { actor } = await env.server.services.auth.authenticate(
|
|
env.users.user.token,
|
|
);
|
|
await env.server.services.auth.revokeAccessToken(actor!, token!);
|
|
|
|
expect(
|
|
(await env.server.services.auth.authenticate(childToken)).actor,
|
|
).toBeUndefined();
|
|
});
|
|
|
|
it('stays dead once it lapses; the desktop asking again gets a new one', async () => {
|
|
const desktop = await newDesktopSession();
|
|
const { token } = await mint(desktop.gui_token);
|
|
const child = await mintScoped(token!);
|
|
expect(await live(child)).toBe(true);
|
|
|
|
// The desktop stopped renewing it: its row's expiry passes.
|
|
await setRow(decode(token!).session_uid!, {
|
|
expires_at: Math.floor(Date.now() / 1000) - 1,
|
|
});
|
|
expect(await live(token!)).toBe(false);
|
|
expect(await live(child)).toBe(false);
|
|
|
|
const next = await mint(desktop.gui_token);
|
|
expect(decode(next.token!).token_uid).not.toBe(decode(token!).token_uid);
|
|
expect(await live(next.token!)).toBe(true);
|
|
expect(await live(token!)).toBe(false);
|
|
expect(await live(child)).toBe(false);
|
|
});
|
|
|
|
it('is replaced and revoked once past its max age, however often renewed', async () => {
|
|
const desktop = await newDesktopSession();
|
|
const { token } = await mint(desktop.gui_token);
|
|
const child = await mintScoped(token!);
|
|
await setRow(decode(token!).session_uid!, {
|
|
created_at: Math.floor(Date.now() / 1000) - 8 * 24 * 60 * 60,
|
|
});
|
|
|
|
const next = await mint(desktop.gui_token);
|
|
expect(decode(next.token!).token_uid).not.toBe(decode(token!).token_uid);
|
|
expect(await live(next.token!)).toBe(true);
|
|
expect(await live(token!)).toBe(false);
|
|
expect(await live(child)).toBe(false);
|
|
});
|
|
|
|
it('lets the tokens it minted die with the desktop session', async () => {
|
|
const desktop = await newDesktopSession();
|
|
const { token } = await mint(desktop.gui_token);
|
|
const child = await mintScoped(token!);
|
|
expect(await live(child)).toBe(true);
|
|
|
|
await env.server.services.auth.revokeSession(
|
|
desktop.session.uuid as string,
|
|
);
|
|
|
|
expect(await live(token!)).toBe(false);
|
|
expect(await live(child)).toBe(false);
|
|
});
|
|
|
|
it('reaches the AI wire routes, where a gate listener may waive the plan', async () => {
|
|
const { token } = await mint(env.users.user.token);
|
|
const route = '/puterai/openai/v1/chat/completions';
|
|
const send = (bearer: string) =>
|
|
fetch(new URL(route, env.apiOrigin), {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${bearer}`,
|
|
},
|
|
body: JSON.stringify({
|
|
model: 'fake',
|
|
provider: 'fake-chat',
|
|
messages: [{ role: 'user', content: 'hi' }],
|
|
max_tokens: 16,
|
|
}),
|
|
});
|
|
|
|
// Past the credential gates, stopped only by the plan.
|
|
expect((await send(token!)).status).toBe(402);
|
|
|
|
env.server.clients.event.on(
|
|
'subscription.gate.route.post.puterai.openai.v1.chat.completions',
|
|
(_key, event) => {
|
|
const gate = event as {
|
|
actor: { accessToken?: { godmodeApp?: { uid: string } } };
|
|
allow: boolean;
|
|
};
|
|
if (gate.actor.accessToken?.godmodeApp?.uid === DEV_CENTER) {
|
|
gate.allow = true;
|
|
}
|
|
},
|
|
);
|
|
expect((await send(token!)).status).not.toBe(402);
|
|
expect((await send(env.users.user.apiToken)).status).toBe(402);
|
|
});
|
|
});
|