Skip to content

Commit e66fd23

Browse files
authored
feat: start adding support for token expiry and invalidation (#3151)
* feat: new expirable + revokable session * fix: msql migration
1 parent 0e2cce1 commit e66fd23

7 files changed

Lines changed: 522 additions & 22 deletions

File tree

src/backend/clients/database/SqliteDatabaseClient.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [
7979
[44, ['0048_old-app-names-unique-tuple.sql']],
8080
[45, ['0049_music-player-pdf-player-updates.sql']],
8181
[46, ['0050_add_preamble_version.sql']],
82+
[47, ['0051_sessions_v2.sql']],
8283
];
8384

8485
export class SqliteDatabaseClient extends AbstractDatabaseClient {

src/backend/clients/database/migrations/mysql/mysql_mig_7.sql

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,26 @@
1414
--
1515
-- You should have received a copy of the GNU Affero General Public License
1616
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
17+
--
18+
-- Idempotent: the ADD COLUMN is guarded by an INFORMATION_SCHEMA check,
19+
-- so re-running the migration directory is safe (required — the runner
20+
-- has no per-file tracking).
21+
22+
DROP PROCEDURE IF EXISTS _puter_add_subdomains_preamble_version;
23+
DELIMITER //
24+
CREATE PROCEDURE _puter_add_subdomains_preamble_version()
25+
BEGIN
26+
IF NOT EXISTS (
27+
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
28+
WHERE TABLE_SCHEMA = DATABASE()
29+
AND TABLE_NAME = 'subdomains' AND COLUMN_NAME = 'preamble_version'
30+
) THEN
31+
ALTER TABLE `subdomains`
32+
ADD COLUMN `preamble_version` varchar(64) DEFAULT NULL;
33+
END IF;
34+
END//
35+
DELIMITER ;
36+
37+
CALL _puter_add_subdomains_preamble_version();
1738

18-
ALTER TABLE `subdomains` ADD COLUMN `preamble_version` varchar(64) DEFAULT NULL;
39+
DROP PROCEDURE IF EXISTS _puter_add_subdomains_preamble_version;
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
-- Extend `sessions` so a single row can represent any token kind
2+
-- (web/app/access_token/asset), carry display metadata for the
3+
-- manage-sessions UI, and be soft-revoked with row-level expiry.
4+
-- Mirrors SQLite migration 0050.
5+
--
6+
-- Idempotent: each ADD COLUMN / ADD INDEX is guarded by an
7+
-- INFORMATION_SCHEMA check, so re-running the migration directory
8+
-- is safe (required — the runner has no per-file tracking).
9+
10+
DROP PROCEDURE IF EXISTS _puter_extend_sessions_v2;
11+
DELIMITER //
12+
CREATE PROCEDURE _puter_extend_sessions_v2()
13+
BEGIN
14+
IF NOT EXISTS (
15+
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
16+
WHERE TABLE_SCHEMA = DATABASE()
17+
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'kind'
18+
) THEN
19+
ALTER TABLE `sessions`
20+
ADD COLUMN `kind` ENUM('web', 'app', 'access_token', 'asset')
21+
NOT NULL DEFAULT 'web';
22+
END IF;
23+
24+
IF NOT EXISTS (
25+
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
26+
WHERE TABLE_SCHEMA = DATABASE()
27+
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'label'
28+
) THEN
29+
ALTER TABLE `sessions` ADD COLUMN `label` VARCHAR(255) DEFAULT NULL;
30+
END IF;
31+
32+
IF NOT EXISTS (
33+
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
34+
WHERE TABLE_SCHEMA = DATABASE()
35+
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'parent_session_id'
36+
) THEN
37+
ALTER TABLE `sessions`
38+
ADD COLUMN `parent_session_id` VARCHAR(64) DEFAULT NULL;
39+
END IF;
40+
41+
IF NOT EXISTS (
42+
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
43+
WHERE TABLE_SCHEMA = DATABASE()
44+
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'last_ip'
45+
) THEN
46+
ALTER TABLE `sessions` ADD COLUMN `last_ip` VARCHAR(64) DEFAULT NULL;
47+
END IF;
48+
49+
IF NOT EXISTS (
50+
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
51+
WHERE TABLE_SCHEMA = DATABASE()
52+
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'last_user_agent'
53+
) THEN
54+
ALTER TABLE `sessions`
55+
ADD COLUMN `last_user_agent` VARCHAR(512) DEFAULT NULL;
56+
END IF;
57+
58+
IF NOT EXISTS (
59+
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
60+
WHERE TABLE_SCHEMA = DATABASE()
61+
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'revoked_at'
62+
) THEN
63+
ALTER TABLE `sessions` ADD COLUMN `revoked_at` BIGINT DEFAULT NULL;
64+
END IF;
65+
66+
IF NOT EXISTS (
67+
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
68+
WHERE TABLE_SCHEMA = DATABASE()
69+
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'expires_at'
70+
) THEN
71+
ALTER TABLE `sessions` ADD COLUMN `expires_at` BIGINT DEFAULT NULL;
72+
END IF;
73+
74+
IF NOT EXISTS (
75+
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
76+
WHERE TABLE_SCHEMA = DATABASE()
77+
AND TABLE_NAME = 'sessions'
78+
AND INDEX_NAME = 'idx_sessions_user_revoked'
79+
) THEN
80+
ALTER TABLE `sessions`
81+
ADD INDEX `idx_sessions_user_revoked` (`user_id`, `revoked_at`);
82+
END IF;
83+
84+
IF NOT EXISTS (
85+
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
86+
WHERE TABLE_SCHEMA = DATABASE()
87+
AND TABLE_NAME = 'sessions'
88+
AND INDEX_NAME = 'idx_sessions_parent'
89+
) THEN
90+
ALTER TABLE `sessions`
91+
ADD INDEX `idx_sessions_parent` (`parent_session_id`);
92+
END IF;
93+
END//
94+
DELIMITER ;
95+
96+
CALL _puter_extend_sessions_v2();
97+
98+
DROP PROCEDURE IF EXISTS _puter_extend_sessions_v2;
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
-- Copyright (C) 2024-present Puter Technologies Inc.
2+
--
3+
-- This file is part of Puter.
4+
--
5+
-- Puter is free software: you can redistribute it and/or modify
6+
-- it under the terms of the GNU Affero General Public License as published
7+
-- by the Free Software Foundation, either version 3 of the License, or
8+
-- (at your option) any later version.
9+
--
10+
-- This program is distributed in the hope that it will be useful,
11+
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
-- GNU Affero General Public License for more details.
14+
--
15+
-- You should have received a copy of the GNU Affero General Public License
16+
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
17+
18+
-- Extend `sessions` so a single row can represent any token kind
19+
-- (web/app/access_token/asset), carry display metadata for the
20+
-- manage-sessions UI, and be soft-revoked.
21+
22+
ALTER TABLE `sessions` ADD COLUMN `kind` TEXT NOT NULL DEFAULT 'web'
23+
CHECK (`kind` IN ('web', 'app', 'access_token', 'asset'));
24+
ALTER TABLE `sessions` ADD COLUMN `label` TEXT;
25+
ALTER TABLE `sessions` ADD COLUMN `parent_session_id` TEXT;
26+
ALTER TABLE `sessions` ADD COLUMN `last_ip` TEXT;
27+
ALTER TABLE `sessions` ADD COLUMN `last_user_agent` TEXT;
28+
ALTER TABLE `sessions` ADD COLUMN `revoked_at` INTEGER;
29+
ALTER TABLE `sessions` ADD COLUMN `expires_at` INTEGER;
30+
31+
CREATE INDEX IF NOT EXISTS `idx_sessions_user_revoked`
32+
ON `sessions` (`user_id`, `revoked_at`);
33+
CREATE INDEX IF NOT EXISTS `idx_sessions_parent`
34+
ON `sessions` (`parent_session_id`);

src/backend/services/auth/AuthService.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,12 @@ export class AuthService extends PuterService {
120120
token: string;
121121
gui_token: string;
122122
}> {
123-
const session = await this.stores.session.create(user.id, meta);
123+
const session = await this.stores.session.create(user.id, {
124+
meta,
125+
kind: 'web',
126+
last_ip: (meta.ip as string | undefined) ?? null,
127+
last_user_agent: (meta.user_agent as string | undefined) ?? null,
128+
});
124129

125130
const token = this.services.token.sign('auth', {
126131
type: 'session',

src/backend/stores/session/SessionStore.js

Lines changed: 104 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,10 @@ import { PuterStore } from '../types';
2525
// the cached row doesn't gate anything, and eating a Redis write per
2626
// request isn't worth it.
2727

28-
const CACHE_KEY_PREFIX = 'sessions';
28+
// Prefix is versioned so a schema change (new columns added to the
29+
// row shape) doesn't leak stale cached rows through Redis on a
30+
// rolling deploy. Bump the suffix on the next schema change.
31+
const CACHE_KEY_PREFIX = 'sessions:v2';
2932
const CACHE_TTL_SECONDS = 15 * 60;
3033
// Min interval between successive activity flushes per session/user.
3134
// In-memory throttle keeps DB writes bounded; multi-node duplicates
@@ -44,15 +47,21 @@ export class SessionStore extends PuterStore {
4447
#lastSessionTouchMs = new Map();
4548
#lastUserTouchMs = new Map();
4649

47-
/** Look up a session by its uuid. Returns `null` if not found. */
50+
/**
51+
* Look up an active session by its uuid. Returns `null` if not
52+
* found or soft-revoked.
53+
*/
4854
async getByUuid(uuid) {
4955
if (!uuid) return null;
5056

5157
const cached = await this.#readCache(uuid);
52-
if (cached) return cached;
58+
if (cached) {
59+
if (cached.revoked_at != null) return null;
60+
return cached;
61+
}
5362

5463
const rows = await this.clients.db.read(
55-
'SELECT * FROM `sessions` WHERE `uuid` = ? LIMIT 1',
64+
'SELECT * FROM `sessions` WHERE `uuid` = ? AND `revoked_at` IS NULL LIMIT 1',
5665
[uuid],
5766
);
5867
const normalized = this.#normalizeRow(rows[0]);
@@ -64,32 +73,65 @@ export class SessionStore extends PuterStore {
6473
return normalized;
6574
}
6675

67-
/** Get all sessions for a user. */
68-
async getByUserId(userId) {
69-
const rows = await this.clients.db.read(
70-
'SELECT * FROM `sessions` WHERE `user_id` = ?',
71-
[userId],
72-
);
76+
/**
77+
* Get sessions for a user. By default returns only active rows;
78+
* pass `{ includeRevoked: true }` to include soft-revoked rows.
79+
*/
80+
async getByUserId(userId, { includeRevoked = false } = {}) {
81+
const sql = includeRevoked
82+
? 'SELECT * FROM `sessions` WHERE `user_id` = ?'
83+
: 'SELECT * FROM `sessions` WHERE `user_id` = ? AND `revoked_at` IS NULL';
84+
const rows = await this.clients.db.read(sql, [userId]);
7385
return rows.map((r) => this.#normalizeRow(r)).filter(Boolean);
7486
}
7587

7688
/**
77-
* Create a new session.
89+
* Create a new session row.
7890
*
7991
* @param userId - User ID (numeric)
80-
* @param meta - Metadata object (IP, user-agent, etc.)
81-
* @returns The created session row
92+
* @param opts.meta - Request-context metadata (IP, UA, etc.) stored as JSON.
93+
* @param opts.kind - 'web' (default), 'app', 'access_token', 'asset'.
94+
* @param opts.label - User-editable label for manage-sessions UI.
95+
* @param opts.parent_session_id - uuid of root session, for derived kinds.
96+
* @param opts.last_ip - Request IP at creation.
97+
* @param opts.last_user_agent - Request User-Agent at creation.
98+
* @param opts.expires_at - Row-level expiry (unix seconds). NULL means
99+
* JWT `exp` is the sole truth. AUTH-4 slides this forward on activity.
100+
* @returns The created session row.
82101
*/
83-
async create(userId, meta = {}) {
102+
async create(
103+
userId,
104+
{
105+
meta = {},
106+
kind = 'web',
107+
label = null,
108+
parent_session_id = null,
109+
last_ip = null,
110+
last_user_agent = null,
111+
expires_at = null,
112+
} = {},
113+
) {
84114
const uuid = uuidv4();
85115
const now = Math.floor(Date.now() / 1000);
86116

87117
meta.created = new Date().toISOString();
88118
meta.created_unix = now;
89119

90120
await this.clients.db.write(
91-
'INSERT INTO `sessions` (`uuid`, `user_id`, `meta`, `last_activity`, `created_at`) VALUES (?, ?, ?, ?, ?)',
92-
[uuid, userId, JSON.stringify(meta), now, now],
121+
'INSERT INTO `sessions` (`uuid`, `user_id`, `meta`, `last_activity`, `created_at`, `kind`, `label`, `parent_session_id`, `last_ip`, `last_user_agent`, `expires_at`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
122+
[
123+
uuid,
124+
userId,
125+
JSON.stringify(meta),
126+
now,
127+
now,
128+
kind,
129+
label,
130+
parent_session_id,
131+
last_ip,
132+
last_user_agent,
133+
expires_at,
134+
],
93135
);
94136

95137
return {
@@ -98,20 +140,62 @@ export class SessionStore extends PuterStore {
98140
meta,
99141
created_at: now,
100142
last_activity: now,
143+
kind,
144+
label,
145+
parent_session_id,
146+
last_ip,
147+
last_user_agent,
148+
revoked_at: null,
149+
expires_at,
101150
};
102151
}
103152

104-
/** Delete a session by uuid. Invalidates cache on this node + peers. */
153+
/**
154+
* Soft-revoke a session by uuid. The row remains in the table
155+
* with `revoked_at` set; subsequent `getByUuid` calls treat it
156+
* as not found. Invalidates cache on this node + peers.
157+
*/
105158
async removeByUuid(uuid) {
106-
await this.clients.db.write('DELETE FROM `sessions` WHERE `uuid` = ?', [
107-
uuid,
108-
]);
159+
const now = Math.floor(Date.now() / 1000);
160+
await this.clients.db.write(
161+
'UPDATE `sessions` SET `revoked_at` = ? WHERE `uuid` = ? AND `revoked_at` IS NULL',
162+
[now, uuid],
163+
);
109164
await this.publishCacheKeys({
110165
keys: [this.#cacheKey(uuid)],
111166
broadcast: true,
112167
});
113168
}
114169

170+
/**
171+
* Soft-revoke a root session and every derived session that
172+
* points back to it via `parent_session_id`. Broadcasts cache
173+
* invalidation for each affected row.
174+
*/
175+
async revokeCascade(rootUuid) {
176+
if (!rootUuid) return;
177+
178+
// Collect affected uuids first so we can broadcast cache
179+
// invalidation for each row. A single UPDATE ... RETURNING
180+
// would be cleaner but isn't portable between sqlite/mysql.
181+
const rows = await this.clients.db.read(
182+
'SELECT `uuid` FROM `sessions` WHERE (`uuid` = ? OR `parent_session_id` = ?) AND `revoked_at` IS NULL',
183+
[rootUuid, rootUuid],
184+
);
185+
if (rows.length === 0) return;
186+
187+
const now = Math.floor(Date.now() / 1000);
188+
await this.clients.db.write(
189+
'UPDATE `sessions` SET `revoked_at` = ? WHERE (`uuid` = ? OR `parent_session_id` = ?) AND `revoked_at` IS NULL',
190+
[now, rootUuid, rootUuid],
191+
);
192+
193+
await this.publishCacheKeys({
194+
keys: rows.map((r) => this.#cacheKey(r.uuid)),
195+
broadcast: true,
196+
});
197+
}
198+
115199
/** Update session activity timestamp. */
116200
async updateActivity(uuid, lastActivity) {
117201
await this.clients.db.write(

0 commit comments

Comments
 (0)