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. */