Compare commits

...
Author SHA1 Message Date
Daniel Salazar d4aaae58ae feat(gui): a top-up asked for from a page names the app it runs as
The framed top-up passes the deployment's dialog the app the framing page's
origin resolves to, as the framed upgrade does. The dashboard link puter.js
opens when no frame can show carries the app in `topup_app`.
2026-11-13 20:18:27 -07:00
Daniel Salazar b2eb8ed895 feat(auth): a first-party page served on its own origin signs in with a session of its own (#4635)
A deployment can list origins of its own pages in
`first_party_session_origins`. When such a page asks the GUI to sign it in,
the sign-in hands it a GUI token on a `web` session of its own, parented to
the user's sign-in, instead of an app token.

The session is minted by POST /auth/first-party-session, which only the GUI's
own page script may call, and only as the user's sign-in itself: app tokens,
access tokens and a session minted this way are refused. It never gets a
session cookie, ends with the sign-in it was handed out under (revoking either
takes the tokens minted under the page along), is not counted as a sign-in,
and stays up with its sign-in when all other sessions are revoked.

The sessions list shows it under its sign-in, titled with the page's host and
badged as a site session.
2026-11-13 17:47:57 -07:00
Daniel Salazar d1cbfb9fb1 fix(puter-js): a godmode app opened in its own tab stays signed in across a reload (#4632)
A tab the desktop opened for an app boots the SDK as a top-level page, where
a session-type token was kept in memory only. Once the desktop hands such a
tab its launch token, the SDK now keeps it in sessionStorage, as a framed app
does, and a reload of that tab reads it back before any stored app token.

A page that wasn't handed a token this way still persists no session token.
2026-11-13 15:46:17 -07:00
Neal ShahandClaude Opus 5.5 ac44de819d fix(hosting): choose the site and subdomain from Host, not X-Forwarded-Host (#4459)
With trust proxy on, express derives req.hostname and req.subdomains from
X-Forwarded-Host, which any client can send, while host validation reads the
raw Host. A request could serve one hosted site's files on the main domain,
or reach the API on a hosting host. The header is now dropped before routing.

Fixes PUT-2272

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-13 14:56:16 -05:00
24 changed files with 912 additions and 34 deletions

No files matched your search

@@ -4487,6 +4487,34 @@ export class AuthController extends PuterController {
res.json({ token: guiToken });
}
@Post('/auth/first-party-session', {
subdomain: 'api',
rateLimit: SESSION_LIMIT,
// Hands out a session, so only the GUI's own page script may ask, and
// only as the user's sign-in itself.
guiOriginOnly: true,
requireUserActor: true,
})
async handleFirstPartySession(req: Request, res: Response): Promise<void> {
const { origin } = req.body ?? {};
if (typeof origin !== 'string' || !origin) {
throw new HttpError(400, 'Missing `origin`', {
legacyCode: 'bad_request',
});
}
const token = await this.services.auth.createFirstPartySessionToken(
req.actor!,
origin,
{
ip: req.ip || req.socket?.remoteAddress,
user_agent: req.headers?.['user-agent'],
origin: req.headers?.origin,
host: req.headers?.host,
},
);
res.json({ token });
}
@Get('/session/sync-cookie', {
rateLimit: SESSION_LIMIT,
// Installs the session cookie. Only page script on our own origin
@@ -4511,6 +4539,14 @@ export class AuthController extends PuterController {
res.status(400).end();
return;
}
// A session handed to a first-party page never gets a cookie.
if (req.actor.session.parentUid) {
throw new HttpError(
403,
'This session cannot install a session cookie',
{ legacyCode: 'forbidden' },
);
}
// As in `/get-gui-token`: the actor is the user's row as auth read it.
const sessionToken = this.services.auth.createSessionTokenForSession(
req.actor.user as UserRow,
@@ -0,0 +1,282 @@
/*
* 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 type { IConfig } from '../../types.js';
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
const PAGE = 'https://desk.first-party.test';
// Seeded with godmode on.
const DEV_CENTER = 'app-0b37f054-07d4-4627-8765-11bd23e889d4';
/**
* One of the deployment's own pages, served apart from the GUI, signs in with a
* `web` session of its own under the user's sign-in.
*/
describe('first-party page sessions', () => {
let env: PuterTestEnv;
let userId = 0;
beforeAll(async () => {
env = await setupPuterTestEnv({
first_party_session_origins: [`${PAGE}/`],
} as IConfig);
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,
headers: Record<string, string> = {},
) =>
fetch(new URL(path, env.apiOrigin), {
method,
headers: {
'content-type': 'application/json',
authorization: `Bearer ${token}`,
...headers,
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
/** A sign-in of its own, so revoking it leaves the shared one up. */
const signIn = async () => {
const user = await env.server.stores.user.getById(userId);
const { gui_token, session } =
await env.server.services.auth.createSessionToken(user!);
return { token: gui_token, uuid: session.uuid as string };
};
const mint = async (token: string, origin = PAGE) => {
const res = await call('POST', '/auth/first-party-session', token, {
origin,
});
expect(res.status).toBe(200);
return ((await res.json()) as { token: string }).token;
};
const actorOf = async (token: string) =>
(await env.server.services.auth.authenticate(token)).actor;
const sessions = async (token: string) => {
const res = await call('GET', '/auth/list-sessions', token);
expect(res.status).toBe(200);
return (await res.json()) as Array<Record<string, unknown>>;
};
it('hands the page a session of its own under the sign-in', async () => {
const root = await signIn();
const token = await mint(root.token);
const actor = await actorOf(token);
expect(actor?.app ?? null).toBeNull();
expect(actor?.accessToken ?? null).toBeNull();
expect(actor?.session).toMatchObject({
kind: 'web',
parentUid: root.uuid,
});
expect(actor?.session?.uid).not.toBe(root.uuid);
const row = (await sessions(root.token)).find(
(s) => s.uuid === actor!.session!.uid,
);
expect(row).toMatchObject({
kind: 'web',
parent_session_id: root.uuid,
first_party_origin: PAGE,
current: false,
});
});
it('reuses the session on a second ask', async () => {
const root = await signIn();
const first = await actorOf(await mint(root.token));
const second = await actorOf(await mint(root.token));
expect(second?.session?.uid).toBe(first?.session?.uid);
});
it('acts as the account: launches apps, godmode ones included', async () => {
const root = await signIn();
const token = await mint(root.token);
const res = await call('POST', '/auth/get-user-app-token', token, {
app_uid: DEV_CENTER,
});
expect(res.status).toBe(200);
expect(await res.json()).toMatchObject({ godmode: true });
});
it('refuses an origin that is not listed', async () => {
const root = await signIn();
const res = await call(
'POST',
'/auth/first-party-session',
root.token,
{
origin: 'https://elsewhere.test',
},
);
expect(res.status).toBe(403);
});
it('refuses a page on another origin, the listed page included', async () => {
const root = await signIn();
for (const origin of [PAGE, 'https://elsewhere.test']) {
const res = await call(
'POST',
'/auth/first-party-session',
root.token,
{ origin: PAGE },
{ origin },
);
expect(res.status).toBe(403);
}
});
it('refuses app tokens, access tokens and a first-party session', async () => {
const root = await signIn();
const page = await mint(root.token);
const app = (await (
await call('POST', '/auth/get-user-app-token', root.token, {
origin: 'https://some-app.test',
})
).json()) as { token: string };
const godmode = (await (
await call('POST', '/auth/get-user-app-token', root.token, {
app_uid: DEV_CENTER,
})
).json()) as { token: string };
for (const token of [page, app.token, godmode.token]) {
const res = await call('POST', '/auth/first-party-session', token, {
origin: PAGE,
});
expect(res.status).toBe(403);
}
});
it('never gets a session cookie', async () => {
const root = await signIn();
const page = await mint(root.token);
const syncCookie = (token: string) =>
fetch(new URL('/session/sync-cookie', env.origin), {
headers: { authorization: `Bearer ${token}` },
});
expect((await syncCookie(root.token)).status).toBe(204);
const res = await syncCookie(page);
expect(res.status).toBe(403);
expect(res.headers.get('set-cookie')).toBeNull();
});
it('ends with the sign-in it was handed out under', async () => {
const root = await signIn();
const page = await mint(root.token);
expect(await actorOf(page)).toBeDefined();
await env.server.services.auth.revokeSession(root.uuid);
const result = await env.server.services.auth.authenticate(page);
expect(result.actor).toBeUndefined();
expect(result.reauth?.reason).toBe('session_revoked');
});
it('takes the tokens minted under it along when its sign-in ends', async () => {
const root = await signIn();
const page = await mint(root.token);
const godmode = (await (
await call('POST', '/auth/get-user-app-token', page, {
app_uid: DEV_CENTER,
})
).json()) as { token: string };
const scoped = (await (
await call('POST', '/auth/create-access-token', godmode.token, {
permissions: ['service:foo:ii:read'],
expiresIn: '7d',
})
).json()) as { token: string };
expect(await actorOf(scoped.token)).toBeDefined();
await env.server.services.auth.revokeSession(root.uuid);
for (const token of [godmode.token, scoped.token]) {
expect(
(await env.server.services.auth.authenticate(token)).actor,
).toBeUndefined();
}
});
it('is revoked on its own without ending the sign-in', async () => {
const root = await signIn();
const page = await mint(root.token);
const pageUuid = (await actorOf(page))!.session!.uid;
await env.server.services.auth.revokeSession(pageUuid);
expect(
(await env.server.services.auth.authenticate(page)).actor,
).toBeUndefined();
expect(await actorOf(root.token)).toBeDefined();
});
it('keeps the whole current sign-in when revoking all others', async () => {
const root = await signIn();
const page = await mint(root.token);
const other = await signIn();
const otherPage = await mint(other.token);
// Asked from the page: its sign-in stays, and so does the page.
await env.server.services.auth.revokeAllSessions(
(await actorOf(page))!,
);
expect(await actorOf(page)).toBeDefined();
expect(await actorOf(root.token)).toBeDefined();
expect(
(await env.server.services.auth.authenticate(other.token)).actor,
).toBeUndefined();
expect(
(await env.server.services.auth.authenticate(otherPage)).actor,
).toBeUndefined();
// Asked from the sign-in: the page under it stays too.
await env.server.services.auth.revokeAllSessions(
(await actorOf(root.token))!,
);
expect(await actorOf(page)).toBeDefined();
});
it('is not counted as a sign-in', async () => {
const root = await signIn();
const before = await env.server.stores.session.listSignIns(userId, {
limit: 100,
});
await mint(root.token);
const after = await env.server.stores.session.listSignIns(userId, {
limit: 100,
});
expect(after.length).toBe(before.length);
});
});
+7 -1
View File
@@ -76,8 +76,14 @@ export interface Actor {
/**
* Set when authenticated by a session token or an app-under-user token
* carrying one. `kind` mirrors the session row (`web`, `app`, `worker`).
* `parentUid` is the sign-in a `web` session was handed out under, set only
* on a first-party page's session.
*/
session?: { uid: string; kind?: string | null } | null;
session?: {
uid: string;
kind?: string | null;
parentUid?: string | null;
} | null;
/**
* Events handler runs behind this actor's writes; set only from an events
* handler token or a token one minted. Provenance, not authority, so
@@ -26,7 +26,7 @@ import type { IConfig } from '../../../types';
* Browsers send none of those variations, but config values do — a hand-edited
* `origin` with a trailing slash would otherwise reject every real request.
*/
const normalizeOrigin = (raw: string | undefined): string =>
export const normalizeOrigin = (raw: string | undefined): string =>
(raw ?? '').trim().replace(/\/+$/, '').toLowerCase();
/**
+53
View File
@@ -645,6 +645,59 @@ describe('PuterServer host header validation — permissive modes', () => {
});
});
describe('PuterServer behind a trusted proxy', () => {
let server: PuterServer;
let port: number;
beforeAll(async () => {
port = await allocateEphemeralPort();
server = await setupTestServer(
{
port,
domain: 'puter.localhost',
origin: `http://puter.localhost:${port}`,
api_base_url: `http://api.puter.localhost:${port}`,
allow_all_host_values: false,
allow_no_host_header: false,
custom_domains_enabled: false,
static_hosting_domain: 'site.puter.localhost',
trust_proxy: 1,
} as unknown as IConfig,
{ listen: true },
);
});
afterAll(async () => {
await server?.shutdown();
});
it('serves the main domain whatever X-Forwarded-Host names', async () => {
const res = await rawRequest(port, '/healthcheck', {
host: `puter.localhost:${port}`,
'x-forwarded-host': 'nosuchsite.site.puter.localhost',
});
expect(res.status).toBe(200);
expect(res.headers['x-frame-options']).toBe('SAMEORIGIN');
});
it('keeps a hosted-site host on site hosting', async () => {
const res = await rawRequest(port, '/healthcheck', {
host: `nosuchsite.site.puter.localhost:${port}`,
'x-forwarded-host': 'api.puter.localhost',
});
expect(res.status).toBe(404);
});
it('reads subdomains from Host', async () => {
const res = await rawRequest(port, '/healthcheck', {
host: `api.puter.localhost:${port}`,
'x-forwarded-host': 'puter.localhost',
});
expect(res.status).toBe(200);
expect(res.headers['x-frame-options']).toBeUndefined();
});
});
describe('PuterServer host header validation — custom domains with every host accepted', () => {
let server: PuterServer;
let port: number;
+11
View File
@@ -416,6 +416,17 @@ export class PuterServer {
* `#materializeRoute` as those options ship.
*/
#installGlobalMiddleware() {
// -- Forwarded host ------------------------------------------
// With `trust proxy` set, express derives `req.hostname` and
// `req.subdomains` from X-Forwarded-Host, which any client can send.
// Dropping it keeps them on Host, the header host validation checks,
// so a request can't pick a different site or subdomain router than
// the host it was sent to.
this.#app.use((req, _res, next) => {
delete req.headers['x-forwarded-host'];
next();
});
// -- Egress metering -----------------------------------------
// First, so the byte counter wraps `res.write` before compression
// does and therefore counts what actually goes out rather than what
+95 -6
View File
@@ -26,6 +26,7 @@ import {
type Actor,
} from '../../core/actor';
import { HttpError } from '../../core/http/HttpError.js';
import { normalizeOrigin } from '../../core/http/middleware/originGate.js';
import { WEB_AND_EXTENSION_PROTOCOLS } from '../../util/validation.js';
import { awaitLater } from '../../util/concurrency.js';
import { appGenerationStart } from '../../util/appGeneration.js';
@@ -393,6 +394,68 @@ export class AuthService extends PuterService {
);
}
/**
* Whether `origin` is one of the deployment's own pages that sign in with a
* session of their own (`first_party_session_origins`).
*/
isFirstPartySessionOrigin(origin: unknown): boolean {
if (typeof origin !== 'string' || !origin) return false;
const wanted = normalizeOrigin(origin);
return (this.config.first_party_session_origins ?? []).some(
(listed) => normalizeOrigin(listed) === wanted,
);
}
/**
* Mint the GUI token one of the deployment's own pages signs in with: a
* `web` session of its own under the caller's sign-in, so it is listed and
* revoked apart from that sign-in and ends with it. Only a sign-in itself
* may ask, never a session handed out this way. No session cookie is ever
* issued for the row.
*/
async createFirstPartySessionToken(
actor: Actor,
origin: string,
meta: Record<string, unknown> = {},
): Promise<string> {
if (
!isPlainUserActor(actor) ||
!actor.user?.id ||
!actor.user.uuid ||
actor.session?.kind !== 'web' ||
actor.session.parentUid
) {
throw new HttpError(
403,
'Only a signed-in browser session can sign this page in',
{ legacyCode: 'forbidden' },
);
}
if (!this.isFirstPartySessionOrigin(origin)) {
throw new HttpError(403, 'This origin cannot be signed in', {
legacyCode: 'forbidden',
});
}
const auth_id = this.#authIdFor(actor.user as UserRow);
const row = await this.stores.session.getOrCreateFirstPartySession(
actor.user.id,
{
parentSessionUid: actor.session.uid,
origin: normalizeOrigin(origin),
expiresAt: nowSeconds() + WEB_WINDOW_SECONDS,
meta,
auth_id,
},
);
if (!row?.uuid) {
throw new HttpError(500, 'Could not sign this page in', {
legacyCode: 'internal_error',
});
}
return this.createGuiToken(actor.user as UserRow, row.uuid);
}
/** Sign a session token for an existing session (upgrade from GUI token). */
createSessionTokenForSession(user: UserRow, sessionUuid: string): string {
return this.#signSessionTypeToken(
@@ -790,7 +853,7 @@ export class AuthService extends PuterService {
row.kind !== 'asset' &&
// Operator-created debug sessions are never the user's own.
row.kind !== DEBUG_APP_SESSION_KIND &&
(row.kind !== 'access_token' ||
((row.kind !== 'access_token' && row.kind !== 'web') ||
!row.parent_session_id ||
live.has(row.parent_session_id)),
);
@@ -1097,16 +1160,24 @@ export class AuthService extends PuterService {
legacyCode: 'forbidden',
});
}
const currentUuid = actor.session?.uid;
const rows = await this.stores.session.getByUserId(
// The current sign-in is the session, the sign-in it was handed out
// under, and the sessions handed out under either.
const current = new Set(
[actor.session?.uid, actor.session?.parentUid].filter(Boolean),
);
const rows = (await this.stores.session.getByUserId(
actor.user.id as number,
{ primary: true },
);
)) as SessionRow[];
await this.#revokeCascades(
rows
.filter((row) =>
row.kind === 'web'
? opts.includeCurrent || row.uuid !== currentUuid
? opts.includeCurrent ||
!(
current.has(row.uuid) ||
current.has(row.parent_session_id ?? '')
)
: row.kind === 'app' && opts.includeApps,
)
.map((row) => row.uuid as string),
@@ -2753,6 +2824,18 @@ export class AuthService extends PuterService {
const session: SessionRow | null = rawRow;
if (!session) return { invalid: true };
// A session handed to a first-party page lives no longer than the
// sign-in it was handed out under.
if (session.kind === 'web' && session.parent_session_id) {
const parent = (await this.stores.session.getByUuidAny(
session.parent_session_id,
)) as SessionRow | null;
if (!parent) {
return { reauth: { reason: 'session_revoked', auth_id } };
}
const parentEnded = this.#endedSession(parent, auth_id);
if (parentEnded) return parentEnded;
}
if (!(await deployed)) return { invalid: true };
void this.#touchSession(session.uuid, user.id, ctx);
@@ -3045,7 +3128,13 @@ export class AuthService extends PuterService {
return makeActor({
user: this.#actorUserFromRow(user),
session: session
? { uid: session.uuid, kind: session.kind ?? null }
? {
uid: session.uuid,
kind: session.kind ?? null,
...(session.kind === 'web' && session.parent_session_id
? { parentUid: session.parent_session_id }
: {}),
}
: null,
});
}
@@ -399,8 +399,11 @@ export class PuterHomepageService extends PuterService {
// instead of the socket wire.
eventsNotifications: notificationsFoldInEnabled(this.config),
...meta,
// After `meta`, so nothing page-specific can name the opener.
// After `meta`, so nothing page-specific can name the opener or
// the pages a sign-in popup hands a session.
...(popupFrame ? { embed_host_origin: popupFrame.hostOrigin } : {}),
first_party_session_origins:
this.config.first_party_session_origins ?? [],
};
// Only ever this request's; without one the GUI asks for its token as
// it always has.
+72 -3
View File
@@ -63,6 +63,9 @@ export const ASSET_WINDOW_SECONDS = 7 * 24 * 60 * 60; // 7 days
// An app session created on a user's behalf for debugging: hard expiry, never
// slid, and hidden from the user's own session list.
export const DEBUG_APP_SESSION_KIND = 'debug_app';
// `created_via` of a `web` row handed to one of the deployment's own pages
// under another sign-in (its `parent_session_id`).
export const FIRST_PARTY_SESSION_VIA = 'first_party';
// 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.
@@ -166,7 +169,8 @@ export class SessionStore extends PuterStore {
* Interactive sign-ins for a user, newest first, for the account's own
* activity view. Revoked rows are included -- a session someone opened and
* closed is exactly what the reader is looking for. Derived kinds (app,
* access token, asset, worker) are not sign-ins and stay out.
* access token, asset, worker) and sessions handed out under another
* sign-in are not sign-ins and stay out.
*
* Uncached and keyset-paginated on `id`: the caller merges this with
* another stream, so it asks for one page at a time rather than the whole
@@ -179,7 +183,7 @@ export class SessionStore extends PuterStore {
async listSignIns(userId, { limit, beforeId = null }) {
const rows = await this.clients.db.read(
'SELECT `id`, `created_at`, `last_ip`, `last_user_agent` FROM `sessions` ' +
"WHERE `user_id` = ? AND `kind` = 'web'" +
"WHERE `user_id` = ? AND `kind` = 'web' AND `parent_session_id` IS NULL" +
(beforeId === null ? '' : ' AND `id` < ?') +
' ORDER BY `id` DESC LIMIT ?',
beforeId === null ? [userId, limit] : [userId, beforeId, limit],
@@ -493,13 +497,19 @@ export class SessionStore extends PuterStore {
* sessions costs a few primary round trips rather than a few per session.
* Returns `{ userId, uuids }` for each user that had rows revoked.
*
* A first-party page's session, revoked under its sign-in, is cascaded from
* in turn: it has derived rows of its own.
*
* @param {string[]} rootUuids
*/
async revokeCascades(rootUuids) {
const roots = [...new Set(rootUuids.filter(Boolean).map(String))];
const queued = new Set(roots);
const uuidsByUser = new Map();
for (let i = 0; i < roots.length; i += REVOKE_CHUNK_SIZE) {
// `roots` grows as it is walked, so each pass takes what is there.
for (let i = 0; i < roots.length; ) {
const chunk = roots.slice(i, i + REVOKE_CHUNK_SIZE);
i += chunk.length;
const placeholders = chunk.map(() => '?').join(', ');
const scope = `(\`uuid\` IN (${placeholders}) OR \`parent_session_id\` IN (${placeholders})) AND \`revoked_at\` IS NULL`;
const scopeParams = [...chunk, ...chunk];
@@ -531,6 +541,14 @@ export class SessionStore extends PuterStore {
const userId = Number(r.user_id);
if (!uuidsByUser.has(userId)) uuidsByUser.set(userId, []);
uuidsByUser.get(userId).push(String(r.uuid));
if (
r.kind === 'web' &&
r.parent_session_id &&
!queued.has(String(r.uuid))
) {
queued.add(String(r.uuid));
roots.push(String(r.uuid));
}
}
}
return [...uuidsByUser].map(([userId, uuids]) => ({ userId, uuids }));
@@ -759,6 +777,57 @@ export class SessionStore extends PuterStore {
return { row, replaced: found ?? null };
}
/**
* The `web` session one of the deployment's own pages runs on under a
* sign-in: the live one for (`parentSessionUid`, `origin`) if there is one,
* else a new row. Revoking the parent cascades here. Concurrent first asks
* can each insert a row; both stay valid.
*
* @param {number} userId - User row id.
* @param {{
* parentSessionUid: string;
* origin: string;
* expiresAt: number;
* meta?: Record<string, unknown>;
* auth_id?: string;
* }} opts
* - `origin` is the page's origin, kept in `meta.first_party_origin`. `meta`
* is the request context, as on any `web` row.
*/
async getOrCreateFirstPartySession(
userId,
{ parentSessionUid, origin, expiresAt, meta = {}, auth_id },
) {
if (!userId || !parentSessionUid || !origin) return null;
const now = nowSeconds();
// One per listed origin under a sign-in, so a handful at most.
const rows = await this.clients.db.pread(
"SELECT * FROM `sessions` WHERE `kind` = 'web' AND `user_id` = ? AND `parent_session_id` = ? AND `created_via` = ? AND `revoked_at` IS NULL ORDER BY `id` DESC LIMIT 20",
[userId, parentSessionUid, FIRST_PARTY_SESSION_VIA],
);
const found = rows
.map((row) => this.#normalizeRow(row))
.find(
(row) =>
row &&
!isExpired(row, now) &&
row.meta?.first_party_origin === origin,
);
if (found) return found;
return this.create(userId, {
kind: 'web',
parent_session_id: parentSessionUid,
created_via: FIRST_PARTY_SESSION_VIA,
meta: { ...meta, first_party_origin: origin },
last_ip: meta.ip ?? null,
last_user_agent: meta.user_agent ?? null,
expires_at: expiresAt,
auth_id,
});
}
/**
* Idempotent "give me the app session for this (user, app)" lookup. Returns
* the existing active app session if one exists, or creates a new one.
+15 -3
View File
@@ -269,7 +269,12 @@ export interface IPreludeConfig {
* an RCS agent provisioned in the Prelude account to actually use RCS.
*/
preferredChannel?:
'sms' | 'rcs' | 'whatsapp' | 'viber' | 'zalo' | 'telegram';
| 'sms'
| 'rcs'
| 'whatsapp'
| 'viber'
| 'zalo'
| 'telegram';
}
/**
@@ -849,6 +854,14 @@ interface IConfigOptional {
* obtain its token via the AuthMe flow instead.
*/
allow_gui_origins?: string[];
/**
* Origins of this deployment's own pages, served apart from the GUI, that
* the GUI's sign-in may hand a session of their own instead of an app token
* (see `AuthService.createFirstPartySessionToken`). Exact origins only.
* Such a page acts as the account, so list only pages this deployment
* builds and serves itself.
*/
first_party_session_origins?: string[];
/** HMAC secret for signed file URLs (/file, /writeFile, /sign). */
url_signature_secret: string;
/**
@@ -1411,8 +1424,7 @@ export interface ServerDrainInfo {
}
export interface WithCostsReporting extends WithLifecycle {
getReportedCosts?: () =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getReportedCosts?: () => // eslint-disable-next-line @typescript-eslint/no-explicit-any
| Promise<Record<string, any>[]>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
| Record<string, any>[];
+20 -1
View File
@@ -47,12 +47,24 @@ const deviceIconSvg = (session) => {
if ( session.kind === 'worker' ) return ICONS.worker;
if ( session.kind === 'access_token' ) return ICONS.key;
if ( session.kind === 'app' ) return ICONS.app;
if ( firstPartyHost(session) ) return ICONS.globe;
const { os } = parseUserAgent(session.last_user_agent);
if ( os === 'iOS' || os === 'Android' ) return ICONS.phone;
if ( os ) return ICONS.laptop;
return ICONS.globe;
};
// Host of the first-party page a session was handed to under a sign-in, or
// null for any other session.
export const firstPartyHost = (session) => {
if ( typeof session?.first_party_origin !== 'string' ) return null;
try {
return new URL(session.first_party_origin).host || null;
} catch {
return null;
}
};
// Short, human label for the kind pill (falls back to the raw kind).
const kindBadgeLabel = (kind) => {
switch ( kind ) {
@@ -303,7 +315,8 @@ const UIWindowManageSessions = async function UIWindowManageSessions (options) {
|| i18n('ui_session_kind_access_token') || 'Access token';
}
if ( session.kind === 'web' ) {
return session.label || i18n('ui_session_kind_web') || 'Browser session';
return session.label || firstPartyHost(session)
|| i18n('ui_session_kind_web') || 'Browser session';
}
return session.label || session.kind || 'Session';
};
@@ -320,6 +333,7 @@ const UIWindowManageSessions = async function UIWindowManageSessions (options) {
sessionTitle(session),
session.label,
session.kind,
firstPartyHost(session),
session.last_ip,
session.app?.title,
session.app?.name,
@@ -509,6 +523,11 @@ const UIWindowManageSessions = async function UIWindowManageSessions (options) {
b.classList.add('session-widget-badge', `session-widget-badge-${session.kind}`);
b.textContent = kindBadgeLabel(session.kind);
el_badges.appendChild(b);
} else if ( firstPartyHost(session) ) {
const b = document.createElement('span');
b.classList.add('session-widget-badge', 'session-widget-badge-site');
b.textContent = i18n('ui_session_kind_site') || 'Site session';
el_badges.appendChild(b);
}
el_titleline.appendChild(el_badges);
el_main.appendChild(el_titleline);
+5
View File
@@ -3988,6 +3988,11 @@ html.dark-mode .usage-table-fade-overlay {
color: #1d4ed8;
}
.session-widget-badge-site {
background: #dcfce7;
color: #15803d;
}
/* Meta lines */
.session-widget-meta {
display: flex;
+84
View File
@@ -0,0 +1,84 @@
/*
* 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/>.
*/
/**
* Pages of this deployment that are served apart from the GUI sign in with a
* session of their own rather than an app token. The server lists their
* origins (`gui_params.first_party_session_origins`) and mints the session.
*/
const originOf = (value) => {
try {
return new URL(value).origin;
} catch {
return null;
}
};
/**
* Whether a sign-in for the page at `origin` hands over a session.
*
* @param {string} origin
* @param {string[]} [listed]
* @returns {boolean}
*/
export const isFirstPartySessionOrigin = (
origin,
listed = window.gui_params?.first_party_session_origins,
) => {
const wanted = originOf(origin);
if ( !wanted || !Array.isArray(listed) ) return false;
return listed.some(entry => originOf(entry) === wanted);
};
/**
* The token a sign-in hands the page that asked for it: a session of the
* page's own for a first-party page, else the app token the exchange returned.
* Throws when the session can't be minted, since an app token can't run such
* a page.
*
* @param {string} origin - the page's attested origin
* @param {string} appToken
* @param {object} [deps]
* @param {string[]} [deps.listed]
* @param {typeof fetch} [deps.fetchImpl]
* @param {string} [deps.apiOrigin]
* @param {string} [deps.authToken] - the GUI session token
* @returns {Promise<string>}
*/
export const signInTokenForOpener = async (origin, appToken, deps = {}) => {
if ( ! isFirstPartySessionOrigin(origin, deps.listed) ) return appToken;
const fetchImpl = deps.fetchImpl ?? fetch;
const apiOrigin = deps.apiOrigin ?? window.api_origin;
const authToken = deps.authToken ?? window.auth_token;
const response = await fetchImpl(`${apiOrigin}/auth/first-party-session`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${authToken}`,
},
body: JSON.stringify({ origin: originOf(origin) }),
});
const body = response.ok ? await response.json() : null;
if ( typeof body?.token !== 'string' || !body.token ) {
throw new Error('first-party session request returned no token');
}
return body.token;
};
@@ -0,0 +1,80 @@
/*
* 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 { describe, expect, it, vi } from 'vitest';
import { isFirstPartySessionOrigin, signInTokenForOpener } from './firstPartySession.js';
const PAGE = 'https://desk.puter.test';
const listed = [`${PAGE}/`];
const answering = (status, body) => vi.fn(async () => ({
ok: status >= 200 && status < 300,
json: async () => body,
}));
describe('isFirstPartySessionOrigin', () => {
it('matches a listed origin exactly', () => {
expect(isFirstPartySessionOrigin(PAGE, listed)).toBe(true);
expect(isFirstPartySessionOrigin(`${PAGE}/some/path`, listed)).toBe(true);
});
it('matches nothing else', () => {
expect(isFirstPartySessionOrigin('http://desk.puter.test', listed)).toBe(false);
expect(isFirstPartySessionOrigin('https://desk.puter.test:8443', listed)).toBe(false);
expect(isFirstPartySessionOrigin('https://x.desk.puter.test', listed)).toBe(false);
expect(isFirstPartySessionOrigin('https://desk.puter.test.evil.example', listed)).toBe(false);
expect(isFirstPartySessionOrigin('', listed)).toBe(false);
expect(isFirstPartySessionOrigin(PAGE, null)).toBe(false);
expect(isFirstPartySessionOrigin(PAGE, [])).toBe(false);
});
});
describe('signInTokenForOpener', () => {
const deps = (fetchImpl) => ({
listed,
fetchImpl,
apiOrigin: 'https://api.puter.test',
authToken: 'gui-token',
});
it('hands any other page its app token without asking the server', async () => {
const fetchImpl = answering(200, { token: 'session-token' });
const token = await signInTokenForOpener('https://notes.example.com', 'app-token', deps(fetchImpl));
expect(token).toBe('app-token');
expect(fetchImpl).not.toHaveBeenCalled();
});
it('hands a first-party page the session the server mints for its origin', async () => {
const fetchImpl = answering(200, { token: 'session-token' });
const token = await signInTokenForOpener(PAGE, 'app-token', deps(fetchImpl));
expect(token).toBe('session-token');
expect(fetchImpl).toHaveBeenCalledOnce();
const [url, init] = fetchImpl.mock.calls[0];
expect(url).toBe('https://api.puter.test/auth/first-party-session');
expect(init.headers.Authorization).toBe('Bearer gui-token');
expect(JSON.parse(init.body)).toEqual({ origin: PAGE });
});
it('fails rather than fall back to the app token', async () => {
await expect(signInTokenForOpener(PAGE, 'app-token', deps(answering(403, { code: 'forbidden' }))))
.rejects.toThrow();
await expect(signInTokenForOpener(PAGE, 'app-token', deps(answering(200, {}))))
.rejects.toThrow();
});
});
+3 -1
View File
@@ -410,11 +410,13 @@ export const showFramedUpgrade = (win, attribution, tell) => showFramedDialog(wi
* the user bought credit, once the dialog closes.
*
* @param {Window} win
* @param {{ appUid?: string }} attribution - the app whose page frames it; an empty one is passed as null
* @param {(state: 'ready') => void} tell
* @returns {Promise<boolean>}
*/
export const showFramedTopUp = (win, tell) => showFramedDialog(win, (closed) => {
export const showFramedTopUp = (win, attribution, tell) => showFramedDialog(win, (closed) => {
const dialog = new win.UITopUp({
attribution: Object.keys(attribution).length ? attribution : null,
onClose: (result) => closed(result?.toppedUp === true),
});
return dialog.open();
+15 -5
View File
@@ -443,17 +443,27 @@ describe('showFramedTopUp', () => {
let options;
const result = showFramedTopUp(topUpWindow(async (o) => {
options = o;
}), tell);
}), { appUid: 'app-1' }, tell);
await vi.waitFor(() => expect(tell).toHaveBeenCalledWith('ready'));
expect(options.attribution).toEqual({ appUid: 'app-1' });
expect(tell).toHaveBeenCalledTimes(1);
options.onClose({ toppedUp: true });
await expect(result).resolves.toBe(true);
});
it('passes no attribution when the framing page resolves to no app', async () => {
let options;
await showFramedTopUp(topUpWindow(async (o) => {
options = o;
o.onClose();
}), {}, vi.fn());
expect(options.attribution).toBeNull();
});
it('resolves false for a close without a purchase', async () => {
await expect(showFramedTopUp(topUpWindow(async (o) => o.onClose({ toppedUp: false })), vi.fn())).resolves.toBe(false);
await expect(showFramedTopUp(topUpWindow(async (o) => o.onClose()), vi.fn())).resolves.toBe(false);
await expect(showFramedTopUp(topUpWindow(async (o) => o.onClose({ toppedUp: false })), {}, vi.fn())).resolves.toBe(false);
await expect(showFramedTopUp(topUpWindow(async (o) => o.onClose()), {}, vi.fn())).resolves.toBe(false);
});
it('resolves false once the dialog is gone without reporting a close', async () => {
@@ -462,7 +472,7 @@ describe('showFramedTopUp', () => {
root.setAttribute('aria-modal', 'true');
const result = showFramedTopUp(topUpWindow(async () => {
document.body.append(root);
}), vi.fn());
}), {}, vi.fn());
await vi.advanceTimersByTimeAsync(0);
root.remove();
await vi.advanceTimersByTimeAsync(600);
@@ -472,7 +482,7 @@ describe('showFramedTopUp', () => {
it('rejects when the dialog fails to open', async () => {
await expect(showFramedTopUp(topUpWindow(async () => {
throw new Error('no dialog');
}), vi.fn())).rejects.toThrow('no dialog');
}), {}, vi.fn())).rejects.toThrow('no dialog');
});
});
+1
View File
@@ -701,6 +701,7 @@ const en = {
ui_session_expires: 'Expires',
ui_session_kind_access_token: 'Access token',
ui_session_kind_app: 'App session',
ui_session_kind_site: 'Site session',
ui_session_kind_web: 'Browser session',
ui_session_kind_worker: 'Worker',
ui_session_last_active: 'Last active',
+3 -1
View File
@@ -61,7 +61,9 @@ describe('the popup token hand-off in postAuthActions', () => {
const steps = [
postAuthActions.indexOf('fetch(`${window.api_origin}/login/set`'),
postAuthActions.indexOf('runsUserAppTokenExchange(action)'),
postAuthActions.search(/success: true,\s+token: data\.token/),
postAuthActions.search(
/success: true,\s+token: await signInTokenForOpener\(origin, data\.token\)/,
),
];
for (const step of steps) {
expect(step).toBeGreaterThan(gate);
+17 -5
View File
@@ -88,6 +88,7 @@ import item_icon from './helpers/itemIcon.js';
import { installAppIconFallback } from './helpers/appIcon.js';
import launch_app from './helpers/launchApp.js';
import { urlFileLaunchOptions } from './helpers/confirmUrlFileAccess.js';
import { signInTokenForOpener } from './helpers/firstPartySession.js';
import { readSignupBonusCode } from './helpers/signupBonusCode.js';
import { guiTokenBootStep, pageRenderedForThisLoad, sameTokenClaims } from './helpers/guiToken.js';
import { parse_url_paths } from './helpers/urlPaths.js';
@@ -529,7 +530,7 @@ const postAuthActions = async (action) => {
window.opener?.postMessage({
msg: 'puter.token',
success: true,
token: data.token,
token: await signInTokenForOpener(origin, data.token),
app_uid: data.app_uid,
username: window.user.username,
msg_id: msg_id,
@@ -1088,12 +1089,17 @@ const postAuthActions = async (action) => {
// -------------------------------------------------------------------------------------
// Action: top-up — the deployment's top-up dialog for the first-party page framing it,
// which hears whether the user bought credit once the dialog closes.
// which hears whether the user bought credit once the dialog closes. The app it credits
// is the one the framing page's origin resolves to.
// -------------------------------------------------------------------------------------
if ( action === 'top-up' && window.embedded_in_frame ) {
let toppedUp;
try {
toppedUp = await showFramedTopUp(window, window.tell_popup_frame);
toppedUp = await showFramedTopUp(
window,
window.host_app_uid ? { appUid: window.host_app_uid } : {},
window.tell_popup_frame,
);
} catch (e) {
// A dialog that failed to open leaves the page to open the top-up in a tab.
console.error('top-up action failed', e);
@@ -2861,7 +2867,10 @@ window.initgui = async function (options) {
msg: 'puter.token',
success: true,
msg_id: msg_id,
token: data.token,
token: await signInTokenForOpener(
window.openerOrigin,
data.token,
),
username: window.user.username,
app_uid: data.app_uid,
},
@@ -2948,7 +2957,10 @@ window.initgui = async function (options) {
msg: 'puter.token',
success: true,
msg_id: msg_id,
token: data.token,
token: await signInTokenForOpener(
window.openerOrigin,
data.token,
),
username: window.user.username,
app_uid: data.app_uid,
},
+24 -4
View File
@@ -277,6 +277,15 @@ export class Puter {
*/
openerLaunchItem_ = null;
/**
* Whether this is a tab the GUI opened for an app and handed a session
* token, which it then keeps in sessionStorage the way a framed app does.
*
* @internal
* @type {boolean}
*/
appTabSession_ = false;
// Holds the unique app instance ID that is provided by the host environment
appInstanceID;
@@ -751,6 +760,9 @@ export class Puter {
// the SDK needs to show a dialog to the user to ask for permission to access their Puter account.
else if (this.env === 'web') {
try {
// A godmode app reloading in the tab the GUI opened it in;
// only that handoff stores a session token on this origin.
const godmodeToken = this.readStoredSessionToken_();
const storedToken = this.normalizeAuthTokenCandidate(
localStorage.getItem(STORAGE_KEY_V2),
);
@@ -763,7 +775,10 @@ export class Puter {
const boundOrigin = this.normalizeStringCandidate(
localStorage.getItem(STORAGE_KEY_ORIGIN_V2),
);
if (
if (godmodeToken) {
this.appTabSession_ = true;
this.setAuthToken(godmodeToken);
} else if (
storedToken &&
this._storedTokenUsableForCurrentOrigin(boundOrigin)
) {
@@ -800,7 +815,12 @@ export class Puter {
launch.item,
);
}
if (launch?.token) this.setAuthToken(launch.token);
if (launch?.token) {
this.appTabSession_ = this.isUserSessionToken_(
launch.token,
);
this.setAuthToken(launch.token);
}
});
}
@@ -1041,10 +1061,10 @@ export class Puter {
// If the SDK is running on a 3rd-party site or an app, then save the authToken in localStorage.
// A user session token (a godmode app's launch token) never goes there:
// an app keeps it in sessionStorage so a frame reload still has it.
// an app keeps it in sessionStorage so a reload still has it.
if (this.env === 'web' || this.env === 'app') {
this.storeSessionToken_(
this.env === 'app' &&
(this.env === 'app' || this.appTabSession_) &&
this.isUserSessionToken_(normalizedAuthToken)
? normalizedAuthToken
: null,
+70
View File
@@ -286,6 +286,76 @@ describe('web-mode stored token', () => {
});
});
describe('godmode app in a tab the GUI opened', () => {
const TAB = '?puter.app_instance_id=i';
/** Boots a top-level tab whose GUI opener hands it `token`. */
const bootTab = async (token) => {
const opener = {
closed: false,
postMessage: (data, guiOrigin) =>
window.dispatchEvent(
new MessageEvent('message', {
origin: guiOrigin,
source: opener,
data: {
msg: 'puter.launch',
appInstanceID: data.appInstanceID,
token,
},
}),
),
};
Object.defineProperty(globalThis, 'opener', {
value: opener,
configurable: true,
writable: true,
});
const puter = await bootWeb(TAB);
await puter.openerLaunch_;
return puter;
};
afterEach(() => {
globalThis.opener = null;
});
it('keeps the handed-over token in sessionStorage, so a reload has it', async () => {
const puter = await bootTab(GODMODE_TOKEN);
expect(puter.env).toBe('web');
expect(puter.authToken).toBe(GODMODE_TOKEN);
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
expect(sessionStorage.getItem(STORAGE_KEY)).toBe(GODMODE_TOKEN);
// The opener was cut off, so the reload has only what it stored.
expect(globalThis.opener).toBeNull();
const reloaded = await bootWeb(TAB);
expect(reloaded.authToken).toBe(GODMODE_TOKEN);
expect(sessionStorage.getItem(STORAGE_KEY)).toBe(GODMODE_TOKEN);
});
it('reloads on the godmode token over an app token stored earlier', async () => {
localStorage.setItem(STORAGE_KEY, APP_TOKEN);
localStorage.setItem(ORIGIN_KEY, 'https://api.puter.com');
await bootTab(GODMODE_TOKEN);
const reloaded = await bootWeb(TAB);
expect(reloaded.authToken).toBe(GODMODE_TOKEN);
});
it('drops the sessionStorage copy on sign-out', async () => {
const puter = await bootTab(GODMODE_TOKEN);
puter.resetAuthToken();
expect(sessionStorage.getItem(STORAGE_KEY)).toBeNull();
});
it('still keeps an app token in localStorage only', async () => {
await bootTab(APP_TOKEN);
expect(localStorage.getItem(STORAGE_KEY)).toBe(APP_TOKEN);
expect(sessionStorage.getItem(STORAGE_KEY)).toBeNull();
});
});
describe('GUI cache refresh', () => {
it('keeps one refresh loop however often the token is set', async () => {
const puter = await bootApp(`${LAUNCH}${APP_TOKEN}`);
+1 -1
View File
@@ -1889,7 +1889,7 @@ export class UIModule extends EventListener {
onMessage: () => {},
});
if ( framed ) return framed;
await this.#openUpgradeTab(topUpUrl(puter.defaultGUIOrigin), 'This website wants to open the Puter top-up page.');
await this.#openUpgradeTab(topUpUrl(puter.defaultGUIOrigin, appIDOf(puter)), 'This website wants to open the Puter top-up page.');
return { toppedUp: false };
})();
this.#topUpFrame = showing;
@@ -186,6 +186,16 @@ describe('ui.showTopUp_ on a signed-in first-party page', () => {
});
describe('ui.showTopUp_ elsewhere on the web', () => {
it('names the app the page runs as, so the dashboard can credit it', async () => {
fakePuter({ guiOrigin: 'https://puter.com' });
globalThis.puter.appID = 'app-0b8a6d4e-6c1f-4a52-9d3e-2f7b1c9e8a10';
await makeUI().showTopUp_();
expect(window.open).toHaveBeenCalledWith(
'https://puter.com/dashboard/?topup=&topup_app=app-0b8a6d4e-6c1f-4a52-9d3e-2f7b1c9e8a10#usage',
'_blank',
);
});
it.each([
['a page outside the Puter origin', { guiOrigin: 'https://puter.com' }],
['a signed-out first-party page', { signedIn: false }],
+3 -1
View File
@@ -32,11 +32,13 @@ export const upgradeUrl = ({ reason, appID, method } = {}) => {
* The dashboard page that opens the top-up flow, for buying add-on credit.
*
* @param {string} [guiOrigin] The deployment whose dashboard it is; Puter's by default.
* @param {string} [appID] The app whose page asked for the top-up.
* @returns {string}
*/
export const topUpUrl = (guiOrigin) => {
export const topUpUrl = (guiOrigin, appID) => {
const url = new URL('/dashboard/', guiOrigin || UPGRADE_PAGE);
url.searchParams.set('topup', '');
if ( appID ) url.searchParams.set('topup_app', appID);
url.hash = 'usage';
return url.href;
};