diff --git a/package-lock.json b/package-lock.json index 035f25dc4..7927e9648 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5984,19 +5984,18 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/eventemitter": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/fetch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", - "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", "license": "BSD-3-Clause", "dependencies": { - "@protobufjs/aspromise": "^1.1.1", - "@protobufjs/inquire": "^1.1.0" + "@protobufjs/aspromise": "^1.1.1" } }, "node_modules/@protobufjs/float": { @@ -6006,9 +6005,9 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/inquire": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.1.tgz", - "integrity": "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", + "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/path": { @@ -7436,6 +7435,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, "node_modules/@types/memcached": { "version": "2.2.10", "resolved": "https://registry.npmjs.org/@types/memcached/-/memcached-2.2.10.tgz", @@ -7459,6 +7469,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mysql": { "version": "2.15.26", "resolved": "https://registry.npmjs.org/@types/mysql/-/mysql-2.15.26.tgz", @@ -7570,6 +7587,13 @@ "@types/node": "*" } }, + "node_modules/@types/validator": { + "version": "13.15.10", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", + "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -8934,9 +8958,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { @@ -10156,9 +10180,9 @@ } }, "node_modules/engine.io": { - "version": "6.6.7", - "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.7.tgz", - "integrity": "sha512-DgOngfDKM2EviOH3Mr9m7ks1q8roetLy/IMmYthAYzbpInMbYc/GS+fWFA3rl1gvwKVsQrVV61fo5emD1y3OJQ==", + "version": "6.6.8", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.8.tgz", + "integrity": "sha512-2agL3ueZhqxoVrfmntO8yuVj+uNSlIOnhykYHk3Cq0ShYPdUjjUiSJrQvXjq01I9jAuI0Zl2YO8Evv5Mqytm5g==", "license": "MIT", "dependencies": { "@types/cors": "^2.8.12", @@ -10170,7 +10194,7 @@ "cors": "~2.8.5", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", - "ws": "~8.18.3" + "ws": "~8.20.1" }, "engines": { "node": ">=10.2.0" @@ -10225,9 +10249,9 @@ } }, "node_modules/engine.io/node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -15400,24 +15424,24 @@ } }, "node_modules/protobufjs": { - "version": "7.5.6", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.6.tgz", - "integrity": "sha512-M71sTMB146U3u0di3yup8iM+zv8yPRNQVr1KK4tyBitl3qFvEGucq/rGDRShD2rsJhtN02RJaJ7j5X5hmy8SJg==", + "version": "7.6.1", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.1.tgz", + "integrity": "sha512-4K0myLaWL5EteuSAro91EGFgcfVgxb64Jx+7oDAY6GOkXD4M69yuSEljNcInGVCA5sOPxmZ/EqDLj2x0Q0+Ygg==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.1", + "@protobufjs/inquire": "^1.1.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", - "long": "^5.0.0" + "long": "^5.3.2" }, "engines": { "node": ">=12.0.0" @@ -15481,9 +15505,9 @@ } }, "node_modules/qs": { - "version": "6.15.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", - "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -16610,13 +16634,13 @@ } }, "node_modules/socket.io-adapter": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz", - "integrity": "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==", + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.7.tgz", + "integrity": "sha512-e0LyK91f3cUxTmv95/KzoLg47+zF+s/sbxRGDNsyG4dmIP8ZSX8ax6byOxfJXeNNtS/8AZlfD+uP7gBeR7DLlg==", "license": "MIT", "dependencies": { "debug": "~4.4.1", - "ws": "~8.18.3" + "ws": "~8.20.1" } }, "node_modules/socket.io-adapter/node_modules/debug": { @@ -16637,9 +16661,9 @@ } }, "node_modules/socket.io-adapter/node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -18182,9 +18206,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", - "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -18401,7 +18425,9 @@ "devDependencies": { "@types/bcrypt": "^6.0.0", "@types/busboy": "^1.5.4", + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^24.0.0", + "@types/validator": "^13.15.10", "chai": "^4.3.7", "nodemon": "^3.1.0", "typescript": "^5.9.3", diff --git a/src/backend/clients/database/MySQLDatabaseClient.test.ts b/src/backend/clients/database/MySQLDatabaseClient.test.ts new file mode 100644 index 000000000..1d74adde6 --- /dev/null +++ b/src/backend/clients/database/MySQLDatabaseClient.test.ts @@ -0,0 +1,95 @@ +/** + * 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 . + */ + +import { describe, expect, it } from 'vitest'; +import { compareMigrationFilenames } from './MySQLDatabaseClient.js'; + +describe('compareMigrationFilenames', () => { + it('orders numbered migrations numerically, not lexically', () => { + const input = [ + 'mysql_mig_10.sql', + 'mysql_mig_2.sql', + 'mysql_mig_1.sql', + 'mysql_mig_9.sql', + 'mysql_mig_3.sql', + ]; + const sorted = [...input].sort(compareMigrationFilenames); + expect(sorted).toEqual([ + 'mysql_mig_1.sql', + 'mysql_mig_2.sql', + 'mysql_mig_3.sql', + 'mysql_mig_9.sql', + 'mysql_mig_10.sql', + ]); + }); + + it('keeps mig_10 after mig_9 when the real prod set is shuffled', () => { + // Reflects the current migrations/mysql/ listing — guards against + // a future "let's just rename to padded" suggestion accidentally + // re-introducing the lex bug if the rename is incomplete. + const real = [ + 'mysql_mig_9.sql', + 'mysql_mig_3.sql', + 'mysql_mig_10.sql', + 'mysql_mig_1.sql', + 'mysql_mig_7.sql', + 'mysql_mig_5.sql', + 'mysql_mig_2.sql', + 'mysql_mig_4.sql', + 'mysql_mig_8.sql', + 'mysql_mig_6.sql', + ]; + const sorted = [...real].sort(compareMigrationFilenames); + for (let i = 1; i <= sorted.length; i += 1) { + expect(sorted[i - 1]).toBe(`mysql_mig_${i}.sql`); + } + }); + + it('sorts non-numeric filenames after numbered ones, lexically among themselves', () => { + // Numbered files always run first (they're the canonical history); + // unmatched names follow in localeCompare order. Mixing the two + // sets prevents a vendor dump from accidentally wedging itself + // between mig_4 and mig_5 if it happened to lex-sort there. + const mixed = [ + 'mysql_mig_2.sql', + 'mysql_vendor_dump.sql', + 'mysql_mig_10.sql', + 'mysql_bootstrap.sql', + 'mysql_mig_1.sql', + ]; + const sorted = [...mixed].sort(compareMigrationFilenames); + expect(sorted).toEqual([ + 'mysql_mig_1.sql', + 'mysql_mig_2.sql', + 'mysql_mig_10.sql', + 'mysql_bootstrap.sql', + 'mysql_vendor_dump.sql', + ]); + }); + + it('is stable for already-sorted input', () => { + const sorted = [ + 'mysql_mig_1.sql', + 'mysql_mig_2.sql', + 'mysql_mig_10.sql', + 'mysql_mig_11.sql', + ]; + expect([...sorted].sort(compareMigrationFilenames)).toEqual(sorted); + }); +}); diff --git a/src/backend/clients/database/MySQLDatabaseClient.ts b/src/backend/clients/database/MySQLDatabaseClient.ts index cbdf1f495..6119688f4 100644 --- a/src/backend/clients/database/MySQLDatabaseClient.ts +++ b/src/backend/clients/database/MySQLDatabaseClient.ts @@ -44,6 +44,37 @@ const RETRIABLE_ERROR_MESSAGES = [ 'ETIMEDOUT', ]; +/** + * Comparator for MySQL migration filenames. + * + * Existing files are named `mysql_mig_.sql` with unpadded N, so a + * plain lexical sort puts `mysql_mig_10.sql` BEFORE `mysql_mig_2.sql`. + * Pull the trailing integer out and sort numerically. Anything that + * doesn't match the `_.sql` shape (one-off, vendor dump) falls + * back to lexical comparison so the order stays deterministic, and + * unmatched names sort *after* numbered files so future numbered + * migrations don't get interleaved into a one-off's namespace. + * + * Exported only for the unit test — the production caller is the + * `runMigrations()` loop inside this file. + */ +export const compareMigrationFilenames = (a: string, b: string): number => { + const numericIndex = (name: string): number => { + const m = /_(\d+)\.sql$/.exec(name); + return m ? Number.parseInt(m[1], 10) : Number.NaN; + }; + const na = numericIndex(a); + const nb = numericIndex(b); + if (Number.isFinite(na) && Number.isFinite(nb)) { + if (na !== nb) return na - nb; + } else if (Number.isFinite(na)) { + return -1; + } else if (Number.isFinite(nb)) { + return 1; + } + return a.localeCompare(b); +}; + type PoolConfig = Parameters[0]; enum Configuration { @@ -247,7 +278,7 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient { .filter( (f) => f.endsWith('.sql') && f.startsWith('mysql'), ) - .sort(); + .sort(compareMigrationFilenames); } catch (e) { throw new Error( `[mysql] migration path is unreadable: ${dir}`, diff --git a/src/backend/clients/database/SqliteDatabaseClient.ts b/src/backend/clients/database/SqliteDatabaseClient.ts index e75cd4741..7bf97d9ca 100644 --- a/src/backend/clients/database/SqliteDatabaseClient.ts +++ b/src/backend/clients/database/SqliteDatabaseClient.ts @@ -81,6 +81,7 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [ [46, ['0050_add_preamble_version.sql']], [47, ['0051_sessions_v2.sql']], [48, ['0052_sessions_v2_lookups.sql']], + [49, ['0053_sessions_access_token_uid.sql']], ]; export class SqliteDatabaseClient extends AbstractDatabaseClient { diff --git a/src/backend/clients/database/migrations/mysql/mysql_mig_10.sql b/src/backend/clients/database/migrations/mysql/mysql_mig_10.sql new file mode 100644 index 000000000..8ecd88df6 --- /dev/null +++ b/src/backend/clients/database/migrations/mysql/mysql_mig_10.sql @@ -0,0 +1,53 @@ +-- 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 . + +-- AUTH-5 (PUT-1019) — mirrors SQLite migration 0053. Adds the +-- `access_token_uid` reverse-lookup column on `sessions` so raw-uuid +-- revoke can find the matching session row when only the v2 token_uid +-- (no JWT) is presented. +-- +-- Idempotent: each ADD COLUMN / ADD INDEX is guarded so the migration +-- directory can be replayed safely. + +DROP PROCEDURE IF EXISTS _puter_sessions_access_token_uid; +DELIMITER // +CREATE PROCEDURE _puter_sessions_access_token_uid() +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'access_token_uid' + ) THEN + ALTER TABLE `sessions` + ADD COLUMN `access_token_uid` VARCHAR(64) 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_access_token_uid' + ) THEN + ALTER TABLE `sessions` + ADD INDEX `idx_sessions_access_token_uid` (`access_token_uid`); + END IF; +END// +DELIMITER ; + +CALL _puter_sessions_access_token_uid(); + +DROP PROCEDURE IF EXISTS _puter_sessions_access_token_uid; diff --git a/src/backend/clients/database/migrations/sqlite/0053_sessions_access_token_uid.sql b/src/backend/clients/database/migrations/sqlite/0053_sessions_access_token_uid.sql new file mode 100644 index 000000000..e781000f0 --- /dev/null +++ b/src/backend/clients/database/migrations/sqlite/0053_sessions_access_token_uid.sql @@ -0,0 +1,29 @@ +-- 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 . + +-- AUTH-5 (PUT-1019) — let `kind='access_token'` rows be reverse-looked-up +-- from the `token_uid` claim that lives only in `access_token_permissions`. +-- Required so `POST /auth/revoke-access-token` with a raw token_uid input +-- (no JWT) can find and soft-revoke the session row, matching the JWT +-- input path's coverage. Without it, raw-uuid revoke would only drop the +-- permissions row, leaving the session-row kill switch un-flipped. + +ALTER TABLE `sessions` ADD COLUMN `access_token_uid` TEXT; + +CREATE INDEX IF NOT EXISTS `idx_sessions_access_token_uid` + ON `sessions` (`access_token_uid`) + WHERE `access_token_uid` IS NOT NULL; diff --git a/src/backend/controllers/auth/AuthController.test.ts b/src/backend/controllers/auth/AuthController.test.ts index a7ade5a3b..3cecc4dac 100644 --- a/src/backend/controllers/auth/AuthController.test.ts +++ b/src/backend/controllers/auth/AuthController.test.ts @@ -30,6 +30,7 @@ */ import bcrypt from 'bcrypt'; +import jwt from 'jsonwebtoken'; import { v4 as uuidv4 } from 'uuid'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import type { EventClient } from '../../clients/event/EventClient.js'; @@ -3405,4 +3406,259 @@ describe('AuthController.handleRevokeSession additional branches', () => { const body = res.body as { sessions: unknown[] }; expect(Array.isArray(body.sessions)).toBe(true); }); + + it('refuses to revoke the caller’s OWN current session row (400)', async () => { + // PUT-1019 invariant: a self-revoke leaves the client in an + // ambiguous identity state because the response can't write + // fresh auth state. /logout is the only path that should end + // the session you're currently authenticated under. + const { user, actor } = await makeUserAndActor(); + const sessionRes = await server.services.auth.createSessionToken( + user, + {}, + ); + const sessionUid = (sessionRes.session as { uuid: string }).uuid; + const actorWithSession = { + ...actor, + session: { uid: sessionUid }, + } as Actor; + await expect( + controller.handleRevokeSession( + makeReq({ uuid: sessionUid }, { actor: actorWithSession }), + makeRes(), + ), + ).rejects.toMatchObject({ + statusCode: 400, + legacyCode: 'bad_request', + }); + }); + + it('still allows revoking a DIFFERENT session belonging to the same user', async () => { + // Sanity check that the self-revoke guard only blocks the + // caller's own uuid — sibling rows must still be revokable + // (that's the whole point of manage-sessions). + const { user, actor } = await makeUserAndActor(); + const callerSession = await server.services.auth.createSessionToken( + user, + {}, + ); + const targetSession = await server.services.auth.createSessionToken( + user, + {}, + ); + const actorWithSession = { + ...actor, + session: { + uid: (callerSession.session as { uuid: string }).uuid, + }, + } as Actor; + const res = makeRes(); + await controller.handleRevokeSession( + makeReq( + { uuid: (targetSession.session as { uuid: string }).uuid }, + { actor: actorWithSession }, + ), + res, + ); + expect((res.body as { sessions: unknown[] }).sessions).toBeDefined(); + }); +}); + +// ── handleMigrateToken (PUT-1021 SDK-1) ───────────────────────────── + +describe('AuthController.handleMigrateToken', () => { + const TEST_ORIGIN = 'https://migrate.test.local'; + + // PuterServer keeps config in a private field (#config), so we go + // through the controller — IController stores it as `protected + // config` which TS marks but JS doesn't enforce, and the controller + // is the actual consumer of #isMigrateTokenOriginAllowed anyway. + const controllerConfig = () => + (controller as { config: Record }).config; + + // Mints a v1-shaped JWT signed under the test server's legacy + // secret. The body matches what migrateLegacyToken expects per + // `decoded.type`. + const mintV1Token = (payload: Record): string => { + const legacy = controllerConfig().jwt_secret as string | undefined; + if (!legacy) throw new Error('test config missing jwt_secret'); + return jwt.sign(payload, legacy); + }; + + beforeAll(() => { + // Make the origin allow-check pass for these tests. We mutate + // the live config because setupTestServer is shared across the + // file; the original value is undefined (default config has no + // `origin`) so we don't need to restore. + controllerConfig().origin = TEST_ORIGIN; + }); + + it('rejects when the Origin header is missing', async () => { + await expect( + controller.handleMigrateToken(makeReq({}), makeRes()), + ).rejects.toMatchObject({ statusCode: 403 }); + }); + + it('rejects when the Origin header is not in config.origin or the allowlist', async () => { + const { user } = await makeUserAndActor(); + const v1 = mintV1Token({ + type: 'access-token', + token_uid: uuidv4(), + user_uid: user.uuid, + }); + await expect( + controller.handleMigrateToken( + makeReq( + {}, + { + headers: { + origin: 'https://not-allowed.example', + authorization: `Bearer ${v1}`, + }, + }, + ), + makeRes(), + ), + ).rejects.toMatchObject({ statusCode: 403 }); + }); + + it('normalizes trailing slash on the request Origin (B4)', async () => { + // The Origin header per spec doesn't carry a trailing slash, but + // a misconfigured proxy or a deployment with config.origin + // ending in `/` would otherwise force every call to reject. + const { user } = await makeUserAndActor(); + const v1 = mintV1Token({ + type: 'access-token', + token_uid: uuidv4(), + user_uid: user.uuid, + }); + const res = makeRes(); + await controller.handleMigrateToken( + makeReq( + {}, + { + headers: { + origin: `${TEST_ORIGIN}/`, // trailing slash + authorization: `Bearer ${v1}`, + }, + }, + ), + res, + ); + expect((res.body as { kind: string }).kind).toBe('access_token'); + }); + + it('normalizes case on the request Origin (B4)', async () => { + const { user } = await makeUserAndActor(); + const v1 = mintV1Token({ + type: 'access-token', + token_uid: uuidv4(), + user_uid: user.uuid, + }); + const res = makeRes(); + await controller.handleMigrateToken( + makeReq( + {}, + { + headers: { + origin: TEST_ORIGIN.toUpperCase(), + authorization: `Bearer ${v1}`, + }, + }, + ), + res, + ); + expect((res.body as { kind: string }).kind).toBe('access_token'); + }); + + it('returns 409 reauth_required for v1 web/session tokens', async () => { + // Web tokens never migrate silently — they always go through the + // interactive reauth flow (PUT-1023). The body code is what + // puter.js / GUI key on; the 409 status is what tells SDK code + // "this isn't a generic auth failure, route through reauth". + const { user } = await makeUserAndActor(); + const v1 = mintV1Token({ + type: 'session', + user_uid: user.uuid, + uuid: uuidv4(), + }); + await expect( + controller.handleMigrateToken( + makeReq( + {}, + { + headers: { + origin: TEST_ORIGIN, + authorization: `Bearer ${v1}`, + }, + }, + ), + makeRes(), + ), + ).rejects.toMatchObject({ + statusCode: 409, + code: 'reauth_required', + }); + }); + + it('does NOT set the puter_token_v2 cookie when migrating an access token (B3)', async () => { + // Access tokens are programmatic — they ride in Authorization + // headers, not browser cookies. Setting a cookie here would + // confuse cookie-only middleware downstream. + const { user } = await makeUserAndActor(); + const v1 = mintV1Token({ + type: 'access-token', + token_uid: uuidv4(), + user_uid: user.uuid, + }); + const res = makeRes(); + await controller.handleMigrateToken( + makeReq( + {}, + { + headers: { + origin: TEST_ORIGIN, + authorization: `Bearer ${v1}`, + }, + }, + ), + res, + ); + expect(res.cookies.puter_token_v2).toBeUndefined(); + expect((res.body as { kind: string }).kind).toBe('access_token'); + expect((res.body as { token: string }).token).toBeTruthy(); + }); + + it('sets the puter_token_v2 cookie when migrating an app-under-user token (B3)', async () => { + // App tokens DO get a cookie companion — the app runs inside an + // iframe in the GUI, and the GUI's cookie-only middleware + // authenticates subsequent calls from the iframe via the cookie + // rather than the client having to plumb Authorization through + // every request. + const { user } = await makeUserAndActor(); + const appUid = `app-${uuidv4()}`; + const v1 = mintV1Token({ + type: 'app-under-user', + user_uid: user.uuid, + app_uid: appUid, + }); + const res = makeRes(); + await controller.handleMigrateToken( + makeReq( + {}, + { + headers: { + origin: TEST_ORIGIN, + authorization: `Bearer ${v1}`, + }, + }, + ), + res, + ); + expect((res.body as { kind: string }).kind).toBe('app'); + const cookie = res.cookies.puter_token_v2; + expect(cookie).toBeDefined(); + expect(cookie.value).toBe((res.body as { token: string }).token); + expect(cookie.opts?.httpOnly).toBe(true); + }); }); diff --git a/src/backend/controllers/auth/AuthController.ts b/src/backend/controllers/auth/AuthController.ts index e3a508ef0..a9c541da5 100644 --- a/src/backend/controllers/auth/AuthController.ts +++ b/src/backend/controllers/auth/AuthController.ts @@ -26,7 +26,10 @@ import { Controller, Get, Post } from '../../core/http/decorators.js'; import { HttpError } from '../../core/http/HttpError.js'; import { antiCsrf } from '../../core/http/middleware/antiCsrf.js'; import { generateCaptcha } from '../../core/http/middleware/captcha.js'; -import { createUserProtectedGate } from '../../core/http/middleware/userProtected.js'; +import { + createSessionCookieGate, + createUserProtectedGate, +} from '../../core/http/middleware/userProtected.js'; import type { PuterRouter } from '../../core/http/PuterRouter.js'; import { ROUTES_METADATA_KEY, @@ -158,7 +161,10 @@ export class AuthController extends PuterController { } // Verify password - const passwordMatch = await bcrypt.compare(password, user.password); + const passwordMatch = await bcrypt.compare( + password, + user.password as string, + ); if (!passwordMatch) { throw new HttpError(401, 'Incorrect password.', { legacyCode: 'password_mismatch', @@ -210,7 +216,10 @@ export class AuthController extends PuterController { let decoded; try { - decoded = this.services.token.verify('otp', token); + decoded = this.services.token.verify<{ + user_uid: string; + purpose: string; + }>('otp', token); } catch { throw new HttpError(400, 'Invalid token.', { legacyCode: 'bad_request', @@ -264,7 +273,10 @@ export class AuthController extends PuterController { let decoded; try { - decoded = this.services.token.verify('otp', token); + decoded = this.services.token.verify<{ + user_uid: string; + purpose: string; + }>('otp', token); } catch { throw new HttpError(400, 'Invalid token.', { legacyCode: 'bad_request', @@ -288,7 +300,7 @@ export class AuthController extends PuterController { } const hashed = hashRecoveryCode(code); - const codes = (user.otp_recovery_codes || '') + const codes = ((user.otp_recovery_codes as string) || '') .split(',') .filter(Boolean); const idx = codes.indexOf(hashed); @@ -700,7 +712,7 @@ export class AuthController extends PuterController { }) async handleLogout(req: Request, res: Response): Promise { // Clear the session cookie - res.clearCookie(this.config.cookie_name!); + res.clearCookie(this.config.cookie_name ?? 'puter_token'); // Remove the session (fire-and-forget) if (req.token) { @@ -711,7 +723,9 @@ export class AuthController extends PuterController { // same path as /user-protected/delete-own-user — so we don't // orphan fsentries/sessions/permissions. if (req.actor?.user && !req.actor.user.email) { - const user = await this.stores.user.getByUuid(req.actor.user.uuid); + const user = await this.stores.user.getByUuid( + req.actor.user.uuid as string, + ); if (user && user.password === null && user.email === null) { this.#cascadeDeleteUser(user.id).catch((e) => { console.warn('[logout] temp-user cleanup failed:', e); @@ -944,7 +958,12 @@ export class AuthController extends PuterController { let decoded; try { - decoded = this.services.token.verify('otp', token); + decoded = this.services.token.verify<{ + user_uid: string; + email: string; + exp: number; + purpose: string; + }>('otp', token); } catch { throw new HttpError(400, 'Invalid or expired token.', { legacyCode: 'token_expired' as never, @@ -956,7 +975,7 @@ export class AuthController extends PuterController { }); } - const user = await this.stores.user.getByUuid(decoded.user_uid); + const user = await this.stores.user.getByUuid(decoded?.user_uid); if (!user || user.email !== decoded.email) { throw new HttpError(400, 'Token is no longer valid.', { legacyCode: 'bad_request', @@ -968,7 +987,7 @@ export class AuthController extends PuterController { }); } - const exp = decoded.exp; + const exp = decoded.exp as number; const time_remaining = exp ? Math.max(0, exp - Math.floor(Date.now() / 1000)) : 0; @@ -1001,7 +1020,12 @@ export class AuthController extends PuterController { let decoded; try { - decoded = this.services.token.verify('otp', token); + decoded = this.services.token.verify<{ + user_uid: string; + email: string; + token: string; + purpose: string; + }>('otp', token); } catch { throw new HttpError(400, 'Invalid or expired token.', { legacyCode: 'token_expired' as never, @@ -1741,12 +1765,10 @@ export class AuthController extends PuterController { res.json(sessions); } - @Post('/auth/revoke-session', { - subdomain: 'api', - requireUserActor: true, - allowUnconfirmed: true, - antiCsrf: true, - }) + // Wired imperatively in `registerRoutes` so the cookie-only gate + // (built from `this.config`) can be composed in. Cookie-only is + // mandatory: an access token must not be able to revoke its own + // issuing web session. async handleRevokeSession(req: Request, res: Response): Promise { const { uuid } = req.body; if (!uuid || typeof uuid !== 'string') { @@ -1754,6 +1776,18 @@ export class AuthController extends PuterController { legacyCode: 'bad_request', }); } + // The caller's own session row must go through /logout, not a + // self-revoke — otherwise the response can't write fresh auth + // state and the client ends up with an ambiguous post-revoke + // identity. /auth/revoke-all-sessions still supports a separate + // `include_current` opt-in for the nuclear case. + if (uuid === req.actor!.session?.uid) { + throw new HttpError( + 400, + 'Cannot revoke your current session — use /logout instead', + { legacyCode: 'bad_request' }, + ); + } const session = await this.stores.session.getByUuid(uuid); if (session.user_id !== req.actor!.user.id) { throw new HttpError(403, 'Can only revoke your own sessions', { @@ -1765,6 +1799,16 @@ export class AuthController extends PuterController { res.json({ sessions }); } + async handleRevokeAllSessions(req: Request, res: Response): Promise { + const { include_current, include_apps } = req.body ?? {}; + await this.services.auth.revokeAllSessions(req.actor!, { + includeCurrent: !!include_current, + includeApps: !!include_apps, + }); + const sessions = await this.services.auth.listSessions(req.actor!); + res.json({ sessions }); + } + // ── Dev app permissions ───────────────────────────────────────── @Post('/auth/grant-dev-app', { subdomain: 'api', requireUserActor: true }) @@ -2001,6 +2045,74 @@ export class AuthController extends PuterController { // ── Access tokens ─────────────────────────────────────────────── + async handleMigrateToken(req: Request, res: Response): Promise { + // 1. Origin lock. Reject anything that isn't same-origin to + // `config.origin` or in the explicit per-deployment allowlist. + // No Origin header → reject (this endpoint is browser-only by + // design; server-side callers should re-auth properly). + const reqOrigin = req.headers.origin; + if (!reqOrigin || !this.#isMigrateTokenOriginAllowed(reqOrigin)) { + throw new HttpError(403, 'Origin not allowed', { + legacyCode: 'forbidden', + }); + } + + // 2. Extract token. Header takes precedence so body-capture logs + // (if any) never see the credential. + const authHeader = req.headers.authorization; + const headerToken = + typeof authHeader === 'string' && authHeader.startsWith('Bearer ') + ? authHeader.slice('Bearer '.length).trim() + : null; + const bodyToken = + typeof req.body?.token === 'string' ? req.body.token.trim() : null; + const v1Token = headerToken || bodyToken; + if (!v1Token) { + throw new HttpError(400, 'Missing token', { + legacyCode: 'bad_request', + }); + } + + const result = await this.services.auth.migrateLegacyToken(v1Token, { + ip: req.ip, + userAgent: + typeof req.headers['user-agent'] === 'string' + ? req.headers['user-agent'] + : undefined, + }); + // `puter_token_v2` is the cookie companion to v2 app tokens — + // the app runs in-browser (we already gated on Origin above) so + // the GUI's cookie-only middleware can authenticate subsequent + // calls from the same iframe without the client having to + // forward Authorization headers. Access tokens are programmatic + // (no browser cookie surface) so we deliberately skip them here. + if (result.kind === 'app') { + res.cookie('puter_token_v2', result.token, { + ...sessionCookieFlags(this.config), + httpOnly: true, + }); + } + res.json(result); + } + + #isMigrateTokenOriginAllowed(origin: string): boolean { + // Origin headers and config values both reach us in inconsistent + // shapes (trailing slash, mixed case from misconfigured deploys, + // stray whitespace from JSON config edits). Normalize both sides + // before equality so a `config.origin` with a trailing slash + // doesn't reject every same-origin browser call. + const normalize = (raw: string | undefined): string => + (raw ?? '').trim().replace(/\/+$/, '').toLowerCase(); + const incoming = normalize(origin); + if (!incoming) return false; + if (incoming === normalize(this.config.origin)) return true; + const allowlist = ( + this.config as { allow_migrate_token_origins?: string[] } + ).allow_migrate_token_origins; + if (!Array.isArray(allowlist)) return false; + return allowlist.some((entry) => normalize(entry) === incoming); + } + @Post('/auth/create-access-token', { subdomain: 'api', requireAuth: true, @@ -2032,10 +2144,10 @@ export class AuthController extends PuterController { res.json({ token }); } - @Post('/auth/revoke-access-token', { - subdomain: 'api', - requireUserActor: true, - }) + // Wired imperatively in `registerRoutes` so the cookie-only gate + // (built from `this.config`) can be composed in. Cookie-only is + // mandatory: a leaked access token must not be able to silently + // revoke its own siblings. async handleRevokeAccessToken(req: Request, res: Response): Promise { let { tokenOrUuid } = req.body; if (!tokenOrUuid || typeof tokenOrUuid !== 'string') { @@ -2362,7 +2474,7 @@ export class AuthController extends PuterController { user, req.actor.session.uid, ); - res.cookie(this.config.cookie_name, sessionToken, { + res.cookie(this.config.cookie_name ?? 'puter_token', sessionToken, { ...sessionCookieFlags(this.config), httpOnly: true, }); @@ -2378,7 +2490,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!); + res.clearCookie(this.config.cookie_name ?? 'puter_token'); res.clearCookie('puter_revalidation'); await this.#cascadeDeleteUser(userId); res.json({ success: true }); @@ -2525,6 +2637,63 @@ export class AuthController extends PuterController { }, (req, res) => this.handleDeleteOwnUser(req, res), ); + + const sessionCookieGate = createSessionCookieGate(this.config); + + router.post( + '/auth/revoke-session', + { + subdomain: 'api', + requireUserActor: true, + allowUnconfirmed: true, + antiCsrf: true, + middleware: [sessionCookieGate], + }, + (req, res) => this.handleRevokeSession(req, res), + ); + + router.post( + '/auth/revoke-all-sessions', + { + subdomain: 'api', + requireUserActor: true, + allowUnconfirmed: true, + antiCsrf: true, + rateLimit: { + scope: 'revoke-all-sessions', + limit: 10, + window: 60 * 60_000, + key: 'user', + }, + middleware: [sessionCookieGate], + }, + (req, res) => this.handleRevokeAllSessions(req, res), + ); + + router.post( + '/auth/revoke-access-token', + { + subdomain: 'api', + requireUserActor: true, + antiCsrf: true, + middleware: [sessionCookieGate], + }, + (req, res) => this.handleRevokeAccessToken(req, res), + ); + + router.post( + '/auth/migrate-token', + { + subdomain: 'api', + rateLimit: { + scope: 'migrate-token', + limit: 20, + window: 15 * 60_000, + key: 'ip', + }, + }, + (req, res) => this.handleMigrateToken(req, res), + ); } // ── Private helpers ────────────────────────────────────────────── @@ -2636,7 +2805,7 @@ export class AuthController extends PuterController { await this.services.auth.createSessionToken(user as never, meta); // HTTP-only cookie gets the session token - res.cookie(this.config.cookie_name, sessionToken, { + res.cookie(this.config.cookie_name ?? 'puter_token', sessionToken, { ...sessionCookieFlags(this.config), httpOnly: true, }); diff --git a/src/backend/core/http/middleware/authProbe.ts b/src/backend/core/http/middleware/authProbe.ts index 3b385456c..3c77da9ac 100644 --- a/src/backend/core/http/middleware/authProbe.ts +++ b/src/backend/core/http/middleware/authProbe.ts @@ -35,7 +35,6 @@ interface AuthProbeOptions { kvStore?: SystemKVStore; } -/** Day-bucketed KV key for AUTH-4 metrics. */ const authV2MetricsKey = (): string => { const now = new Date(); const yyyy = now.getUTCFullYear(); diff --git a/src/backend/core/http/middleware/rateLimit.js b/src/backend/core/http/middleware/rateLimit.js index 42879c987..56087fdf2 100644 --- a/src/backend/core/http/middleware/rateLimit.js +++ b/src/backend/core/http/middleware/rateLimit.js @@ -492,6 +492,29 @@ export async function checkDriverRateLimit(req, ifaceName, method, opts = {}) { } } +// ── Imperative helper ─────────────────────────────────────────────── + +/** + * Imperative rate-limit check (no middleware shape). For handlers that + * need a second-axis limit after their route-level limit fires — + * e.g. `/auth/migrate-token` limits per IP at the route, then per + * `auth_id` once the v1 token is decoded. Returns true if allowed, + * false if rate-limited. Fails open on backend error, matching the + * rest of this module's policy. + */ +export async function checkRateLimit(key, limit, windowMs, backend) { + const bk = resolveBackend(backend); + try { + return await bk.rate(key, limit, windowMs); + } catch (err) { + console.error( + '[rate-limit] imperative check failed, failing open:', + err, + ); + return true; + } +} + // ── Subscription-aware limit resolution ───────────────────────────── /** diff --git a/src/backend/core/http/middleware/userProtected.ts b/src/backend/core/http/middleware/userProtected.ts index 934db1be6..7a27d4d12 100644 --- a/src/backend/core/http/middleware/userProtected.ts +++ b/src/backend/core/http/middleware/userProtected.ts @@ -63,6 +63,30 @@ export interface UserProtectedGateDeps { tokenService: TokenService; } +/** + * Cookie-only credential check. Rejects API tokens, GUI tokens, + * `x-api-key` headers, and query-string tokens — only a request whose + * resolved `req.token` matches the session cookie passes. Used both by + * `createUserProtectedGate` (as its first stage) and standalone by + * routes that need session-cookie-only credentials without the full + * password-revalidation gate (e.g. `/auth/revoke-*`, where an access + * token shouldn't be able to revoke its own issuing web session). + */ +export const createSessionCookieGate = (config: IConfig): RequestHandler => { + const cookieName = config.cookie_name ?? 'puter_token'; + return (req, _res, next) => { + const cookieValue = req.cookies?.[cookieName]; + if (!cookieValue || (req.token && req.token !== cookieValue)) { + return next( + new HttpError(401, 'Session cookie required', { + legacyCode: 'session_required', + }), + ); + } + next(); + }; +}; + export interface UserProtectedGateOptions { /** Allow temp accounts (no password + no email) through. Default: false. */ allowTempUsers?: boolean; @@ -95,19 +119,10 @@ export const createUserProtectedGate = ( options: UserProtectedGateOptions = {}, ): RequestHandler[] => { const { config, userStore, oidcService, tokenService } = deps; - const cookieName = config.cookie_name ?? 'puter_token'; const allowTemp = !!options.allowTempUsers; - // 1. Session cookie only. - const requireSessionCookie: RequestHandler = (req, _res, next) => { - const cookieValue = req.cookies?.[cookieName]; - if (!cookieValue || (req.token && req.token !== cookieValue)) { - throw new HttpError(401, 'Session cookie required', { - legacyCode: 'session_required', - }); - } - next(); - }; + // 1. Session cookie only. Shared with the standalone cookie-only gate. + const requireSessionCookie = createSessionCookieGate(config); // 2. Fresh user row (bypass cache to catch just-suspended accounts). // `getById` doesn't take options; go through `getByProperty` with diff --git a/src/backend/package.json b/src/backend/package.json index c36f1ffcf..f46e7e5b6 100644 --- a/src/backend/package.json +++ b/src/backend/package.json @@ -75,7 +75,9 @@ "devDependencies": { "@types/bcrypt": "^6.0.0", "@types/busboy": "^1.5.4", + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^24.0.0", + "@types/validator": "^13.15.10", "chai": "^4.3.7", "nodemon": "^3.1.0", "typescript": "^5.9.3", diff --git a/src/backend/services/auth/AuthService.test.ts b/src/backend/services/auth/AuthService.test.ts index 295afc38e..5643fede9 100644 --- a/src/backend/services/auth/AuthService.test.ts +++ b/src/backend/services/auth/AuthService.test.ts @@ -517,6 +517,241 @@ describe('AuthService (integration)', () => { await server.stores.session.getByUuid(sessionUuid), ).toBeFalsy(); }); + + it('listSessions excludes kind="asset" rows', async () => { + // Asset rows are per-cookie children of `web` rows, revoked + // transitively via the cascade — surfacing them in the + // manage-sessions UI as standalone entries would be confusing. + const user = await makeUser(); + const { session: webSession } = + await authService.createSessionToken(user, {}); + const webUuid = (webSession as { uuid: string }).uuid; + const assetRow = await server.stores.session.create(user.id, { + kind: 'asset', + parent_session_id: webUuid, + }); + const actor = { + user: { id: user.id, uuid: user.uuid, username: user.username }, + session: { uid: webUuid }, + } as unknown as Actor; + const rows = await authService.listSessions(actor); + expect( + rows.find( + (r) => + (r as { uuid: string }).uuid === + (assetRow as { uuid: string }).uuid, + ), + ).toBeUndefined(); + expect( + rows.find((r) => (r as { uuid: string }).uuid === webUuid), + ).toBeTruthy(); + }); + + it('listSessions enriches rows with kind / expires_at / last_ip / created_via', async () => { + // Manage-sessions GUI keys on these fields to render the rich + // row layout (kind badge, IP, expires-in). Lock the shape so + // future GUI work can rely on them. + const user = await makeUser(); + const { session } = await authService.createSessionToken(user, { + user_agent: 'shape-probe', + ip: '203.0.113.7', + }); + const sessionUuid = (session as { uuid: string }).uuid; + const actor = { + user: { id: user.id, uuid: user.uuid, username: user.username }, + session: { uid: sessionUuid }, + } as unknown as Actor; + const rows = await authService.listSessions(actor); + const row = rows.find( + (r) => (r as { uuid: string }).uuid === sessionUuid, + ) as Record | undefined; + expect(row).toBeTruthy(); + expect(row!.kind).toBe('web'); + expect(typeof row!.created_at).toBe('number'); + expect(typeof row!.last_activity).toBe('number'); + expect(row!.expires_at).toEqual(expect.any(Number)); + expect(row!.last_ip).toBe('203.0.113.7'); + // app_uid / app are null for web rows; present for app rows. + expect(row!.app_uid).toBeNull(); + expect(row!.app).toBeNull(); + }); + + it('listSessions joins kind="app" rows with the apps table', async () => { + // App rows carry an `app_uid`; AuthService.listSessions does a + // batch lookup against the apps table so the GUI doesn't need a + // second round trip. If the app row exists, the response + // includes a non-null `app: { uid, name, title, icon }`. + const user = await makeUser(); + const appUid = `app-${uuidv4()}`; + await server.clients.db.write( + 'INSERT INTO `apps` (`uid`, `name`, `title`, `icon`, `description`, `index_url`, `owner_user_id`) VALUES (?, ?, ?, ?, ?, ?, ?)', + [ + appUid, + `app_name_${Math.random().toString(36).slice(2, 10)}`, + 'Listed App Title', + 'data:image/png;base64,ICON', + '', + `https://${Math.random().toString(36).slice(2, 10)}.example`, + user.id ?? null, + ], + ); + await server.stores.session.create(user.id, { + kind: 'app', + app_uid: appUid, + }); + const actor = { + user: { id: user.id, uuid: user.uuid, username: user.username }, + } as unknown as Actor; + const rows = await authService.listSessions(actor); + const appRow = rows.find( + (r) => (r as { kind?: string }).kind === 'app', + ) as Record | undefined; + expect(appRow).toBeTruthy(); + expect(appRow!.app_uid).toBe(appUid); + const app = appRow!.app as { title: string; icon: string }; + expect(app.title).toBe('Listed App Title'); + expect(app.icon).toBe('data:image/png;base64,ICON'); + }); + + it('listSessions sorts the actor’s current session first, then by last_activity desc', async () => { + // Manage-sessions GUI anchors "you are here" at the top of the + // list; downstream rendering doesn't re-sort, so the backend + // order is what users see. + const user = await makeUser(); + const { session: olderSession } = + await authService.createSessionToken(user, {}); + const { session: newerSession } = + await authService.createSessionToken(user, {}); + const { session: currentSession } = + await authService.createSessionToken(user, {}); + const olderUuid = (olderSession as { uuid: string }).uuid; + const newerUuid = (newerSession as { uuid: string }).uuid; + const currentUuid = (currentSession as { uuid: string }).uuid; + // Bump `last_activity` to FUTURE values — updateActivity has + // a `last_activity < ?` guard that skips no-op updates, so + // any past timestamp gets silently dropped after the fresh + // rows created above stamped `last_activity = now`. + const future = Math.floor(Date.now() / 1000) + 60_000; + await server.stores.session.updateActivity(olderUuid, future); + await server.stores.session.updateActivity(newerUuid, future + 1000); + const actor = { + user: { id: user.id, uuid: user.uuid, username: user.username }, + session: { uid: currentUuid }, + } as unknown as Actor; + const rows = await authService.listSessions(actor); + const ourRows = rows.filter((r) => + [olderUuid, newerUuid, currentUuid].includes( + (r as { uuid: string }).uuid, + ), + ); + expect( + (ourRows[0] as { uuid: string; current: boolean }).uuid, + ).toBe(currentUuid); + expect((ourRows[0] as { current: boolean }).current).toBe(true); + // Newer non-current row comes before the older one. + const newerIdx = ourRows.findIndex( + (r) => (r as { uuid: string }).uuid === newerUuid, + ); + const olderIdx = ourRows.findIndex( + (r) => (r as { uuid: string }).uuid === olderUuid, + ); + expect(newerIdx).toBeLessThan(olderIdx); + }); + }); + + describe('createWorkerSessionToken / createWorkerAppToken', () => { + // The test config's v2 jwt_secret is the source of truth for + // verifying claims; go through TokenService to mirror how + // production decodes the same tokens. + const decodeAuth = (token: string): Record => { + return server.services.token.verify('auth', token) as Record< + string, + unknown + >; + }; + + it('createWorkerSessionToken mints a kind="web" row tagged meta.worker, with the WORKER_WINDOW_SECONDS expiry', async () => { + const user = await makeUser(); + const before = Math.floor(Date.now() / 1000); + const { session, token, gui_token } = + await authService.createWorkerSessionToken(user, { + user_agent: 'worker-agent', + }); + + const row = (await server.stores.session.getByUuid( + (session as { uuid: string }).uuid, + )) as Record; + expect(row.kind).toBe('web'); + // expires_at lands in the ~99-year window — assert lower + // bound only so the test isn't fragile to small drift or a + // future constant adjustment. + expect(row.expires_at as number).toBeGreaterThanOrEqual( + before + 50 * 365 * 24 * 60 * 60, + ); + const meta = + typeof row.meta === 'string' + ? (JSON.parse(row.meta as string) as Record< + string, + unknown + >) + : (row.meta as Record); + expect(meta.worker).toBe(true); + + // Both JWTs carry the worker claim so downstream code can + // distinguish without re-reading the session row. + expect(decodeAuth(token).worker).toBe(true); + expect(decodeAuth(gui_token).worker).toBe(true); + }); + + it('createWorkerAppToken mints a kind="app" row tagged meta.worker, with the WORKER_WINDOW_SECONDS expiry', async () => { + const user = await makeUser(); + // Existing AuthService surfaces (e.g. getUserAppToken in the + // tests below) shape app_uid as `app-${uuid}` — keep the + // same shape here so any downstream validator that asserts + // on the hyphenated form doesn't reject the row. + const appUid = `app-${uuidv4()}`; + const actor = { + user: { id: user.id, uuid: user.uuid, username: user.username }, + } as Actor; + const before = Math.floor(Date.now() / 1000); + const token = await authService.createWorkerAppToken( + actor, + appUid, + ); + + const decoded = decodeAuth(token); + expect(decoded.type).toBe('app-under-user'); + expect(decoded.worker).toBe(true); + expect(decoded.app_uid).toBe(appUid); + expect(decoded.user_uid).toBe(user.uuid); + + const sessionUid = decoded.session_uid as string; + const row = (await server.stores.session.getByUuid( + sessionUid, + )) as Record; + expect(row.kind).toBe('app'); + expect(row.app_uid).toBe(appUid); + expect(row.expires_at as number).toBeGreaterThanOrEqual( + before + 50 * 365 * 24 * 60 * 60, + ); + const meta = + typeof row.meta === 'string' + ? (JSON.parse(row.meta as string) as Record< + string, + unknown + >) + : (row.meta as Record); + expect(meta.worker).toBe(true); + }); + + it('createWorkerAppToken refuses an actor with no user (403)', async () => { + await expect( + authService.createWorkerAppToken( + { user: undefined } as unknown as Actor, + 'app-x', + ), + ).rejects.toMatchObject({ statusCode: 403 }); + }); }); describe('appUidFromOrigin', () => { @@ -789,4 +1024,357 @@ describe('AuthService (integration)', () => { ).toThrow(); }); }); + + // ── AUTH-5 (PUT-1019) revoke coverage ──────────────────────────── + + describe('revokeAccessToken raw-uuid session-row coverage', () => { + // The JWT-input branch has always flipped the session row's + // revoked_at. AUTH-5 closes the raw-uuid gap: the new + // `sessions.access_token_uid` column lets revoke find the row + // for v2-minted tokens even when no JWT was presented. + + it('soft-revokes the v2 session row when revoked by raw token_uid', async () => { + const user = await makeUser(); + const actor = { + user: { id: user.id, uuid: user.uuid, username: user.username }, + } as Actor; + const jwt = await authService.createAccessToken(actor, [ + ['service:foo:ii:read'], + ]); + const decoded = server.services.token.verify('auth', jwt) as { + token_uid: string; + session_uid: string; + }; + + // Confirm session row is active before revoke. + const before = await server.stores.session.getByUuid( + decoded.session_uid, + ); + expect(before).toBeTruthy(); + + await authService.revokeAccessToken(actor, decoded.token_uid); + + // Row is soft-revoked, not just permissions-stripped. + const after = await server.stores.session.getByUuid( + decoded.session_uid, + ); + expect(after).toBeNull(); + }); + }); + + describe('revokeAllSessions', () => { + it('throws 403 when actor has no user', async () => { + await expect( + authService.revokeAllSessions({ + user: undefined, + } as unknown as Actor), + ).rejects.toMatchObject({ statusCode: 403 }); + }); + + it('revokes every web session except the caller by default', async () => { + const user = await makeUser(); + const otherDevice = await authService.createSessionToken(user, {}); + const otherUuid = (otherDevice.session as { uuid: string }).uuid; + const currentDevice = await authService.createSessionToken(user, {}); + const currentUuid = (currentDevice.session as { uuid: string }) + .uuid; + const actor = { + user: { id: user.id, uuid: user.uuid, username: user.username }, + session: { uid: currentUuid }, + } as unknown as Actor; + + await authService.revokeAllSessions(actor); + + // Caller's session survives. + expect( + await server.stores.session.getByUuid(currentUuid), + ).toBeTruthy(); + // Other device's session is gone. + expect( + await server.stores.session.getByUuid(otherUuid), + ).toBeNull(); + }); + + it('with includeCurrent=true also revokes the caller', async () => { + const user = await makeUser(); + const currentDevice = await authService.createSessionToken(user, {}); + const currentUuid = (currentDevice.session as { uuid: string }) + .uuid; + const actor = { + user: { id: user.id, uuid: user.uuid, username: user.username }, + session: { uid: currentUuid }, + } as unknown as Actor; + + await authService.revokeAllSessions(actor, { + includeCurrent: true, + }); + + expect( + await server.stores.session.getByUuid(currentUuid), + ).toBeNull(); + }); + + it('leaves app authorizations alone by default', async () => { + const user = await makeUser(); + const actor = { + user: { id: user.id, uuid: user.uuid, username: user.username }, + } as Actor; + const appUid = `app-${uuidv4()}`; + // Mint an app authorization (creates a kind='app' session row). + await authService.getUserAppToken(actor, appUid); + + // Plus a web session that revoke-all should touch. + const web = await authService.createSessionToken(user, {}); + const webUuid = (web.session as { uuid: string }).uuid; + + await authService.revokeAllSessions({ + user: actor.user, + session: { uid: 'unrelated' }, + } as unknown as Actor); + + // Web is gone, app survives. + expect( + await server.stores.session.getByUuid(webUuid), + ).toBeNull(); + const appSession = await server.stores.session.getOrCreateApp( + user.id, + appUid, + ); + expect(appSession?.revoked_at ?? null).toBeNull(); + }); + + it('with includeApps=true also revokes app authorizations', async () => { + const user = await makeUser(); + const actor = { + user: { id: user.id, uuid: user.uuid, username: user.username }, + } as Actor; + const appUid = `app-${uuidv4()}`; + const appToken = await authService.getUserAppToken(actor, appUid); + const appDecoded = server.services.token.verify('auth', appToken) as { + session_uid: string; + }; + + await authService.revokeAllSessions( + { + user: actor.user, + session: { uid: 'unrelated' }, + } as unknown as Actor, + { includeApps: true }, + ); + + expect( + await server.stores.session.getByUuid(appDecoded.session_uid), + ).toBeNull(); + }); + }); + + // ── SDK-1 (PUT-1021) migrate-token ──────────────────────────────── + + describe('migrateLegacyToken', () => { + // Hand-mint v1 tokens using the same compression dict the + // TokenService's verify path will decompress against. + + const encodeUuid = (u: string): string => + Buffer.from(u.replace(/-/g, ''), 'hex').toString('base64'); + + const signV1AccessToken = (opts: { + tokenUid: string; + userUid: string; + appUid?: string; + }): string => { + const payload: Record = { + t: 't', + token_uid: opts.tokenUid, + uu: encodeUuid(opts.userUid), + }; + if (opts.appUid) { + payload.au = encodeUuid( + opts.appUid.startsWith('app-') + ? opts.appUid.slice('app-'.length) + : opts.appUid, + ); + } + return jwt.sign(payload, 'dev-jwt-secret-change-me'); + }; + + const signV1AppToken = (opts: { + userUid: string; + appUid: string; + }): string => { + const stripped = opts.appUid.startsWith('app-') + ? opts.appUid.slice('app-'.length) + : opts.appUid; + return jwt.sign( + { + t: 'au', + uu: encodeUuid(opts.userUid), + au: encodeUuid(stripped), + }, + 'dev-jwt-secret-change-me', + ); + }; + + const signV1SessionToken = (opts: { + userUid: string; + sessionUuid: string; + }): string => + jwt.sign( + { + t: 's', + u: encodeUuid(opts.sessionUuid), + uu: encodeUuid(opts.userUid), + }, + 'dev-jwt-secret-change-me', + ); + + it('migrates a v1 access-token to a v2 token preserving token_uid', async () => { + const user = await makeUser(); + const tokenUid = uuidv4(); + const v1 = signV1AccessToken({ + tokenUid, + userUid: user.uuid, + }); + + const result = await authService.migrateLegacyToken(v1); + + expect(result.kind).toBe('access_token'); + expect(result.auth_id).toBe(user.uuid); + expect(typeof result.session_uid).toBe('string'); + + // v2 token re-verifies and carries the same token_uid + + // a fresh session_uid. + const decoded = server.services.token.verify('auth', result.token) as { + type: string; + token_uid: string; + session_uid: string; + user_uid: string; + }; + expect(decoded.type).toBe('access-token'); + expect(decoded.token_uid).toBe(tokenUid); + expect(decoded.user_uid).toBe(user.uuid); + expect(decoded.session_uid).toBe(result.session_uid); + }); + + it('access-token migration is idempotent (same session_uid on retry)', async () => { + const user = await makeUser(); + const tokenUid = uuidv4(); + const v1 = signV1AccessToken({ tokenUid, userUid: user.uuid }); + + const first = await authService.migrateLegacyToken(v1); + const second = await authService.migrateLegacyToken(v1); + + expect(first.session_uid).toBe(second.session_uid); + }); + + it('migrates a v1 app-under-user token to a v2 token', async () => { + const user = await makeUser(); + const appUid = `app-${uuidv4()}`; + const v1 = signV1AppToken({ + userUid: user.uuid, + appUid, + }); + + const result = await authService.migrateLegacyToken(v1); + + expect(result.kind).toBe('app'); + expect(result.auth_id).toBe(user.uuid); + const decoded = server.services.token.verify('auth', result.token) as { + type: string; + app_uid: string; + user_uid: string; + session_uid: string; + }; + expect(decoded.type).toBe('app-under-user'); + expect(decoded.app_uid).toBe(appUid); + expect(decoded.user_uid).toBe(user.uuid); + expect(decoded.session_uid).toBe(result.session_uid); + }); + + it('app-token migration is idempotent on (user_id, app_uid)', async () => { + const user = await makeUser(); + const appUid = `app-${uuidv4()}`; + const v1a = signV1AppToken({ userUid: user.uuid, appUid }); + const v1b = signV1AppToken({ userUid: user.uuid, appUid }); + + const first = await authService.migrateLegacyToken(v1a); + const second = await authService.migrateLegacyToken(v1b); + + expect(first.session_uid).toBe(second.session_uid); + }); + + it('returns 409 reauth_required for v1 session tokens', async () => { + const user = await makeUser(); + const v1 = signV1SessionToken({ + userUid: user.uuid, + sessionUuid: uuidv4(), + }); + + await expect( + authService.migrateLegacyToken(v1), + ).rejects.toMatchObject({ + statusCode: 409, + code: 'reauth_required', + }); + }); + + it('rejects v2 tokens with 401 (nothing to migrate)', async () => { + const user = await makeUser(); + const v2 = await authService.createAccessToken( + { + user: { id: user.id, uuid: user.uuid, username: user.username }, + } as Actor, + [['service:foo:ii:read']], + ); + await expect( + authService.migrateLegacyToken(v2), + ).rejects.toMatchObject({ statusCode: 401 }); + }); + + it('rejects garbage tokens with 401', async () => { + await expect( + authService.migrateLegacyToken('not-a-jwt'), + ).rejects.toMatchObject({ statusCode: 401 }); + }); + + it('returns 410 for app tokens when allow_v1_app_migration=false', async () => { + // Use a scoped server with the flag flipped — toggling + // `this.config` on the shared server would race with other + // tests. + const scopedServer = await setupTestServer({ + allow_v1_app_migration: false, + } as never); + try { + const scopedAuth = scopedServer.services.auth as unknown as + AuthService; + const user = await scopedServer.stores.user.create({ + username: `mt-${uuidv4().slice(0, 8)}`, + uuid: uuidv4(), + password: null, + email: `mt-${uuidv4().slice(0, 8)}@test.local`, + free_storage: 100 * 1024 * 1024, + requires_email_confirmation: false, + }); + const appUid = `app-${uuidv4()}`; + const v1App = signV1AppToken({ + userUid: user.uuid, + appUid, + }); + await expect( + scopedAuth.migrateLegacyToken(v1App), + ).rejects.toMatchObject({ + statusCode: 410, + code: 'app_migration_disabled', + }); + // Access-token migration stays on regardless. + const v1AccessToken = signV1AccessToken({ + tokenUid: uuidv4(), + userUid: user.uuid, + }); + const ok = await scopedAuth.migrateLegacyToken(v1AccessToken); + expect(ok.kind).toBe('access_token'); + } finally { + await scopedServer.shutdown(); + } + }); + }); }); diff --git a/src/backend/services/auth/AuthService.ts b/src/backend/services/auth/AuthService.ts index 9ee0c7d0e..e5d25f5d4 100644 --- a/src/backend/services/auth/AuthService.ts +++ b/src/backend/services/auth/AuthService.ts @@ -20,9 +20,11 @@ import { v4 as uuidv4, v5 as uuidv5 } from 'uuid'; import type { Actor } from '../../core/actor'; import { HttpError } from '../../core/http/HttpError.js'; +import { checkRateLimit } from '../../core/http/middleware/rateLimit.js'; import { ASSET_WINDOW_SECONDS, WEB_WINDOW_SECONDS, + WORKER_WINDOW_SECONDS, } from '../../stores/session/SessionStore.js'; import type { UserRow } from '../../stores/user/UserStore'; import type { LayerInstances } from '../../types'; @@ -196,8 +198,9 @@ export class AuthService extends PuterService { user: UserRow, sessionUuid: string, authId: string, + opts: { worker?: boolean } = {}, ): string { - return this.services.token.sign('auth', { + const claims: Record = { type, version: '2', // `uuid` retained alongside `session_uid` so any legacy reader @@ -207,19 +210,96 @@ export class AuthService extends PuterService { session_uid: sessionUuid, user_uid: user.uuid, auth_id: authId, - }); + }; + if (opts.worker) claims.worker = true; + return this.services.token.sign('auth', claims); } /** - * Stable per-user identity carried on every v2 token (PUT-1010). Survives - * re-login so the login endpoint can re-attach a new session to the same - * underlying account — critical for temp users whose files are keyed off - * the account that owns them. - * - * For normal users this is `user.uuid` (already stable). Temp-user - * dedicated ids are PUT-1016's territory; until then, the uuid is fine - * because temp re-login swaps the row but keeps the uuid. + * Worker variant of `createSessionToken`. Mints a new `kind='web'` + * session row tagged `meta.worker = true` and expiring after + * `WORKER_WINDOW_SECONDS` (vs. WEB_WINDOW_SECONDS for an interactive + * session). The emitted JWT carries `worker: true` so downstream + * code can tell a worker session from a user-driven one without a + * DB round-trip. Same return shape as createSessionToken. */ + async createWorkerSessionToken( + user: UserRow, + meta: Record = {}, + ): Promise<{ + session: Record; + token: string; + gui_token: string; + }> { + const auth_id = this.#authIdFor(user); + const session = await this.stores.session.create(user.id, { + meta: { ...meta, worker: true }, + kind: 'web', + last_ip: (meta.ip as string | undefined) ?? null, + last_user_agent: (meta.user_agent as string | undefined) ?? null, + expires_at: nowSeconds() + WORKER_WINDOW_SECONDS, + auth_id, + }); + + const token = this.#signSessionTypeToken( + 'session', + user, + session.uuid, + auth_id, + { worker: true }, + ); + const gui_token = this.#signSessionTypeToken( + 'gui', + user, + session.uuid, + auth_id, + { worker: true }, + ); + + return { session, token, gui_token }; + } + + /** + * Worker variant of `getUserAppToken`. Bypasses the idempotent + * `getOrCreateApp` path (which would return the existing + * interactive app session at WEB/APP_WINDOW_SECONDS) and creates a + * fresh `kind='app'` row tagged `meta.worker = true` with a + * `WORKER_WINDOW_SECONDS` expiry. The emitted JWT is shaped like a + * standard app-under-user token plus a `worker: true` claim so the + * downstream consumer can tell them apart. + * + * NOTE: the v2 `idx_sessions_user_app_active` index ensures one + * active app row per (user, app). A worker session here will + * collide with an existing non-worker app session for the same + * (user, app) pair. Future schema work can carve workers out of + * that uniqueness; for now callers must accept that constraint. + */ + async createWorkerAppToken(actor: Actor, appUid: string): Promise { + if (!actor.user) { + throw new HttpError(403, 'Actor must be a user', { + legacyCode: 'forbidden', + }); + } + const auth_id = this.#authIdFor(actor.user as UserRow); + const session = await this.stores.session.create(actor.user.id, { + meta: { worker: true }, + kind: 'app', + app_uid: appUid, + expires_at: nowSeconds() + WORKER_WINDOW_SECONDS, + auth_id, + }); + + return this.services.token.sign('auth', { + type: 'app-under-user', + version: '2', + user_uid: actor.user.uuid, + app_uid: appUid, + session_uid: session.uuid, + auth_id, + worker: true, + }); + } + #authIdFor(user: UserRow): string { return user.uuid; } @@ -252,13 +332,6 @@ export class AuthService extends PuterService { return now + seconds; } - /** - * Remove the session referenced by a session/GUI JWT. Cascades to - * derived rows (asset cookies parented to this web session) so - * logout transitively kills every cookie minted under the session. - * App authorizations are top-level (no parent) and survive logout - * per the PUT-1010 hierarchy. - */ async removeSessionByToken(token: string): Promise { let decoded: AnyTokenPayload; try { @@ -277,26 +350,86 @@ export class AuthService extends PuterService { await this.stores.session.revokeCascade(sessionUuid); } - /** List all sessions for an actor's user. */ + /** + * List sessions surfaced to the manage-sessions UI. Excludes `asset` + * rows (per-cookie children of `web` rows, revoked transitively via + * cascade — surfacing them as standalone entries would be confusing). + * App rows are joined to the apps table so the UI can render the + * authorizing app's title and icon without a second round trip. + */ async listSessions(actor: Actor): Promise>> { if (!actor.user?.id) return []; - const rows = await this.stores.session.getByUserId(actor.user.id); + const rows = (await this.stores.session.getByUserId( + actor.user.id, + )) as Array>; - return rows.map((row: Record) => { + const visible = rows.filter((row) => row.kind !== 'asset'); + + const appUids = [ + ...new Set( + visible + .map((row) => row.app_uid) + .filter( + (uid): uid is string => + typeof uid === 'string' && uid.length > 0, + ), + ), + ]; + const apps = new Map>(); + await Promise.all( + appUids.map(async (uid) => { + try { + const app = await this.stores.app.getByUid(uid); + if (app) apps.set(uid, app); + } catch { + // App lookup failures fall back to app_uid only. + } + }), + ); + + const enriched = visible.map((row) => { const meta = (typeof row.meta === 'string' ? JSON.parse(row.meta as string) : row.meta) ?? {}; const isCurrent = actor.session?.uid === row.uuid; + const appUid = typeof row.app_uid === 'string' ? row.app_uid : null; + const app = appUid ? (apps.get(appUid) ?? null) : null; return { + ...meta, uuid: row.uuid, + kind: row.kind, + current: isCurrent, + label: row.label ?? null, created_at: row.created_at, last_activity: row.last_activity, - current: isCurrent, - ...meta, + expires_at: row.expires_at ?? null, + last_ip: row.last_ip ?? null, + created_via: row.created_via ?? null, + app_uid: appUid, + app: app + ? { + uid: app.uid, + name: app.name, + title: app.title, + icon: app.icon, + } + : null, }; }); + + // Sort: current session first, then most-recently-active. The + // manage-sessions UI relies on this so the "you are here" row + // anchors the top of the list. + enriched.sort((a, b) => { + if (a.current !== b.current) return a.current ? -1 : 1; + const al = Number(a.last_activity ?? 0); + const bl = Number(b.last_activity ?? 0); + return bl - al; + }); + + return enriched; } /** @@ -308,6 +441,235 @@ export class AuthService extends PuterService { await this.stores.session.revokeCascade(uuid); } + async revokeAllSessions( + actor: Actor, + opts: { includeCurrent?: boolean; includeApps?: boolean } = {}, + ): Promise { + if (!actor.user) { + throw new HttpError(403, 'Actor must be a user', { + legacyCode: 'forbidden', + }); + } + const currentUuid = actor.session?.uid; + const rows = await this.stores.session.getByUserId( + actor.user.id as number, + ); + for (const row of rows) { + if (row.kind === 'web') { + if (!opts.includeCurrent && row.uuid === currentUuid) continue; + await this.stores.session.revokeCascade(row.uuid as string); + } else if (row.kind === 'app' && opts.includeApps) { + await this.stores.session.revokeCascade(row.uuid as string); + } + } + } + + async migrateLegacyToken( + v1Token: string, + _ctx: { ip?: string; userAgent?: string } = {}, + ): Promise<{ + token: string; + session_uid: string; + auth_id: string; + kind: 'access_token' | 'app'; + }> { + // 1. Verify under v1 secret. `TokenService.verify` tags v1 + // results with `legacy: true`; anything else is either a v2 + // token (nothing to migrate) or invalid. + let decoded: AnyTokenPayload; + try { + decoded = this.services.token.verify( + 'auth', + v1Token, + ); + } catch { + throw new HttpError(401, 'Invalid token', { + legacyCode: 'token_invalid', + }); + } + if (!decoded.legacy) { + throw new HttpError(401, 'Token is not v1', { + legacyCode: 'token_invalid', + }); + } + if (!decoded.type) { + throw new HttpError(401, 'Invalid token type', { + legacyCode: 'token_invalid', + }); + } + + // 2. Web tokens never migrate silently — they go through the + // interactive reauth flow. The `code` field is what puter.js / + // GUI clients key on; `legacyCode` keeps the body shape valid + // for legacy error readers. + if (decoded.type === 'session' || decoded.type === 'gui') { + throw new HttpError(409, 'Reauthentication required', { + legacyCode: 'unauthorized', + code: 'reauth_required', + }); + } + + // 3. Branch by kind. + if (decoded.type === 'access-token') { + return this.#migrateAccessToken(decoded as AccessTokenPayload); + } + if (decoded.type === 'app-under-user') { + // Per ROLLOUT-1, app-token migration is the kind that + // ultimately retires — flag-gated independently from the + // top-level `allow_v1_tokens` so access-token migration + // can stay on indefinitely. + const allowAppMigration = + (this.config as { allow_v1_app_migration?: boolean }) + .allow_v1_app_migration !== false; + if (!allowAppMigration) { + throw new HttpError(410, 'App-token migration disabled', { + legacyCode: 'unauthorized', + code: 'app_migration_disabled', + }); + } + return this.#migrateAppToken(decoded as AppUnderUserTokenPayload); + } + + throw new HttpError(401, 'Unsupported token type', { + legacyCode: 'token_invalid', + }); + } + + async #migrateAccessToken(decoded: AccessTokenPayload): Promise<{ + token: string; + session_uid: string; + auth_id: string; + kind: 'access_token'; + }> { + if (!decoded.token_uid || !decoded.user_uid) { + throw new HttpError(401, 'Invalid token claims', { + legacyCode: 'token_invalid', + }); + } + const user = (await this.stores.user.getByUuid( + decoded.user_uid, + )) as UserRow | null; + if (!user) { + throw new HttpError(401, 'User not found', { + legacyCode: 'unauthorized', + }); + } + const auth_id = this.#authIdFor(user); + + // Per-auth_id rate limit — second axis beyond the route-level + // per-IP limit. Catches an attacker who has both the token and + // a rotating IP pool. + await this.#enforceMigrateAuthIdLimit(auth_id); + + const session = await this.stores.session.findOrCreateLegacyAccessToken( + decoded.token_uid, + { userId: user.id, auth_id }, + ); + if (!session) { + throw new HttpError(500, 'Session backfill failed', { + legacyCode: 'internal_error', + }); + } + + // Mint v2 access token. token_uid is preserved so the existing + // `access_token_permissions` rows (keyed by token_uid) keep + // applying — only the JWT envelope and session-row binding + // change. + const jwtPayload: Record = { + type: 'access-token', + version: '2', + token_uid: decoded.token_uid, + user_uid: user.uuid, + session_uid: session.uuid as string, + auth_id, + }; + if (decoded.app_uid) jwtPayload.app_uid = decoded.app_uid; + const token = this.services.token.sign('auth', jwtPayload); + + return { + token, + session_uid: session.uuid as string, + auth_id, + kind: 'access_token', + }; + } + + async #migrateAppToken(decoded: AppUnderUserTokenPayload): Promise<{ + token: string; + session_uid: string; + auth_id: string; + kind: 'app'; + }> { + if (!decoded.user_uid || !decoded.app_uid) { + throw new HttpError(401, 'Invalid token claims', { + legacyCode: 'token_invalid', + }); + } + const user = (await this.stores.user.getByUuid( + decoded.user_uid, + )) as UserRow | null; + if (!user) { + throw new HttpError(401, 'User not found', { + legacyCode: 'unauthorized', + }); + } + const auth_id = this.#authIdFor(user); + + await this.#enforceMigrateAuthIdLimit(auth_id); + + // Idempotent on `(user_id, app_uid)` via the partial unique + const session = await this.stores.session.getOrCreateApp( + user.id, + decoded.app_uid, + { auth_id }, + ); + if (!session) { + throw new HttpError(500, 'Session backfill failed', { + legacyCode: 'internal_error', + }); + } + + const jwtPayload: Record = { + type: 'app-under-user', + version: '2', + user_uid: user.uuid, + app_uid: decoded.app_uid, + session_uid: session.uuid as string, + auth_id, + }; + const token = this.services.token.sign('auth', jwtPayload); + + return { + token, + session_uid: session.uuid as string, + auth_id, + kind: 'app', + }; + } + + /** + * Per-`auth_id` rate limit for migrate-token. Keyed on the stable + * v2 identity so an attacker rotating IPs but holding one user's + * v1 token still hits a ceiling. + */ + async #enforceMigrateAuthIdLimit(auth_id: string): Promise { + // 20 migrations per 15min per identity matches the per-IP + // route limit — either axis trips first depending on the + // attack shape. Generous enough that a healthy client (one + // app open per device) never sees it. + const ok = await checkRateLimit( + `migrate-token-auth:${auth_id}`, + 20, + 15 * 60_000, + ); + if (!ok) { + throw new HttpError(429, 'Too many migration attempts', { + legacyCode: 'too_many_requests', + fields: { 'retry-after': 900 }, + }); + } + } + // ── App / origin resolution ───────────────────────────────────── /** @@ -540,13 +902,6 @@ export class AuthService extends PuterService { return typeof uid === 'string' && uid ? uid : null; } - /** - * Sign an app-under-user token for the given app UID. Idempotent per - * `(user.id, appUid)` — repeat opens of the same app reuse the existing - * `kind='app'` session row rather than minting a fresh one. The row is - * top-level (no `parent_session_id`) so signing out of the web session - * doesn't kill the app authorization (PUT-1010 hierarchy). - */ async getUserAppToken(actor: Actor, appUid: string): Promise { if (!actor.user) throw new HttpError(403, 'Actor must be a user', { @@ -865,7 +1220,12 @@ export class AuthService extends PuterService { async createAccessToken( actor: Actor, permissions: Array<[string, Record?]>, - options: { expiresIn?: string } = {}, + // `expiresIn` follows jsonwebtoken's expiresIn semantics — either + // a number of seconds (integer) or a duration string ('1h', + // '30d'). `#hardExpiryFromExpiresIn` supports both, and existing + // callers / tests pass the string form, so narrowing to `number` + // here would force unsafe casts at every call site. + options: { expiresIn?: string | number } = {}, ): Promise { if (!actor.user) throw new HttpError(403, 'Actor must be a user', { @@ -902,6 +1262,10 @@ export class AuthService extends PuterService { parent_session_id, expires_at: expiresAt, auth_id, + // Stored on the session row so a raw-uuid revoke (caller has the + // token_uid but no JWT) can reverse-find the row and flip + // `revoked_at` — see `revokeAccessToken`. + access_token_uid: tokenUid, }, ); @@ -917,7 +1281,16 @@ export class AuthService extends PuterService { jwtPayload.app_uid = actor.app.uid; } - const jwt = this.services.token.sign('auth', jwtPayload, options); + // jsonwebtoken's SignOptions.expiresIn is typed as `number | + // ${number}${unit}` (template literal), so a plain string can't + // be statically proven safe. The runtime accepts the same range + // of strings #hardExpiryFromExpiresIn parses ('1h', '30d'), so + // the cast is faithful to actual behavior. + const jwt = this.services.token.sign( + 'auth', + jwtPayload, + options as { expiresIn?: number }, + ); // Store each permission grant const db = this.stores.permission as unknown as { @@ -999,19 +1372,27 @@ export class AuthService extends PuterService { } } + // Permissions rows still DELETE — the AUTH-5 "no DELETE on revoke" + // rule scoped to the `sessions` table (where the audit trail of + // when a session existed/was revoked is load-bearing for forensic + // queries and the cascade graph). `access_token_permissions` + // rows are the grant manifest for an *active* token; once its + // session is soft-revoked, the grants are dead-weight cache + // entries that would only confuse `checkMany`. If we later need + // permission-grant history for audit, that becomes a + // `revoked_at` column on this table, not a behavior change here. await this.clients.db.write( 'DELETE FROM `access_token_permissions` WHERE `token_uid` = ?', [tokenUid], ); await this.stores.permission.invalidateAccessTokenPerms(tokenUid); - // v2 access tokens carry a session row whose `revoked_at` is the - // authoritative kill switch — flip it so a stolen token can't - // resurrect by re-grabbing the deleted permissions. v1 tokens - // (or raw-uuid input where the JWT wasn't presented) have no - // session uuid here; AUTH-5 owns the full back-fill revoke flow. if (sessionUidFromJwt) { await this.stores.session.removeByUuid(sessionUidFromJwt); + } else { + const row = + await this.stores.session.findActiveByAccessTokenUid(tokenUid); + if (row) await this.stores.session.removeByUuid(row.uuid); } } diff --git a/src/backend/services/auth/TokenService.ts b/src/backend/services/auth/TokenService.ts index 1815bf091..515b48850 100644 --- a/src/backend/services/auth/TokenService.ts +++ b/src/backend/services/auth/TokenService.ts @@ -129,7 +129,7 @@ const AUTH_COMPRESSION = def({ app_uid: { short: 'au', ...uuidCompression('app-') }, // v2 unified session-row binding — present on every v2 token kind. session_uid: { short: 'su', ...uuidCompression() }, - // v2 stable per-user identity that survives re-login (PUT-1010). + // v2 stable per-user identity that survives re-login auth_id: { short: 'ai', ...uuidCompression() }, }); @@ -161,17 +161,6 @@ const COMPRESSION: Record = { // ── TokenService ──────────────────────────────────────────────────── -/** - * Signs and verifies JWTs. - * - * Two secrets coexist for the v1→v2 migration: - * - `jwt_secret_v2` signs every new token (`kid: 'v2'` header). - * - `jwt_secret` is verify-only for tokens minted before this rolled out; - * verified-legacy results carry `legacy: true` so AuthService can - * drive lazy-backfill + the re-auth migration flow (AUTH-4). - * - * `allow_v1_tokens=false` (ROLLOUT-1) hard-rejects v1 tokens at verify. - */ export class TokenService extends PuterService { #secretV2: string = ''; #secretLegacy: string = ''; diff --git a/src/backend/services/auth/types.ts b/src/backend/services/auth/types.ts index 52fbf4055..c98d83c80 100644 --- a/src/backend/services/auth/types.ts +++ b/src/backend/services/auth/types.ts @@ -36,7 +36,7 @@ interface TokenPayloadBase { type: TokenType; /** v2: unified session-row binding (uuid of the `sessions` row). */ session_uid?: string; - /** v2: stable per-user identity that survives re-login (PUT-1010). */ + /** v2: stable per-user identity that survives re-login */ auth_id?: string; /** Set by TokenService when the token verified via the legacy secret. */ legacy?: boolean; @@ -103,12 +103,10 @@ export interface SessionRow { meta?: Record | string | null; created_at?: number | null; last_activity?: number | null; - /** PUT-1013: 'web' | 'app' | 'access_token' | 'asset'. */ kind?: string | null; parent_session_id?: string | null; revoked_at?: number | null; expires_at?: number | null; - /** PUT-1014: composite-key columns. */ app_uid?: string | null; legacy_token_uid?: string | null; created_via?: string | null; diff --git a/src/backend/services/socket/SocketService.ts b/src/backend/services/socket/SocketService.ts index 2888656b1..28ca0bdae 100644 --- a/src/backend/services/socket/SocketService.ts +++ b/src/backend/services/socket/SocketService.ts @@ -25,11 +25,6 @@ import { isAccessTokenActor, isAppActor } from '../../core/actor.js'; import type { AuthResult, AuthService } from '../auth/AuthService.js'; import { PuterService } from '../types.js'; -/** - * Error carrying the AUTH-4 reauth payload. socket.io forwards `error.data` - * to the client's `connect_error` callback, so clients receive the same - * `{ code, reason, auth_id }` shape the HTTP gate emits. - */ export type SocketReauthError = Error & { data: Record }; /** @@ -322,9 +317,6 @@ export class SocketService extends PuterService { try { const result = await authService.authenticate(token); - // Log the AUTH-4 signal here; `decideSocketAuth` stays - // pure (no side effects) for unit testing. The `(ws)` - // suffix lets the same grep find HTTP + socket events. if (result.reauth) { console.info( `[auth-v2] reauth reason=${result.reauth.reason} auth_id=${result.reauth.auth_id ?? '-'} (ws)`, diff --git a/src/backend/stores/session/SessionStore.js b/src/backend/stores/session/SessionStore.js index e78ca075e..c7d81d246 100644 --- a/src/backend/stores/session/SessionStore.js +++ b/src/backend/stores/session/SessionStore.js @@ -45,8 +45,9 @@ const TOUCH_THROTTLE_MAX_ENTRIES = 10000; // session never expires. `access_token` rows are *not* slid — their // `expires_at` is hard-set at mint to the caller-specified value. // Exported so AuthService can use the same values when seeding new rows. -export const WEB_WINDOW_SECONDS = 30 * 24 * 60 * 60; // 30 days -export const APP_WINDOW_SECONDS = 90 * 24 * 60 * 60; // 90 days +export const WEB_WINDOW_SECONDS = 365 * 24 * 60 * 60; // 1y +export const APP_WINDOW_SECONDS = 365 * 24 * 60 * 60; // 1y +export const WORKER_WINDOW_SECONDS = 99 * 365 * 24 * 60 * 60; // 99y (virtually infinite); TODO DS: have workers pass in flag when creating worker token so that we can give them infinite time export const ASSET_WINDOW_SECONDS = 7 * 24 * 60 * 60; // 7 days const sqlTimestamp = (ms) => @@ -124,6 +125,10 @@ export class SessionStore extends PuterStore { * index. * @param opts.legacy_token_uid - v1 token_uid this row backfills. * Only set for `created_via='legacy_backfill'`. + * @param opts.access_token_uid - For `kind='access_token'` v2 rows: the + * `token_uid` claim that lives in `access_token_permissions`. Lets + * raw-uuid revoke reverse-find the session row when no JWT was + * presented. * @param opts.created_via - Audit sentinel (e.g. 'legacy_backfill'). * @param opts.auth_id - Stable per-user identity (survives re-login); * carried on every v2 JWT so manage-sessions can group by identity. @@ -152,6 +157,7 @@ export class SessionStore extends PuterStore { expires_at = null, app_uid = null, legacy_token_uid = null, + access_token_uid = null, created_via = null, auth_id = null, } = {}, @@ -171,7 +177,7 @@ export class SessionStore extends PuterStore { : 'INSERT INTO'; await this.clients.db.write( - `${insertVerb} \`sessions\` (\`uuid\`, \`user_id\`, \`meta\`, \`last_activity\`, \`created_at\`, \`kind\`, \`label\`, \`parent_session_id\`, \`last_ip\`, \`last_user_agent\`, \`expires_at\`, \`app_uid\`, \`legacy_token_uid\`, \`created_via\`, \`auth_id\`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + `${insertVerb} \`sessions\` (\`uuid\`, \`user_id\`, \`meta\`, \`last_activity\`, \`created_at\`, \`kind\`, \`label\`, \`parent_session_id\`, \`last_ip\`, \`last_user_agent\`, \`expires_at\`, \`app_uid\`, \`legacy_token_uid\`, \`access_token_uid\`, \`created_via\`, \`auth_id\`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ uuid, userId, @@ -186,6 +192,7 @@ export class SessionStore extends PuterStore { expires_at, app_uid, legacy_token_uid, + access_token_uid, created_via, auth_id, ], @@ -206,6 +213,7 @@ export class SessionStore extends PuterStore { expires_at, app_uid, legacy_token_uid, + access_token_uid, created_via, auth_id, }; @@ -282,6 +290,23 @@ export class SessionStore extends PuterStore { await this.publishCacheKeys({ keys, broadcast: true }); } + /** + * Active session row whose access-token identity matches `tokenUid`. + * Covers both v2 (`access_token_uid` set at mint) and v1 lazy-backfill + * (`legacy_token_uid` set on first verify) rows so raw-uuid revoke can + * find the row regardless of whether the token was originally v1 or v2. + * Returns `null` if no active row matches. + */ + async findActiveByAccessTokenUid(tokenUid) { + if (!tokenUid) return null; + const now = nowSeconds(); + const rows = await this.clients.db.read( + "SELECT * FROM `sessions` WHERE `kind` = 'access_token' AND (`access_token_uid` = ? OR `legacy_token_uid` = ?) AND `revoked_at` IS NULL AND (`expires_at` IS NULL OR `expires_at` > ?) ORDER BY `id` DESC LIMIT 1", + [tokenUid, tokenUid, now], + ); + return this.#normalizeRow(rows[0]); + } + /** * Idempotent "give me the app session for this (user, app)" lookup. * Returns the existing active app session if one exists, or creates @@ -299,7 +324,8 @@ export class SessionStore extends PuterStore { * @param appUid - App UID (string). * @param opts.last_ip / opts.last_user_agent - Request context for * first-time creation. Ignored when a row already exists. - * @param opts.auth_id - Stable per-user identity (PUT-1010). + * @param opts.auth_id - Stable per-user identity (survives re-login); + * carried on every v2 JWT so manage-sessions can group by identity. */ async getOrCreateApp(userId, appUid, opts = {}) { if (!userId || !appUid) return null; diff --git a/src/backend/stores/session/SessionStore.test.ts b/src/backend/stores/session/SessionStore.test.ts index a49e24783..7d743059c 100644 --- a/src/backend/stores/session/SessionStore.test.ts +++ b/src/backend/stores/session/SessionStore.test.ts @@ -21,6 +21,10 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { v4 as uuidv4 } from 'uuid'; import { setupTestServer } from '../../testUtil.ts'; import { PuterServer } from '../../server.ts'; +import { + APP_WINDOW_SECONDS, + WEB_WINDOW_SECONDS, +} from './SessionStore.js'; describe('SessionStore', () => { let server: PuterServer; @@ -282,11 +286,13 @@ describe('SessionStore', () => { expect(row.app_uid).toBe(appUid); expect(row.parent_session_id).toBeNull(); expect(row.user_id).toBe(user.id); - // Sliding window seeded — 90 days for app. + // Sliding window seeded for app — bound matches the live + // APP_WINDOW_SECONDS constant so a future bump to the window + // doesn't silently fail this assertion. const now = Math.floor(Date.now() / 1000); expect(row.expires_at).toBeGreaterThan(now); expect(row.expires_at).toBeLessThanOrEqual( - now + 90 * 24 * 60 * 60 + 5, + now + APP_WINDOW_SECONDS + 5, ); }); @@ -370,13 +376,15 @@ describe('SessionStore', () => { await target.updateActivity(session.uuid, now); const row = await rawRow(session.uuid); - // After slide, expires_at = now + 30d (within a tolerance - // for between-statement wall-clock drift). + // After slide, expires_at = now + WEB_WINDOW_SECONDS (within + // a tolerance for between-statement wall-clock drift). Use + // the constant directly so a future window bump doesn't + // need a parallel edit here. expect(row.expires_at).toBeGreaterThanOrEqual( - now + 30 * 24 * 60 * 60 - 5, + now + WEB_WINDOW_SECONDS - 5, ); expect(row.expires_at).toBeLessThanOrEqual( - now + 30 * 24 * 60 * 60 + 5, + now + WEB_WINDOW_SECONDS + 5, ); }); diff --git a/src/backend/types.ts b/src/backend/types.ts index c854830c0..6c692699d 100644 --- a/src/backend/types.ts +++ b/src/backend/types.ts @@ -491,6 +491,22 @@ interface IConfigOptional { * Default true during the v1→v2 migration window. */ allow_v1_tokens: boolean; + /** + * When false, `POST /auth/migrate-token` returns 410 Gone for v1 + * `app-under-user` tokens. Per ROLLOUT-1, app-token migration is + * retired ahead of access-token migration — keeping these on + * separate flags lets ops kill apps first and keep API-key + * migration on indefinitely. Default true. + */ + allow_v1_app_migration: boolean; + /** + * Optional explicit allowlist of `Origin` header values that may + * call `POST /auth/migrate-token` cross-origin. The main `origin` + * is always allowed. Used to thread the SDK migration call through + * app subdomains (e.g. `*.puter.site`) without opening the + * endpoint to arbitrary attacker pages. + */ + allow_migrate_token_origins?: string[]; /** HMAC secret for signed file URLs (/file, /writeFile, /sign). */ url_signature_secret: string; /** Name of the session cookie the auth probe reads. */