feat: start adding support for token expiry and invalidation (#3151)

* feat: new expirable + revokable session

* fix: msql migration
This commit is contained in:
Daniel Salazar
2026-05-25 12:30:20 -07:00
committed by GitHub
parent 0e2cce152d
commit e66fd2373f
7 changed files with 522 additions and 22 deletions
@@ -79,6 +79,7 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [
[44, ['0048_old-app-names-unique-tuple.sql']],
[45, ['0049_music-player-pdf-player-updates.sql']],
[46, ['0050_add_preamble_version.sql']],
[47, ['0051_sessions_v2.sql']],
];
export class SqliteDatabaseClient extends AbstractDatabaseClient {
@@ -14,5 +14,26 @@
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
--
-- Idempotent: the ADD COLUMN is guarded by an INFORMATION_SCHEMA check,
-- so re-running the migration directory is safe (required — the runner
-- has no per-file tracking).
ALTER TABLE `subdomains` ADD COLUMN `preamble_version` varchar(64) DEFAULT NULL;
DROP PROCEDURE IF EXISTS _puter_add_subdomains_preamble_version;
DELIMITER //
CREATE PROCEDURE _puter_add_subdomains_preamble_version()
BEGIN
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'subdomains' AND COLUMN_NAME = 'preamble_version'
) THEN
ALTER TABLE `subdomains`
ADD COLUMN `preamble_version` varchar(64) DEFAULT NULL;
END IF;
END//
DELIMITER ;
CALL _puter_add_subdomains_preamble_version();
DROP PROCEDURE IF EXISTS _puter_add_subdomains_preamble_version;
@@ -0,0 +1,98 @@
-- Extend `sessions` so a single row can represent any token kind
-- (web/app/access_token/asset), carry display metadata for the
-- manage-sessions UI, and be soft-revoked with row-level expiry.
-- Mirrors SQLite migration 0050.
--
-- Idempotent: each ADD COLUMN / ADD INDEX is guarded by an
-- INFORMATION_SCHEMA check, so re-running the migration directory
-- is safe (required — the runner has no per-file tracking).
DROP PROCEDURE IF EXISTS _puter_extend_sessions_v2;
DELIMITER //
CREATE PROCEDURE _puter_extend_sessions_v2()
BEGIN
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'kind'
) THEN
ALTER TABLE `sessions`
ADD COLUMN `kind` ENUM('web', 'app', 'access_token', 'asset')
NOT NULL DEFAULT 'web';
END IF;
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'label'
) THEN
ALTER TABLE `sessions` ADD COLUMN `label` VARCHAR(255) DEFAULT NULL;
END IF;
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'parent_session_id'
) THEN
ALTER TABLE `sessions`
ADD COLUMN `parent_session_id` VARCHAR(64) DEFAULT NULL;
END IF;
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'last_ip'
) THEN
ALTER TABLE `sessions` ADD COLUMN `last_ip` VARCHAR(64) DEFAULT NULL;
END IF;
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'last_user_agent'
) THEN
ALTER TABLE `sessions`
ADD COLUMN `last_user_agent` VARCHAR(512) DEFAULT NULL;
END IF;
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'revoked_at'
) THEN
ALTER TABLE `sessions` ADD COLUMN `revoked_at` BIGINT DEFAULT NULL;
END IF;
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'expires_at'
) THEN
ALTER TABLE `sessions` ADD COLUMN `expires_at` BIGINT DEFAULT NULL;
END IF;
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'sessions'
AND INDEX_NAME = 'idx_sessions_user_revoked'
) THEN
ALTER TABLE `sessions`
ADD INDEX `idx_sessions_user_revoked` (`user_id`, `revoked_at`);
END IF;
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'sessions'
AND INDEX_NAME = 'idx_sessions_parent'
) THEN
ALTER TABLE `sessions`
ADD INDEX `idx_sessions_parent` (`parent_session_id`);
END IF;
END//
DELIMITER ;
CALL _puter_extend_sessions_v2();
DROP PROCEDURE IF EXISTS _puter_extend_sessions_v2;
@@ -0,0 +1,34 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Extend `sessions` so a single row can represent any token kind
-- (web/app/access_token/asset), carry display metadata for the
-- manage-sessions UI, and be soft-revoked.
ALTER TABLE `sessions` ADD COLUMN `kind` TEXT NOT NULL DEFAULT 'web'
CHECK (`kind` IN ('web', 'app', 'access_token', 'asset'));
ALTER TABLE `sessions` ADD COLUMN `label` TEXT;
ALTER TABLE `sessions` ADD COLUMN `parent_session_id` TEXT;
ALTER TABLE `sessions` ADD COLUMN `last_ip` TEXT;
ALTER TABLE `sessions` ADD COLUMN `last_user_agent` TEXT;
ALTER TABLE `sessions` ADD COLUMN `revoked_at` INTEGER;
ALTER TABLE `sessions` ADD COLUMN `expires_at` INTEGER;
CREATE INDEX IF NOT EXISTS `idx_sessions_user_revoked`
ON `sessions` (`user_id`, `revoked_at`);
CREATE INDEX IF NOT EXISTS `idx_sessions_parent`
ON `sessions` (`parent_session_id`);
+6 -1
View File
@@ -120,7 +120,12 @@ export class AuthService extends PuterService {
token: string;
gui_token: string;
}> {
const session = await this.stores.session.create(user.id, meta);
const session = await this.stores.session.create(user.id, {
meta,
kind: 'web',
last_ip: (meta.ip as string | undefined) ?? null,
last_user_agent: (meta.user_agent as string | undefined) ?? null,
});
const token = this.services.token.sign('auth', {
type: 'session',
+104 -20
View File
@@ -25,7 +25,10 @@ import { PuterStore } from '../types';
// the cached row doesn't gate anything, and eating a Redis write per
// request isn't worth it.
const CACHE_KEY_PREFIX = 'sessions';
// Prefix is versioned so a schema change (new columns added to the
// row shape) doesn't leak stale cached rows through Redis on a
// rolling deploy. Bump the suffix on the next schema change.
const CACHE_KEY_PREFIX = 'sessions:v2';
const CACHE_TTL_SECONDS = 15 * 60;
// Min interval between successive activity flushes per session/user.
// In-memory throttle keeps DB writes bounded; multi-node duplicates
@@ -44,15 +47,21 @@ export class SessionStore extends PuterStore {
#lastSessionTouchMs = new Map();
#lastUserTouchMs = new Map();
/** Look up a session by its uuid. Returns `null` if not found. */
/**
* Look up an active session by its uuid. Returns `null` if not
* found or soft-revoked.
*/
async getByUuid(uuid) {
if (!uuid) return null;
const cached = await this.#readCache(uuid);
if (cached) return cached;
if (cached) {
if (cached.revoked_at != null) return null;
return cached;
}
const rows = await this.clients.db.read(
'SELECT * FROM `sessions` WHERE `uuid` = ? LIMIT 1',
'SELECT * FROM `sessions` WHERE `uuid` = ? AND `revoked_at` IS NULL LIMIT 1',
[uuid],
);
const normalized = this.#normalizeRow(rows[0]);
@@ -64,23 +73,44 @@ export class SessionStore extends PuterStore {
return normalized;
}
/** Get all sessions for a user. */
async getByUserId(userId) {
const rows = await this.clients.db.read(
'SELECT * FROM `sessions` WHERE `user_id` = ?',
[userId],
);
/**
* Get sessions for a user. By default returns only active rows;
* pass `{ includeRevoked: true }` to include soft-revoked rows.
*/
async getByUserId(userId, { includeRevoked = false } = {}) {
const sql = includeRevoked
? 'SELECT * FROM `sessions` WHERE `user_id` = ?'
: 'SELECT * FROM `sessions` WHERE `user_id` = ? AND `revoked_at` IS NULL';
const rows = await this.clients.db.read(sql, [userId]);
return rows.map((r) => this.#normalizeRow(r)).filter(Boolean);
}
/**
* Create a new session.
* Create a new session row.
*
* @param userId - User ID (numeric)
* @param meta - Metadata object (IP, user-agent, etc.)
* @returns The created session row
* @param opts.meta - Request-context metadata (IP, UA, etc.) stored as JSON.
* @param opts.kind - 'web' (default), 'app', 'access_token', 'asset'.
* @param opts.label - User-editable label for manage-sessions UI.
* @param opts.parent_session_id - uuid of root session, for derived kinds.
* @param opts.last_ip - Request IP at creation.
* @param opts.last_user_agent - Request User-Agent at creation.
* @param opts.expires_at - Row-level expiry (unix seconds). NULL means
* JWT `exp` is the sole truth. AUTH-4 slides this forward on activity.
* @returns The created session row.
*/
async create(userId, meta = {}) {
async create(
userId,
{
meta = {},
kind = 'web',
label = null,
parent_session_id = null,
last_ip = null,
last_user_agent = null,
expires_at = null,
} = {},
) {
const uuid = uuidv4();
const now = Math.floor(Date.now() / 1000);
@@ -88,8 +118,20 @@ export class SessionStore extends PuterStore {
meta.created_unix = now;
await this.clients.db.write(
'INSERT INTO `sessions` (`uuid`, `user_id`, `meta`, `last_activity`, `created_at`) VALUES (?, ?, ?, ?, ?)',
[uuid, userId, JSON.stringify(meta), now, now],
'INSERT INTO `sessions` (`uuid`, `user_id`, `meta`, `last_activity`, `created_at`, `kind`, `label`, `parent_session_id`, `last_ip`, `last_user_agent`, `expires_at`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[
uuid,
userId,
JSON.stringify(meta),
now,
now,
kind,
label,
parent_session_id,
last_ip,
last_user_agent,
expires_at,
],
);
return {
@@ -98,20 +140,62 @@ export class SessionStore extends PuterStore {
meta,
created_at: now,
last_activity: now,
kind,
label,
parent_session_id,
last_ip,
last_user_agent,
revoked_at: null,
expires_at,
};
}
/** Delete a session by uuid. Invalidates cache on this node + peers. */
/**
* Soft-revoke a session by uuid. The row remains in the table
* with `revoked_at` set; subsequent `getByUuid` calls treat it
* as not found. Invalidates cache on this node + peers.
*/
async removeByUuid(uuid) {
await this.clients.db.write('DELETE FROM `sessions` WHERE `uuid` = ?', [
uuid,
]);
const now = Math.floor(Date.now() / 1000);
await this.clients.db.write(
'UPDATE `sessions` SET `revoked_at` = ? WHERE `uuid` = ? AND `revoked_at` IS NULL',
[now, uuid],
);
await this.publishCacheKeys({
keys: [this.#cacheKey(uuid)],
broadcast: true,
});
}
/**
* Soft-revoke a root session and every derived session that
* points back to it via `parent_session_id`. Broadcasts cache
* invalidation for each affected row.
*/
async revokeCascade(rootUuid) {
if (!rootUuid) return;
// Collect affected uuids first so we can broadcast cache
// invalidation for each row. A single UPDATE ... RETURNING
// would be cleaner but isn't portable between sqlite/mysql.
const rows = await this.clients.db.read(
'SELECT `uuid` FROM `sessions` WHERE (`uuid` = ? OR `parent_session_id` = ?) AND `revoked_at` IS NULL',
[rootUuid, rootUuid],
);
if (rows.length === 0) return;
const now = Math.floor(Date.now() / 1000);
await this.clients.db.write(
'UPDATE `sessions` SET `revoked_at` = ? WHERE (`uuid` = ? OR `parent_session_id` = ?) AND `revoked_at` IS NULL',
[now, rootUuid, rootUuid],
);
await this.publishCacheKeys({
keys: rows.map((r) => this.#cacheKey(r.uuid)),
broadcast: true,
});
}
/** Update session activity timestamp. */
async updateActivity(uuid, lastActivity) {
await this.clients.db.write(
@@ -0,0 +1,257 @@
/**
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { v4 as uuidv4 } from 'uuid';
import { setupTestServer } from '../../testUtil.ts';
import { PuterServer } from '../../server.ts';
describe('SessionStore', () => {
let server: PuterServer;
let target: any;
beforeAll(async () => {
server = await setupTestServer();
target = server.stores.session;
});
afterAll(async () => {
await server?.shutdown();
});
const makeUser = async () => {
const username = `ss-${Math.random().toString(36).slice(2, 10)}`;
return server.stores.user.create({
username,
uuid: uuidv4(),
password: null,
email: `${username}@test.local`,
email_confirmed: 1,
} as never);
};
// Reads the raw row (including revoked) so tests can assert
// soft-revoke semantics — `getByUuid` filters revoked rows.
const rawRow = async (uuid: string) => {
const rows = await server.clients.db.read(
'SELECT * FROM `sessions` WHERE `uuid` = ? LIMIT 1',
[uuid],
);
return rows[0] ?? null;
};
describe('create', () => {
it('defaults to kind="web" and stores request metadata', async () => {
const user = await makeUser();
const session = await target.create(user.id, {
meta: { ip: '1.2.3.4' },
last_ip: '1.2.3.4',
last_user_agent: 'test-agent',
});
expect(session.kind).toBe('web');
expect(session.parent_session_id).toBeNull();
expect(session.revoked_at).toBeNull();
const row = await rawRow(session.uuid);
expect(row.kind).toBe('web');
expect(row.last_ip).toBe('1.2.3.4');
expect(row.last_user_agent).toBe('test-agent');
});
it('stores expires_at when provided', async () => {
const user = await makeUser();
const future = Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60;
const session = await target.create(user.id, {
expires_at: future,
});
expect(session.expires_at).toBe(future);
const row = await rawRow(session.uuid);
expect(row.expires_at).toBe(future);
});
it('leaves expires_at NULL by default (JWT exp is sole truth)', async () => {
const user = await makeUser();
const session = await target.create(user.id);
expect(session.expires_at).toBeNull();
const row = await rawRow(session.uuid);
expect(row.expires_at).toBeNull();
});
it('accepts derived kinds with a parent_session_id', async () => {
const user = await makeUser();
const parent = await target.create(user.id);
const child = await target.create(user.id, {
kind: 'app',
parent_session_id: parent.uuid,
label: 'Some App',
});
expect(child.kind).toBe('app');
expect(child.parent_session_id).toBe(parent.uuid);
const row = await rawRow(child.uuid);
expect(row.parent_session_id).toBe(parent.uuid);
expect(row.label).toBe('Some App');
});
});
describe('getByUuid', () => {
it('returns the row for an active session', async () => {
const user = await makeUser();
const session = await target.create(user.id);
const fetched = await target.getByUuid(session.uuid);
expect(fetched).toBeTruthy();
expect(fetched.uuid).toBe(session.uuid);
});
it('returns null for a soft-revoked session', async () => {
const user = await makeUser();
const session = await target.create(user.id);
await target.removeByUuid(session.uuid);
const fetched = await target.getByUuid(session.uuid);
expect(fetched).toBeNull();
});
it('returns the row even when expires_at is in the past (AUTH-4 owns expiry)', async () => {
const user = await makeUser();
const past = Math.floor(Date.now() / 1000) - 60;
const session = await target.create(user.id, { expires_at: past });
const fetched = await target.getByUuid(session.uuid);
expect(fetched).toBeTruthy();
expect(fetched.uuid).toBe(session.uuid);
expect(fetched.expires_at).toBe(past);
});
it('returns null when uuid is empty', async () => {
expect(await target.getByUuid('')).toBeNull();
expect(await target.getByUuid(undefined)).toBeNull();
});
});
describe('getByUserId', () => {
it('returns only active sessions by default', async () => {
const user = await makeUser();
const active = await target.create(user.id);
const revoked = await target.create(user.id);
await target.removeByUuid(revoked.uuid);
const rows = await target.getByUserId(user.id);
const uuids = rows.map((r: { uuid: string }) => r.uuid);
expect(uuids).toContain(active.uuid);
expect(uuids).not.toContain(revoked.uuid);
});
it('returns revoked sessions when includeRevoked is true', async () => {
const user = await makeUser();
const active = await target.create(user.id);
const revoked = await target.create(user.id);
await target.removeByUuid(revoked.uuid);
const rows = await target.getByUserId(user.id, {
includeRevoked: true,
});
const uuids = rows.map((r: { uuid: string }) => r.uuid);
expect(uuids).toContain(active.uuid);
expect(uuids).toContain(revoked.uuid);
});
});
describe('removeByUuid', () => {
it('soft-revokes — row remains in DB with revoked_at set', async () => {
const user = await makeUser();
const session = await target.create(user.id);
await target.removeByUuid(session.uuid);
const row = await rawRow(session.uuid);
expect(row).toBeTruthy();
expect(row.revoked_at).not.toBeNull();
expect(row.revoked_at).toBeGreaterThan(0);
});
it('is idempotent — second call does not overwrite revoked_at', async () => {
const user = await makeUser();
const session = await target.create(user.id);
await target.removeByUuid(session.uuid);
const firstRevokedAt = (await rawRow(session.uuid)).revoked_at;
await new Promise((r) => setTimeout(r, 1100));
await target.removeByUuid(session.uuid);
const secondRevokedAt = (await rawRow(session.uuid)).revoked_at;
expect(secondRevokedAt).toBe(firstRevokedAt);
});
});
describe('revokeCascade', () => {
it('revokes the root session and all child sessions', async () => {
const user = await makeUser();
const parent = await target.create(user.id);
const child1 = await target.create(user.id, {
kind: 'app',
parent_session_id: parent.uuid,
});
const child2 = await target.create(user.id, {
kind: 'access_token',
parent_session_id: parent.uuid,
});
await target.revokeCascade(parent.uuid);
expect(await target.getByUuid(parent.uuid)).toBeNull();
expect(await target.getByUuid(child1.uuid)).toBeNull();
expect(await target.getByUuid(child2.uuid)).toBeNull();
expect((await rawRow(parent.uuid)).revoked_at).not.toBeNull();
expect((await rawRow(child1.uuid)).revoked_at).not.toBeNull();
expect((await rawRow(child2.uuid)).revoked_at).not.toBeNull();
});
it('does not touch unrelated sessions', async () => {
const user = await makeUser();
const parent = await target.create(user.id);
const sibling = await target.create(user.id);
const childOfSibling = await target.create(user.id, {
kind: 'app',
parent_session_id: sibling.uuid,
});
await target.revokeCascade(parent.uuid);
expect(await target.getByUuid(sibling.uuid)).toBeTruthy();
expect(await target.getByUuid(childOfSibling.uuid)).toBeTruthy();
});
it('is a no-op when the root uuid does not exist', async () => {
await expect(
target.revokeCascade('nonexistent-uuid'),
).resolves.toBeUndefined();
});
it('is a no-op when called with a falsy uuid', async () => {
await expect(target.revokeCascade('')).resolves.toBeUndefined();
await expect(
target.revokeCascade(undefined),
).resolves.toBeUndefined();
});
});
});