diff --git a/config.default.json b/config.default.json index 17fc9fa99..9f1f9b925 100644 --- a/config.default.json +++ b/config.default.json @@ -6,6 +6,8 @@ "domain": "puter.localhost", "cookie_name": "puter_auth_token", "jwt_secret": "dev-jwt-secret-change-me", + "jwt_secret_v2": "dev-jwt-secret-v2-change-me", + "allow_v1_tokens": true, "url_signature_secret": "dev-url-signature-secret-change-me", "allow_all_host_values": true, "allow_no_host_header": true, diff --git a/config.template.jsonc b/config.template.jsonc index 8d761c2f7..f42a98ddd 100644 --- a/config.template.jsonc +++ b/config.template.jsonc @@ -67,7 +67,11 @@ // ── Auth / session ────────────────────────────────────────────────── // ALWAYS replace these for any public install — `openssl rand -hex 64`. + // `jwt_secret` is legacy verify-only; new tokens sign with `jwt_secret_v2`. "jwt_secret": "change-me", + "jwt_secret_v2": "change-me", + // Set false to retire v1 tokens entirely (ROLLOUT-1). + "allow_v1_tokens": true, "url_signature_secret": "change-me", "cookie_name": "puter_auth_token", "min_pass_length": 6, diff --git a/doc/self-hosting.md b/doc/self-hosting.md index e8bb745bb..c8730f70f 100644 --- a/doc/self-hosting.md +++ b/doc/self-hosting.md @@ -48,6 +48,7 @@ MARIADB_ROOT_PASSWORD=$(openssl rand -hex 32) MARIADB_PASSWORD=$(openssl rand -hex 32) S3_SECRET_KEY=$(openssl rand -hex 32) JWT_SECRET=$(openssl rand -hex 64) +JWT_SECRET_V2=$(openssl rand -hex 64) URL_SIGNATURE_SECRET=$(openssl rand -hex 64) cat > .env < puter/config/config.json <` tag instead of waiting on a manifest that doesn't exist. - `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. - `dynamo.bootstrapTables: true` — Puter creates its KV table on boot. **Only set against a local emulator**, never real AWS. diff --git a/install.ps1 b/install.ps1 index 32a4cb531..fbafc4da3 100644 --- a/install.ps1 +++ b/install.ps1 @@ -113,7 +113,10 @@ if ($writeConfig) { $mariadbRootPw = New-HexSecret 32 $mariadbPw = New-HexSecret 32 $s3SecretKey = New-HexSecret 32 + # Two JWT secrets: $jwtSecret is verify-only for legacy v1 tokens + # already in circulation; $jwtSecretV2 signs every new token. $jwtSecret = New-HexSecret 64 + $jwtSecretV2 = New-HexSecret 64 $urlSigSecret = New-HexSecret 64 $envContent = @" @@ -142,6 +145,8 @@ S3_BUCKET=puter-local private_app_hosting_domain = "app.$PuterDomain" private_app_hosting_domain_alt = "dev.$PuterDomain" jwt_secret = $jwtSecret + jwt_secret_v2 = $jwtSecretV2 + allow_v1_tokens = $true url_signature_secret = $urlSigSecret database = [ordered]@{ engine = 'mysql' diff --git a/install.sh b/install.sh index a373420e7..218a7f444 100755 --- a/install.sh +++ b/install.sh @@ -92,7 +92,11 @@ if [ "$write_config" = "1" ]; then MARIADB_ROOT_PASSWORD=$(openssl rand -hex 32) MARIADB_PASSWORD=$(openssl rand -hex 32) S3_SECRET_KEY=$(openssl rand -hex 32) + # Two JWT secrets: `jwt_secret` is verify-only for legacy v1 tokens + # already in circulation; `jwt_secret_v2` signs every new token. + # Both are required at boot (`jwt_secret` only when verifying v1). JWT_SECRET=$(openssl rand -hex 64) + JWT_SECRET_V2=$(openssl rand -hex 64) URL_SIGNATURE_SECRET=$(openssl rand -hex 64) cat > .env <. + +-- AUTH-2 (PUT-1014) — composite-key lookups + audit columns. Mirrors +-- SQLite migration 0052. MySQL has no partial unique indexes, so the +-- "at most one active row per (user_id, app_uid)" / "one active row +-- per legacy_token_uid" semantics are encoded via VIRTUAL generated +-- columns that are NULL when the row isn't subject to the rule — +-- MySQL allows multiple NULLs in a UNIQUE index, so non-applicable +-- rows don't conflict. +-- +-- Idempotent: each ADD COLUMN / ADD INDEX is guarded so the migration +-- directory can be replayed safely. + +DROP PROCEDURE IF EXISTS _puter_sessions_v2_lookups; +DELIMITER // +CREATE PROCEDURE _puter_sessions_v2_lookups() +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'app_uid' + ) THEN + ALTER TABLE `sessions` ADD COLUMN `app_uid` VARCHAR(64) DEFAULT NULL; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'legacy_token_uid' + ) THEN + ALTER TABLE `sessions` + ADD COLUMN `legacy_token_uid` VARCHAR(64) DEFAULT NULL; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'created_via' + ) THEN + ALTER TABLE `sessions` ADD COLUMN `created_via` VARCHAR(32) DEFAULT NULL; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'auth_id' + ) THEN + ALTER TABLE `sessions` ADD COLUMN `auth_id` VARCHAR(64) DEFAULT NULL; + END IF; + + -- Generated discriminant: non-NULL only for active app-authorization rows, + -- so UNIQUE(app_unique_key) enforces "one active app session per + -- (user_id, app_uid)" while permitting any number of revoked rows. + IF NOT EXISTS ( + SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'app_unique_key' + ) THEN + ALTER TABLE `sessions` + ADD COLUMN `app_unique_key` VARCHAR(150) + GENERATED ALWAYS AS ( + IF(`kind` = 'app' AND `revoked_at` IS NULL, + CONCAT(`user_id`, '|', `app_uid`), + NULL) + ) VIRTUAL; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'sessions' AND COLUMN_NAME = 'legacy_token_unique_key' + ) THEN + ALTER TABLE `sessions` + ADD COLUMN `legacy_token_unique_key` VARCHAR(64) + GENERATED ALWAYS AS ( + IF(`revoked_at` IS NULL, `legacy_token_uid`, NULL) + ) VIRTUAL; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'sessions' + AND INDEX_NAME = 'idx_sessions_user_app_active' + ) THEN + ALTER TABLE `sessions` + ADD UNIQUE INDEX `idx_sessions_user_app_active` (`app_unique_key`); + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'sessions' + AND INDEX_NAME = 'idx_sessions_legacy_token_active' + ) THEN + ALTER TABLE `sessions` + ADD UNIQUE INDEX `idx_sessions_legacy_token_active` + (`legacy_token_unique_key`); + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'sessions' + AND INDEX_NAME = 'idx_sessions_kind_user' + ) THEN + ALTER TABLE `sessions` + ADD INDEX `idx_sessions_kind_user` (`kind`, `user_id`); + END IF; +END// +DELIMITER ; + +CALL _puter_sessions_v2_lookups(); + +DROP PROCEDURE IF EXISTS _puter_sessions_v2_lookups; diff --git a/src/backend/clients/database/migrations/sqlite/0052_sessions_v2_lookups.sql b/src/backend/clients/database/migrations/sqlite/0052_sessions_v2_lookups.sql new file mode 100644 index 000000000..8cb86ac0d --- /dev/null +++ b/src/backend/clients/database/migrations/sqlite/0052_sessions_v2_lookups.sql @@ -0,0 +1,45 @@ +-- 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-2 (PUT-1014) — composite-key lookups + audit columns. +-- - `app_uid` : binds `kind='app'` rows to their app authorization +-- target. (user_id, app_uid) is the idempotency key. +-- - `legacy_token_uid` : keys lazy-backfilled rows to the v1 token_uid that +-- originally minted them. +-- - `created_via` : audit sentinel (e.g. 'legacy_backfill'). +-- - `auth_id` : stable per-user identity that survives re-login +-- (PUT-1010); lets manage-sessions group by identity. + +ALTER TABLE `sessions` ADD COLUMN `app_uid` TEXT; +ALTER TABLE `sessions` ADD COLUMN `legacy_token_uid` TEXT; +ALTER TABLE `sessions` ADD COLUMN `created_via` TEXT; +ALTER TABLE `sessions` ADD COLUMN `auth_id` TEXT; + +-- Partial unique indexes keep "at most one active row per key" without +-- breaking the soft-revoke pattern (revoked rows stay for audit). + +CREATE UNIQUE INDEX IF NOT EXISTS `idx_sessions_user_app_active` + ON `sessions` (`user_id`, `app_uid`) + WHERE `kind` = 'app' AND `revoked_at` IS NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS `idx_sessions_legacy_token_active` + ON `sessions` (`legacy_token_uid`) + WHERE `legacy_token_uid` IS NOT NULL AND `revoked_at` IS NULL; + +-- Supports manage-sessions list queries grouped by kind. +CREATE INDEX IF NOT EXISTS `idx_sessions_kind_user` + ON `sessions` (`kind`, `user_id`); diff --git a/src/backend/controllers/auth/AuthController.ts b/src/backend/controllers/auth/AuthController.ts index 3149459bc..e3a508ef0 100644 --- a/src/backend/controllers/auth/AuthController.ts +++ b/src/backend/controllers/auth/AuthController.ts @@ -1933,7 +1933,10 @@ export class AuthController extends PuterController { {}, ); - const token = this.services.auth.getUserAppToken(req.actor!, app_uid); + const token = await this.services.auth.getUserAppToken( + req.actor!, + app_uid, + ); const missingFSPathPromise = (async () => { // Ensure the app's per-user AppData directory exists. @@ -1988,7 +1991,7 @@ export class AuthController extends PuterController { token?: string; } = { app_uid, authenticated }; if (authenticated) { - result.token = this.services.auth.getUserAppToken( + result.token = await this.services.auth.getUserAppToken( req.actor!, app_uid, ); diff --git a/src/backend/controllers/fs/LegacyFSController.ts b/src/backend/controllers/fs/LegacyFSController.ts index 35acfa507..addaad3c9 100644 --- a/src/backend/controllers/fs/LegacyFSController.ts +++ b/src/backend/controllers/fs/LegacyFSController.ts @@ -964,7 +964,10 @@ export class LegacyFSController extends PuterController { legacyCode: 'not_found', }); grantApp = { uid: app.uid }; - result.token = this.services.auth.getUserAppToken(actor, app.uid); + result.token = await this.services.auth.getUserAppToken( + actor, + app.uid, + ); } for (const rawItem of items) { @@ -1397,7 +1400,10 @@ export class LegacyFSController extends PuterController { {}, { reason: 'open_item' }, ); - token = this.services.auth.getUserAppToken(actor, defaultAppUid); + token = await this.services.auth.getUserAppToken( + actor, + defaultAppUid, + ); } const signingCfg = signingConfigFromAppConfig(this.config); diff --git a/src/backend/core/http/middleware/privateAppGate.test.ts b/src/backend/core/http/middleware/privateAppGate.test.ts index 25dbea0f6..df854c6b4 100644 --- a/src/backend/core/http/middleware/privateAppGate.test.ts +++ b/src/backend/core/http/middleware/privateAppGate.test.ts @@ -675,7 +675,7 @@ describe('resolvePrivateIdentity', () => { it('returns the sticky private-cookie identity when the token matches the expected app/subdomain/host', async () => { const user = await makeUser(); const appUid = `app-${uuidv4()}`; - const token = authService.createPrivateAssetToken({ + const token = await authService.createPrivateAssetToken({ appUid, userUid: user.uuid, subdomain: 'beans', @@ -700,7 +700,7 @@ describe('resolvePrivateIdentity', () => { it('falls through to req.actor when the private cookie is for a different app', async () => { const user = await makeUser(); - const wrongToken = authService.createPrivateAssetToken({ + const wrongToken = await authService.createPrivateAssetToken({ appUid: `app-${uuidv4()}`, userUid: user.uuid, subdomain: 'beans', @@ -799,7 +799,7 @@ describe('resolvePrivateIdentity', () => { 1, ], ); - const wrongAppToken = authService.getUserAppToken( + const wrongAppToken = await authService.getUserAppToken( { user: { id: user.id, uuid: user.uuid } } as unknown as Parameters< typeof authService.getUserAppToken >[0], @@ -838,7 +838,7 @@ describe('resolvePrivateIdentity', () => { 1, ], ); - const matchedToken = authService.getUserAppToken( + const matchedToken = await authService.getUserAppToken( { user: { id: user.id, uuid: user.uuid } } as unknown as Parameters< typeof authService.getUserAppToken >[0], @@ -873,7 +873,7 @@ describe('resolvePublicHostedIdentity', () => { it('returns the cookie identity when present and valid', async () => { const user = await makeUser(); const appUid = `app-${uuidv4()}`; - const token = authService.createPublicHostedActorToken({ + const token = await authService.createPublicHostedActorToken({ appUid, userUid: user.uuid, subdomain: 'beans', diff --git a/src/backend/core/http/middleware/puterSite.ts b/src/backend/core/http/middleware/puterSite.ts index 3b08eac2d..c5d226581 100644 --- a/src/backend/core/http/middleware/puterSite.ts +++ b/src/backend/core/http/middleware/puterSite.ts @@ -323,13 +323,14 @@ export const createPuterSiteMiddleware = ( // visitors but still refreshes after rotation/expiry. if (!identity.hasValidPrivateCookie) { try { - const token = layers.services.auth.createPrivateAssetToken({ - appUid: privateApp!.uid, - userUid: identity.userUid, - sessionUuid: identity.sessionUuid, - subdomain, - privateHost: host, - }); + const token = + await layers.services.auth.createPrivateAssetToken({ + appUid: privateApp!.uid, + userUid: identity.userUid, + sessionUuid: identity.sessionUuid, + subdomain, + privateHost: host, + }); res.cookie( layers.services.auth.getPrivateAssetCookieName(), token, @@ -390,13 +391,15 @@ export const createPuterSiteMiddleware = ( associatedApp?.uid ) { const token = - layers.services.auth.createPublicHostedActorToken({ - appUid: associatedApp.uid, - userUid: identity.userUid, - sessionUuid: identity.sessionUuid, - subdomain, - host, - }); + await layers.services.auth.createPublicHostedActorToken( + { + appUid: associatedApp.uid, + userUid: identity.userUid, + sessionUuid: identity.sessionUuid, + subdomain, + host, + }, + ); res.cookie( layers.services.auth.getPublicHostedActorCookieName(), token, diff --git a/src/backend/drivers/workers/WorkerDriver.ts b/src/backend/drivers/workers/WorkerDriver.ts index e40cba134..ede3a5177 100644 --- a/src/backend/drivers/workers/WorkerDriver.ts +++ b/src/backend/drivers/workers/WorkerDriver.ts @@ -161,10 +161,13 @@ export class WorkerDriver extends PuterDriver { ); } appOwnerId = actor.app?.id; - authorization = this.services.auth.getUserAppToken(actor, appId); + authorization = await this.services.auth.getUserAppToken( + actor, + appId, + ); } if (!authorization && actor.app?.uid) { - authorization = this.services.auth.getUserAppToken( + authorization = await this.services.auth.getUserAppToken( actor, actor.app.uid, ); @@ -600,7 +603,7 @@ export class WorkerDriver extends PuterDriver { // App-scoped: get the app's uid, then mint an app-under-user token const app = await this.stores.app.getById(appOwnerId); if (!app) continue; // app gone - authorization = this.services.auth.getUserAppToken( + authorization = await this.services.auth.getUserAppToken( ownerActor, app.uid, ); diff --git a/src/backend/services/auth/AuthService.test.ts b/src/backend/services/auth/AuthService.test.ts index 2930afd46..9a2137dcb 100644 --- a/src/backend/services/auth/AuthService.test.ts +++ b/src/backend/services/auth/AuthService.test.ts @@ -312,12 +312,12 @@ describe('AuthService (integration)', () => { describe('getUserAppToken', () => { it('throws 403 when actor has no user', async () => { - expect(() => + await expect( authService.getUserAppToken( { user: undefined } as unknown as Actor, 'app-foo', ), - ).toThrow(/Actor must be a user/); + ).rejects.toThrow(/Actor must be a user/); }); it('signs an app-under-user JWT carrying user_uid + app_uid', async () => { @@ -326,7 +326,7 @@ describe('AuthService (integration)', () => { user: { id: user.id, uuid: user.uuid, username: user.username }, } as Actor; const appUid = `app-${uuidv4()}`; - const token = authService.getUserAppToken(actor, appUid); + const token = await authService.getUserAppToken(actor, appUid); const decoded = server.services.token.verify('auth', token) as { type: string; user_uid: string; @@ -337,19 +337,25 @@ describe('AuthService (integration)', () => { expect(decoded.app_uid).toBe(appUid); }); - it('includes the session claim when actor.session.uid is set', async () => { + it('binds the JWT to a kind="app" session row and reuses it on repeat calls', async () => { const user = await makeUser(); - const sessionUuid = uuidv4(); const actor = { user: { id: user.id, uuid: user.uuid, username: user.username }, - session: { uid: sessionUuid }, } as Actor; const appUid = `app-${uuidv4()}`; - const token = authService.getUserAppToken(actor, appUid); - const decoded = server.services.token.verify('auth', token) as { - session: string; - }; - expect(decoded.session).toBe(sessionUuid); + const first = await authService.getUserAppToken(actor, appUid); + const second = await authService.getUserAppToken(actor, appUid); + const decodedFirst = server.services.token.verify( + 'auth', + first, + ) as { session_uid: string }; + const decodedSecond = server.services.token.verify( + 'auth', + second, + ) as { session_uid: string }; + // Idempotent per (user_id, app_uid) — both tokens reference the + // same app session row. + expect(decodedFirst.session_uid).toBe(decodedSecond.session_uid); }); }); @@ -480,7 +486,7 @@ describe('AuthService (integration)', () => { const { session } = await authService.createSessionToken(user, {}); const sessionUuid = (session as { uuid: string }).uuid; const appUid = `app-${uuidv4()}`; - const token = authService.createPrivateAssetToken({ + const token = await authService.createPrivateAssetToken({ appUid, userUid: user.uuid, sessionUuid, @@ -493,14 +499,18 @@ describe('AuthService (integration)', () => { expect(decoded.userUid).toBe(user.uuid); expect(decoded.appUid).toBe(appUid); expect(decoded.subdomain).toBe('priv'); - expect(decoded.sessionUuid).toBe(sessionUuid); + // v2 cookies carry the *asset* session row's uuid, not the + // web session's. The asset row is parented to the web + // session so logout cascade still invalidates the cookie. + expect(typeof decoded.sessionUuid).toBe('string'); + expect(decoded.sessionUuid).not.toBe(sessionUuid); }); it('verifyPrivateAssetToken throws 401 when expected app_uid mismatches', async () => { const user = await makeUser(); const appA = `app-${uuidv4()}`; const appB = `app-${uuidv4()}`; - const token = authService.createPrivateAssetToken({ + const token = await authService.createPrivateAssetToken({ appUid: appA, userUid: user.uuid, }); @@ -515,7 +525,7 @@ describe('AuthService (integration)', () => { const user = await makeUser(); const { session } = await authService.createSessionToken(user, {}); const sessionUuid = (session as { uuid: string }).uuid; - const token = authService.createPrivateAssetToken({ + const token = await authService.createPrivateAssetToken({ appUid: `app-${uuidv4()}`, userUid: user.uuid, sessionUuid, @@ -529,7 +539,7 @@ describe('AuthService (integration)', () => { it('public hosted-actor token round-trips and enforces expectations', async () => { const user = await makeUser(); const appUid = `app-${uuidv4()}`; - const token = authService.createPublicHostedActorToken({ + const token = await authService.createPublicHostedActorToken({ appUid, userUid: user.uuid, host: 'host.example', @@ -543,9 +553,9 @@ describe('AuthService (integration)', () => { expect(decoded.host).toBe('host.example'); }); - it('verifyPublicHostedActorToken rejects a private-kind token (kind mismatch)', () => { + it('verifyPublicHostedActorToken rejects a private-kind token (kind mismatch)', async () => { const user = { uuid: uuidv4() }; - const privateToken = authService.createPrivateAssetToken({ + const privateToken = await authService.createPrivateAssetToken({ appUid: `app-${uuidv4()}`, userUid: user.uuid, }); diff --git a/src/backend/services/auth/AuthService.ts b/src/backend/services/auth/AuthService.ts index 0b859b1df..2408f48e0 100644 --- a/src/backend/services/auth/AuthService.ts +++ b/src/backend/services/auth/AuthService.ts @@ -20,11 +20,15 @@ import { v4 as uuidv4, v5 as uuidv5 } from 'uuid'; import type { Actor } from '../../core/actor'; import { HttpError } from '../../core/http/HttpError.js'; +import { + ASSET_WINDOW_SECONDS, + WEB_WINDOW_SECONDS, +} from '../../stores/session/SessionStore.js'; import type { UserRow } from '../../stores/user/UserStore'; import type { LayerInstances } from '../../types'; +import { sessionCookieFlags } from '../../util/cookieFlags.js'; import type { puterServices } from '../index'; import { PuterService } from '../types'; -import { sessionCookieFlags } from '../../util/cookieFlags.js'; import type { AccessTokenPayload, AnyTokenPayload, @@ -35,6 +39,8 @@ import type { const APP_ORIGIN_UUID_NAMESPACE = '33de3768-8ee0-43e9-9e73-db192b97a5d8'; +const nowSeconds = (): number => Math.floor(Date.now() / 1000); + /** * Authentication service. * @@ -120,51 +126,121 @@ export class AuthService extends PuterService { token: string; gui_token: string; }> { + const auth_id = this.#authIdFor(user); const session = await this.stores.session.create(user.id, { meta, kind: 'web', last_ip: (meta.ip as string | undefined) ?? null, last_user_agent: (meta.user_agent as string | undefined) ?? null, + expires_at: nowSeconds() + WEB_WINDOW_SECONDS, + auth_id, }); - const token = this.services.token.sign('auth', { - type: 'session', - version: '0.0.0', - uuid: session.uuid, - user_uid: user.uuid, - }); - - const gui_token = this.services.token.sign('auth', { - type: 'gui', - version: '0.0.0', - uuid: session.uuid, - user_uid: user.uuid, - }); + const token = this.#signSessionTypeToken( + 'session', + user, + session.uuid, + auth_id, + ); + const gui_token = this.#signSessionTypeToken( + 'gui', + user, + session.uuid, + auth_id, + ); return { session, token, gui_token }; } /** Sign a GUI token for an existing session. */ createGuiToken(user: UserRow, sessionUuid: string): string { - return this.services.token.sign('auth', { - type: 'gui', - version: '0.0.0', - uuid: sessionUuid, - user_uid: user.uuid, - }); + return this.#signSessionTypeToken( + 'gui', + user, + sessionUuid, + this.#authIdFor(user), + ); } /** Sign a session token for an existing session (upgrade from GUI token). */ createSessionTokenForSession(user: UserRow, sessionUuid: string): string { + return this.#signSessionTypeToken( + 'session', + user, + sessionUuid, + this.#authIdFor(user), + ); + } + + /** Shared signer for session/gui tokens — keeps the v2 claim shape consistent. */ + #signSessionTypeToken( + type: 'session' | 'gui', + user: UserRow, + sessionUuid: string, + authId: string, + ): string { return this.services.token.sign('auth', { - type: 'session', - version: '0.0.0', + type, + version: '2', + // `uuid` retained alongside `session_uid` so any legacy reader + // (e.g. middleware that hasn't been updated to v2 claims yet) + // still finds the session id where it expects. uuid: sessionUuid, + session_uid: sessionUuid, user_uid: user.uuid, + auth_id: authId, }); } - /** Remove the session referenced by a session/GUI JWT. */ + /** + * 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. + */ + #authIdFor(user: UserRow): string { + return user.uuid; + } + + /** + * Convert a jsonwebtoken-style `expiresIn` (seconds, or `'1h'`/`'30d'`) + * into an absolute unix-seconds timestamp for the session row. Returns + * `null` when no expiry is requested (caller passed `undefined`). + * Mirrors `jsonwebtoken`'s allowed unit suffixes (s/m/h/d/w/y). + */ + #hardExpiryFromExpiresIn( + expiresIn: string | number | undefined, + ): number | null { + if (expiresIn === undefined) return null; + const now = nowSeconds(); + if (typeof expiresIn === 'number') return now + Math.floor(expiresIn); + const match = /^(\d+)\s*([smhdwy])?$/.exec(expiresIn.trim()); + if (!match) return null; + const value = parseInt(match[1], 10); + const unit = match[2] ?? 's'; + const multiplier: Record = { + s: 1, + m: 60, + h: 60 * 60, + d: 24 * 60 * 60, + w: 7 * 24 * 60 * 60, + y: 365 * 24 * 60 * 60, + }; + const seconds = value * (multiplier[unit] ?? 1); + 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 { @@ -176,9 +252,11 @@ export class AuthService extends PuterService { return; } if (decoded.type !== 'session' && decoded.type !== 'gui') return; - await this.stores.session.removeByUuid( - (decoded as SessionTokenPayload).uuid, - ); + const sessionPayload = decoded as SessionTokenPayload; + const sessionUuid = + (sessionPayload.session_uid as string | undefined) ?? + sessionPayload.uuid; + await this.stores.session.revokeCascade(sessionUuid); } /** List all sessions for an actor's user. */ @@ -203,9 +281,13 @@ export class AuthService extends PuterService { }); } - /** Revoke a specific session by uuid. */ + /** + * Revoke a session by uuid, cascading to any rows whose + * `parent_session_id` points at it. Used by the manage-sessions UI + * and by `removeSessionByToken` — semantics are identical. + */ async revokeSession(uuid: string): Promise { - await this.stores.session.removeByUuid(uuid); + await this.stores.session.revokeCascade(uuid); } // ── App / origin resolution ───────────────────────────────────── @@ -441,20 +523,35 @@ export class AuthService extends PuterService { } /** - * Sign an app-under-user token for the given app UID. - * Requires a user actor in the provided actor. + * 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). */ - getUserAppToken(actor: Actor, appUid: string): string { + async getUserAppToken(actor: Actor, appUid: string): Promise { if (!actor.user) throw new HttpError(403, 'Actor must be a user', { legacyCode: 'forbidden', }); + + // Request-context (IP / UA) isn't available on the Actor shape — + // the app row's `last_ip` / `last_user_agent` start NULL and get + // populated later via `SessionStore.touch` on the first verified + // request that carries those headers. + const appSession = await this.stores.session.getOrCreateApp( + actor.user.id, + appUid, + { auth_id: this.#authIdFor(actor.user as UserRow) }, + ); + return this.services.token.sign('auth', { type: 'app-under-user', - version: '0.0.0', + version: '2', user_uid: actor.user.uuid, app_uid: appUid, - ...(actor.session ? { session: actor.session.uid } : {}), + session_uid: appSession?.uuid, + auth_id: this.#authIdFor(actor.user as UserRow), }); } @@ -498,42 +595,84 @@ export class AuthService extends PuterService { return this.#hostedAssetCookieOptions(opts.requestHostname); } - createPrivateAssetToken(claims: { + async createPrivateAssetToken(claims: { appUid: string; userUid: string; sessionUuid?: string; subdomain?: string; privateHost?: string; - }): string { + }): Promise { + const assetSessionUuid = await this.#mintAssetSessionUuid( + claims.sessionUuid, + ); return this.services.token.sign('hosted-asset', { kind: 'private', - version: '0.0.0', + version: '2', user_uid: claims.userUid, app_uid: claims.appUid, - ...(claims.sessionUuid ? { session_uuid: claims.sessionUuid } : {}), + ...(assetSessionUuid + ? { session_uuid: assetSessionUuid } + : claims.sessionUuid + ? { session_uuid: claims.sessionUuid } + : {}), ...(claims.subdomain ? { subdomain: claims.subdomain } : {}), ...(claims.privateHost ? { host: claims.privateHost } : {}), }); } - createPublicHostedActorToken(claims: { + async createPublicHostedActorToken(claims: { appUid: string; userUid: string; sessionUuid?: string; subdomain?: string; host?: string; - }): string { + }): Promise { + const assetSessionUuid = await this.#mintAssetSessionUuid( + claims.sessionUuid, + ); return this.services.token.sign('hosted-asset', { kind: 'public', - version: '0.0.0', + version: '2', user_uid: claims.userUid, app_uid: claims.appUid, - ...(claims.sessionUuid ? { session_uuid: claims.sessionUuid } : {}), + ...(assetSessionUuid + ? { session_uuid: assetSessionUuid } + : claims.sessionUuid + ? { session_uuid: claims.sessionUuid } + : {}), ...(claims.subdomain ? { subdomain: claims.subdomain } : {}), ...(claims.host ? { host: claims.host } : {}), }); } + /** + * Materialize the `kind='asset'` session row that the cookie's + * `session_uuid` claim points at. Parented to the web session so a + * logout cascade kills every asset cookie minted under it. Returns + * `null` when the caller didn't supply a web session — the cookie + * still mints, but unparented (matches v1 behavior for access-token- + * minted cookies that aren't tied to an interactive session). + */ + async #mintAssetSessionUuid( + webSessionUuid: string | undefined, + ): Promise { + if (!webSessionUuid) return null; + const webSession = await this.stores.session.getByUuid(webSessionUuid); + if (!webSession) return null; + const row = await this.stores.session.create( + (webSession as SessionRow).user_id as number, + { + kind: 'asset', + parent_session_id: webSessionUuid, + expires_at: nowSeconds() + ASSET_WINDOW_SECONDS, + auth_id: + ((webSession as SessionRow).auth_id as string | null) ?? + null, + }, + ); + return row.uuid; + } + async verifyPrivateAssetToken( token: string, expected: { @@ -725,11 +864,36 @@ export class AuthService extends PuterService { } const tokenUid = uuidv4(); + const auth_id = this.#authIdFor(actor.user as UserRow); + + // Access tokens carry a *hard* row-level expiry — no slide. If the + // caller passed `expiresIn`, the row's `expires_at` matches the JWT + // exp; otherwise both are absent (open-ended access tokens). + const expiresAt = this.#hardExpiryFromExpiresIn(options.expiresIn); + + // App-issued access tokens parent to the issuing app's session row + // so cascading the app authorization kills its scoped tokens. User- + // issued tokens (no actor.app) stay top-level. + const parent_session_id = + actor.app && actor.session ? actor.session.uid : null; + + const tokenSession = await this.stores.session.create( + actor.user.id as number, + { + kind: 'access_token', + parent_session_id, + expires_at: expiresAt, + auth_id, + }, + ); + const jwtPayload: Record = { type: 'access-token', - version: '0.0.0', + version: '2', token_uid: tokenUid, user_uid: actor.user.uuid, + session_uid: tokenSession.uuid, + auth_id, }; if (actor.app) { jwtPayload.app_uid = actor.app.uid; @@ -776,6 +940,7 @@ export class AuthService extends PuterService { let tokenUid: string; let issuerUuidFromJwt: string | undefined; + let sessionUidFromJwt: string | undefined; const isJwt = /^[\w-]+\.[\w-]+\.[\w-]+$/.test(tokenOrUuid.trim()); if (isJwt) { const decoded = this.services.token.verify( @@ -789,6 +954,7 @@ export class AuthService extends PuterService { } tokenUid = decoded.token_uid; issuerUuidFromJwt = decoded.user_uid; + sessionUidFromJwt = decoded.session_uid; } else { tokenUid = tokenOrUuid; } @@ -820,6 +986,15 @@ export class AuthService extends PuterService { [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); + } } // ── Internals ─────────────────────────────────────────────────── @@ -836,12 +1011,32 @@ export class AuthService extends PuterService { async #actorFromSessionToken( decoded: SessionTokenPayload, ): Promise { - const session = await this.stores.session.getByUuid(decoded.uuid); - if (!session) return null; - const user = await this.stores.user.getByUuid(decoded.user_uid); if (!user) return null; + // v2 tokens prefer `session_uid`; v1 only carries `uuid`. Both + // store the web-session uuid. + const sessionUuid = decoded.session_uid ?? decoded.uuid; + + let session: SessionRow | null = sessionUuid + ? ((await this.stores.session.getByUuid( + sessionUuid, + )) as SessionRow | null) + : null; + + // Legacy v1 tokens whose row never existed (or whose row was + // pre-PUT-1013) get lazy-backfilled so revoke works during the + // migration window. AUTH-4 will still emit `reauth_required` + // for these, but until then the session validates. + if (!session && decoded.legacy) { + session = (await this.stores.session.findOrCreateLegacyWeb({ + userId: user.id, + auth_id: this.#authIdFor(user as UserRow), + })) as SessionRow | null; + } + + if (!session) return null; + this.stores.session .touch({ uuid: session.uuid, userId: user.id }) .catch(() => {}); @@ -852,31 +1047,46 @@ export class AuthService extends PuterService { async #actorFromAppUnderUserToken( decoded: AppUnderUserTokenPayload, ): Promise { - // Best-effort session resolution. v1 enforced session presence - // strictly here, but two factors make that unsafe right now: - // 1. Old v1 tokens still in circulation carry FPE-encrypted - // session UUIDs that won't resolve against the v2 session - // store no matter what. - // 2. Pre-token_auth_failed clients (older puter-js builds, the - // signalling worker, etc.) don't auto-relogin on 401 — they - // just bubble "Unauthorized" — so cascading invalidation - // strands users until they manually log back in. - // Once the legacy-token bleed has stopped and the puter-js - // re-login patch has propagated, re-add the strict - // `if (!session) return null` to restore logout-cascades-to-app - // behavior. The private-asset cookie path still enforces session - // presence; the bound-to-session property is preserved there. - let session: SessionRow | null = null; - if (decoded.session) { - session = await this.stores.session.getByUuid(decoded.session); - } - const user = await this.stores.user.getByUuid(decoded.user_uid); if (!user) return null; const app = await this.stores.app.getByUid(decoded.app_uid); if (!app) return null; + // v2 tokens point at the app's own `kind='app'` session row via + // `session_uid`. v1 tokens (or v2 tokens minted before this + // landed) get lazy-backfilled to the same idempotent row keyed + // on (user_id, app_uid). For decoded.legacy we always backfill; + // for decoded.session_uid we trust the row reference. + let session: SessionRow | null = null; + if (decoded.session_uid) { + session = (await this.stores.session.getByUuid( + decoded.session_uid, + )) as SessionRow | null; + } + if (!session && decoded.legacy) { + session = (await this.stores.session.getOrCreateApp( + user.id, + decoded.app_uid, + { auth_id: this.#authIdFor(user as UserRow) }, + )) as SessionRow | null; + } + + // v2 tokens whose session_uid is missing/revoked are rejected + // outright — the row is the authoritative kill switch. + if (!session && !decoded.legacy) return null; + + // Pre-v2 fallback: v1 tokens that carried `decoded.session` + // (raw web-session uuid). Best-effort — if the row doesn't + // resolve we still proceed so old puter-js builds without the + // re-login patch don't strand users. AUTH-4 owns the migration + // pressure. + if (!session && decoded.session) { + session = (await this.stores.session.getByUuid( + decoded.session, + )) as SessionRow | null; + } + this.stores.session .touch({ uuid: session?.uuid, userId: user.id }) .catch(() => {}); @@ -892,6 +1102,33 @@ export class AuthService extends PuterService { const user = await this.stores.user.getByUuid(decoded.user_uid); if (!user) return null; + // v2 access tokens carry their session row uuid as `session_uid` + // and the row is the kill switch — reject if missing/revoked. + // v1 tokens lazy-backfill keyed on `token_uid` so revoke works + // during the migration window. + let session: SessionRow | null = null; + if (decoded.session_uid) { + session = (await this.stores.session.getByUuid( + decoded.session_uid, + )) as SessionRow | null; + if (!session) return null; + } else if (decoded.legacy) { + session = (await this.stores.session.findOrCreateLegacyAccessToken( + decoded.token_uid, + { + userId: user.id, + auth_id: this.#authIdFor(user as UserRow), + }, + )) as SessionRow | null; + // If backfill fails (DB write contention etc.) we don't + // strand the legacy token — it falls through to the + // permission-table path that v1 used. + } + // Otherwise: v2 token without `session_uid` shouldn't happen + // (the mint path always emits it), but if a malformed token + // reaches here we let it through with no session binding — + // the access_token_permissions table is the v1 contract. + // The authorizer is the identity whose permissions the access token // can exercise — either a plain user or an app-under-user. let authorizer: Actor; @@ -903,6 +1140,12 @@ export class AuthService extends PuterService { authorizer = this.#buildUserActor(user, null); } + if (session) { + this.stores.session + .touch({ uuid: session.uuid, userId: user.id }) + .catch(() => {}); + } + return { user: this.#actorUserFromRow(user), accessToken: { diff --git a/src/backend/services/auth/TokenService.test.ts b/src/backend/services/auth/TokenService.test.ts new file mode 100644 index 000000000..a1ca01d3a --- /dev/null +++ b/src/backend/services/auth/TokenService.test.ts @@ -0,0 +1,237 @@ +/** + * 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 jwt from 'jsonwebtoken'; +import { describe, expect, it } from 'vitest'; +import { TokenService } from './TokenService.js'; + +const V2_SECRET = 'test-v2-secret'; +const V1_SECRET = 'test-v1-secret'; + +function createTokenService( + overrides: { + jwt_secret?: string; + jwt_secret_v2?: string; + allow_v1_tokens?: boolean; + } = {}, +): TokenService { + const config = { + jwt_secret: V1_SECRET, + jwt_secret_v2: V2_SECRET, + allow_v1_tokens: true, + ...overrides, + } as ConstructorParameters[0]; + const [clients, stores, services] = [{}, {}, {}] as [ + ConstructorParameters[1], + ConstructorParameters[2], + ConstructorParameters[3], + ]; + const svc = new TokenService(config, clients, stores, services); + svc.onServerStart(); + return svc; +} + +/** Hand-mint a v1-shaped token (no `kid` header) signed with the legacy secret. */ +function mintV1Token(payload: Record): string { + return jwt.sign(payload, V1_SECRET); +} + +describe('TokenService.onServerStart', () => { + it('refuses to start without jwt_secret_v2', () => { + const config = { + jwt_secret: V1_SECRET, + } as ConstructorParameters[0]; + const [clients, stores, services] = [{}, {}, {}] as [ + ConstructorParameters[1], + ConstructorParameters[2], + ConstructorParameters[3], + ]; + const svc = new TokenService(config, clients, stores, services); + expect(() => svc.onServerStart()).toThrow(/jwt_secret_v2/); + }); +}); + +describe('TokenService.sign', () => { + it('emits v2 tokens with `kid: "v2"` header', () => { + const svc = createTokenService(); + const token = svc.sign('auth', { + type: 'session', + user_uid: 'user-uuid-1', + session_uid: 'session-uuid-1', + auth_id: 'auth-id-1', + }); + const decoded = jwt.decode(token, { complete: true }); + expect(decoded).toMatchObject({ header: { kid: 'v2' } }); + }); + + it('signs with v2 secret (not legacy)', () => { + const svc = createTokenService(); + const token = svc.sign('auth', { + type: 'session', + user_uid: 'user-uuid-1', + }); + // Verifying with v2 secret succeeds… + expect(() => jwt.verify(token, V2_SECRET)).not.toThrow(); + // …and with v1 secret fails. + expect(() => jwt.verify(token, V1_SECRET)).toThrow(); + }); + + it('emits `iat` automatically', () => { + const svc = createTokenService(); + const before = Math.floor(Date.now() / 1000); + const token = svc.sign('auth', { type: 'session' }); + const payload = jwt.verify(token, V2_SECRET) as Record; + expect(typeof payload.iat).toBe('number'); + expect(payload.iat as number).toBeGreaterThanOrEqual(before); + }); + + it('honors caller `expiresIn` for the `exp` claim', () => { + const svc = createTokenService(); + const token = svc.sign( + 'auth', + { type: 'access-token' }, + { expiresIn: '1h' }, + ); + const payload = jwt.verify(token, V2_SECRET) as Record; + expect(typeof payload.exp).toBe('number'); + expect((payload.exp as number) - (payload.iat as number)).toBe(3600); + }); + + it('omits `exp` when caller passes no `expiresIn` (web/app/asset)', () => { + const svc = createTokenService(); + const token = svc.sign('auth', { type: 'session' }); + const payload = jwt.verify(token, V2_SECRET) as Record; + expect(payload.exp).toBeUndefined(); + }); + + it('caller cannot override the `kid` routing discriminant', () => { + const svc = createTokenService(); + const token = svc.sign( + 'auth', + { type: 'session' }, + { keyid: 'v3' } as never, + ); + const decoded = jwt.decode(token, { complete: true }); + expect(decoded).toMatchObject({ header: { kid: 'v2' } }); + }); +}); + +describe('TokenService.verify — v2', () => { + it('round-trips session_uid and auth_id claims through compression', () => { + const svc = createTokenService(); + const sessionUuid = '11111111-1111-1111-1111-111111111111'; + const authId = '22222222-2222-2222-2222-222222222222'; + const userUid = '33333333-3333-3333-3333-333333333333'; + const token = svc.sign('auth', { + type: 'session', + user_uid: userUid, + session_uid: sessionUuid, + auth_id: authId, + }); + const payload = svc.verify>('auth', token); + expect(payload).toMatchObject({ + type: 'session', + user_uid: userUid, + session_uid: sessionUuid, + auth_id: authId, + }); + // v2 tokens never carry the legacy flag. + expect(payload.legacy).toBeUndefined(); + }); + + it('rejects expired v2 tokens', () => { + const svc = createTokenService(); + // expiresIn must be a string or number-of-seconds; negative is fine. + const token = svc.sign( + 'auth', + { type: 'access-token' }, + { expiresIn: -60 }, + ); + expect(() => svc.verify('auth', token)).toThrow(); + }); + + it('tolerates 30s of clock skew on `iat`', () => { + const svc = createTokenService(); + // Manually issue with iat 25s in the future — within tolerance. + const future = Math.floor(Date.now() / 1000) + 25; + const token = jwt.sign({ type: 'session', iat: future }, V2_SECRET, { + keyid: 'v2', + noTimestamp: true, + }); + expect(() => svc.verify('auth', token)).not.toThrow(); + }); +}); + +describe('TokenService.verify — v1 fallback', () => { + it('verifies a v1-shaped token and tags result with legacy: true', () => { + const svc = createTokenService(); + // v1 stored `session` (short `s`) on app-under-user. Compress manually. + const sessionUuidShort = Buffer.from( + '11111111111111111111111111111111', + 'hex', + ).toString('base64'); + const token = mintV1Token({ + t: 'au', + v: '0.0.0', + uu: Buffer.from( + '33333333333333333333333333333333', + 'hex', + ).toString('base64'), + au: Buffer.from( + '44444444444444444444444444444444', + 'hex', + ).toString('base64'), + s: sessionUuidShort, + }); + const payload = svc.verify>('auth', token); + expect(payload).toMatchObject({ + type: 'app-under-user', + legacy: true, + }); + expect(payload.session).toBe( + '11111111-1111-1111-1111-111111111111', + ); + }); + + it('rejects v1 tokens when allow_v1_tokens=false', () => { + const svc = createTokenService({ allow_v1_tokens: false }); + const token = mintV1Token({ t: 's', uu: 'whatever' }); + expect(() => svc.verify('auth', token)).toThrow(/v1 tokens/); + }); + + it('rejects a v1 token signed with the wrong secret', () => { + const svc = createTokenService(); + const token = jwt.sign({ t: 's' }, 'not-the-legacy-secret'); + expect(() => svc.verify('auth', token)).toThrow(); + }); + + it('falls back to v1 verify when header `kid` is missing', () => { + const svc = createTokenService(); + const token = jwt.sign({ t: 's' }, V1_SECRET); + const payload = svc.verify>('auth', token); + expect(payload).toMatchObject({ type: 'session', legacy: true }); + }); + + it('falls back to v1 verify when header `kid` is an unknown value', () => { + const svc = createTokenService(); + const token = jwt.sign({ t: 's' }, V1_SECRET, { keyid: 'v99' }); + const payload = svc.verify>('auth', token); + expect(payload).toMatchObject({ type: 'session', legacy: true }); + }); +}); diff --git a/src/backend/services/auth/TokenService.ts b/src/backend/services/auth/TokenService.ts index 693017728..1815bf091 100644 --- a/src/backend/services/auth/TokenService.ts +++ b/src/backend/services/auth/TokenService.ts @@ -20,6 +20,11 @@ import jwt, { type SignOptions } from 'jsonwebtoken'; import { PuterService } from '../types'; +// Clock-skew tolerance for `iat` / `exp` checks. 30s matches the +// design-doc allowance and absorbs ordinary NTP drift between nodes +// without papering over a genuinely-expired token. +const CLOCK_TOLERANCE_SECONDS = 30; + // ── Compression tables ────────────────────────────────────────────── // // Token payloads are compressed on the wire: full field names become @@ -109,6 +114,7 @@ const uuidCompression = (prefix?: string) => ({ const AUTH_COMPRESSION = def({ uuid: { short: 'u', ...uuidCompression() }, + // v1 per-type field on app-under-user. v2 uses `session_uid` instead. session: { short: 's', ...uuidCompression() }, version: 'v', type: { @@ -121,6 +127,10 @@ const AUTH_COMPRESSION = def({ }, user_uid: { short: 'uu', ...uuidCompression() }, 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). + auth_id: { short: 'ai', ...uuidCompression() }, }); // `hosted-asset` scope signs the sticky cookies set after a visitor @@ -154,25 +164,38 @@ const COMPRESSION: Record = { /** * Signs and verifies JWTs. * - * Kept intentionally small — no session lifecycle, no revocation list, no - * cookie shaping. That logic lives in `AuthService` (actor resolution) and - * will live in a future session controller (mint/rotate/revoke). + * 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 { - #secret: string = ''; + #secretV2: string = ''; + #secretLegacy: string = ''; + #allowV1Tokens = true; override onServerStart(): void { - const secret = this.config.jwt_secret; - if (!secret) { - throw new Error('TokenService requires `jwt_secret` in config'); + const secretV2 = this.config.jwt_secret_v2; + if (!secretV2) { + throw new Error( + 'TokenService requires `jwt_secret_v2` in config — v2 signing cannot proceed without it', + ); } - this.#secret = secret; + this.#secretV2 = secretV2; + // Legacy secret is optional in fresh installs (no v1 tokens to verify), + // but every existing deployment carries one. Don't fail boot if it's + // missing — instead, refuse to verify v1 tokens later. + this.#secretLegacy = this.config.jwt_secret ?? ''; + this.#allowV1Tokens = this.config.allow_v1_tokens !== false; } /** - * Sign a payload for the given scope. The compression table for `scope` - * is applied to the payload before signing, so what reaches the wire is - * the short-key form. + * Sign a payload for the given scope. Always emits v2 — the JWT header + * carries `kid: 'v2'` so the verifier can route to the right secret. + * Compression for `scope` is applied to the payload before signing. */ sign( scope: string, @@ -181,21 +204,60 @@ export class TokenService extends PuterService { ): string { const context = COMPRESSION[scope]; const compressed = this.#compressPayload(context, payload); - return jwt.sign(compressed, this.#secret, options ?? {}); + return jwt.sign(compressed, this.#secretV2, { + ...(options ?? {}), + // `keyid` is the SignOption name; it surfaces in the JWT header + // as `kid`. Caller-supplied options can't override this — `kid` + // is the routing discriminant. + keyid: 'v2', + }); } /** - * Verify and decompress. Throws on invalid signature / expired / malformed - * (propagating `jsonwebtoken`'s errors). Callers in the auth probe should - * catch and treat as "no actor". + * Verify and decompress. Routes by header `kid`: + * - `kid === 'v2'` → verify against the v2 secret. + * - else → verify against the legacy secret and tag the result with + * `legacy: true`. Rejected outright if `allow_v1_tokens=false`. + * + * Throws on invalid signature / expired / malformed (propagates + * `jsonwebtoken`'s errors). Callers in the auth probe should catch and + * treat as "no actor". */ verify>(scope: string, token: string): T { const context = COMPRESSION[scope]; - const payload = jwt.verify(token, this.#secret) as Record< - string, - unknown - >; - return this.#decompressPayload(context, payload) as unknown as T; + const decoded = jwt.decode(token, { complete: true }); + const kid = + typeof decoded === 'object' && decoded + ? (decoded.header?.kid ?? null) + : null; + + if (kid === 'v2') { + const payload = jwt.verify(token, this.#secretV2, { + clockTolerance: CLOCK_TOLERANCE_SECONDS, + }) as Record; + return this.#decompressPayload(context, payload) as unknown as T; + } + + // Legacy / unsigned-kid path. + if (!this.#allowV1Tokens) { + throw new Error('v1 tokens are disabled'); + } + if (!this.#secretLegacy) { + throw new Error( + 'v1 token presented but no legacy `jwt_secret` configured', + ); + } + const payload = jwt.verify(token, this.#secretLegacy, { + clockTolerance: CLOCK_TOLERANCE_SECONDS, + }) as Record; + const decompressed = this.#decompressPayload( + context, + payload, + ) as Record; + // `legacy: true` lets AuthService run the v1-token migration paths + // (lazy session backfill, re-auth signal for web sessions). + decompressed.legacy = true; + return decompressed as unknown as T; } // ── Internals ─────────────────────────────────────────────────── diff --git a/src/backend/services/auth/types.ts b/src/backend/services/auth/types.ts index 5b3523641..52fbf4055 100644 --- a/src/backend/services/auth/types.ts +++ b/src/backend/services/auth/types.ts @@ -23,10 +23,23 @@ // ── Token payload shapes (after `TokenService.verify` decompression) ── -/** Base fields every non-legacy auth token carries. */ +/** + * Base fields every auth token carries. + * + * `session_uid` and `auth_id` are present on v2 tokens (`kid: 'v2'`) and + * absent on v1. `legacy: true` is set by `TokenService.verify` when a + * token verified through the legacy-secret fallback; AuthService keys + * the v1→v2 backfill and re-auth flows off this flag. + */ interface TokenPayloadBase { version?: string; 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). */ + auth_id?: string; + /** Set by TokenService when the token verified via the legacy secret. */ + legacy?: boolean; } export type TokenType = 'session' | 'gui' | 'app-under-user' | 'access-token'; @@ -40,7 +53,10 @@ export type TokenType = 'session' | 'gui' | 'app-under-user' | 'access-token'; */ export interface SessionTokenPayload extends TokenPayloadBase { type: 'session' | 'gui'; - /** Session uuid (plain, not FPE-encrypted for session tokens). */ + /** + * Session uuid. v1 tokens carry this as the only session reference; + * v2 tokens carry the same value in both `uuid` and `session_uid`. + */ uuid: string; /** User uuid (plain). */ user_uid: string; @@ -49,16 +65,16 @@ export interface SessionTokenPayload extends TokenPayloadBase { /** * App-under-user token — issued to an app acting on behalf of a user. * - * `session`, when present, is the raw session uuid (no encryption). The token - * is bound to that session, so user logout invalidates it. App tokens minted - * outside an interactive session context (e.g., from an access-token actor) - * omit the field entirely. + * v1: `session` carries the web session uuid the app token was minted + * under. + * v2: `session_uid` carries the app's *own* session row uuid (kind='app'). + * The (web session, app) parenting is recorded on the row, not the JWT. */ export interface AppUnderUserTokenPayload extends TokenPayloadBase { type: 'app-under-user'; user_uid: string; app_uid: string; - /** Raw session uuid (optional — some app tokens have no session). */ + /** v1: raw web-session uuid (optional). v2: unused. */ session?: string; } @@ -87,6 +103,16 @@ 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; + auth_id?: string | null; } export {}; diff --git a/src/backend/stores/session/SessionStore.js b/src/backend/stores/session/SessionStore.js index 90fafd873..39d6b6705 100644 --- a/src/backend/stores/session/SessionStore.js +++ b/src/backend/stores/session/SessionStore.js @@ -40,29 +40,48 @@ const TOUCH_THROTTLE_MS = 60 * 1000; // brief burst of redundant UPDATEs. const TOUCH_THROTTLE_MAX_ENTRIES = 10000; +// Per-kind sliding-expiry windows. The `touch` path bumps `expires_at` +// to `now + window` for the session's kind on activity, so an active +// 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 ASSET_WINDOW_SECONDS = 7 * 24 * 60 * 60; // 7 days + const sqlTimestamp = (ms) => new Date(ms).toISOString().slice(0, 19).replace('T', ' '); +const nowSeconds = () => Math.floor(Date.now() / 1000); + +/** True when a row's `expires_at` has passed. NULL == no row-level expiry. */ +const isExpired = (row, now = nowSeconds()) => + row?.expires_at != null && row.expires_at <= now; + export class SessionStore extends PuterStore { #lastSessionTouchMs = new Map(); #lastUserTouchMs = new Map(); /** * Look up an active session by its uuid. Returns `null` if not - * found or soft-revoked. + * found, soft-revoked, or past its `expires_at`. The cached row is + * gated by both `revoked_at` and `expires_at` so a stale-but- + * expired row in cache doesn't grant access. */ async getByUuid(uuid) { if (!uuid) return null; + const now = nowSeconds(); const cached = await this.#readCache(uuid); if (cached) { if (cached.revoked_at != null) return null; + if (isExpired(cached, now)) return null; return cached; } const rows = await this.clients.db.read( - 'SELECT * FROM `sessions` WHERE `uuid` = ? AND `revoked_at` IS NULL LIMIT 1', - [uuid], + 'SELECT * FROM `sessions` WHERE `uuid` = ? AND `revoked_at` IS NULL AND (`expires_at` IS NULL OR `expires_at` > ?) LIMIT 1', + [uuid, now], ); const normalized = this.#normalizeRow(rows[0]); if (!normalized) return null; @@ -96,10 +115,31 @@ export class SessionStore extends PuterStore { * @param opts.last_ip - Request IP at creation. * @param opts.last_user_agent - Request User-Agent at creation. * @param opts.expires_at - Row-level expiry (unix seconds). NULL means - * JWT `exp` is the sole truth. AUTH-4 slides this forward on activity. + * no row-level expiry (used for `access_token` rows whose JWT `exp` + * is the truth). Sliding kinds (web/app/asset) get this populated by + * the caller per the lifetime table; `touch()` then slides it. + * @param opts.app_uid - App UID this row authorizes. Only set for + * `kind='app'`; participates in the (user_id, app_uid) idempotency + * index. + * @param opts.legacy_token_uid - v1 token_uid this row backfills. + * Only set for `created_via='legacy_backfill'`. + * @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. * @returns The created session row. */ - async create( + async create(userId, opts = {}) { + return this.#insertSession(userId, opts, { ignoreConflict: false }); + } + + /** + * Shared INSERT implementation for `create()` and the idempotent + * `getOrCreate*` paths. `ignoreConflict: true` switches to engine- + * specific INSERT-IGNORE so partial-unique-index collisions silently + * no-op rather than throw — the idempotent callers handle the "row + * already existed" path via a re-SELECT. + */ + async #insertSession( userId, { meta = {}, @@ -109,16 +149,28 @@ export class SessionStore extends PuterStore { last_ip = null, last_user_agent = null, expires_at = null, + app_uid = null, + legacy_token_uid = null, + created_via = null, + auth_id = null, } = {}, + { ignoreConflict = false } = {}, ) { const uuid = uuidv4(); - const now = Math.floor(Date.now() / 1000); + const now = nowSeconds(); meta.created = new Date().toISOString(); meta.created_unix = now; + const insertVerb = ignoreConflict + ? this.clients.db.case({ + sqlite: 'INSERT OR IGNORE INTO', + otherwise: 'INSERT IGNORE INTO', + }) + : 'INSERT INTO'; + await this.clients.db.write( - 'INSERT INTO `sessions` (`uuid`, `user_id`, `meta`, `last_activity`, `created_at`, `kind`, `label`, `parent_session_id`, `last_ip`, `last_user_agent`, `expires_at`) 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\`, \`created_via\`, \`auth_id\`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ uuid, userId, @@ -131,10 +183,14 @@ export class SessionStore extends PuterStore { last_ip, last_user_agent, expires_at, + app_uid, + legacy_token_uid, + created_via, + auth_id, ], ); - return { + const row = { uuid, user_id: userId, meta, @@ -147,22 +203,51 @@ export class SessionStore extends PuterStore { last_user_agent, revoked_at: null, expires_at, + app_uid, + legacy_token_uid, + created_via, + auth_id, }; + + // Warm the uuid cache so the immediately-following verify hits + // Redis instead of the DB. Note: in the ignoreConflict path this + // may warm a row that wasn't actually inserted (concurrent racer + // won). That's harmless — the idempotent caller re-SELECTs and + // overwrites the cache with the winning row. + if (!ignoreConflict) { + this.#writeCache(row).catch(() => {}); + } + + return row; } /** * Soft-revoke a session by uuid. The row remains in the table * with `revoked_at` set; subsequent `getByUuid` calls treat it - * as not found. Invalidates cache on this node + peers. + * as not found. Invalidates the uuid cache and every composite + * cache key that pointed at this row (app / legacy-token), so a + * subsequent re-auth doesn't get a stale "already authorized" + * mapping. */ async removeByUuid(uuid) { - const now = Math.floor(Date.now() / 1000); + if (!uuid) return; + + // SELECT first so we know which composite cache keys point at + // this row. A single UPDATE ... RETURNING would be cleaner but + // isn't portable between sqlite/mysql. + const rows = await this.clients.db.read( + 'SELECT `uuid`, `user_id`, `kind`, `app_uid`, `legacy_token_uid` FROM `sessions` WHERE `uuid` = ? AND `revoked_at` IS NULL LIMIT 1', + [uuid], + ); + if (rows.length === 0) return; + + const now = nowSeconds(); await this.clients.db.write( 'UPDATE `sessions` SET `revoked_at` = ? WHERE `uuid` = ? AND `revoked_at` IS NULL', [now, uuid], ); await this.publishCacheKeys({ - keys: [this.#cacheKey(uuid)], + keys: this.#allCacheKeysForRow(rows[0]), broadcast: true, }); } @@ -170,37 +255,210 @@ export class SessionStore extends PuterStore { /** * Soft-revoke a root session and every derived session that * points back to it via `parent_session_id`. Broadcasts cache - * invalidation for each affected row. + * invalidation for each affected row's uuid + composite keys. */ async revokeCascade(rootUuid) { if (!rootUuid) return; - // Collect affected uuids first so we can broadcast cache - // invalidation for each row. A single UPDATE ... RETURNING - // would be cleaner but isn't portable between sqlite/mysql. + // Read each affected row's identity columns up-front — every + // composite cache mapping (app, legacy-token) must be invalidated + // alongside the uuid key, otherwise a follow-up `getOrCreateApp` + // would short-circuit to the freshly-revoked row. const rows = await this.clients.db.read( - 'SELECT `uuid` FROM `sessions` WHERE (`uuid` = ? OR `parent_session_id` = ?) AND `revoked_at` IS NULL', + 'SELECT `uuid`, `user_id`, `kind`, `app_uid`, `legacy_token_uid` FROM `sessions` WHERE (`uuid` = ? OR `parent_session_id` = ?) AND `revoked_at` IS NULL', [rootUuid, rootUuid], ); if (rows.length === 0) return; - const now = Math.floor(Date.now() / 1000); + const now = nowSeconds(); await this.clients.db.write( 'UPDATE `sessions` SET `revoked_at` = ? WHERE (`uuid` = ? OR `parent_session_id` = ?) AND `revoked_at` IS NULL', [now, rootUuid, rootUuid], ); - await this.publishCacheKeys({ - keys: rows.map((r) => this.#cacheKey(r.uuid)), - broadcast: true, - }); + const keys = []; + for (const r of rows) keys.push(...this.#allCacheKeysForRow(r)); + await this.publishCacheKeys({ keys, broadcast: true }); } - /** Update session activity timestamp. */ + /** + * Idempotent "give me the app session for this (user, app)" lookup. + * Returns the existing active app session if one exists, or creates + * a new one. Concurrent callers converge on a single row via the + * partial unique index `idx_sessions_user_app_active`. + * + * Cache flow: + * 1. Try `sessions:v2:app::` (full row). + * 2. If miss, SELECT; on hit, warm both composite + uuid cache. + * 3. If still nothing, INSERT (idempotent under concurrency); the + * losing racer falls through to SELECT and finds the winner's + * row. + * + * @param userId - User row id (numeric). + * @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). + */ + async getOrCreateApp(userId, appUid, opts = {}) { + if (!userId || !appUid) return null; + + const cacheKey = this.#cacheKeyApp(userId, appUid); + const now = nowSeconds(); + + const cached = await this.#readCacheKey(cacheKey); + if (cached && cached.revoked_at == null && !isExpired(cached, now)) { + return cached; + } + + const existing = await this.#selectAppRow(userId, appUid); + if (existing) { + await this.#writeCacheKey(cacheKey, existing); + this.#writeCache(existing).catch(() => {}); + return existing; + } + + // INSERT-or-IGNORE so concurrent racers don't throw on the + // partial unique index; we re-SELECT below to find the row + // that actually won. + const created = await this.#insertSession( + userId, + { + kind: 'app', + app_uid: appUid, + parent_session_id: null, + last_ip: opts.last_ip ?? null, + last_user_agent: opts.last_user_agent ?? null, + expires_at: now + APP_WINDOW_SECONDS, + auth_id: opts.auth_id ?? null, + created_via: opts.created_via ?? null, + meta: opts.meta ?? {}, + }, + { ignoreConflict: true }, + ); + + const winner = await this.#selectAppRow(userId, appUid); + const row = winner ?? created; + await this.#writeCacheKey(cacheKey, row); + this.#writeCache(row).catch(() => {}); + return row; + } + + /** + * Idempotent "give me the lazy-backfill row for this v1 token_uid" + * lookup. Mirrors `getOrCreateApp` but keys on `legacy_token_uid`. + */ + async findOrCreateLegacyAccessToken(tokenUid, opts = {}) { + if (!tokenUid || !opts.userId) return null; + + const cacheKey = this.#cacheKeyLegacyAt(tokenUid); + const now = nowSeconds(); + + const cached = await this.#readCacheKey(cacheKey); + if (cached && cached.revoked_at == null && !isExpired(cached, now)) { + return cached; + } + + const existing = await this.#selectLegacyAccessTokenRow(tokenUid); + if (existing) { + await this.#writeCacheKey(cacheKey, existing); + this.#writeCache(existing).catch(() => {}); + return existing; + } + + const created = await this.#insertSession( + opts.userId, + { + kind: 'access_token', + parent_session_id: opts.parent_session_id ?? null, + last_ip: opts.last_ip ?? null, + last_user_agent: opts.last_user_agent ?? null, + expires_at: opts.expires_at ?? null, + legacy_token_uid: tokenUid, + created_via: 'legacy_backfill', + auth_id: opts.auth_id ?? null, + }, + { ignoreConflict: true }, + ); + + const winner = await this.#selectLegacyAccessTokenRow(tokenUid); + const row = winner ?? created; + await this.#writeCacheKey(cacheKey, row); + this.#writeCache(row).catch(() => {}); + return row; + } + + /** + * Best-effort lazy-backfill row for a v1 web session. The keying tuple + * is `(user_id, last_ip, last_user_agent)` — a UA/IP shift on a roaming + * client produces a fresh row, which is the spec's accepted trade-off. + * No partial unique index here; collisions are tolerated. + */ + async findOrCreateLegacyWeb(opts = {}) { + if (!opts.userId) return null; + + const ip = opts.last_ip ?? null; + const ua = opts.last_user_agent ?? null; + const cacheKey = this.#cacheKeyLegacyWeb(opts.userId, ip, ua); + const now = nowSeconds(); + + const cached = await this.#readCacheKey(cacheKey); + if (cached && cached.revoked_at == null && !isExpired(cached, now)) { + return cached; + } + + const rows = await this.clients.db.read( + "SELECT * FROM `sessions` WHERE `kind` = 'web' AND `user_id` = ? AND `created_via` = 'legacy_backfill' AND IFNULL(`last_ip`, '') = IFNULL(?, '') AND IFNULL(`last_user_agent`, '') = IFNULL(?, '') AND `revoked_at` IS NULL AND (`expires_at` IS NULL OR `expires_at` > ?) ORDER BY `id` ASC LIMIT 1", + [opts.userId, ip, ua, now], + ); + const existing = this.#normalizeRow(rows[0]); + if (existing) { + await this.#writeCacheKey(cacheKey, existing); + this.#writeCache(existing).catch(() => {}); + return existing; + } + + const created = await this.create(opts.userId, { + kind: 'web', + last_ip: ip, + last_user_agent: ua, + expires_at: now + WEB_WINDOW_SECONDS, + created_via: 'legacy_backfill', + auth_id: opts.auth_id ?? null, + }); + await this.#writeCacheKey(cacheKey, created); + // uuid cache already warmed by create() + return created; + } + + /** + * Bump `last_activity` and slide `expires_at` per the row's kind in a + * single UPDATE. Sliding kinds (web/app/asset) get their `expires_at` + * extended to `now + window`; `access_token` (and unknown kinds) keep + * their existing `expires_at` (hard expiry). The `last_activity < ?` + * guard makes the UPDATE idempotent across nodes so concurrent touches + * don't fight. + */ async updateActivity(uuid, lastActivity) { + const webExpires = lastActivity + WEB_WINDOW_SECONDS; + const appExpires = lastActivity + APP_WINDOW_SECONDS; + const assetExpires = lastActivity + ASSET_WINDOW_SECONDS; await this.clients.db.write( - 'UPDATE `sessions` SET `last_activity` = ? WHERE `uuid` = ? AND (`last_activity` IS NULL OR `last_activity` < ?)', - [lastActivity, uuid, lastActivity], + 'UPDATE `sessions` SET `last_activity` = ?, `expires_at` = CASE `kind` ' + + "WHEN 'web' THEN ? " + + "WHEN 'app' THEN ? " + + "WHEN 'asset' THEN ? " + + 'ELSE `expires_at` ' + + 'END ' + + 'WHERE `uuid` = ? AND (`last_activity` IS NULL OR `last_activity` < ?)', + [ + lastActivity, + webExpires, + appExpires, + assetExpires, + uuid, + lastActivity, + ], ); } @@ -272,9 +530,48 @@ export class SessionStore extends PuterStore { return `${CACHE_KEY_PREFIX}:uuid:${uuid}`; } + #cacheKeyApp(userId, appUid) { + return `${CACHE_KEY_PREFIX}:app:${userId}:${appUid}`; + } + + #cacheKeyLegacyAt(tokenUid) { + return `${CACHE_KEY_PREFIX}:legacy-at:${tokenUid}`; + } + + /** + * Cache key for the (legacy-web) backfill lookup. IP and UA are + * percent-encoded into a single key segment so a UA containing `:` + * doesn't fracture the namespace. + */ + #cacheKeyLegacyWeb(userId, ip, ua) { + const tag = encodeURIComponent(`${ip ?? ''}|${ua ?? ''}`); + return `${CACHE_KEY_PREFIX}:legacy-web:${userId}:${tag}`; + } + + /** + * Every cache key currently mapped to a given row. Used by revoke + * paths so a single revocation invalidates every cached view onto + * the same row in lockstep. + */ + #allCacheKeysForRow(row) { + if (!row?.uuid) return []; + const keys = [this.#cacheKey(row.uuid)]; + if (row.kind === 'app' && row.user_id && row.app_uid) { + keys.push(this.#cacheKeyApp(row.user_id, row.app_uid)); + } + if (row.legacy_token_uid) { + keys.push(this.#cacheKeyLegacyAt(row.legacy_token_uid)); + } + return keys; + } + async #readCache(uuid) { + return this.#readCacheKey(this.#cacheKey(uuid)); + } + + async #readCacheKey(key) { try { - const raw = await this.clients.redis.get(this.#cacheKey(uuid)); + const raw = await this.clients.redis.get(key); return raw ? JSON.parse(raw) : null; } catch { return null; @@ -283,10 +580,14 @@ export class SessionStore extends PuterStore { async #writeCache(session) { if (!session?.uuid) return; + await this.#writeCacheKey(this.#cacheKey(session.uuid), session); + } + + async #writeCacheKey(key, value) { try { await this.clients.redis.set( - this.#cacheKey(session.uuid), - JSON.stringify(session), + key, + JSON.stringify(value), 'EX', CACHE_TTL_SECONDS, ); @@ -295,6 +596,30 @@ export class SessionStore extends PuterStore { } } + /** + * Active app session for (userId, appUid). Matches the partial unique + * index `idx_sessions_user_app_active`. Kept private — public callers + * should go through `getOrCreateApp` so cache and idempotency stay in + * sync. + */ + async #selectAppRow(userId, appUid) { + const now = nowSeconds(); + const rows = await this.clients.db.read( + "SELECT * FROM `sessions` WHERE `kind` = 'app' AND `user_id` = ? AND `app_uid` = ? AND `revoked_at` IS NULL AND (`expires_at` IS NULL OR `expires_at` > ?) LIMIT 1", + [userId, appUid, now], + ); + return this.#normalizeRow(rows[0]); + } + + async #selectLegacyAccessTokenRow(tokenUid) { + const now = nowSeconds(); + const rows = await this.clients.db.read( + 'SELECT * FROM `sessions` WHERE `legacy_token_uid` = ? AND `revoked_at` IS NULL AND (`expires_at` IS NULL OR `expires_at` > ?) LIMIT 1', + [tokenUid, now], + ); + return this.#normalizeRow(rows[0]); + } + #normalizeRow(row) { if (!row) return null; // Meta may be stored as JSON string (SQLite) or already parsed diff --git a/src/backend/stores/session/SessionStore.test.ts b/src/backend/stores/session/SessionStore.test.ts index 0dd00562f..a49e24783 100644 --- a/src/backend/stores/session/SessionStore.test.ts +++ b/src/backend/stores/session/SessionStore.test.ts @@ -130,14 +130,27 @@ describe('SessionStore', () => { expect(fetched).toBeNull(); }); - it('returns the row even when expires_at is in the past (AUTH-4 owns expiry)', async () => { + it('returns null when expires_at is in the past', async () => { + // PUT-1014 moved expires_at enforcement into getByUuid so the + // row is the single source of truth — no AUTH-4 re-mint pass + // needed (we run long-lived JWTs in v2). const user = await makeUser(); const past = Math.floor(Date.now() / 1000) - 60; const session = await target.create(user.id, { expires_at: past }); const fetched = await target.getByUuid(session.uuid); + expect(fetched).toBeNull(); + }); + + it('returns the row when expires_at is in the future', async () => { + const user = await makeUser(); + const future = Math.floor(Date.now() / 1000) + 3600; + const session = await target.create(user.id, { + expires_at: future, + }); + const fetched = await target.getByUuid(session.uuid); expect(fetched).toBeTruthy(); expect(fetched.uuid).toBe(session.uuid); - expect(fetched.expires_at).toBe(past); + expect(fetched.expires_at).toBe(future); }); it('returns null when uuid is empty', async () => { @@ -254,4 +267,145 @@ describe('SessionStore', () => { ).resolves.toBeUndefined(); }); }); + + // ── PUT-1014 composite-key lookups ────────────────────────────── + + describe('getOrCreateApp', () => { + it('creates a kind="app" row on first call with the right shape', async () => { + const user = await makeUser(); + const appUid = `app-${uuidv4()}`; + const row = await target.getOrCreateApp(user.id, appUid, { + auth_id: user.uuid, + }); + expect(row).toBeTruthy(); + expect(row.kind).toBe('app'); + 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. + const now = Math.floor(Date.now() / 1000); + expect(row.expires_at).toBeGreaterThan(now); + expect(row.expires_at).toBeLessThanOrEqual( + now + 90 * 24 * 60 * 60 + 5, + ); + }); + + it('is idempotent for the same (user_id, app_uid)', async () => { + const user = await makeUser(); + const appUid = `app-${uuidv4()}`; + const a = await target.getOrCreateApp(user.id, appUid); + const b = await target.getOrCreateApp(user.id, appUid); + expect(a.uuid).toBe(b.uuid); + }); + + it('converges to a single row under concurrent racers', async () => { + const user = await makeUser(); + const appUid = `app-${uuidv4()}`; + const results = await Promise.all( + Array.from({ length: 10 }, () => + target.getOrCreateApp(user.id, appUid), + ), + ); + const uuids = new Set(results.map((r: { uuid: string }) => r.uuid)); + expect(uuids.size).toBe(1); + }); + + it('returns null when called with falsy inputs', async () => { + expect(await target.getOrCreateApp(null, 'app-x')).toBeNull(); + expect(await target.getOrCreateApp(1, null)).toBeNull(); + }); + + it('mints a new row after the previous one was revoked', async () => { + // After revoke, the partial-unique index has no active row for + // (user_id, app_uid), so a fresh INSERT succeeds with a new uuid. + const user = await makeUser(); + const appUid = `app-${uuidv4()}`; + const first = await target.getOrCreateApp(user.id, appUid); + await target.removeByUuid(first.uuid); + const second = await target.getOrCreateApp(user.id, appUid); + expect(second.uuid).not.toBe(first.uuid); + }); + }); + + describe('findOrCreateLegacyAccessToken', () => { + it('creates a kind="access_token" row tagged legacy_backfill', async () => { + const user = await makeUser(); + const tokenUid = uuidv4(); + const row = await target.findOrCreateLegacyAccessToken(tokenUid, { + userId: user.id, + auth_id: user.uuid, + }); + expect(row.kind).toBe('access_token'); + expect(row.legacy_token_uid).toBe(tokenUid); + expect(row.created_via).toBe('legacy_backfill'); + }); + + it('is idempotent for the same legacy_token_uid', async () => { + const user = await makeUser(); + const tokenUid = uuidv4(); + const a = await target.findOrCreateLegacyAccessToken(tokenUid, { + userId: user.id, + }); + const b = await target.findOrCreateLegacyAccessToken(tokenUid, { + userId: user.id, + }); + expect(a.uuid).toBe(b.uuid); + }); + }); + + describe('touch slides expires_at per kind', () => { + it('extends expires_at on web sessions', async () => { + const user = await makeUser(); + const session = await target.create(user.id, { kind: 'web' }); + // Backdate the row so the SQL `last_activity < ?` guard + // fires deterministically on the slide call. Test would + // otherwise be racy against same-second precision. + const ancient = Math.floor(Date.now() / 1000) - 3600; + await server.clients.db.write( + 'UPDATE `sessions` SET `last_activity` = ?, `expires_at` = ? WHERE `uuid` = ?', + [ancient, ancient + 30 * 24 * 60 * 60, session.uuid], + ); + + const now = Math.floor(Date.now() / 1000); + 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). + expect(row.expires_at).toBeGreaterThanOrEqual( + now + 30 * 24 * 60 * 60 - 5, + ); + expect(row.expires_at).toBeLessThanOrEqual( + now + 30 * 24 * 60 * 60 + 5, + ); + }); + + it('does NOT slide expires_at on access_token rows (hard expiry)', async () => { + const user = await makeUser(); + const hard = Math.floor(Date.now() / 1000) + 3600; + const session = await target.create(user.id, { + kind: 'access_token', + expires_at: hard, + }); + await target.updateActivity( + session.uuid, + Math.floor(Date.now() / 1000), + ); + const row = await rawRow(session.uuid); + expect(row.expires_at).toBe(hard); + }); + }); + + describe('revokeCascade invalidates composite caches', () => { + it('a revoked app row is not re-served by getOrCreateApp cache hit', async () => { + // First create primes the app composite cache. Revoke via + // cascade should invalidate it so the next call mints fresh. + const user = await makeUser(); + const appUid = `app-${uuidv4()}`; + const first = await target.getOrCreateApp(user.id, appUid); + await target.revokeCascade(first.uuid); + const second = await target.getOrCreateApp(user.id, appUid); + expect(second.uuid).not.toBe(first.uuid); + }); + }); }); diff --git a/src/backend/types.ts b/src/backend/types.ts index eb4a3f1d0..c854830c0 100644 --- a/src/backend/types.ts +++ b/src/backend/types.ts @@ -478,8 +478,19 @@ interface IConfigOptional { // ── Auth / session ────────────────────────────────────────────── - /** HMAC secret used to sign auth JWTs. */ + /** + * Legacy HMAC secret for v1 JWTs. New tokens are always signed with + * `jwt_secret_v2`; this value is verify-only and accepted as long as + * `allow_v1_tokens` is true (flipped off in ROLLOUT-1 to retire v1). + */ jwt_secret: string; + /** HMAC secret used to sign and verify v2 auth JWTs (`kid: 'v2'`). */ + jwt_secret_v2: string; + /** + * When false, v1 tokens (no `kid` header) are rejected at verify. + * Default true during the v1→v2 migration window. + */ + allow_v1_tokens: boolean; /** HMAC secret for signed file URLs (/file, /writeFile, /sign). */ url_signature_secret: string; /** Name of the session cookie the auth probe reads. */