This commit is contained in:
Nariman Jelveh
2026-04-30 16:03:51 -07:00
7 changed files with 133 additions and 31 deletions
@@ -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
+13 -1
View File
@@ -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();
};
+21 -11
View File
@@ -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) {
@@ -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 */
}
}
+20 -4
View File
@@ -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,
+4 -1
View File
@@ -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. */
+62 -12
View File
@@ -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<T> {
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<KVResult<boolean>> {
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<KVResult<unknown>> {
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<KVResult<unknown>> {
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<KVResult<unknown>> {
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;