/*
* 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 .
*/
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('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) => {
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);
});
});