feat (PUT-1025 PUT-1026 and PUT-1017): better handling old token auth required in gui (#3166)

* feat (PUT-1025 PUT-1026 and PUT-1017): better handling old token forcing in gui

* fix: misc issues with token inval project
This commit is contained in:
Daniel Salazar
2026-05-27 02:26:35 -07:00
committed by GitHub
parent b188942436
commit 8f8efcb349
10 changed files with 1342 additions and 112 deletions
@@ -2080,6 +2080,104 @@ describe('AuthController session endpoints', () => {
),
).rejects.toMatchObject({ statusCode: 403 });
});
it('rename-session: 400 when uuid param is missing', async () => {
const { actor } = await makeUserAndActor();
await expect(
controller.handleRenameSession(
makeReq({ label: 'x' }, { actor, params: {} }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('rename-session: 400 when label is the wrong type', async () => {
const { actor } = await makeUserAndActor();
await expect(
controller.handleRenameSession(
makeReq(
{ label: 123 as unknown as string },
{ actor, params: { uuid: 'whatever' } },
),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('rename-session: 400 when label field is missing entirely', async () => {
// Guards against accidental "PATCH with empty body silently clears
// the label". Type guard rejects `undefined` before reaching the
// service layer.
const { actor } = await makeUserAndActor();
await expect(
controller.handleRenameSession(
makeReq({}, { actor, params: { uuid: 'whatever' } }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('rename-session: 404 when the uuid belongs to another user', async () => {
const { user: u1 } = await makeUserAndActor();
const { actor: a2 } = await makeUserAndActor();
const sessionRes = await server.services.auth.createSessionToken(
u1,
{},
);
const uuid = (sessionRes.session as { uuid: string }).uuid;
await expect(
controller.handleRenameSession(
makeReq(
{ label: 'pwned' },
{ actor: a2, params: { uuid } },
),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 404 });
});
it('rename-session: success updates the row label', async () => {
const { user, actor } = await makeUserAndActor();
const sessionRes = await server.services.auth.createSessionToken(
user,
{},
);
const uuid = (sessionRes.session as { uuid: string }).uuid;
const res = makeRes();
await controller.handleRenameSession(
makeReq({ label: 'My Phone' }, { actor, params: { uuid } }),
res,
);
expect(res.body).toEqual({});
const rows = await server.clients.db.read(
'SELECT `label` FROM `sessions` WHERE `uuid` = ?',
[uuid],
);
expect((rows[0] as { label: string }).label).toBe('My Phone');
});
it('rename-session: accepts null to clear the label', async () => {
const { user, actor } = await makeUserAndActor();
const sessionRes = await server.services.auth.createSessionToken(
user,
{},
);
const uuid = (sessionRes.session as { uuid: string }).uuid;
// Seed a non-null label so the clear-to-null transition is observable.
await server.clients.db.write(
'UPDATE `sessions` SET `label` = ? WHERE `uuid` = ?',
['something', uuid],
);
await controller.handleRenameSession(
makeReq({ label: null }, { actor, params: { uuid } }),
makeRes(),
);
const rows = await server.clients.db.read(
'SELECT `label` FROM `sessions` WHERE `uuid` = ?',
[uuid],
);
expect((rows[0] as { label: string | null }).label).toBeNull();
});
});
// ── Dev-app grants/revokes ─────────────────────────────────────────
+48 -1
View File
@@ -769,8 +769,12 @@ export class AuthController extends PuterController {
antiCsrf: true,
})
async handleLogout(req: Request, res: Response): Promise<void> {
// Clear the session cookie
// Clear the session cookie + the v2 migrate-token companion
// cookie (set after `/auth/migrate-token` for app-under-user
// iframes). authProbe reads `puter_token_v2` as a fallback, so
// a stale value would re-authenticate the next request.
res.clearCookie(this.config.cookie_name ?? 'puter_token');
res.clearCookie('puter_token_v2');
// Remove the session (fire-and-forget)
if (req.token) {
@@ -1846,7 +1850,16 @@ export class AuthController extends PuterController {
{ legacyCode: 'bad_request' },
);
}
// `getByUuid` returns null when the row is missing, already
// soft-revoked, or past `expires_at` — surface as 404 so a stale
// manage-sessions UI doesn't 500 when it clicks revoke on a row
// that already went away.
const session = await this.stores.session.getByUuid(uuid);
if (!session) {
throw new HttpError(404, 'Session not found', {
legacyCode: 'not_found',
});
}
if (session.user_id !== req.actor!.user.id) {
throw new HttpError(403, 'Can only revoke your own sessions', {
legacyCode: 'unauthorized',
@@ -1867,6 +1880,27 @@ export class AuthController extends PuterController {
res.json({ sessions });
}
async handleRenameSession(req: Request, res: Response): Promise<void> {
const uuid = req.params.uuid;
const { label } = (req.body ?? {}) as { label?: unknown };
if (!uuid || typeof uuid !== 'string') {
throw new HttpError(400, 'Missing or invalid `uuid`', {
legacyCode: 'bad_request',
});
}
if (label !== null && typeof label !== 'string') {
throw new HttpError(400, '`label` must be a string or null', {
legacyCode: 'bad_request',
});
}
await this.services.auth.setSessionLabel(
req.actor!,
uuid,
label ?? null,
);
res.json({});
}
// -- Dev app permissions -----------------------------------------
@Post('/auth/grant-dev-app', { subdomain: 'api', requireUserActor: true })
@@ -2549,6 +2583,7 @@ export class AuthController extends PuterController {
async handleDeleteOwnUser(req: Request, res: Response): Promise<void> {
const userId = req.actor!.user.id!;
res.clearCookie(this.config.cookie_name ?? 'puter_token');
res.clearCookie('puter_token_v2');
res.clearCookie('puter_revalidation');
await this.#cascadeDeleteUser(userId);
res.json({ success: true });
@@ -2739,6 +2774,18 @@ export class AuthController extends PuterController {
(req, res) => this.handleRevokeAccessToken(req, res),
);
router.patch(
'/auth/sessions/:uuid/label',
{
subdomain: 'api',
requireUserActor: true,
allowUnconfirmed: true,
antiCsrf: true,
middleware: [webSessionGate],
},
(req, res) => this.handleRenameSession(req, res),
);
router.post(
'/auth/migrate-token',
{
+23 -5
View File
@@ -85,7 +85,13 @@ export const createAuthProbe = (opts: AuthProbeOptions): RequestHandler => {
}
try {
const result = await authService.authenticate(token);
// Thread the request IP and User-Agent into authenticate so
// SessionStore.touch can refresh `last_ip` / `last_user_agent`
// when a session roams to a new network / browser.
const result = await authService.authenticate(token, {
ip: req.ip,
userAgent: req.headers['user-agent'] ?? undefined,
});
if (result.reauth) {
bumpCounter(kvStore, {
@@ -166,10 +172,22 @@ const extractToken = (req: Request, cookieName?: string): string | null => {
// arbitrary browser Origin spend an ambient session cookie against the
// credentialed API CORS surface; bearer/body/x-api-key tokens remain
// available for cross-origin SDK requests.
if (cookieName && !isCrossOriginBrowserRequest(req)) {
const cookieToken = req.cookies?.[cookieName];
if (typeof cookieToken === 'string' && cookieToken.length > 0) {
return stripBearer(cookieToken);
//
// `puter_token_v2` is the cookie companion to v2 app-under-user
// tokens set by `POST /auth/migrate-token`. We accept it under the
// same same-origin gate as the primary session cookie so a private
// app iframe can authenticate subsequent calls without re-attaching
// an `Authorization` header on every request.
if (!isCrossOriginBrowserRequest(req)) {
if (cookieName) {
const cookieToken = req.cookies?.[cookieName];
if (typeof cookieToken === 'string' && cookieToken.length > 0) {
return stripBearer(cookieToken);
}
}
const v2Token = req.cookies?.puter_token_v2;
if (typeof v2Token === 'string' && v2Token.length > 0) {
return stripBearer(v2Token);
}
}
+301 -8
View File
@@ -338,11 +338,15 @@ describe('AuthService (integration)', () => {
it('access-token: returns reauth.session_revoked when the access-token session is revoked', async () => {
const user = await makeUser();
// Use the auto-implicated `user:<own-uuid>:email:read`
// permission so the createAccessToken permission-subset
// check passes without a separate grant; the permission
// identity isn't what this test exercises.
const accessToken = await authService.createAccessToken(
{
user: { id: user.id, uuid: user.uuid, username: user.username },
} as Actor,
[['fs:abc:read']],
[[`user:${user.uuid}:email:read`]],
);
const decoded = server.services.token.verify(
'auth',
@@ -367,7 +371,7 @@ describe('AuthService (integration)', () => {
{
user: { id: user.id, uuid: user.uuid, username: user.username },
} as Actor,
[['fs:abc:read']],
[[`user:${user.uuid}:email:read`]],
{ expiresIn: '1h' },
);
const decoded = server.services.token.verify(
@@ -574,6 +578,45 @@ describe('AuthService (integration)', () => {
// app_uid / app are null for web rows; present for app rows.
expect(row!.app_uid).toBeNull();
expect(row!.app).toBeNull();
// parent_session_id is null for top-level web rows but the
// field must be present so the GUI tree-builder can key on
// it; same for last_user_agent (powers UA→browser/OS render).
expect(row!).toHaveProperty('parent_session_id');
expect(row!.parent_session_id).toBeNull();
expect(row!).toHaveProperty('last_user_agent');
});
it('listSessions surfaces parent_session_id and last_user_agent for derived rows', async () => {
// GUI tree-nesting (PUT-1025) reads `parent_session_id` to
// attach children under the right parent; the UA parser
// reads `last_user_agent`. If either drops out of the
// projection the GUI degrades to a flat list with no client
// label.
const user = await makeUser();
const { session: parent } = await authService.createSessionToken(
user,
{ ip: '198.51.100.1', user_agent: 'parent-ua' },
);
const parentUuid = (parent as { uuid: string }).uuid;
const child = await server.stores.session.create(user.id, {
kind: 'app',
parent_session_id: parentUuid,
last_user_agent: 'child-ua',
last_ip: '198.51.100.2',
});
const actor = {
user: { id: user.id, uuid: user.uuid, username: user.username },
session: { uid: parentUuid },
} as unknown as Actor;
const rows = await authService.listSessions(actor);
const childRow = rows.find(
(r) =>
(r as { uuid: string }).uuid ===
(child as { uuid: string }).uuid,
) as Record<string, unknown> | undefined;
expect(childRow).toBeTruthy();
expect(childRow!.parent_session_id).toBe(parentUuid);
expect(childRow!.last_user_agent).toBe('child-ua');
});
it('listSessions joins kind="app" rows with the apps table', async () => {
@@ -659,6 +702,256 @@ describe('AuthService (integration)', () => {
});
});
describe('authenticate (ctx threading: IP/UA roam refresh)', () => {
// The touch path is throttled per-uuid by TOUCH_THROTTLE_MS, so a
// fresh session won't fire updateActivity again on the next
// authenticate() call. Backdating `last_activity` AND the
// in-memory throttle map is the smallest surgery to make the
// touch deterministic from the test.
const ageSessionForTouch = async (sessionUuid: string) => {
const ancient = Math.floor(Date.now() / 1000) - 3600;
await server.clients.db.write(
'UPDATE `sessions` SET `last_activity` = ? WHERE `uuid` = ?',
[ancient, sessionUuid],
);
// The store's in-memory throttle is keyed on uuid — clear it
// so the next touch isn't coalesced by the recent-create
// entry from createSessionToken.
const store = server.stores.session as unknown as {
['#lastSessionTouchMs']?: Map<string, number>;
};
// Private field access via the public clear path: a `clear()`
// helper isn't exposed, so we re-construct the touch by
// running it once with a long-ago timestamp that the SQL
// guard accepts. Simpler: read raw row directly after
// authenticate to confirm column was rewritten.
// (Throttle map values live on the instance — but at module
// boundary across `describe`s they should be empty for a
// fresh uuid.)
void store; // intentional no-op — kept as a docstring anchor
await server.clients.redis.del(
`sessions:v2:uuid:${sessionUuid}`,
);
};
const readRawRow = async (uuid: string) => {
const rows = await server.clients.db.read(
'SELECT `last_ip`, `last_user_agent` FROM `sessions` WHERE `uuid` = ? LIMIT 1',
[uuid],
);
return rows[0] as
| { last_ip: string | null; last_user_agent: string | null }
| undefined;
};
it('session token: passing ctx.ip and ctx.userAgent refreshes the row', async () => {
const user = await makeUser();
const { token, session } = await authService.createSessionToken(
user,
{ ip: '1.1.1.1', user_agent: 'old-ua' },
);
const sessionUuid = (session as { uuid: string }).uuid;
await ageSessionForTouch(sessionUuid);
await authService.authenticate(token, {
ip: '9.9.9.9',
userAgent: 'new-ua',
});
const row = await readRawRow(sessionUuid);
expect(row?.last_ip).toBe('9.9.9.9');
expect(row?.last_user_agent).toBe('new-ua');
});
it('session token: omitting ctx leaves last_ip / last_user_agent unchanged', async () => {
const user = await makeUser();
const { token, session } = await authService.createSessionToken(
user,
{ ip: '5.5.5.5', user_agent: 'stable-ua' },
);
const sessionUuid = (session as { uuid: string }).uuid;
await ageSessionForTouch(sessionUuid);
await authService.authenticate(token);
const row = await readRawRow(sessionUuid);
expect(row?.last_ip).toBe('5.5.5.5');
expect(row?.last_user_agent).toBe('stable-ua');
});
it('app-under-user token: ctx refreshes the app session row', async () => {
const user = await makeUser();
// makeApp helper from the outer describe isn't in scope; inline a minimal app row.
const appUid = `app-${uuidv4()}`;
await server.clients.db.write(
'INSERT INTO `apps` (`uid`, `name`, `title`, `index_url`, `owner_user_id`) VALUES (?, ?, ?, ?, ?)',
[
appUid,
`n-${appUid}`,
`t-${appUid}`,
`https://${appUid}.example/`,
1,
],
);
const appToken = await authService.getUserAppToken(
{
user: { id: user.id, uuid: user.uuid, username: user.username },
} as Actor,
appUid,
);
const decoded = server.services.token.verify(
'auth',
appToken,
) as { session_uid: string };
await ageSessionForTouch(decoded.session_uid);
await authService.authenticate(appToken, {
ip: '10.0.0.1',
userAgent: 'app-roam-ua',
});
const row = await readRawRow(decoded.session_uid);
expect(row?.last_ip).toBe('10.0.0.1');
expect(row?.last_user_agent).toBe('app-roam-ua');
});
it('access-token: ctx refreshes the access-token session row', async () => {
const user = await makeUser();
const accessToken = await authService.createAccessToken(
{
user: { id: user.id, uuid: user.uuid, username: user.username },
} as Actor,
[[`user:${user.uuid}:email:read`]],
{ expiresIn: '1h' },
);
const decoded = server.services.token.verify(
'auth',
accessToken,
) as { session_uid: string };
await ageSessionForTouch(decoded.session_uid);
await authService.authenticate(accessToken, {
ip: '203.0.113.20',
userAgent: 'at-roam-ua',
});
const row = await readRawRow(decoded.session_uid);
expect(row?.last_ip).toBe('203.0.113.20');
expect(row?.last_user_agent).toBe('at-roam-ua');
});
});
describe('setSessionLabel', () => {
it('throws 403 when actor has no user', async () => {
await expect(
authService.setSessionLabel(
{ user: undefined } as unknown as Actor,
uuidv4(),
'x',
),
).rejects.toMatchObject({ statusCode: 403 });
});
it('throws 404 when the uuid does not exist', async () => {
const user = await makeUser();
const actor = {
user: { id: user.id, uuid: user.uuid, username: user.username },
} as Actor;
await expect(
authService.setSessionLabel(actor, uuidv4(), 'nope'),
).rejects.toMatchObject({ statusCode: 404 });
});
it('throws 404 when the uuid belongs to another user', async () => {
const owner = await makeUser();
const interloper = await makeUser();
const { session } = await authService.createSessionToken(owner, {});
const sessionUuid = (session as { uuid: string }).uuid;
const interloperActor = {
user: {
id: interloper.id,
uuid: interloper.uuid,
username: interloper.username,
},
} as Actor;
await expect(
authService.setSessionLabel(
interloperActor,
sessionUuid,
'pwned',
),
).rejects.toMatchObject({ statusCode: 404 });
});
it('renames the row for the owning user', async () => {
const user = await makeUser();
const { session } = await authService.createSessionToken(user, {});
const sessionUuid = (session as { uuid: string }).uuid;
const actor = {
user: { id: user.id, uuid: user.uuid, username: user.username },
} as Actor;
await authService.setSessionLabel(actor, sessionUuid, 'My Laptop');
const rows = await server.clients.db.read(
'SELECT `label` FROM `sessions` WHERE `uuid` = ?',
[sessionUuid],
);
expect((rows[0] as { label: string }).label).toBe('My Laptop');
});
it('trims whitespace and caps at 64 characters', async () => {
const user = await makeUser();
const { session } = await authService.createSessionToken(user, {});
const sessionUuid = (session as { uuid: string }).uuid;
const actor = {
user: { id: user.id, uuid: user.uuid, username: user.username },
} as Actor;
// Lead/trail whitespace + 80 chars of body — expect trim then 64-char cap.
const padded = ' ' + 'a'.repeat(80) + ' ';
await authService.setSessionLabel(actor, sessionUuid, padded);
const rows = await server.clients.db.read(
'SELECT `label` FROM `sessions` WHERE `uuid` = ?',
[sessionUuid],
);
const stored = (rows[0] as { label: string }).label;
expect(stored.length).toBe(64);
expect(stored).toBe('a'.repeat(64));
});
it('stores null when label is empty / whitespace / explicit null', async () => {
const user = await makeUser();
const { session } = await authService.createSessionToken(user, {
user_agent: 'unused',
});
const sessionUuid = (session as { uuid: string }).uuid;
// Seed with a non-null label so we can prove a follow-up null clears it.
await server.clients.db.write(
'UPDATE `sessions` SET `label` = ? WHERE `uuid` = ?',
['initial', sessionUuid],
);
const actor = {
user: { id: user.id, uuid: user.uuid, username: user.username },
} as Actor;
for (const empty of ['', ' ', null]) {
await authService.setSessionLabel(
actor,
sessionUuid,
empty as string | null,
);
const rows = await server.clients.db.read(
'SELECT `label` FROM `sessions` WHERE `uuid` = ?',
[sessionUuid],
);
expect((rows[0] as { label: string | null }).label).toBeNull();
// Re-seed for the next iteration.
await server.clients.db.write(
'UPDATE `sessions` SET `label` = ? WHERE `uuid` = ?',
['initial', sessionUuid],
);
}
});
});
describe('createWorkerSessionToken / createWorkerAppToken', () => {
// The test config's v2 jwt_secret is the source of truth for
// verifying claims; go through TokenService to mirror how
@@ -961,7 +1254,7 @@ describe('AuthService (integration)', () => {
user: { id: user.id, uuid: user.uuid, username: user.username },
} as Actor;
const jwt = await authService.createAccessToken(actor, [
['service:foo:ii:read'],
[`user:${user.uuid}:email:read`],
]);
const decoded = server.services.token.verify('auth', jwt) as {
type: string;
@@ -979,7 +1272,7 @@ describe('AuthService (integration)', () => {
user: { id: user.id, uuid: user.uuid, username: user.username },
} as Actor;
const jwt = await authService.createAccessToken(actor, [
['service:foo:ii:read'],
[`user:${user.uuid}:email:read`],
]);
await authService.revokeAccessToken(actor, jwt);
@@ -1004,7 +1297,7 @@ describe('AuthService (integration)', () => {
user: { id: u2.id, uuid: u2.uuid, username: u2.username },
} as Actor;
const jwt = await authService.createAccessToken(a1, [
['service:foo:ii:read'],
[`user:${u1.uuid}:email:read`],
]);
await expect(
authService.revokeAccessToken(a2, jwt),
@@ -1017,7 +1310,7 @@ describe('AuthService (integration)', () => {
user: { id: user.id, uuid: user.uuid, username: user.username },
} as Actor;
const jwt = await authService.createAccessToken(actor, [
['service:foo:ii:read'],
[`user:${user.uuid}:email:read`],
]);
const decoded = server.services.token.verify('auth', jwt) as {
token_uid: string;
@@ -1275,7 +1568,7 @@ describe('AuthService (integration)', () => {
user: { id: user.id, uuid: user.uuid, username: user.username },
} as Actor;
const jwt = await authService.createAccessToken(actor, [
['service:foo:ii:read'],
[`user:${user.uuid}:email:read`],
]);
const decoded = server.services.token.verify('auth', jwt) as {
token_uid: string;
@@ -1559,7 +1852,7 @@ describe('AuthService (integration)', () => {
{
user: { id: user.id, uuid: user.uuid, username: user.username },
} as Actor,
[['service:foo:ii:read']],
[[`user:${user.uuid}:email:read`]],
);
await expect(
authService.migrateLegacyToken(v2),
+163 -15
View File
@@ -30,6 +30,7 @@ import type { LayerInstances } from '../../types';
import { sessionCookieFlags } from '../../util/cookieFlags.js';
import type { puterServices } from '../index';
import { PuterService } from '../types';
import { V1TokensDisabledError } from './TokenService';
import type {
AccessTokenPayload,
AnyTokenPayload,
@@ -134,14 +135,29 @@ export class AuthService extends PuterService {
return { authId: decoded.auth_id };
}
async authenticate(token: string): Promise<AuthResult> {
async authenticate(
token: string,
ctx: { ip?: string; userAgent?: string } = {},
): Promise<AuthResult> {
let decoded: AnyTokenPayload;
try {
decoded = this.services.token.verify<AnyTokenPayload>(
'auth',
token,
);
} catch {
} catch (err) {
// v1 tokens disabled — surface a `reauth_required` signal
// with an advisory `auth_id` hint so stragglers on cached
// old bundles see the re-login modal instead of a bare 401.
// The hint is read from the *unverified* payload; it's only
// used to label the response, never to grant access.
if (err instanceof V1TokensDisabledError) {
const hint = err.payload;
const auth_id =
(hint.auth_id as string | undefined) ??
(hint.user_uid as string | undefined);
return { reauth: { reason: 'token_v1', auth_id } };
}
return { invalid: true };
}
@@ -152,13 +168,13 @@ export class AuthService extends PuterService {
switch (decoded.type) {
case 'session':
case 'gui':
result = await this.#actorFromSessionToken(decoded);
result = await this.#actorFromSessionToken(decoded, ctx);
break;
case 'app-under-user':
result = await this.#actorFromAppUnderUserToken(decoded);
result = await this.#actorFromAppUnderUserToken(decoded, ctx);
break;
case 'access-token':
result = await this.#actorFromAccessTokenToken(decoded);
result = await this.#actorFromAccessTokenToken(decoded, ctx);
break;
default:
return { invalid: true };
@@ -397,20 +413,35 @@ export class AuthService extends PuterService {
}
async removeSessionByToken(token: string): Promise<void> {
let decoded: AnyTokenPayload;
// Try the signed path first. If verify fails (typically because
// the JWT expired between authProbe and this logout call —
// `req.token` was valid at probe time but the user took a while
// before clicking logout), fall back to an *unverified* decode
// (with the same decompression as the verified path) just to
// recover the `session_uid` so the row still gets soft-revoked.
// The recovered uuid is only used as a `revokeCascade` pointer;
// a forged uuid (worst case for an unverified read) can't
// escalate — `revokeCascade` is a no-op against unknown rows
// and only flips `revoked_at` on existing ones.
let decoded: AnyTokenPayload | null = null;
try {
decoded = this.services.token.verify<AnyTokenPayload>(
'auth',
token,
);
} catch {
return;
decoded = this.services.token.decodeWithoutVerify<AnyTokenPayload>(
'auth',
token,
);
}
if (!decoded) return;
if (decoded.type !== 'session' && decoded.type !== 'gui') return;
const sessionPayload = decoded as SessionTokenPayload;
const sessionUuid =
(sessionPayload.session_uid as string | undefined) ??
sessionPayload.uuid;
if (!sessionUuid) return;
await this.stores.session.revokeCascade(sessionUuid);
}
@@ -466,10 +497,12 @@ export class AuthService extends PuterService {
kind: row.kind,
current: isCurrent,
label: row.label ?? null,
parent_session_id: row.parent_session_id ?? null,
created_at: row.created_at,
last_activity: row.last_activity,
expires_at: row.expires_at ?? null,
last_ip: row.last_ip ?? null,
last_user_agent: row.last_user_agent ?? null,
created_via: row.created_via ?? null,
app_uid: appUid,
app: app
@@ -505,6 +538,59 @@ export class AuthService extends PuterService {
await this.stores.session.revokeCascade(uuid);
}
/**
* Rename a session's user-visible label. Throws 404 when the row
* doesn't exist or belongs to another user ownership is enforced
* inside `SessionStore.setLabel` via the (uuid, user_id) WHERE
* clause, so the 404 vs 403 distinction is collapsed (a user can't
* tell from this endpoint whether a uuid exists under another
* account).
*/
async setSessionLabel(
actor: Actor,
uuid: string,
label: string | null,
): Promise<void> {
if (!actor.user) {
throw new HttpError(403, 'Actor must be a user', {
legacyCode: 'forbidden',
});
}
const trimmed =
typeof label === 'string' ? label.trim().slice(0, 64) : null;
const ok = await this.stores.session.setLabel(
uuid,
actor.user.id as number,
trimmed && trimmed.length > 0 ? trimmed : null,
);
if (!ok) {
throw new HttpError(404, 'Session not found', {
legacyCode: 'not_found',
});
}
}
/**
* Admin-driven cascade: revoke EVERY session row for the given user
* (web, app, access_token, asset, worker). No actor context this
* is the "suspension / forced sign-out" path, where workers
* deliberately go too (a suspended user shouldn't keep long-lived
* worker credentials calling back into the backend). Distinct from
* `revokeAllSessions` which is the user-driven UI flow and exempts
* workers + standalone access tokens by design.
*
* Iterates each top-level row through `revokeCascade` so derived
* rows (asset under web, app-issued access tokens under their app
* session) follow via the parent_session_id link.
*/
async revokeAllSessionsForUserId(userId: number): Promise<void> {
if (!userId) return;
const rows = await this.stores.session.getByUserId(userId);
for (const row of rows) {
await this.stores.session.revokeCascade(row.uuid as string);
}
}
async revokeAllSessions(
actor: Actor,
opts: { includeCurrent?: boolean; includeApps?: boolean } = {},
@@ -1100,17 +1186,27 @@ export class AuthService extends PuterService {
* Materialize the `kind='asset'` session row that the cookie's
* `session_uuid` claim points at. Parented to the web session so a
* logout cascade kills every asset cookie minted under it. Both
* fields are `null` when the caller didn't supply a web session
* the cookie still mints, but unparented and without an `auth_id`
* claim (matches v1 behavior for access-token-minted cookies that
* aren't tied to an interactive session).
* fields are `null` only when the caller didn't supply a web session
* at all the cookie still mints unparented and without an
* `auth_id` claim (matches v1 behavior for access-token-minted
* cookies that aren't tied to an interactive session).
*
* If the caller DID supply a `webSessionUuid` but the lookup misses
* (row revoked / expired between mint request and this lookup),
* throw otherwise we'd quietly emit an unparented "ghost" cookie
* that has no revocation hook for 7 days. The extra check piggybacks
* on the lookup we already had to do, so no added perf cost.
*/
async #mintAssetSessionContext(
webSessionUuid: string | undefined,
): Promise<{ assetSessionUuid: string | null; authId: string | null }> {
if (!webSessionUuid) return { assetSessionUuid: null, authId: null };
const webSession = await this.stores.session.getByUuid(webSessionUuid);
if (!webSession) return { assetSessionUuid: null, authId: null };
if (!webSession) {
throw new HttpError(401, 'session no longer valid', {
legacyCode: 'session_required',
});
}
const authId =
((webSession as SessionRow).auth_id as string | null) ?? null;
const row = await this.stores.session.create(
@@ -1343,6 +1439,40 @@ export class AuthService extends PuterService {
);
}
// Permission-subset enforcement: an access token can only carry
// permissions the issuer itself holds. Without this, an
// app-under-user actor (third-party app authorized by the user)
// could mint a token claiming permissions it was never granted —
// those grants live in `access_token_permissions` and are
// returned verbatim at check-time, with no re-validation against
// the authorizer. `checkMany` is one pipelined MGET against the
// per-actor permission cache so the cost is small even for
// many-permission mints.
const requestedPerms = [
...new Set(
permissions
.map(([p]) => p)
.filter((p): p is string => typeof p === 'string' && !!p),
),
];
if (requestedPerms.length > 0) {
const granted = await this.services.permission.checkMany(
actor,
requestedPerms,
);
const missing = requestedPerms.filter((p) => !granted.get(p));
if (missing.length > 0) {
throw new HttpError(
403,
`Issuer lacks permission(s): ${missing.join(', ')}`,
{
legacyCode: 'forbidden',
fields: { missing_permissions: missing },
},
);
}
}
const tokenUid = uuidv4();
const auth_id = this.#authIdFor(actor.user as UserRow);
@@ -1511,6 +1641,7 @@ export class AuthService extends PuterService {
async #actorFromSessionToken(
decoded: SessionTokenPayload,
ctx: { ip?: string; userAgent?: string } = {},
): Promise<AuthResult> {
const user = await this.stores.user.getByUuid(decoded.user_uid);
if (!user) return { invalid: true };
@@ -1545,7 +1676,12 @@ export class AuthService extends PuterService {
if (!session) return { invalid: true };
this.stores.session
.touch({ uuid: session.uuid, userId: user.id })
.touch({
uuid: session.uuid,
userId: user.id,
ip: ctx.ip,
userAgent: ctx.userAgent,
})
.catch(() => {});
return { actor: this.#buildUserActor(user, session) };
@@ -1553,6 +1689,7 @@ export class AuthService extends PuterService {
async #actorFromAppUnderUserToken(
decoded: AppUnderUserTokenPayload,
ctx: { ip?: string; userAgent?: string } = {},
): Promise<AuthResult> {
const user = await this.stores.user.getByUuid(decoded.user_uid);
if (!user) return { invalid: true };
@@ -1595,7 +1732,12 @@ export class AuthService extends PuterService {
}
this.stores.session
.touch({ uuid: session?.uuid, userId: user.id })
.touch({
uuid: session?.uuid,
userId: user.id,
ip: ctx.ip,
userAgent: ctx.userAgent,
})
.catch(() => {});
return {
@@ -1605,6 +1747,7 @@ export class AuthService extends PuterService {
async #actorFromAccessTokenToken(
decoded: AccessTokenPayload,
ctx: { ip?: string; userAgent?: string } = {},
): Promise<AuthResult> {
if (!decoded.token_uid || !decoded.user_uid) return { invalid: true };
@@ -1649,7 +1792,12 @@ export class AuthService extends PuterService {
if (session) {
this.stores.session
.touch({ uuid: session.uuid, userId: user.id })
.touch({
uuid: session.uuid,
userId: user.id,
ip: ctx.ip,
userAgent: ctx.userAgent,
})
.catch(() => {});
}
+51 -1
View File
@@ -163,6 +163,20 @@ const COMPRESSION: Record<string, CompressionContext> = {
'hosted-asset': HOSTED_ASSET_COMPRESSION,
};
/**
* Thrown by `verify()` when a v1 token is presented while
* `auth.allow_v1_tokens=false`. Carries the **unverified** payload so
* the auth probe can mint a `reauth_required` response with an
* `auth_id` hint the hint is advisory only (never trusted as
* identity), so reading it from an unsigned payload is safe.
*/
export class V1TokensDisabledError extends Error {
constructor(public readonly payload: Record<string, unknown>) {
super('v1 tokens are disabled');
this.name = 'V1TokensDisabledError';
}
}
// -- TokenService ----------------------------------------------------
export class TokenService extends PuterService {
@@ -233,7 +247,20 @@ export class TokenService extends PuterService {
// Legacy / unsigned-kid path.
if (!this.#allowV1Tokens) {
throw new Error('v1 tokens are disabled');
// Surface a structured error so the auth probe can route to a
// `reauth_required` response (with an `auth_id` hint) instead
// of a bare 401 that strands the user. Decompress the
// *unverified* payload — the hint is advisory, never trusted
// as identity.
const rawPayload =
decoded &&
typeof decoded === 'object' &&
decoded.payload &&
typeof decoded.payload === 'object'
? (decoded.payload as Record<string, unknown>)
: {};
const hint = this.#decompressPayload(context, rawPayload);
throw new V1TokensDisabledError(hint);
}
if (!this.#secretLegacy) {
throw new Error(
@@ -253,6 +280,29 @@ export class TokenService extends PuterService {
return decompressed as unknown as T;
}
/**
* Decode + decompress *without* verifying the signature. Returns
* `null` for malformed tokens. Use **only** for paths that need to
* recover advisory hints from an expired / unsignable token (e.g.,
* the logout path that wants to revoke a session row even if the
* JWT has expired since the user opened the tab). The result is
* never trusted as identity only as a pointer for cleanup
* operations the caller would otherwise authorize via a different
* channel.
*/
decodeWithoutVerify<T = Record<string, unknown>>(
scope: string,
token: string,
): T | null {
const context = COMPRESSION[scope];
const decoded = jwt.decode(token);
if (!decoded || typeof decoded !== 'object') return null;
return this.#decompressPayload(
context,
decoded as Record<string, unknown>,
) as unknown as T;
}
// -- Internals ---------------------------------------------------
#compressPayload(
+12 -1
View File
@@ -315,7 +315,18 @@ export class SocketService extends PuterService {
}
try {
const result = await authService.authenticate(token);
const handshakeHeaders =
(socket.handshake.headers as
| Record<string, string | string[] | undefined>
| undefined) ?? {};
const uaHeader = handshakeHeaders['user-agent'];
const userAgent = Array.isArray(uaHeader)
? uaHeader[0]
: uaHeader;
const result = await authService.authenticate(token, {
ip: socket.handshake.address,
userAgent: userAgent ?? undefined,
});
if (result.reauth) {
console.info(
+159 -25
View File
@@ -230,6 +230,39 @@ export class SessionStore extends PuterStore {
return row;
}
/**
* Rename a session's label. Ownership is enforced via the user_id
* filter a label edit by user A can't touch a row owned by user B
* even if A guesses B's session uuid.
*
* Returns `true` when a row was updated, `false` when no row matched
* the (uuid, user_id) pair (either the uuid doesn't exist, the row
* belongs to another user, or it's already soft-revoked).
*/
async setLabel(uuid, userId, label) {
if (!uuid || !userId) return false;
const result = await this.clients.db.write(
'UPDATE `sessions` SET `label` = ? WHERE `uuid` = ? AND `user_id` = ? AND `revoked_at` IS NULL',
[label, uuid, userId],
);
const affected = result?.affectedRows ?? 0;
if (affected > 0) {
// 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',
[uuid],
);
if (rows[0]) {
await this.publishCacheKeys({
keys: this.#allCacheKeysForRow(rows[0]),
broadcast: true,
});
}
}
return affected > 0;
}
/**
* Soft-revoke a session by uuid. The row remains in the table
* with `revoked_at` set; subsequent `getByUuid` calls treat it
@@ -248,22 +281,32 @@ export class SessionStore extends PuterStore {
// meta.worker_name for the worker cache key; without it the
// composite worker cache entry would survive revocation and
// getOrCreateWorker would serve the stale (revoked) row for
// up to CACHE_TTL_SECONDS.
// up to CACHE_TTL_SECONDS. `created_via` + `last_ip` +
// `last_user_agent` ride along for the symmetric legacy-web
// key derivation.
const rows = await this.clients.db.read(
'SELECT `uuid`, `user_id`, `kind`, `app_uid`, `legacy_token_uid`, `meta` FROM `sessions` WHERE `uuid` = ? AND `revoked_at` IS NULL LIMIT 1',
'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',
[uuid],
);
if (rows.length === 0) return;
// Double-delete pattern: invalidate the cache BEFORE the SQL
// UPDATE, then again after. The pre-DEL drops any cached
// active-row view so a concurrent reader between the DEL and the
// UPDATE goes to the DB (seeing the still-active row is fine —
// that's the truth at that instant). The post-DEL clears any
// entry a racer might have re-cached during the window. Pays
// one extra pipelined DEL per revoke; revokes are rare so the
// cost is negligible.
const keys = this.#allCacheKeysForRow(rows[0]);
await this.publishCacheKeys({ keys, broadcast: true });
const now = nowSeconds();
await this.clients.db.write(
'UPDATE `sessions` SET `revoked_at` = ? WHERE `uuid` = ? AND `revoked_at` IS NULL',
[now, uuid],
);
await this.publishCacheKeys({
keys: this.#allCacheKeysForRow(rows[0]),
broadcast: true,
});
await this.publishCacheKeys({ keys, broadcast: true });
}
/**
@@ -275,23 +318,27 @@ export class SessionStore extends PuterStore {
if (!rootUuid) return;
// Read each affected row's identity columns up-front — every
// composite cache mapping (app, legacy-token) must be invalidated
// alongside the uuid key, otherwise a follow-up `getOrCreateApp`
// would short-circuit to the freshly-revoked row.
// composite cache mapping (app, legacy-token, legacy-web) must
// be invalidated alongside the uuid key, otherwise a follow-up
// `getOrCreateApp` / `findOrCreateLegacyWeb` 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` FROM `sessions` WHERE (`uuid` = ? OR `parent_session_id` = ?) AND `revoked_at` IS NULL',
'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',
[rootUuid, rootUuid],
);
if (rows.length === 0) return;
// Double-delete: see `removeByUuid` for rationale.
const keys = [];
for (const r of rows) keys.push(...this.#allCacheKeysForRow(r));
await this.publishCacheKeys({ keys, broadcast: true });
const now = nowSeconds();
await this.clients.db.write(
'UPDATE `sessions` SET `revoked_at` = ? WHERE (`uuid` = ? OR `parent_session_id` = ?) AND `revoked_at` IS NULL',
[now, rootUuid, rootUuid],
);
const keys = [];
for (const r of rows) keys.push(...this.#allCacheKeysForRow(r));
await this.publishCacheKeys({ keys, broadcast: true });
}
@@ -495,9 +542,17 @@ export class SessionStore extends PuterStore {
/**
* Best-effort lazy-backfill row for a v1 web session. The keying tuple
* is `(user_id, last_ip, last_user_agent)` a UA/IP shift on a roaming
* client produces a fresh row, which is the spec's accepted trade-off.
* No partial unique index here; collisions are tolerated.
* is `(user_id, last_ip, last_user_agent)` a UA/IP shift on a
* roaming client produces a fresh row, which is the spec's accepted
* trade-off.
*
* No partial unique index exists for this tuple (a UA string is too
* variable to index), so concurrent racers can both INSERT. We
* resolve via an optimistic-lock pass: after our INSERT we re-SELECT
* the oldest matching row; if we lost the race, soft-revoke our own
* row and return the winner so every caller converges on a single
* `session_uuid`. Cheap (one extra SELECT per legacy-backfill mint,
* which only runs on the first contact from a stale v1 client).
*/
async findOrCreateLegacyWeb(opts = {}) {
if (!opts.userId) return null;
@@ -512,10 +567,13 @@ export class SessionStore extends PuterStore {
return cached;
}
const rows = await this.clients.db.read(
"SELECT * FROM `sessions` WHERE `kind` = 'web' AND `user_id` = ? AND `created_via` = 'legacy_backfill' AND IFNULL(`last_ip`, '') = IFNULL(?, '') AND IFNULL(`last_user_agent`, '') = IFNULL(?, '') AND `revoked_at` IS NULL AND (`expires_at` IS NULL OR `expires_at` > ?) ORDER BY `id` ASC LIMIT 1",
[opts.userId, ip, ua, now],
);
const selectOldest = () =>
this.clients.db.read(
"SELECT * FROM `sessions` WHERE `kind` = 'web' AND `user_id` = ? AND `created_via` = 'legacy_backfill' AND IFNULL(`last_ip`, '') = IFNULL(?, '') AND IFNULL(`last_user_agent`, '') = IFNULL(?, '') AND `revoked_at` IS NULL AND (`expires_at` IS NULL OR `expires_at` > ?) ORDER BY `id` ASC LIMIT 1",
[opts.userId, ip, ua, now],
);
const rows = await selectOldest();
const existing = this.#normalizeRow(rows[0]);
if (existing) {
await this.#writeCacheKey(cacheKey, existing);
@@ -531,6 +589,21 @@ export class SessionStore extends PuterStore {
created_via: 'legacy_backfill',
auth_id: opts.auth_id ?? null,
});
// Optimistic conflict resolution: re-SELECT the oldest row that
// matches the same tuple. If a concurrent racer beat us to the
// INSERT, fold to their row and revoke ours so the (rare) pair
// doesn't both linger for a year. `removeByUuid` is a no-op when
// the row was already revoked by a third party.
const winnerRows = await selectOldest();
const winner = this.#normalizeRow(winnerRows[0]);
if (winner && winner.uuid !== created.uuid) {
await this.removeByUuid(created.uuid);
await this.#writeCacheKey(cacheKey, winner);
this.#writeCache(winner).catch(() => {});
return winner;
}
await this.#writeCacheKey(cacheKey, created);
// uuid cache already warmed by create()
return created;
@@ -543,8 +616,19 @@ export class SessionStore extends PuterStore {
* their existing `expires_at` (hard expiry). The `last_activity < ?`
* guard makes the UPDATE idempotent across nodes so concurrent touches
* don't fight.
*
* When `ip` / `userAgent` are provided and differ from the stored
* values, they are written into `last_ip` / `last_user_agent` in the
* same UPDATE and the uuid cache is invalidated so the next read
* doesn't serve the pre-roam values. Unchanged values are no-ops at
* the SQL level (the CASE guards keep the column write conditional)
* and skip the cache invalidate.
*/
async updateActivity(uuid, lastActivity) {
async updateActivity(
uuid,
lastActivity,
{ ip = null, userAgent = null } = {},
) {
const webExpires = lastActivity + WEB_WINDOW_SECONDS;
const appExpires = lastActivity + APP_WINDOW_SECONDS;
const assetExpires = lastActivity + ASSET_WINDOW_SECONDS;
@@ -554,17 +638,45 @@ export class SessionStore extends PuterStore {
"WHEN 'app' THEN ? " +
"WHEN 'asset' THEN ? " +
'ELSE `expires_at` ' +
'END ' +
'END, ' +
'`last_ip` = CASE WHEN ? IS NOT NULL AND (`last_ip` IS NULL OR `last_ip` <> ?) THEN ? ELSE `last_ip` END, ' +
'`last_user_agent` = CASE WHEN ? IS NOT NULL AND (`last_user_agent` IS NULL OR `last_user_agent` <> ?) THEN ? ELSE `last_user_agent` END ' +
'WHERE `uuid` = ? AND (`last_activity` IS NULL OR `last_activity` < ?)',
[
lastActivity,
webExpires,
appExpires,
assetExpires,
ip,
ip,
ip,
userAgent,
userAgent,
userAgent,
uuid,
lastActivity,
],
);
// When IP or UA actually changed, the cached row at
// `sessions:v2:uuid:<uuid>` is now stale (it carries the old
// `last_ip` / `last_user_agent`). Manage-sessions reads off the
// cached row, so without this invalidate the UI keeps showing the
// pre-roam values until the 15-minute TTL expires.
if (ip != null || userAgent != null) {
const cached = await this.#readCache(uuid);
if (cached) {
const ipChanged = ip != null && cached.last_ip !== ip;
const uaChanged =
userAgent != null && cached.last_user_agent !== userAgent;
if (ipChanged || uaChanged) {
await this.publishCacheKeys({
keys: [this.#cacheKey(uuid)],
broadcast: true,
});
}
}
}
}
/** Update user-level last activity timestamp. */
@@ -580,9 +692,16 @@ export class SessionStore extends PuterStore {
* `last_activity` column and the owning user's `user.last_activity_ts`
* if either hasn't been touched within `TOUCH_THROTTLE_MS`.
*
* When `ip` / `userAgent` are passed, they ride along into
* `updateActivity` so a roaming session also refreshes its
* `last_ip` / `last_user_agent`. Throttle still applies the IP/UA
* fields only get a chance to update once per `TOUCH_THROTTLE_MS`.
*
* Callers fire-and-forget failures are swallowed.
*
* @param {{uuid?: string, userId?: number, ip?: string|null, userAgent?: string|null}} [args]
*/
async touch({ uuid, userId } = {}) {
async touch({ uuid, userId, ip = null, userAgent = null } = {}) {
const nowMs = Date.now();
const sessionDue =
@@ -614,9 +733,10 @@ export class SessionStore extends PuterStore {
const tasks = [];
if (sessionDue) {
tasks.push(
this.updateActivity(uuid, Math.floor(nowMs / 1000)).catch(
() => {},
),
this.updateActivity(uuid, Math.floor(nowMs / 1000), {
ip,
userAgent,
}).catch(() => {}),
);
}
if (userDue) {
@@ -695,6 +815,20 @@ export class SessionStore extends PuterStore {
);
}
}
// Legacy-web backfill rows are cached by (user_id, last_ip,
// last_user_agent) inside `findOrCreateLegacyWeb`. Without this
// branch, revoke would clear the uuid key but leave the composite
// key serving the stale revoked row for up to CACHE_TTL_SECONDS,
// letting a same-IP/UA replay re-authenticate.
if (row.kind === 'web' && row.created_via === 'legacy_backfill') {
keys.push(
this.#cacheKeyLegacyWeb(
row.user_id,
row.last_ip ?? null,
row.last_user_agent ?? null,
),
);
}
if (row.legacy_token_uid) {
keys.push(this.#cacheKeyLegacyAt(row.legacy_token_uid));
}
@@ -404,6 +404,117 @@ describe('SessionStore', () => {
});
});
describe('updateActivity refreshes last_ip / last_user_agent', () => {
// Backdate so the SQL `last_activity < ?` guard fires deterministically.
const backdate = async (uuid: string) => {
const ancient = Math.floor(Date.now() / 1000) - 3600;
await server.clients.db.write(
'UPDATE `sessions` SET `last_activity` = ? WHERE `uuid` = ?',
[ancient, uuid],
);
};
it('writes new ip and user-agent when changed', async () => {
const user = await makeUser();
const session = await target.create(user.id, {
kind: 'web',
last_ip: '1.1.1.1',
last_user_agent: 'old-agent',
});
await backdate(session.uuid);
await target.updateActivity(
session.uuid,
Math.floor(Date.now() / 1000),
{ ip: '2.2.2.2', userAgent: 'new-agent' },
);
const row = await rawRow(session.uuid);
expect(row.last_ip).toBe('2.2.2.2');
expect(row.last_user_agent).toBe('new-agent');
});
it('leaves existing ip / ua untouched when args are null', async () => {
const user = await makeUser();
const session = await target.create(user.id, {
kind: 'web',
last_ip: '3.3.3.3',
last_user_agent: 'keep-me',
});
await backdate(session.uuid);
await target.updateActivity(
session.uuid,
Math.floor(Date.now() / 1000),
{},
);
const row = await rawRow(session.uuid);
expect(row.last_ip).toBe('3.3.3.3');
expect(row.last_user_agent).toBe('keep-me');
});
it('does not overwrite when values are unchanged', async () => {
const user = await makeUser();
const session = await target.create(user.id, {
kind: 'web',
last_ip: '4.4.4.4',
last_user_agent: 'same-agent',
});
await backdate(session.uuid);
await target.updateActivity(
session.uuid,
Math.floor(Date.now() / 1000),
{ ip: '4.4.4.4', userAgent: 'same-agent' },
);
const row = await rawRow(session.uuid);
expect(row.last_ip).toBe('4.4.4.4');
expect(row.last_user_agent).toBe('same-agent');
});
});
describe('setLabel', () => {
it('renames the label for the owning user', async () => {
const user = await makeUser();
const session = await target.create(user.id, { label: 'old' });
const ok = await target.setLabel(session.uuid, user.id, 'new');
expect(ok).toBe(true);
const row = await rawRow(session.uuid);
expect(row.label).toBe('new');
});
it('returns false when the uuid belongs to another user', async () => {
const owner = await makeUser();
const interloper = await makeUser();
const session = await target.create(owner.id, { label: 'mine' });
const ok = await target.setLabel(
session.uuid,
interloper.id,
'pwned',
);
expect(ok).toBe(false);
const row = await rawRow(session.uuid);
expect(row.label).toBe('mine');
});
it('returns false when the row is soft-revoked', async () => {
const user = await makeUser();
const session = await target.create(user.id, { label: 'live' });
await target.removeByUuid(session.uuid);
const ok = await target.setLabel(session.uuid, user.id, 'after');
expect(ok).toBe(false);
const row = await rawRow(session.uuid);
expect(row.label).toBe('live');
});
it('returns false on falsy uuid / userId', async () => {
expect(await target.setLabel('', 1, 'x')).toBe(false);
expect(await target.setLabel('some-uuid', 0, 'x')).toBe(false);
});
});
describe('revokeCascade invalidates composite caches', () => {
it('a revoked app row is not re-served by getOrCreateApp cache hit', async () => {
// First create primes the app composite cache. Revoke via
+376 -56
View File
@@ -19,6 +19,38 @@
import UIAlert from './UIAlert.js';
import UIWindow from './UIWindow.js';
// Hand-rolled UA → {browser, os} extractor. Covers Chrome/Edge/Firefox/
// Safari/Opera + Windows/macOS/iOS/Android/Linux. The backend already
// has `ua-parser-js`; pulling it into the GUI bundle just for this
// label wasn't worth the bytes.
const parseUserAgent = (ua) => {
if ( ! ua || typeof ua !== 'string' ) return { browser: null, os: null };
let browser = null;
if ( /Edg\//i.test(ua) ) browser = 'Edge';
else if ( /OPR\/|Opera/i.test(ua) ) browser = 'Opera';
else if ( /Chrome\//i.test(ua) && !/Chromium/i.test(ua) ) browser = 'Chrome';
else if ( /Firefox\//i.test(ua) ) browser = 'Firefox';
else if ( /Safari\//i.test(ua) && !/Chrome\//i.test(ua) ) browser = 'Safari';
let os = null;
if ( /Windows NT/i.test(ua) ) os = 'Windows';
// iOS Safari/Chrome UAs include "like Mac OS X", so the iOS device
// check has to win against the macOS regex — otherwise iPhones get
// mislabeled as macOS. Android similarly fakes a "Linux" token, so
// it has to precede the Linux check below.
else if ( /iPhone|iPad|iPod/i.test(ua) ) os = 'iOS';
else if ( /Android/i.test(ua) ) os = 'Android';
else if ( /Mac OS X|Macintosh/i.test(ua) ) os = 'macOS';
else if ( /Linux/i.test(ua) ) os = 'Linux';
return { browser, os };
};
const formatBrowserOs = ({ browser, os }) => {
if ( browser && os ) return `${browser} on ${os}`;
return browser || os || null;
};
const UIWindowManageSessions = async function UIWindowManageSessions (options) {
options = options ?? {};
@@ -30,8 +62,6 @@ const UIWindowManageSessions = async function UIWindowManageSessions (options) {
uid: null,
is_dir: false,
message: 'message',
// body_icon: options.body_icon,
// backdrop: options.backdrop ?? false,
is_droppable: false,
has_head: true,
selectable_body: false,
@@ -40,7 +70,6 @@ const UIWindowManageSessions = async function UIWindowManageSessions (options) {
window_class: 'window-session-manager',
dominant: true,
body_content: '',
// width: 600,
...options.window_options,
});
@@ -65,9 +94,6 @@ const UIWindowManageSessions = async function UIWindowManageSessions (options) {
const sessionTitle = (session) => {
if ( session.kind === 'worker' ) {
// Worker rows surface `worker_name` from meta. Show the
// worker's own name first, then the app it's bound to (if
// any) for context.
const name = session.worker_name || (i18n('ui_session_kind_worker') || 'Worker');
const appPart = session.app?.title || session.app?.name;
return appPart ? `${name} (${appPart})` : name;
@@ -84,18 +110,63 @@ const UIWindowManageSessions = async function UIWindowManageSessions (options) {
return session.label || session.kind || 'Session';
};
const SessionWidget = ({ session }) => {
// Search query lives in closure — rebuilt rows consult it when
// deciding visibility so re-renders after a revoke/reload preserve
// the active filter without re-reading the DOM.
let searchQuery = '';
const rowMatchesQuery = (session, query) => {
if ( !query ) return true;
const q = query.toLowerCase();
const fields = [
sessionTitle(session),
session.label,
session.kind,
session.last_ip,
session.app?.title,
session.app?.name,
session.last_user_agent,
];
const ua = parseUserAgent(session.last_user_agent);
fields.push(ua.browser, ua.os);
return fields.some((f) => typeof f === 'string' && f.toLowerCase().includes(q));
};
const SessionWidget = ({ session, children = [], depth = 0 }) => {
const el = document.createElement('div');
el.classList.add('session-widget');
if ( session.current ) {
el.classList.add('current-session');
}
if ( session.current ) el.classList.add('current-session');
if ( depth > 0 ) el.classList.add('session-widget-child');
el.dataset.uuid = session.uuid;
if ( depth > 0 ) el.style.marginLeft = `${depth * 24}px`;
// ── Header: icon (app) + title + badges
const el_header = document.createElement('div');
el_header.classList.add('session-widget-header');
// Expand/collapse caret for rows with children.
let el_children_container = null;
let el_caret = null;
if ( children.length > 0 ) {
el_caret = document.createElement('button');
el_caret.type = 'button';
el_caret.classList.add('session-widget-caret');
el_caret.textContent = '▾';
el_caret.style.marginRight = '4px';
el_caret.setAttribute(
'aria-label',
i18n('ui_toggle_session_children') || 'Toggle child sessions',
);
el_caret.setAttribute('aria-expanded', 'true');
el_caret.addEventListener('click', () => {
if ( !el_children_container ) return;
const collapsed = el_children_container.style.display === 'none';
el_children_container.style.display = collapsed ? '' : 'none';
el_caret.textContent = collapsed ? '▾' : '▸';
el_caret.setAttribute('aria-expanded', collapsed ? 'true' : 'false');
});
el_header.appendChild(el_caret);
}
if ( session.kind === 'app' && session.app?.icon ) {
const el_icon = document.createElement('img');
el_icon.classList.add('session-widget-app-icon');
@@ -104,10 +175,99 @@ const UIWindowManageSessions = async function UIWindowManageSessions (options) {
el_header.appendChild(el_icon);
}
// Title + inline rename. Pencil opens an <input>; Enter saves,
// Escape cancels. Optimistic update; revert on non-2xx.
const el_title_wrap = document.createElement('div');
el_title_wrap.classList.add('session-widget-title-wrap');
el_title_wrap.style.display = 'inline-flex';
el_title_wrap.style.alignItems = 'center';
el_title_wrap.style.gap = '4px';
const el_title = document.createElement('div');
el_title.classList.add('session-widget-title');
el_title.textContent = sessionTitle(session);
el_header.appendChild(el_title);
el_title_wrap.appendChild(el_title);
const el_rename_btn = document.createElement('button');
el_rename_btn.type = 'button';
el_rename_btn.classList.add('session-widget-rename');
el_rename_btn.textContent = '✎';
el_rename_btn.setAttribute(
'aria-label',
i18n('ui_rename') || 'Rename session',
);
el_rename_btn.title = i18n('ui_rename') || 'Rename';
el_rename_btn.addEventListener('click', () => beginRename());
el_title_wrap.appendChild(el_rename_btn);
const beginRename = () => {
const original = session.label ?? '';
const el_input = document.createElement('input');
el_input.type = 'text';
el_input.value = original;
el_input.maxLength = 64;
el_input.classList.add('session-widget-rename-input');
el_title_wrap.replaceChild(el_input, el_title);
el_rename_btn.style.display = 'none';
el_input.focus();
el_input.select();
// Enter / Escape both unfocus the input, which fires a blur
// *after* the keydown handler runs. Without this guard the
// blur listener would call finish() a second time —
// double-throwing on replaceChild and silently committing
// even when the user pressed Escape.
let finished = false;
const finish = async (commit) => {
if ( finished ) return;
finished = true;
el_input.removeEventListener('blur', onBlur);
el_title_wrap.replaceChild(el_title, el_input);
el_rename_btn.style.display = '';
if ( !commit ) return;
const next = el_input.value.trim().slice(0, 64);
if ( next === (original ?? '').trim() ) return;
// Optimistic
session.label = next || null;
el_title.textContent = sessionTitle(session);
try {
const anti_csrf = await services.get('anti-csrf').token();
const resp = await fetch(
`${window.api_origin}/auth/sessions/${encodeURIComponent(session.uuid)}/label`,
{
method: 'PATCH',
headers: {
Authorization: `Bearer ${puter.authToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
label: next || null,
anti_csrf,
}),
},
);
if ( !resp.ok ) throw new Error(await resp.text());
} catch ( e ) {
// Roll back optimistic update
session.label = original || null;
el_title.textContent = sessionTitle(session);
UIAlert({
parent_uuid: $(w).attr('data-element_uuid'),
stay_on_top: true,
message: e?.toString?.() ?? String(e),
});
}
};
const onBlur = () => finish(true);
el_input.addEventListener('keydown', (ev) => {
if ( ev.key === 'Enter' ) finish(true);
else if ( ev.key === 'Escape' ) finish(false);
});
el_input.addEventListener('blur', onBlur);
};
el_header.appendChild(el_title_wrap);
const el_badges = document.createElement('div');
el_badges.classList.add('session-widget-badges');
@@ -126,7 +286,7 @@ const UIWindowManageSessions = async function UIWindowManageSessions (options) {
el_header.appendChild(el_badges);
el.appendChild(el_header);
// ── Metadata rows
// Metadata rows
const el_meta = document.createElement('div');
el_meta.classList.add('session-widget-meta');
@@ -172,13 +332,30 @@ const UIWindowManageSessions = async function UIWindowManageSessions (options) {
if ( session.last_ip ) {
addRow(i18n('ui_session_ip') || 'IP', session.last_ip);
}
const ua = parseUserAgent(session.last_user_agent);
const uaLabel = formatBrowserOs(ua);
if ( uaLabel ) {
const el_entry = document.createElement('div');
el_entry.classList.add('session-widget-meta-entry');
const el_key = document.createElement('div');
el_key.textContent = i18n('ui_session_client') || 'Client';
el_key.classList.add('session-widget-meta-key');
el_entry.appendChild(el_key);
const el_value = document.createElement('div');
el_value.textContent = uaLabel;
el_value.classList.add('session-widget-meta-value');
// Raw UA string surfaced on hover for the rare case where
// the heuristic mis-classifies and the user wants to know
// what's actually there.
el_value.title = session.last_user_agent;
el_entry.appendChild(el_value);
el_meta.appendChild(el_entry);
}
el.appendChild(el_meta);
// ── Actions: omit revoke entirely for the current session so the
// caller can't self-revoke (backend also rejects this, but the
// button has no useful meaning here either way — /logout is
// the right path for "end the session you're using").
// Actions: omit revoke entirely for the current session so the
// caller can't self-revoke (backend also rejects this).
if ( ! session.current ) {
const el_actions = document.createElement('div');
el_actions.classList.add('session-widget-actions');
@@ -188,11 +365,6 @@ const UIWindowManageSessions = async function UIWindowManageSessions (options) {
el_btn_revoke.classList.add('button', 'button-danger');
el_btn_revoke.addEventListener('click', async () => {
try {
// parent_uuid routes the UIAlert under this window so it
// stacks above the dominant manage-sessions modal.
// Without it, the confirm prompt rendered behind the
// session list because both windows share the dominant
// z-index pool.
const parent_uuid = $(w).attr('data-element_uuid');
const alert_resp = await UIAlert({
parent_uuid,
@@ -204,31 +376,38 @@ const UIWindowManageSessions = async function UIWindowManageSessions (options) {
value: 'yes',
type: 'primary',
},
{
label: i18n('cancel'),
},
{ label: i18n('cancel') },
],
});
if ( alert_resp !== 'yes' ) {
return;
}
if ( alert_resp !== 'yes' ) return;
const anti_csrf = await services.get('anti-csrf').token();
const resp = await fetch(`${window.api_origin}/auth/revoke-session`, {
// Route access-token rows to the dedicated endpoint
// so `access_token_permissions` is cleared in addition
// to the session row being soft-revoked. Everything
// else (web/app/asset/worker) goes through cascade-
// capable /auth/revoke-session.
const isAccessToken = session.kind === 'access_token';
const url = isAccessToken
? `${window.api_origin}/auth/revoke-access-token`
: `${window.api_origin}/auth/revoke-session`;
const body = isAccessToken
? { tokenOrUuid: session.uuid, anti_csrf }
: { uuid: session.uuid, anti_csrf };
const resp = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${puter.authToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
uuid: session.uuid,
anti_csrf,
}),
body: JSON.stringify(body),
});
if ( resp.ok ) {
el.remove();
// Full reload — cascade may have killed children
// we'd otherwise have to detach by hand.
reload_sessions();
return;
}
UIAlert({ parent_uuid, stay_on_top: true, message: await resp.text() });
@@ -244,6 +423,30 @@ const UIWindowManageSessions = async function UIWindowManageSessions (options) {
el.appendChild(el_actions);
}
// Children container — only rendered when this row has any.
if ( children.length > 0 ) {
el_children_container = document.createElement('div');
el_children_container.classList.add('session-widget-children');
for ( const child of children ) {
SessionWidget({
session: child.session,
children: child.children,
depth: depth + 1,
}).appendTo(el_children_container);
}
el.appendChild(el_children_container);
}
// Filter visibility. Hide rows whose subtree contains no match —
// but if a child matches, surface the parent too so the child is
// reachable, even if the parent itself wouldn't match alone.
const subtreeMatches = (sess, kids) =>
rowMatchesQuery(sess, searchQuery) ||
kids.some((k) => subtreeMatches(k.session, k.children));
if ( ! subtreeMatches(session, children) ) {
el.style.display = 'none';
}
return {
appendTo (parent) {
parent.appendChild(el);
@@ -252,38 +455,155 @@ const UIWindowManageSessions = async function UIWindowManageSessions (options) {
};
};
const reload_sessions = async () => {
const resp = await fetch(`${window.api_origin}/auth/list-sessions`, {
headers: {
Authorization: `Bearer ${puter.authToken}`,
},
method: 'GET',
});
const sessions = await resp.json();
for ( const el of w_body.querySelectorAll('.session-widget') ) {
if ( ! sessions.find(s => s.uuid === el.dataset.uuid) ) {
el.remove();
}
const buildTree = (sessions) => {
// Index every row by uuid, then attach each row whose
// parent_session_id matches a known root. Rows whose parent
// is missing (e.g. cross-user link or stale) surface as
// top-level so they're not orphaned and hidden.
const byUuid = new Map();
for ( const s of sessions ) byUuid.set(s.uuid, { session: s, children: [] });
const roots = [];
for ( const s of sessions ) {
const node = byUuid.get(s.uuid);
const parent = s.parent_session_id ? byUuid.get(s.parent_session_id) : null;
if ( parent ) parent.children.push(node);
else roots.push(node);
}
return roots;
};
for ( const session of sessions ) {
if ( w.querySelector(`.session-widget[data-uuid="${session.uuid}"]`) ) {
continue;
}
SessionWidget({ session }).appendTo(w_body);
// Last fetched session list — search filtering re-renders from this
// cache instead of hitting /auth/list-sessions on every keystroke.
// Refreshed by reload_sessions (focus / interval / post-revoke / etc.).
let cachedSessions = [];
// Re-render the visible tree from the in-memory cache. Cheap; safe to
// call on every search keystroke.
const render = () => {
w_body_list.replaceChildren();
const roots = buildTree(cachedSessions);
for ( const root of roots ) {
SessionWidget({
session: root.session,
children: root.children,
depth: 0,
}).appendTo(w_body_list);
}
};
const w_body = w.querySelector('.window-body');
const reload_sessions = async () => {
let resp, sessions;
try {
resp = await fetch(`${window.api_origin}/auth/list-sessions`, {
headers: { Authorization: `Bearer ${puter.authToken}` },
method: 'GET',
});
sessions = await resp.json();
} catch {
// Network flake — keep whatever's currently rendered.
return;
}
if ( !Array.isArray(sessions) ) return;
cachedSessions = sessions;
render();
};
const w_body = w.querySelector('.window-body');
w_body.classList.add('session-manager-list');
// Toolbar: search input + "Revoke all other sessions" button.
const el_toolbar = document.createElement('div');
el_toolbar.classList.add('session-manager-toolbar');
const el_search = document.createElement('input');
el_search.type = 'search';
el_search.placeholder = i18n('ui_search') || 'Search sessions…';
el_search.classList.add('session-manager-search');
el_search.addEventListener('input', () => {
searchQuery = el_search.value.trim();
// Pure client-side filter — re-render from the cached list
// rather than re-fetching /auth/list-sessions per keystroke.
render();
});
el_toolbar.appendChild(el_search);
const el_btn_revoke_all = document.createElement('button');
el_btn_revoke_all.textContent =
i18n('ui_revoke_all_other_sessions') || 'Revoke all other sessions';
el_btn_revoke_all.classList.add('button', 'button-danger');
el_btn_revoke_all.addEventListener('click', async () => {
const parent_uuid = $(w).attr('data-element_uuid');
try {
const alert_resp = await UIAlert({
parent_uuid,
stay_on_top: true,
message:
i18n('confirm_revoke_all_other_sessions') ||
'Revoke all other sessions? You will stay signed in here.',
buttons: [
{
label: i18n('yes'),
value: 'yes',
type: 'primary',
},
{ label: i18n('cancel') },
],
});
if ( alert_resp !== 'yes' ) return;
const anti_csrf = await services.get('anti-csrf').token();
const resp = await fetch(
`${window.api_origin}/auth/revoke-all-sessions`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${puter.authToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
include_current: false,
include_apps: false,
anti_csrf,
}),
},
);
if ( resp.ok ) {
reload_sessions();
return;
}
UIAlert({ parent_uuid, stay_on_top: true, message: await resp.text() });
} catch ( e ) {
UIAlert({
parent_uuid,
stay_on_top: true,
message: e.toString(),
});
}
});
el_toolbar.appendChild(el_btn_revoke_all);
w_body.appendChild(el_toolbar);
const w_body_list = document.createElement('div');
w_body_list.classList.add('session-manager-list-body');
w_body.appendChild(w_body_list);
reload_sessions();
const interval = setInterval(reload_sessions, 8000);
// Two-tier refresh:
// - focus → re-fetch immediately (cheapest signal that something
// in the user's other tabs might have changed sessions).
// - 60s fallback interval so a long-lived but unfocused window
// still eventually sees revocations propagate.
// Older code polled every 8s flat — that burned CPU + network
// continuously even when the window wasn't visible.
const onFocus = () => reload_sessions();
window.addEventListener('focus', onFocus);
const interval = setInterval(reload_sessions, 60_000);
w.on_close = () => {
clearInterval(interval);
window.removeEventListener('focus', onFocus);
};
};