Skip to content

Commit 8be1150

Browse files
authored
feat (put-1012 put-1014): new tokens version signature and logic (#3152)
1 parent e66fd23 commit 8be1150

21 files changed

Lines changed: 1444 additions & 164 deletions

config.default.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
"domain": "puter.localhost",
77
"cookie_name": "puter_auth_token",
88
"jwt_secret": "dev-jwt-secret-change-me",
9+
"jwt_secret_v2": "dev-jwt-secret-v2-change-me",
10+
"allow_v1_tokens": true,
911
"url_signature_secret": "dev-url-signature-secret-change-me",
1012
"allow_all_host_values": true,
1113
"allow_no_host_header": true,

config.template.jsonc

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,11 @@
6767

6868
// ── Auth / session ──────────────────────────────────────────────────
6969
// ALWAYS replace these for any public install — `openssl rand -hex 64`.
70+
// `jwt_secret` is legacy verify-only; new tokens sign with `jwt_secret_v2`.
7071
"jwt_secret": "change-me",
72+
"jwt_secret_v2": "change-me",
73+
// Set false to retire v1 tokens entirely (ROLLOUT-1).
74+
"allow_v1_tokens": true,
7175
"url_signature_secret": "change-me",
7276
"cookie_name": "puter_auth_token",
7377
"min_pass_length": 6,

doc/self-hosting.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ MARIADB_ROOT_PASSWORD=$(openssl rand -hex 32)
4848
MARIADB_PASSWORD=$(openssl rand -hex 32)
4949
S3_SECRET_KEY=$(openssl rand -hex 32)
5050
JWT_SECRET=$(openssl rand -hex 64)
51+
JWT_SECRET_V2=$(openssl rand -hex 64)
5152
URL_SIGNATURE_SECRET=$(openssl rand -hex 64)
5253

5354
cat > .env <<EOF
@@ -78,6 +79,8 @@ cat > puter/config/config.json <<EOF
7879
"private_app_hosting_domain_alt": "dev.puter.localhost",
7980
8081
"jwt_secret": "$JWT_SECRET",
82+
"jwt_secret_v2": "$JWT_SECRET_V2",
83+
"allow_v1_tokens": true,
8184
"url_signature_secret": "$URL_SIGNATURE_SECRET",
8285
8386
"database": {
@@ -131,6 +134,7 @@ Replace `puter.localhost`, `site.puter.localhost`, `host.puter.localhost`, `dev.
131134

132135
Why these knobs:
133136

137+
- `jwt_secret` + `jwt_secret_v2` — Puter signs new auth tokens with `jwt_secret_v2` (v2 token format, `kid: 'v2'` JWT header). `jwt_secret` is verify-only and lets tokens minted before this version (cookies still in users' browsers, stored API tokens) keep working. `allow_v1_tokens: true` keeps that fallback enabled. Fresh installs can leave it as-is; future versions will flip the flag to `false` and retire `jwt_secret` entirely once legacy tokens have drained.
134138
- `env: "prod"` — the bundled `config.default.json` ships with `env: "dev"` (matches the source-tree `npm run start=gui` workflow, which expects webpack-dev-server emitting a CSS manifest). Self-host runs against pre-built static bundles, so `env: "prod"` makes the homepage emit the `/dist/bundle.min.css` `<link>` tag instead of waiting on a manifest that doesn't exist.
135139
- `database.migrationPaths` — Puter applies the bundled MySQL schema on boot. `mysql_mig_1.sql` (tables) and `mysql_mig_2.sql` (default apps: editor, viewer, pdf, camera, player, recorder, git, dev-center, puter-linux). Idempotent — safe to re-run.
136140
- `dynamo.bootstrapTables: true` — Puter creates its KV table on boot. **Only set against a local emulator**, never real AWS.

install.ps1

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,10 @@ if ($writeConfig) {
113113
$mariadbRootPw = New-HexSecret 32
114114
$mariadbPw = New-HexSecret 32
115115
$s3SecretKey = New-HexSecret 32
116+
# Two JWT secrets: $jwtSecret is verify-only for legacy v1 tokens
117+
# already in circulation; $jwtSecretV2 signs every new token.
116118
$jwtSecret = New-HexSecret 64
119+
$jwtSecretV2 = New-HexSecret 64
117120
$urlSigSecret = New-HexSecret 64
118121

119122
$envContent = @"
@@ -142,6 +145,8 @@ S3_BUCKET=puter-local
142145
private_app_hosting_domain = "app.$PuterDomain"
143146
private_app_hosting_domain_alt = "dev.$PuterDomain"
144147
jwt_secret = $jwtSecret
148+
jwt_secret_v2 = $jwtSecretV2
149+
allow_v1_tokens = $true
145150
url_signature_secret = $urlSigSecret
146151
database = [ordered]@{
147152
engine = 'mysql'

install.sh

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,11 @@ if [ "$write_config" = "1" ]; then
9292
MARIADB_ROOT_PASSWORD=$(openssl rand -hex 32)
9393
MARIADB_PASSWORD=$(openssl rand -hex 32)
9494
S3_SECRET_KEY=$(openssl rand -hex 32)
95+
# Two JWT secrets: `jwt_secret` is verify-only for legacy v1 tokens
96+
# already in circulation; `jwt_secret_v2` signs every new token.
97+
# Both are required at boot (`jwt_secret` only when verifying v1).
9598
JWT_SECRET=$(openssl rand -hex 64)
99+
JWT_SECRET_V2=$(openssl rand -hex 64)
96100
URL_SIGNATURE_SECRET=$(openssl rand -hex 64)
97101

98102
cat > .env <<EOF
@@ -123,6 +127,8 @@ EOF
123127
"private_app_hosting_domain_alt": "dev.$PUTER_DOMAIN",
124128
125129
"jwt_secret": "$JWT_SECRET",
130+
"jwt_secret_v2": "$JWT_SECRET_V2",
131+
"allow_v1_tokens": true,
126132
"url_signature_secret": "$URL_SIGNATURE_SECRET",
127133
128134
"database": {

src/backend/clients/database/SqliteDatabaseClient.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [
8080
[45, ['0049_music-player-pdf-player-updates.sql']],
8181
[46, ['0050_add_preamble_version.sql']],
8282
[47, ['0051_sessions_v2.sql']],
83+
[48, ['0052_sessions_v2_lookups.sql']],
8384
];
8485

8586
export class SqliteDatabaseClient extends AbstractDatabaseClient {
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
-- Copyright (C) 2024-present Puter Technologies Inc.
2+
--
3+
-- This file is part of Puter.
4+
--
5+
-- Puter is free software: you can redistribute it and/or modify
6+
-- it under the terms of the GNU Affero General Public License as published
7+
-- by the Free Software Foundation, either version 3 of the License, or
8+
-- (at your option) any later version.
9+
--
10+
-- This program is distributed in the hope that it will be useful,
11+
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
-- GNU Affero General Public License for more details.
14+
--
15+
-- You should have received a copy of the GNU Affero General Public License
16+
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
17+
18+
-- AUTH-2 (PUT-1014) — composite-key lookups + audit columns. Mirrors
19+
-- SQLite migration 0052. MySQL has no partial unique indexes, so the
20+
-- "at most one active row per (user_id, app_uid)" / "one active row
21+
-- per legacy_token_uid" semantics are encoded via VIRTUAL generated
22+
-- columns that are NULL when the row isn't subject to the rule —
23+
-- MySQL allows multiple NULLs in a UNIQUE index, so non-applicable
24+
-- rows don't conflict.
25+
--
26+
-- Idempotent: each ADD COLUMN / ADD INDEX is guarded so the migration
27+
-- directory can be replayed safely.
28+
29+
DROP PROCEDURE IF EXISTS _puter_sessions_v2_lookups;
30+
DELIMITER //
31+
CREATE PROCEDURE _puter_sessions_v2_lookups()
32+
BEGIN
33+
IF NOT EXISTS (
34+
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
35+
WHERE TABLE_SCHEMA = DATABASE()
36+
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'app_uid'
37+
) THEN
38+
ALTER TABLE `sessions` ADD COLUMN `app_uid` VARCHAR(64) DEFAULT NULL;
39+
END IF;
40+
41+
IF NOT EXISTS (
42+
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
43+
WHERE TABLE_SCHEMA = DATABASE()
44+
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'legacy_token_uid'
45+
) THEN
46+
ALTER TABLE `sessions`
47+
ADD COLUMN `legacy_token_uid` VARCHAR(64) DEFAULT NULL;
48+
END IF;
49+
50+
IF NOT EXISTS (
51+
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
52+
WHERE TABLE_SCHEMA = DATABASE()
53+
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'created_via'
54+
) THEN
55+
ALTER TABLE `sessions` ADD COLUMN `created_via` VARCHAR(32) DEFAULT NULL;
56+
END IF;
57+
58+
IF NOT EXISTS (
59+
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
60+
WHERE TABLE_SCHEMA = DATABASE()
61+
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'auth_id'
62+
) THEN
63+
ALTER TABLE `sessions` ADD COLUMN `auth_id` VARCHAR(64) DEFAULT NULL;
64+
END IF;
65+
66+
-- Generated discriminant: non-NULL only for active app-authorization rows,
67+
-- so UNIQUE(app_unique_key) enforces "one active app session per
68+
-- (user_id, app_uid)" while permitting any number of revoked rows.
69+
IF NOT EXISTS (
70+
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
71+
WHERE TABLE_SCHEMA = DATABASE()
72+
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'app_unique_key'
73+
) THEN
74+
ALTER TABLE `sessions`
75+
ADD COLUMN `app_unique_key` VARCHAR(150)
76+
GENERATED ALWAYS AS (
77+
IF(`kind` = 'app' AND `revoked_at` IS NULL,
78+
CONCAT(`user_id`, '|', `app_uid`),
79+
NULL)
80+
) VIRTUAL;
81+
END IF;
82+
83+
IF NOT EXISTS (
84+
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
85+
WHERE TABLE_SCHEMA = DATABASE()
86+
AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'legacy_token_unique_key'
87+
) THEN
88+
ALTER TABLE `sessions`
89+
ADD COLUMN `legacy_token_unique_key` VARCHAR(64)
90+
GENERATED ALWAYS AS (
91+
IF(`revoked_at` IS NULL, `legacy_token_uid`, NULL)
92+
) VIRTUAL;
93+
END IF;
94+
95+
IF NOT EXISTS (
96+
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
97+
WHERE TABLE_SCHEMA = DATABASE()
98+
AND TABLE_NAME = 'sessions'
99+
AND INDEX_NAME = 'idx_sessions_user_app_active'
100+
) THEN
101+
ALTER TABLE `sessions`
102+
ADD UNIQUE INDEX `idx_sessions_user_app_active` (`app_unique_key`);
103+
END IF;
104+
105+
IF NOT EXISTS (
106+
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
107+
WHERE TABLE_SCHEMA = DATABASE()
108+
AND TABLE_NAME = 'sessions'
109+
AND INDEX_NAME = 'idx_sessions_legacy_token_active'
110+
) THEN
111+
ALTER TABLE `sessions`
112+
ADD UNIQUE INDEX `idx_sessions_legacy_token_active`
113+
(`legacy_token_unique_key`);
114+
END IF;
115+
116+
IF NOT EXISTS (
117+
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
118+
WHERE TABLE_SCHEMA = DATABASE()
119+
AND TABLE_NAME = 'sessions'
120+
AND INDEX_NAME = 'idx_sessions_kind_user'
121+
) THEN
122+
ALTER TABLE `sessions`
123+
ADD INDEX `idx_sessions_kind_user` (`kind`, `user_id`);
124+
END IF;
125+
END//
126+
DELIMITER ;
127+
128+
CALL _puter_sessions_v2_lookups();
129+
130+
DROP PROCEDURE IF EXISTS _puter_sessions_v2_lookups;
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
-- Copyright (C) 2024-present Puter Technologies Inc.
2+
--
3+
-- This file is part of Puter.
4+
--
5+
-- Puter is free software: you can redistribute it and/or modify
6+
-- it under the terms of the GNU Affero General Public License as published
7+
-- by the Free Software Foundation, either version 3 of the License, or
8+
-- (at your option) any later version.
9+
--
10+
-- This program is distributed in the hope that it will be useful,
11+
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
-- GNU Affero General Public License for more details.
14+
--
15+
-- You should have received a copy of the GNU Affero General Public License
16+
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
17+
18+
-- AUTH-2 (PUT-1014) — composite-key lookups + audit columns.
19+
-- - `app_uid` : binds `kind='app'` rows to their app authorization
20+
-- target. (user_id, app_uid) is the idempotency key.
21+
-- - `legacy_token_uid` : keys lazy-backfilled rows to the v1 token_uid that
22+
-- originally minted them.
23+
-- - `created_via` : audit sentinel (e.g. 'legacy_backfill').
24+
-- - `auth_id` : stable per-user identity that survives re-login
25+
-- (PUT-1010); lets manage-sessions group by identity.
26+
27+
ALTER TABLE `sessions` ADD COLUMN `app_uid` TEXT;
28+
ALTER TABLE `sessions` ADD COLUMN `legacy_token_uid` TEXT;
29+
ALTER TABLE `sessions` ADD COLUMN `created_via` TEXT;
30+
ALTER TABLE `sessions` ADD COLUMN `auth_id` TEXT;
31+
32+
-- Partial unique indexes keep "at most one active row per key" without
33+
-- breaking the soft-revoke pattern (revoked rows stay for audit).
34+
35+
CREATE UNIQUE INDEX IF NOT EXISTS `idx_sessions_user_app_active`
36+
ON `sessions` (`user_id`, `app_uid`)
37+
WHERE `kind` = 'app' AND `revoked_at` IS NULL;
38+
39+
CREATE UNIQUE INDEX IF NOT EXISTS `idx_sessions_legacy_token_active`
40+
ON `sessions` (`legacy_token_uid`)
41+
WHERE `legacy_token_uid` IS NOT NULL AND `revoked_at` IS NULL;
42+
43+
-- Supports manage-sessions list queries grouped by kind.
44+
CREATE INDEX IF NOT EXISTS `idx_sessions_kind_user`
45+
ON `sessions` (`kind`, `user_id`);

src/backend/controllers/auth/AuthController.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1933,7 +1933,10 @@ export class AuthController extends PuterController {
19331933
{},
19341934
);
19351935

1936-
const token = this.services.auth.getUserAppToken(req.actor!, app_uid);
1936+
const token = await this.services.auth.getUserAppToken(
1937+
req.actor!,
1938+
app_uid,
1939+
);
19371940

19381941
const missingFSPathPromise = (async () => {
19391942
// Ensure the app's per-user AppData directory exists.
@@ -1988,7 +1991,7 @@ export class AuthController extends PuterController {
19881991
token?: string;
19891992
} = { app_uid, authenticated };
19901993
if (authenticated) {
1991-
result.token = this.services.auth.getUserAppToken(
1994+
result.token = await this.services.auth.getUserAppToken(
19921995
req.actor!,
19931996
app_uid,
19941997
);

src/backend/controllers/fs/LegacyFSController.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -964,7 +964,10 @@ export class LegacyFSController extends PuterController {
964964
legacyCode: 'not_found',
965965
});
966966
grantApp = { uid: app.uid };
967-
result.token = this.services.auth.getUserAppToken(actor, app.uid);
967+
result.token = await this.services.auth.getUserAppToken(
968+
actor,
969+
app.uid,
970+
);
968971
}
969972

970973
for (const rawItem of items) {
@@ -1397,7 +1400,10 @@ export class LegacyFSController extends PuterController {
13971400
{},
13981401
{ reason: 'open_item' },
13991402
);
1400-
token = this.services.auth.getUserAppToken(actor, defaultAppUid);
1403+
token = await this.services.auth.getUserAppToken(
1404+
actor,
1405+
defaultAppUid,
1406+
);
14011407
}
14021408

14031409
const signingCfg = signingConfigFromAppConfig(this.config);

0 commit comments

Comments
 (0)