diff --git a/src/backend/controllers/auth/AuthController.test.ts b/src/backend/controllers/auth/AuthController.test.ts index 85ab5ae1f..a65e21292 100644 --- a/src/backend/controllers/auth/AuthController.test.ts +++ b/src/backend/controllers/auth/AuthController.test.ts @@ -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 ───────────────────────────────────────── diff --git a/src/backend/controllers/auth/AuthController.ts b/src/backend/controllers/auth/AuthController.ts index e0804d66f..002bf97ab 100644 --- a/src/backend/controllers/auth/AuthController.ts +++ b/src/backend/controllers/auth/AuthController.ts @@ -769,8 +769,12 @@ export class AuthController extends PuterController { antiCsrf: true, }) async handleLogout(req: Request, res: Response): Promise { - // 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 { + 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 { 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', { diff --git a/src/backend/core/http/middleware/authProbe.ts b/src/backend/core/http/middleware/authProbe.ts index 961d82374..2c1cf0583 100644 --- a/src/backend/core/http/middleware/authProbe.ts +++ b/src/backend/core/http/middleware/authProbe.ts @@ -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); } } diff --git a/src/backend/services/auth/AuthService.test.ts b/src/backend/services/auth/AuthService.test.ts index 635e9f7b6..71d86360c 100644 --- a/src/backend/services/auth/AuthService.test.ts +++ b/src/backend/services/auth/AuthService.test.ts @@ -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::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 | 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; + }; + // 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), diff --git a/src/backend/services/auth/AuthService.ts b/src/backend/services/auth/AuthService.ts index 9a1360218..885b55baa 100644 --- a/src/backend/services/auth/AuthService.ts +++ b/src/backend/services/auth/AuthService.ts @@ -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 { + async authenticate( + token: string, + ctx: { ip?: string; userAgent?: string } = {}, + ): Promise { let decoded: AnyTokenPayload; try { decoded = this.services.token.verify( '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 { - 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( 'auth', token, ); } catch { - return; + decoded = this.services.token.decodeWithoutVerify( + '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 { + 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 { + 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 { 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 { 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 { 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(() => {}); } diff --git a/src/backend/services/auth/TokenService.ts b/src/backend/services/auth/TokenService.ts index bbfae051f..c3da79946 100644 --- a/src/backend/services/auth/TokenService.ts +++ b/src/backend/services/auth/TokenService.ts @@ -163,6 +163,20 @@ const COMPRESSION: Record = { '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) { + 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) + : {}; + 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>( + 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, + ) as unknown as T; + } + // -- Internals --------------------------------------------------- #compressPayload( diff --git a/src/backend/services/socket/SocketService.ts b/src/backend/services/socket/SocketService.ts index 39192f896..62114f8d7 100644 --- a/src/backend/services/socket/SocketService.ts +++ b/src/backend/services/socket/SocketService.ts @@ -315,7 +315,18 @@ export class SocketService extends PuterService { } try { - const result = await authService.authenticate(token); + const handshakeHeaders = + (socket.handshake.headers as + | Record + | 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( diff --git a/src/backend/stores/session/SessionStore.js b/src/backend/stores/session/SessionStore.js index 4770e24d6..0a988d0e7 100644 --- a/src/backend/stores/session/SessionStore.js +++ b/src/backend/stores/session/SessionStore.js @@ -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:` 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)); } diff --git a/src/backend/stores/session/SessionStore.test.ts b/src/backend/stores/session/SessionStore.test.ts index f13430bd4..dcbf9c078 100644 --- a/src/backend/stores/session/SessionStore.test.ts +++ b/src/backend/stores/session/SessionStore.test.ts @@ -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 diff --git a/src/gui/src/UI/UIWindowManageSessions.js b/src/gui/src/UI/UIWindowManageSessions.js index c73005d06..91008fa5c 100644 --- a/src/gui/src/UI/UIWindowManageSessions.js +++ b/src/gui/src/UI/UIWindowManageSessions.js @@ -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 ; 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); }; };