mirror of
https://github.com/HeyPuter/puter.git
synced 2026-05-29 12:50:59 +00:00
feat: start adding support for token expiry and invalidation (#3151)
* feat: new expirable + revokable session * fix: msql migration
This commit is contained in:
@@ -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`);
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user