diff --git a/src/backend/core/http/expressAugmentation.ts b/src/backend/core/http/expressAugmentation.ts index 1330f5bb8..7d044c393 100644 --- a/src/backend/core/http/expressAugmentation.ts +++ b/src/backend/core/http/expressAugmentation.ts @@ -27,6 +27,17 @@ declare global { /** The raw token string, if one was presented and parsed. */ token?: string; + /** + * Set by the global auth probe when a token was extracted from + * the request but failed to resolve to an actor (bad signature, + * expired, references a deleted session/user/app, or has a + * legacy shape v2 can't authenticate). Lets `requireAuthGate` + * distinguish "no token" from "token present but invalid" and + * emit the legacy `token_auth_failed` error so old clients + * trigger their re-login flow. + */ + tokenAuthFailed?: boolean; + /** * Raw request body bytes, captured by the global JSON parser's * `verify` callback. Available for any JSON request — needed by diff --git a/src/backend/core/http/middleware/authProbe.ts b/src/backend/core/http/middleware/authProbe.ts index 6e05b56ad..d2b8eebb5 100644 --- a/src/backend/core/http/middleware/authProbe.ts +++ b/src/backend/core/http/middleware/authProbe.ts @@ -49,9 +49,21 @@ export const createAuthProbe = (opts: AuthProbeOptions): RequestHandler => { if (actor) { req.actor = actor; req.token = token; + } else { + // Token was present but didn't resolve — covers v1-shape + // tokens v2 can't authenticate (e.g. FPE-encrypted session + // UUIDs), as well as tokens whose session/user/app rows + // have been deleted. `requireAuthGate` reads this flag + // to emit `token_auth_failed` (matching v1) so clients + // trigger their re-login flow instead of retrying. + req.tokenAuthFailed = true; } } catch { - // Probe never rejects — invalid tokens just leave `req.actor` undefined. + // Probe never rejects — invalid tokens just leave `req.actor` + // undefined. Same flag as above so the gate's response shape + // is identical regardless of whether the failure came from + // jwt.verify or a downstream lookup. + req.tokenAuthFailed = true; } next(); }; diff --git a/src/backend/core/http/middleware/gates.ts b/src/backend/core/http/middleware/gates.ts index 77e7ad355..810a3f2a3 100644 --- a/src/backend/core/http/middleware/gates.ts +++ b/src/backend/core/http/middleware/gates.ts @@ -1,9 +1,27 @@ -import type { RequestHandler } from 'express'; +import type { Request, RequestHandler } from 'express'; import { HttpError } from '../HttpError'; // Make sure the `Express.Request.actor` augmentation is in scope. import '../expressAugmentation'; +/** + * Build the 401 a route gate emits when `req.actor` is absent. Splits the + * legacy code so old clients can tell the two cases apart: `token_missing` + * means "send a token", `token_auth_failed` means "your token didn't work, + * re-login". Matches the v1 backend's auth-failure shape so the existing + * client retry-vs-reauth logic doesn't need to change. + */ +const rejectAuth = (req: Request): HttpError => { + if (req.tokenAuthFailed) { + return new HttpError(401, 'Authentication failed', { + legacyCode: 'token_auth_failed', + }); + } + return new HttpError(401, 'Missing authentication token', { + legacyCode: 'token_missing', + }); +}; + /** * Per-route gate middlewares. * @@ -55,11 +73,7 @@ export const subdomainGate = (allowed: string | string[]): RequestHandler => { export const requireAuthGate = (): RequestHandler => { return (req, _res, next) => { if (!req.actor) { - next( - new HttpError(401, 'Authentication required', { - legacyCode: 'token_required', - }), - ); + next(rejectAuth(req)); return; } if (req.actor.user.suspended) { @@ -86,11 +100,7 @@ export const requireUserActorGate = (): RequestHandler => { const actor = req.actor; // requireAuth runs first; this gate just narrows the actor type. if (!actor) { - next( - new HttpError(401, 'Authentication required', { - legacyCode: 'token_required', - }), - ); + next(rejectAuth(req)); return; } if (actor.app || actor.accessToken) { diff --git a/src/backend/core/http/middleware/privateAppGate.ts b/src/backend/core/http/middleware/privateAppGate.ts index ddea82f98..e2a697f92 100644 --- a/src/backend/core/http/middleware/privateAppGate.ts +++ b/src/backend/core/http/middleware/privateAppGate.ts @@ -341,7 +341,7 @@ export async function resolvePrivateIdentity(opts: { : null; if (privateCookieToken) { try { - const claims = authService.verifyPrivateAssetToken( + const claims = await authService.verifyPrivateAssetToken( privateCookieToken, { expectedAppUid, @@ -356,7 +356,7 @@ export async function resolvePrivateIdentity(opts: { hasValidPrivateCookie: true, }; } catch { - /* fall through — stale / mismatched cookie */ + /* fall through — stale / mismatched / logged-out cookie */ } } diff --git a/src/backend/services/auth/AuthService.ts b/src/backend/services/auth/AuthService.ts index 6f2897e30..058b238aa 100644 --- a/src/backend/services/auth/AuthService.ts +++ b/src/backend/services/auth/AuthService.ts @@ -424,20 +424,20 @@ export class AuthService extends PuterService { }); } - verifyPrivateAssetToken( + async verifyPrivateAssetToken( token: string, expected: { expectedAppUid?: string; expectedSubdomain?: string; expectedPrivateHost?: string; } = {}, - ): { + ): Promise<{ userUid: string; sessionUuid?: string; appUid?: string; subdomain?: string; privateHost?: string; - } { + }> { const decoded = this.#verifyHostedAssetToken(token, 'private'); this.#assertExpected( decoded, @@ -457,9 +457,25 @@ export class AuthService extends PuterService { expected.expectedPrivateHost, 'expectedPrivateHost', ); + + // Bind the cookie to the user's session lifetime: the session row + // referenced at mint must still exist. Logging out drops the row, + // which transitively invalidates every private-app cookie issued + // under that session — matching v1's `expectedSessionUuid` check + // (private apps only; public hosted-actor cookies stay informational). + // Cookies minted without a session_uuid (e.g. from an access-token + // actor) skip this check; nothing to bind. + const sessionUuid = decoded.session_uuid as string | undefined; + if (sessionUuid) { + const session = await this.stores.session.getByUuid(sessionUuid); + if (!session) { + throw new Error('private-asset token session no longer valid'); + } + } + return { userUid: decoded.user_uid as string, - sessionUuid: decoded.session_uuid as string | undefined, + sessionUuid, appUid: decoded.app_uid as string | undefined, subdomain: decoded.subdomain as string | undefined, privateHost: decoded.host as string | undefined, diff --git a/src/backend/stores/session/SessionStore.js b/src/backend/stores/session/SessionStore.js index 842720fab..af6b8d8d9 100644 --- a/src/backend/stores/session/SessionStore.js +++ b/src/backend/stores/session/SessionStore.js @@ -87,7 +87,10 @@ export class SessionStore extends PuterStore { await this.clients.db.write('DELETE FROM `sessions` WHERE `uuid` = ?', [ uuid, ]); - await this.publishCacheKeys({ keys: [this.#cacheKey(uuid)] }); + await this.publishCacheKeys({ + keys: [this.#cacheKey(uuid)], + broadcast: true, + }); } /** Update session activity timestamp. */ diff --git a/src/backend/stores/systemKv/SystemKVStore.ts b/src/backend/stores/systemKv/SystemKVStore.ts index fdd2f3596..50a1bf737 100644 --- a/src/backend/stores/systemKv/SystemKVStore.ts +++ b/src/backend/stores/systemKv/SystemKVStore.ts @@ -6,6 +6,7 @@ import { SYSTEM_ACTOR_UUID, } from '../../core/actor'; import { PUTER_KV_STORE_TABLE_DEFINITION } from './tableDefinition'; +import { HttpError } from '../../core/http'; // ── Types ──────────────────────────────────────────────────────────── @@ -38,6 +39,7 @@ export interface RecursiveRecord { const GLOBAL_APP_KEY = 'os-global'; const SYSTEM_NAMESPACE = `v1:${SYSTEM_ACTOR_UUID}:${GLOBAL_APP_KEY}`; const MAX_KEY_BYTES = 1024; +const MAX_VALUE_BYTES = 399 * 1024; const BATCH_GET_CHUNK = 100; const PATH_CLEANER_REGEX = /[:\-+/*]/g; @@ -67,9 +69,27 @@ const getNamespace = (actor: Actor, appUuidOverride?: string): string => { }; const assertKey = (key: string): void => { - if (key === '') throw new Error('kv: key is empty'); + if (key === '') + throw new HttpError(400, 'kv: key is empty', { + legacyCode: 'bad_request', + }); if (Buffer.byteLength(key, 'utf8') > MAX_KEY_BYTES) { - throw new Error(`kv: key exceeds ${MAX_KEY_BYTES} byte limit`); + throw new HttpError( + 400, + `kv: key exceeds ${MAX_KEY_BYTES} byte limit`, + { legacyCode: 'bad_request' }, + ); + } +}; + +const assertValueSize = (value: unknown): void => { + const size = Buffer.byteLength(JSON.stringify(value ?? null), 'utf8'); + if (size > MAX_VALUE_BYTES) { + throw new HttpError( + 400, + `kv: value exceeds ${MAX_VALUE_BYTES} byte limit`, + { legacyCode: 'bad_request' }, + ); } }; @@ -93,7 +113,9 @@ const decodeCursor = ( try { return JSON.parse(trimmed); } catch { - throw new Error('kv: invalid cursor'); + throw new HttpError(400, 'kv: invalid cursor', { + legacyCode: 'bad_request', + }); } } }; @@ -102,7 +124,9 @@ const normalizeLimit = (limit?: number): number | undefined => { if (limit === undefined || limit === null) return undefined; const parsed = Number(limit); if (!Number.isFinite(parsed) || parsed <= 0) { - throw new Error('kv: limit must be a positive number'); + throw new HttpError(400, 'kv: limit must be a positive number', { + legacyCode: 'bad_request', + }); } return Math.floor(parsed); }; @@ -110,7 +134,9 @@ const normalizeLimit = (limit?: number): number | undefined => { const normalizePattern = (pattern?: string): string | undefined => { if (pattern === undefined || pattern === null) return undefined; if (typeof pattern !== 'string') - throw new Error('kv: pattern must be a string'); + throw new HttpError(400, 'kv: pattern must be a string', { + legacyCode: 'bad_request', + }); const trimmed = pattern.trim(); if (trimmed === '') return undefined; if (trimmed.endsWith('*')) { @@ -226,6 +252,7 @@ export class SystemKVStore extends PuterStore { opts?: KVOpts, ): Promise> { assertKey(key); + assertValueSize(value); const actor = ensureActor(opts); const namespace = getNamespace(actor, opts?.appUuid); @@ -261,6 +288,7 @@ export class SystemKVStore extends PuterStore { for (const item of items) { const k = String(item.key); assertKey(k); + assertValueSize(item.value); byKey.set(k, { key: k, value: item.value, @@ -368,7 +396,11 @@ export class SystemKVStore extends PuterStore { const kind = as ?? 'entries'; if (!['keys', 'values', 'entries'].includes(kind)) { - throw new Error('kv: list "as" must be keys, values, or entries'); + throw new HttpError( + 400, + 'kv: list "as" must be keys, values, or entries', + { legacyCode: 'bad_request' }, + ); } let items: string[] | unknown[] | { key: string; value: unknown }[] = @@ -459,12 +491,16 @@ export class SystemKVStore extends PuterStore { > { assertKey(key); if (!pathAndAmountMap) - throw new Error('kv: incr requires pathAndAmountMap'); + throw new HttpError(400, 'kv: incr requires pathAndAmountMap', { + legacyCode: 'bad_request', + }); if ( Object.values(pathAndAmountMap).some((v) => typeof v !== 'number') ) { - throw new Error( + throw new HttpError( + 400, 'kv: all values in pathAndAmountMap must be numbers', + { legacyCode: 'bad_request' }, ); } @@ -544,7 +580,12 @@ export class SystemKVStore extends PuterStore { ): Promise> { assertKey(key); if (!pathAndValueMap || Object.keys(pathAndValueMap).length === 0) { - throw new Error('kv: add requires pathAndValueMap'); + throw new HttpError(400, 'kv: add requires pathAndValueMap', { + legacyCode: 'bad_request', + }); + } + for (const val of Object.values(pathAndValueMap)) { + assertValueSize(val); } const actor = ensureActor(opts); @@ -608,7 +649,9 @@ export class SystemKVStore extends PuterStore { ): Promise> { assertKey(key); if (!paths || paths.length === 0) { - throw new Error('kv: remove requires paths'); + throw new HttpError(400, 'kv: remove requires paths', { + legacyCode: 'bad_request', + }); } const actor = ensureActor(opts); @@ -684,7 +727,12 @@ export class SystemKVStore extends PuterStore { ): Promise> { assertKey(key); if (!pathAndValueMap || Object.keys(pathAndValueMap).length === 0) { - throw new Error('kv: update requires pathAndValueMap'); + throw new HttpError(400, 'kv: update requires pathAndValueMap', { + legacyCode: 'bad_request', + }); + } + for (const val of Object.values(pathAndValueMap)) { + assertValueSize(val); } const actor = ensureActor(opts); @@ -728,7 +776,9 @@ export class SystemKVStore extends PuterStore { if (ttl !== undefined) { const ttlSeconds = Number(ttl); if (Number.isNaN(ttlSeconds)) - throw new Error('kv: ttl must be a number'); + throw new HttpError(400, 'kv: ttl must be a number', { + legacyCode: 'bad_request', + }); const timestamp = Math.floor(Date.now() / 1000) + ttlSeconds; setStatements.push('#ttl = :ttl'); valueAttributeValues[':ttl'] = timestamp;