From 15e7a3503bc9302999df1f92b9d7ef4f2ab7a5e3 Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Thu, 26 Feb 2026 14:19:59 -0800 Subject: [PATCH] feat: add private app asset token auth helpers (#2555) * feat: add private app asset token auth helpers Add mint/verify helpers and hardened cookie option helpers for app-private-asset tokens in AuthService. Add focused tests for claims validation, mismatch denial, and cookie option defaults. * fix: add prvate app config for new subdomain --- src/backend/src/config.js | 6 +- src/backend/src/services/auth/AuthService.js | 208 +++++++++++++++--- .../AuthService.privateAssetToken.test.ts | 110 +++++++++ 3 files changed, 294 insertions(+), 30 deletions(-) create mode 100644 src/backend/src/services/auth/AuthService.privateAssetToken.test.ts diff --git a/src/backend/src/config.js b/src/backend/src/config.js index 01b83b4cb..3e2bc7065 100644 --- a/src/backend/src/config.js +++ b/src/backend/src/config.js @@ -168,9 +168,11 @@ const computed_defaults = { ? config.origin : `${config.protocol }://api.${ config.domain }${maybe_port(config)}`, social_card: config => `${config.origin}/assets/img/screenshot.png`, - static_hosting_domain: config => `site.puter.localhost${ maybe_port(config)}`, + static_hosting_domain: config => `site.${ config.domain }${ maybe_port(config)}`, // Hostname-only fallback helps host matching code paths that compare against req.hostname. - static_hosting_domain_alt: () => 'site.puter.localhost', + static_hosting_domain_alt: (config) => `site.${ config.domain }`, + private_app_hosting_domain: config => `apps.${ config.domain }${maybe_port(config)}`, + private_app_hosting_domain_alt: config => `apps.${ config.domain }`, // Hostname-only fallback helps host matching code paths that compare against req.hostname. }; diff --git a/src/backend/src/services/auth/AuthService.js b/src/backend/src/services/auth/AuthService.js index 901cac2b9..63a25084a 100644 --- a/src/backend/src/services/auth/AuthService.js +++ b/src/backend/src/services/auth/AuthService.js @@ -26,6 +26,8 @@ const { UUIDFPE } = require('../../util/uuidfpe'); // This constant defines the namespace used for generating app UUIDs from their origins const APP_ORIGIN_UUID_NAMESPACE = '33de3768-8ee0-43e9-9e73-db192b97a5d8'; +const DEFAULT_PRIVATE_APP_ASSET_TOKEN_TTL_SECONDS = 60 * 60; +const DEFAULT_PRIVATE_APP_ASSET_COOKIE_NAME = 'puter.private.asset.token'; const LegacyTokenError = class extends Error { }; @@ -84,10 +86,12 @@ class AuthService extends BaseService { * @returns {Promise} The authenticated user or app actor. */ async authenticate_from_token (token) { - const decoded = this.modules.jwt.verify(token, - this.global_config.jwt_secret); + const decoded = this.modules.jwt.verify( + token, + this.global_config.jwt_secret, + ); - if ( ! decoded.hasOwnProperty('type') ) { + if ( ! Object.prototype.hasOwnProperty.call(decoded, 'type') ) { throw new LegacyTokenError(); } @@ -232,30 +236,171 @@ class AuthService extends BaseService { user_uid: actor_type.user.uuid, }); - const token = this.modules.jwt.sign({ - type: 'app-under-user', - version: '0.0.0', - user_uid: actor_type.user.uuid, - app_uid, - ...(actor_type.session ? { session: this.uuid_fpe.encrypt(actor_type.session) } : {}), - }, - this.global_config.jwt_secret); + const token = this.modules.jwt.sign( + { + type: 'app-under-user', + version: '0.0.0', + user_uid: actor_type.user.uuid, + app_uid, + ...(actor_type.session ? { session: this.uuid_fpe.encrypt(actor_type.session) } : {}), + }, + this.global_config.jwt_secret, + ); return token; } get_site_app_token ({ site_uid }) { - const token = this.modules.jwt.sign({ - type: 'actor-site', - version: '0.0.0', - site_uid, - }, - this.global_config.jwt_secret, - { expiresIn: '1h' }); + const token = this.modules.jwt.sign( + { + type: 'actor-site', + version: '0.0.0', + site_uid, + }, + this.global_config.jwt_secret, + { expiresIn: '1h' }, + ); return token; } + resolvePositiveInteger (value, fallback) { + const parsed = Number(value); + if ( !Number.isFinite(parsed) || parsed <= 0 ) { + return fallback; + } + return Math.floor(parsed); + } + + getPrivateAssetTokenTtlSeconds () { + return this.resolvePositiveInteger( + this.global_config.private_app_asset_token_ttl_seconds, + DEFAULT_PRIVATE_APP_ASSET_TOKEN_TTL_SECONDS, + ); + } + + getPrivateAssetCookieName () { + const configuredCookieName = this.global_config.private_app_asset_cookie_name; + if ( typeof configuredCookieName === 'string' && configuredCookieName.trim() ) { + return configuredCookieName.trim(); + } + return DEFAULT_PRIVATE_APP_ASSET_COOKIE_NAME; + } + + getPrivateAssetCookieOptions ({ ttlSeconds } = {}) { + const effectiveTtlSeconds = this.resolvePositiveInteger( + ttlSeconds, + this.getPrivateAssetTokenTtlSeconds(), + ); + + const cookieOptions = { + sameSite: 'none', + secure: true, + httpOnly: true, + path: '/', + maxAge: effectiveTtlSeconds * 1000, + }; + + const privateHostingDomain = `${this.global_config.private_app_hosting_domain ?? ''}` + .trim() + .toLowerCase() + .replace(/^\./, ''); + if ( + privateHostingDomain && + privateHostingDomain !== 'localhost' && + !privateHostingDomain.endsWith('.localhost') && + !privateHostingDomain.includes(':') + ) { + cookieOptions.domain = `.${privateHostingDomain}`; + } + + return cookieOptions; + } + + createPrivateAssetToken ({ appUid, userUid, sessionUuid, ttlSeconds } = {}) { + if ( typeof appUid !== 'string' || !appUid.trim() ) { + throw new Error('appUid is required to create private asset token.'); + } + if ( typeof userUid !== 'string' || !userUid.trim() ) { + throw new Error('userUid is required to create private asset token.'); + } + if ( sessionUuid !== undefined && (typeof sessionUuid !== 'string' || !sessionUuid.trim()) ) { + throw new Error('sessionUuid must be a non-empty string when provided.'); + } + + const effectiveTtlSeconds = this.resolvePositiveInteger( + ttlSeconds, + this.getPrivateAssetTokenTtlSeconds(), + ); + + const payload = { + type: 'app-private-asset', + version: '0.0.0', + app_uid: appUid.trim(), + user_uid: userUid.trim(), + ...(sessionUuid ? { session: this.uuid_fpe.encrypt(sessionUuid) } : {}), + }; + + return this.modules.jwt.sign(payload, this.global_config.jwt_secret, { + expiresIn: effectiveTtlSeconds, + }); + } + + verifyPrivateAssetToken ( + token, + { expectedAppUid, expectedUserUid, expectedSessionUuid } = {}, + ) { + let decoded; + try { + decoded = this.modules.jwt.verify(token, this.global_config.jwt_secret); + } catch (e) { + throw APIError.create('token_auth_failed'); + } + + if ( + !decoded || + decoded.type !== 'app-private-asset' || + typeof decoded.app_uid !== 'string' || + !decoded.app_uid || + typeof decoded.user_uid !== 'string' || + !decoded.user_uid + ) { + throw APIError.create('token_auth_failed'); + } + + let sessionUuid; + if ( decoded.session !== undefined ) { + if ( typeof decoded.session !== 'string' || !decoded.session ) { + throw APIError.create('token_auth_failed'); + } + try { + sessionUuid = this.uuid_fpe.decrypt(decoded.session); + } catch (e) { + throw APIError.create('token_auth_failed'); + } + } + + if ( expectedAppUid && decoded.app_uid !== expectedAppUid ) { + throw APIError.create('token_auth_failed'); + } + if ( expectedUserUid && decoded.user_uid !== expectedUserUid ) { + throw APIError.create('token_auth_failed'); + } + if ( expectedSessionUuid ) { + if ( !sessionUuid || sessionUuid !== expectedSessionUuid ) { + throw APIError.create('token_auth_failed'); + } + } + + return { + appUid: decoded.app_uid, + userUid: decoded.user_uid, + sessionUuid, + exp: decoded.exp, + iat: decoded.iat, + }; + } + /** * Internal method for creating a session. * @@ -508,10 +653,12 @@ class AuthService extends BaseService { extra: JSON.stringify(extra ?? {}), }; const cols = Object.keys(insert_object).join(', '); - const vals = Object.values(insert_object).map(v => '?').join(', '); - await this.db.write('INSERT INTO `access_token_permissions` ' + + const vals = Object.values(insert_object).map(() => '?').join(', '); + await this.db.write( + 'INSERT INTO `access_token_permissions` ' + `(${cols}) VALUES (${vals})`, - Object.values(insert_object)); + Object.values(insert_object), + ); } console.log('token uuid?', uuid); @@ -570,8 +717,10 @@ class AuthService extends BaseService { // We won't take the cached sessions here because it's // possible the user has sessions on other servers - const db_sessions = await this.db.read('SELECT uuid, meta FROM `sessions` WHERE `user_id` = ?', - [actor.type.user.id]); + const db_sessions = await this.db.read( + 'SELECT uuid, meta FROM `sessions` WHERE `user_id` = ?', + [actor.type.user.id], + ); for ( const session of db_sessions ) { if ( seen.has(session.uuid) ) { @@ -623,8 +772,10 @@ class AuthService extends BaseService { const app_uid = await this._app_uid_from_origin(origin); // Determine if the app exists - const apps = await this.db.read('SELECT * FROM `apps` WHERE `uid` = ? LIMIT 1', - [app_uid]); + const apps = await this.db.read( + 'SELECT * FROM `apps` WHERE `uid` = ? LIMIT 1', + [app_uid], + ); if ( apps[0] ) { return this.get_user_app_token(app_uid); @@ -639,10 +790,12 @@ class AuthService extends BaseService { const owner_user_id = null; // Create the app - await this.db.write('INSERT INTO `apps` ' + + await this.db.write( + 'INSERT INTO `apps` ' + '(`uid`, `name`, `title`, `description`, `index_url`, `owner_user_id`) ' + 'VALUES (?, ?, ?, ?, ?, ?)', - [app_uid, name, title, description, index_url, owner_user_id]); + [app_uid, name, title, description, index_url, owner_user_id], + ); return this.get_user_app_token(app_uid); } @@ -688,7 +841,6 @@ class AuthService extends BaseService { '__on_install.routes' () { const { app } = this.services.get('web-server'); const config = require('../../config'); - const { subdomain } = require('../../helpers'); const configurable_auth = require('../../middleware/configurable_auth'); const { Endpoint } = require('../../util/expressutil'); const svc_auth = this; diff --git a/src/backend/src/services/auth/AuthService.privateAssetToken.test.ts b/src/backend/src/services/auth/AuthService.privateAssetToken.test.ts new file mode 100644 index 000000000..18ee3d927 --- /dev/null +++ b/src/backend/src/services/auth/AuthService.privateAssetToken.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest'; +import * as jwt from 'jsonwebtoken'; +import { AuthService } from './AuthService.js'; + +type AuthServiceForPrivateTokenTests = AuthService & { + global_config: { + jwt_secret: string; + private_app_asset_token_ttl_seconds: number; + private_app_asset_cookie_name: string; + private_app_hosting_domain: string; + }; + modules: { + jwt: { + sign: typeof jwt.sign; + verify: typeof jwt.verify; + }; + }; + uuid_fpe: { + encrypt: (value: string) => string; + decrypt: (value: string) => string; + }; +}; + +const createAuthService = (): AuthServiceForPrivateTokenTests => { + const authService = Object.create(AuthService.prototype) as AuthServiceForPrivateTokenTests; + authService.global_config = { + jwt_secret: 'private-asset-test-secret', + private_app_asset_token_ttl_seconds: 3600, + private_app_asset_cookie_name: 'puter.private.asset.token', + private_app_hosting_domain: 'puter.app', + }; + authService.modules = { + jwt: { + sign: jwt.sign.bind(jwt), + verify: jwt.verify.bind(jwt), + }, + }; + authService.uuid_fpe = { + encrypt: (value) => value, + decrypt: (value) => value, + }; + return authService; +}; + +describe('AuthService private asset token helpers', () => { + it('creates and verifies private asset tokens with expected claims', () => { + const authService = createAuthService(); + const appUid = 'app-7e2d3016-8d36-456a-9dc7-b75b0f4f7683'; + const userUid = '4b0cecf8-dd6a-4eb5-bcc4-c76cc7e8d7f0'; + const sessionUuid = 'f9000804-2fd3-4da5-819b-afc5296f90f7'; + + const token = authService.createPrivateAssetToken({ + appUid, + userUid, + sessionUuid, + ttlSeconds: 120, + }); + + const claims = authService.verifyPrivateAssetToken(token, { + expectedAppUid: appUid, + expectedUserUid: userUid, + expectedSessionUuid: sessionUuid, + }); + + expect(claims.appUid).toBe(appUid); + expect(claims.userUid).toBe(userUid); + expect(claims.sessionUuid).toBe(sessionUuid); + expect(typeof claims.exp).toBe('number'); + }); + + it('rejects tokens when expected user or app does not match', () => { + const authService = createAuthService(); + const token = authService.createPrivateAssetToken({ + appUid: 'app-9f1c10e3-9a7f-43fb-8671-af4918e65407', + userUid: '9885b80e-1a14-4c8d-9e3f-4fa5915b1136', + }); + + expect(() => authService.verifyPrivateAssetToken(token, { + expectedAppUid: 'app-aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + })).toThrow(); + + expect(() => authService.verifyPrivateAssetToken(token, { + expectedUserUid: 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', + })).toThrow(); + }); + + it('rejects non private-asset tokens', () => { + const authService = createAuthService(); + const token = jwt.sign({ + type: 'session', + uuid: '245f33f0-c07e-40e2-be22-5215752e3462', + user_uid: '6cce4692-3855-4ef8-af7d-5c2a02e6b6d8', + }, authService.global_config.jwt_secret, { expiresIn: 60 }); + + expect(() => authService.verifyPrivateAssetToken(token)).toThrow(); + }); + + it('returns hardened cookie options with config-driven ttl and domain', () => { + const authService = createAuthService(); + const options = authService.getPrivateAssetCookieOptions(); + + expect(authService.getPrivateAssetCookieName()).toBe('puter.private.asset.token'); + expect(options.sameSite).toBe('none'); + expect(options.secure).toBe(true); + expect(options.httpOnly).toBe(true); + expect(options.path).toBe('/'); + expect(options.maxAge).toBe(3_600_000); + expect(options.domain).toBe('.puter.app'); + }); +});