feat (put-1012 put-1014): new tokens version signature and logic (#3152)

This commit is contained in:
Daniel Salazar
2026-05-25 14:19:23 -07:00
committed by GitHub
parent e66fd2373f
commit 8be115096f
21 changed files with 1445 additions and 165 deletions
+2
View File
@@ -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,
+4
View File
@@ -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,
+4
View File
@@ -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 <<EOF
@@ -78,6 +79,8 @@ cat > puter/config/config.json <<EOF
"private_app_hosting_domain_alt": "dev.puter.localhost",
"jwt_secret": "$JWT_SECRET",
"jwt_secret_v2": "$JWT_SECRET_V2",
"allow_v1_tokens": true,
"url_signature_secret": "$URL_SIGNATURE_SECRET",
"database": {
@@ -131,6 +134,7 @@ Replace `puter.localhost`, `site.puter.localhost`, `host.puter.localhost`, `dev.
Why these knobs:
- `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.
- `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.
- `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.
+5
View File
@@ -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'
+6
View File
@@ -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 <<EOF
@@ -123,6 +127,8 @@ EOF
"private_app_hosting_domain_alt": "dev.$PUTER_DOMAIN",
"jwt_secret": "$JWT_SECRET",
"jwt_secret_v2": "$JWT_SECRET_V2",
"allow_v1_tokens": true,
"url_signature_secret": "$URL_SIGNATURE_SECRET",
"database": {
@@ -80,6 +80,7 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [
[45, ['0049_music-player-pdf-player-updates.sql']],
[46, ['0050_add_preamble_version.sql']],
[47, ['0051_sessions_v2.sql']],
[48, ['0052_sessions_v2_lookups.sql']],
];
export class SqliteDatabaseClient extends AbstractDatabaseClient {
@@ -0,0 +1,130 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- 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;
@@ -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 <https://www.gnu.org/licenses/>.
-- 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`);
@@ -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,
);
@@ -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);
@@ -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',
+17 -14
View File
@@ -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,
+6 -3
View File
@@ -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,
);
+28 -18
View File
@@ -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,
});
+307 -64
View File
@@ -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<string, number> = {
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<void> {
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<void> {
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<string> {
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<string> {
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<string> {
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<string | null> {
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<string, unknown> = {
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<AccessTokenPayload>(
@@ -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<Actor | null> {
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<Actor | null> {
// 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: {
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<typeof TokenService>[0];
const [clients, stores, services] = [{}, {}, {}] as [
ConstructorParameters<typeof TokenService>[1],
ConstructorParameters<typeof TokenService>[2],
ConstructorParameters<typeof TokenService>[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, unknown>): 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<typeof TokenService>[0];
const [clients, stores, services] = [{}, {}, {}] as [
ConstructorParameters<typeof TokenService>[1],
ConstructorParameters<typeof TokenService>[2],
ConstructorParameters<typeof TokenService>[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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<Record<string, unknown>>('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<Record<string, unknown>>('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<Record<string, unknown>>('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<Record<string, unknown>>('auth', token);
expect(payload).toMatchObject({ type: 'session', legacy: true });
});
});
+82 -20
View File
@@ -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<string, CompressionContext> = {
/**
* 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 v1v2 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<T = Record<string, unknown>>(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<string, unknown>;
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<string, unknown>;
const decompressed = this.#decompressPayload(
context,
payload,
) as Record<string, unknown>;
// `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 ───────────────────────────────────────────────────
+33 -7
View File
@@ -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 v1v2 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, unknown> | 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 {};
+352 -27
View File
@@ -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:<userId>:<appUid>` (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
+156 -2
View File
@@ -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);
});
});
});
+12 -1
View File
@@ -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 v1v2 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. */