mirror of
https://github.com/HeyPuter/puter.git
synced 2026-09-22 21:26:04 +00:00
feat (put-1012 put-1014): new tokens version signature and logic (#3152)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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 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<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 ───────────────────────────────────────────────────
|
||||
|
||||
@@ -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, 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 {};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user