From adf034b1206af49863ea2064084efd4cb2036459 Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Tue, 3 Mar 2026 20:37:17 -0800 Subject: [PATCH] feat: add subdomain to private asset tokens (#2591) --- .../routers/hosting/puterSiteMiddleware.js | 7 ++ .../hosting/puterSiteMiddleware.test.js | 6 ++ src/backend/src/services/BaseService.d.ts | 2 + src/backend/src/services/auth/AuthService.js | 98 +++++++++++-------- .../AuthService.privateAssetToken.test.ts | 65 +++++++++++- 5 files changed, 136 insertions(+), 42 deletions(-) diff --git a/src/backend/src/routers/hosting/puterSiteMiddleware.js b/src/backend/src/routers/hosting/puterSiteMiddleware.js index 7f8c31969..6928628fd 100644 --- a/src/backend/src/routers/hosting/puterSiteMiddleware.js +++ b/src/backend/src/routers/hosting/puterSiteMiddleware.js @@ -387,6 +387,7 @@ async function resolvePrivateIdentity ({ req, services, appUid }) { const authService = services.get('auth'); const privateCookieName = authService.getPrivateAssetCookieName(); const privateCookieToken = req.cookies?.[privateCookieName]; + const privateAppSubdomain = getSubdomainFromHostedRequest(req) || undefined; const hasPrivateCookie = typeof privateCookieToken === 'string' && !!privateCookieToken; let hasInvalidPrivateCookie = false; @@ -394,11 +395,13 @@ async function resolvePrivateIdentity ({ req, services, appUid }) { try { const claims = authService.verifyPrivateAssetToken(privateCookieToken, { expectedAppUid: appUid, + expectedSubdomain: privateAppSubdomain, }); return { source: 'private-cookie', userUid: claims.userUid, sessionUuid: claims.sessionUuid, + subdomain: claims.subdomain ?? privateAppSubdomain, hasValidPrivateCookie: true, hasPrivateCookie, hasInvalidPrivateCookie, @@ -418,6 +421,7 @@ async function resolvePrivateIdentity ({ req, services, appUid }) { return { source: 'session-cookie', ...identity, + subdomain: privateAppSubdomain, hasValidPrivateCookie: false, hasPrivateCookie, hasInvalidPrivateCookie, @@ -460,6 +464,7 @@ async function resolvePrivateIdentity ({ req, services, appUid }) { return { source: 'bootstrap-token', ...identity, + subdomain: privateAppSubdomain, hasValidPrivateCookie: false, hasPrivateCookie, hasInvalidPrivateCookie, @@ -481,6 +486,7 @@ async function resolvePrivateIdentity ({ req, services, appUid }) { source: 'none', userUid: undefined, sessionUuid: undefined, + subdomain: privateAppSubdomain, hasValidPrivateCookie: false, hasPrivateCookie, hasInvalidPrivateCookie, @@ -779,6 +785,7 @@ async function evaluatePrivateAppAccess ({ req, res, services, app, requestPath appUid: app.uid, userUid: identity.userUid, sessionUuid: identity.sessionUuid, + subdomain: identity.subdomain, }); res.cookie( authService.getPrivateAssetCookieName(), diff --git a/src/backend/src/routers/hosting/puterSiteMiddleware.test.js b/src/backend/src/routers/hosting/puterSiteMiddleware.test.js index 20c4b2e39..2796a00fb 100644 --- a/src/backend/src/routers/hosting/puterSiteMiddleware.test.js +++ b/src/backend/src/routers/hosting/puterSiteMiddleware.test.js @@ -948,6 +948,12 @@ describe('PuterSiteMiddleware', () => { expect(authService.getPrivateAssetCookieOptions).toHaveBeenCalledWith({ requestHostname: 'paid.puter.dev', }); + expect(authService.createPrivateAssetToken).toHaveBeenCalledWith({ + appUid: 'app-11111111-1111-1111-1111-111111111111', + userUid: 'user-allow-111', + sessionUuid: 'session-allow-111', + subdomain: 'paid', + }); expect(mockRes.cookie).toHaveBeenCalledWith( 'puter.private.asset.token', 'private-token', diff --git a/src/backend/src/services/BaseService.d.ts b/src/backend/src/services/BaseService.d.ts index a1cb8afe4..acffc87b1 100644 --- a/src/backend/src/services/BaseService.d.ts +++ b/src/backend/src/services/BaseService.d.ts @@ -18,6 +18,7 @@ import type { MeteringService } from './MeteringService/MeteringService'; import type { MeteringServiceWrapper } from './MeteringService/MeteringServiceWrapper.mjs'; import type { SUService } from './SUService'; import type { UserService } from './UserService'; +import { TokenService } from './auth/TokenService'; export interface ServicesMap { su: SUService; @@ -40,6 +41,7 @@ export interface ServicesMap { 'clean-email': CleanEmailService; 'error-service': ErrorService; driver: DriverService; + 'token': TokenService } export interface ServiceResources { diff --git a/src/backend/src/services/auth/AuthService.js b/src/backend/src/services/auth/AuthService.js index 134f026ff..81e5fdadd 100644 --- a/src/backend/src/services/auth/AuthService.js +++ b/src/backend/src/services/auth/AuthService.js @@ -17,13 +17,14 @@ * along with this program. If not, see . */ const { Actor, UserActorType, AppUnderUserActorType, AccessTokenActorType, SiteActorType } = require('./Actor'); -const BaseService = require('../BaseService'); +const { BaseService } = require('../BaseService'); const { get_user, get_app } = require('../../helpers'); const { Context } = require('../../util/context'); const APIError = require('../../api/APIError'); const { DB_WRITE } = require('../database/consts'); const { UUIDFPE } = require('../../util/uuidfpe'); - +const uuidLib = require('uuid'); +const crypto = require('crypto'); // 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; @@ -37,12 +38,6 @@ const LegacyTokenError = class extends Error { * This class is responsible for handling authentication and authorization tasks for the application. */ class AuthService extends BaseService { - static MODULES = { - jwt: require('jsonwebtoken'), - crypto: require('crypto'), - uuidv5: require('uuid').v5, - uuidv4: require('uuid').v4, - }; async _init () { this.db = await this.services.get('database').get(DB_WRITE, 'auth'); @@ -66,16 +61,12 @@ class AuthService extends BaseService { // We do this to avoid exposing the internal UUID for sessions. const uuid_fpe_key = this.config.uuid_fpe_key ? UUIDFPE.uuidToBuffer(this.config.uuid_fpe_key) - : this.modules.crypto.randomBytes(16); + : crypto.randomBytes(16); this.uuid_fpe = new UUIDFPE(uuid_fpe_key); this.sessions = {}; - const svc_token = await this.services.get('token'); - this.modules.jwt = { - sign: (payload, _, options) => svc_token.sign('auth', payload, options), - verify: (token, _) => svc_token.verify('auth', token), - }; + this.tokenService = await this.services.get('token'); } /** @@ -86,9 +77,9 @@ class AuthService extends BaseService { * @returns {Promise} The authenticated user or app actor. */ async authenticate_from_token (token) { - const decoded = this.modules.jwt.verify( + const decoded = this.tokenService.verify( + 'auth', token, - this.global_config.jwt_secret, ); if ( ! Object.prototype.hasOwnProperty.call(decoded, 'type') ) { @@ -236,7 +227,8 @@ class AuthService extends BaseService { user_uid: actor_type.user.uuid, }); - const token = this.modules.jwt.sign( + const token = this.tokenService.sign( + 'auth', { type: 'app-under-user', version: '0.0.0', @@ -244,20 +236,19 @@ class AuthService extends BaseService { 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( + const token = this.tokenService.sign( + 'auth', { type: 'actor-site', version: '0.0.0', site_uid, }, - this.global_config.jwt_secret, { expiresIn: '1h' }, ); @@ -368,7 +359,13 @@ class AuthService extends BaseService { return cookieOptions; } - createPrivateAssetToken ({ appUid, userUid, sessionUuid, ttlSeconds } = {}) { + normalizePrivateAssetSubdomain (subdomain) { + if ( typeof subdomain !== 'string' ) return undefined; + const normalizedSubdomain = subdomain.trim().toLowerCase(); + return normalizedSubdomain || undefined; + } + + createPrivateAssetToken ({ appUid, userUid, sessionUuid, subdomain, ttlSeconds } = {}) { if ( typeof appUid !== 'string' || !appUid.trim() ) { throw new Error('appUid is required to create private asset token.'); } @@ -378,6 +375,10 @@ class AuthService extends BaseService { if ( sessionUuid !== undefined && (typeof sessionUuid !== 'string' || !sessionUuid.trim()) ) { throw new Error('sessionUuid must be a non-empty string when provided.'); } + const normalizedSubdomain = this.normalizePrivateAssetSubdomain(subdomain); + if ( subdomain !== undefined && !normalizedSubdomain ) { + throw new Error('subdomain must be a non-empty string when provided.'); + } const effectiveTtlSeconds = this.resolvePositiveInteger( ttlSeconds, @@ -390,20 +391,21 @@ class AuthService extends BaseService { app_uid: appUid.trim(), user_uid: userUid.trim(), ...(sessionUuid ? { session: this.uuid_fpe.encrypt(sessionUuid) } : {}), + ...(normalizedSubdomain ? { subdomain: normalizedSubdomain } : {}), }; - return this.modules.jwt.sign(payload, this.global_config.jwt_secret, { + return this.tokenService.sign('auth', payload, { expiresIn: effectiveTtlSeconds, }); } verifyPrivateAssetToken ( token, - { expectedAppUid, expectedUserUid, expectedSessionUuid } = {}, + { expectedAppUid, expectedUserUid, expectedSessionUuid, expectedSubdomain } = {}, ) { let decoded; try { - decoded = this.modules.jwt.verify(token, this.global_config.jwt_secret); + decoded = this.tokenService.verify('auth', token); } catch (e) { throw APIError.create('token_auth_failed'); } @@ -431,6 +433,14 @@ class AuthService extends BaseService { } } + let subdomain; + if ( decoded.subdomain !== undefined ) { + if ( typeof decoded.subdomain !== 'string' || !decoded.subdomain.trim() ) { + throw APIError.create('token_auth_failed'); + } + subdomain = decoded.subdomain.trim().toLowerCase(); + } + if ( expectedAppUid && decoded.app_uid !== expectedAppUid ) { throw APIError.create('token_auth_failed'); } @@ -442,11 +452,21 @@ class AuthService extends BaseService { throw APIError.create('token_auth_failed'); } } + const normalizedExpectedSubdomain = this.normalizePrivateAssetSubdomain(expectedSubdomain); + if ( expectedSubdomain !== undefined && !normalizedExpectedSubdomain ) { + throw APIError.create('token_auth_failed'); + } + if ( normalizedExpectedSubdomain ) { + if ( !subdomain || subdomain !== normalizedExpectedSubdomain ) { + throw APIError.create('token_auth_failed'); + } + } return { appUid: decoded.app_uid, userUid: decoded.user_uid, sessionUuid, + subdomain, exp: decoded.exp, iat: decoded.iat, }; @@ -481,7 +501,7 @@ class AuthService extends BaseService { async resolvePrivateBootstrapIdentityFromToken (token) { let decoded; try { - decoded = this.modules.jwt.verify(token, this.global_config.jwt_secret); + decoded = this.tokenService.verify('auth', token); } catch (e) { throw APIError.create('token_auth_failed'); } @@ -590,13 +610,13 @@ class AuthService extends BaseService { async create_session_token (user, meta) { const session = await this.create_session_(user, meta); - const token = this.modules.jwt.sign({ + const token = this.tokenService.sign('auth', { type: 'session', version: '0.0.0', uuid: session.uuid, // meta: session.meta, user_uid: user.uuid, - }, this.global_config.jwt_secret); + }); return { session, token }; } @@ -612,12 +632,12 @@ class AuthService extends BaseService { * @returns {string} JWT GUI token. */ create_gui_token (user, session) { - return this.modules.jwt.sign({ + return this.tokenService.sign('auth', { type: 'gui', version: '0.0.0', uuid: session.uuid, user_uid: user.uuid, - }, this.global_config.jwt_secret); + }); } /** @@ -631,12 +651,12 @@ class AuthService extends BaseService { * @returns {string} JWT session token. */ create_session_token_for_session (user, session_uuid) { - return this.modules.jwt.sign({ + return this.tokenService.sign('auth', { type: 'session', version: '0.0.0', uuid: session_uuid, user_uid: user.uuid, - }, this.global_config.jwt_secret); + }); } /** @@ -648,9 +668,9 @@ class AuthService extends BaseService { * @returns {object} Object containing the user and token if the token is valid, otherwise an empty object. */ async check_session (cur_token, meta) { - const decoded = this.modules.jwt.verify(cur_token, this.global_config.jwt_secret); + const decoded = this.tokenService.verify('auth', cur_token); - console.log('\x1B[36;1mDECODED SESSION', decoded); + console.debug('\x1B[36;1mDECODED SESSION', decoded); if ( decoded.type && decoded.type !== 'session' && decoded.type !== 'gui' ) { return {}; @@ -708,7 +728,7 @@ class AuthService extends BaseService { * @returns {Promise} */ async remove_session_by_token (token) { - const decoded = this.modules.jwt.verify(token, this.global_config.jwt_secret); + const decoded = this.tokenService.verify('auth', token); if ( decoded.type !== 'session' && decoded.type !== 'gui' ) { return; @@ -751,14 +771,14 @@ class AuthService extends BaseService { throw APIError.create('forbidden'); } - const uuid = this.modules.uuidv4(); + const uuid = uuidLib.v4(); - const jwt = this.modules.jwt.sign({ + const jwt = this.tokenService.sign('auth', { type: 'access-token', version: '0.0.0', token_uid: uuid, ...jwt_obj, - }, this.global_config.jwt_secret, options); + }, options); for ( const permmission_spec of permissions ) { let [permission, extra] = permmission_spec; @@ -798,7 +818,7 @@ class AuthService extends BaseService { const isJwt = typeof tokenOrUuid === 'string' && /^[\w-]*\.[\w-]*\.[\w-]*$/.test(tokenOrUuid.trim()); if ( isJwt ) { - const decoded = this.modules.jwt.verify(tokenOrUuid, this.global_config.jwt_secret); + const decoded = this.tokenService.verify('auth', tokenOrUuid); if ( decoded.type !== 'access-token' || !decoded.token_uid ) { throw APIError.create('token_auth_failed'); } @@ -936,7 +956,7 @@ class AuthService extends BaseService { const svc_event = this.services.get('event'); await svc_event.emit('app.from-origin', event); // UUIDV5 - const uuid = this.modules.uuidv5(event.origin, APP_ORIGIN_UUID_NAMESPACE); + const uuid = uuidLib.v5(event.origin, APP_ORIGIN_UUID_NAMESPACE); return `app-${uuid}`; } diff --git a/src/backend/src/services/auth/AuthService.privateAssetToken.test.ts b/src/backend/src/services/auth/AuthService.privateAssetToken.test.ts index cf023a3b5..14aa4d353 100644 --- a/src/backend/src/services/auth/AuthService.privateAssetToken.test.ts +++ b/src/backend/src/services/auth/AuthService.privateAssetToken.test.ts @@ -16,6 +16,10 @@ type AuthServiceForPrivateTokenTests = AuthService & { verify: typeof jwt.verify; }; }; + tokenService: { + sign: typeof jwt.sign; + verify: typeof jwt.verify; + }; uuid_fpe: { encrypt: (value: string) => string; decrypt: (value: string) => string; @@ -37,6 +41,12 @@ const createAuthService = (): AuthServiceForPrivateTokenTests => { verify: jwt.verify.bind(jwt), }, }; + authService.tokenService = { + sign: (_scope, payload, options) => + jwt.sign(payload as Parameters[0], authService.global_config.jwt_secret, options), + verify: (_scope, token) => + jwt.verify(token, authService.global_config.jwt_secret), + }; authService.uuid_fpe = { encrypt: (value) => value, decrypt: (value) => value, @@ -46,17 +56,33 @@ const createAuthService = (): AuthServiceForPrivateTokenTests => { return authService; }; +const tamperTokenSignature = (token: string): string => { + const parts = token.split('.'); + if ( parts.length !== 3 ) return `${token}x`; + const signature = parts[2]; + if ( signature.length === 0 ) { + parts[2] = 'x'; + return parts.join('.'); + } + const lastChar = signature[signature.length - 1]; + const replacement = lastChar === 'a' ? 'b' : 'a'; + parts[2] = `${signature.slice(0, -1)}${replacement}`; + return parts.join('.'); +}; + 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 subdomain = 'beans'; const token = authService.createPrivateAssetToken({ appUid, userUid, sessionUuid, + subdomain, ttlSeconds: 120, }); @@ -64,11 +90,13 @@ describe('AuthService private asset token helpers', () => { expectedAppUid: appUid, expectedUserUid: userUid, expectedSessionUuid: sessionUuid, + expectedSubdomain: subdomain, }); expect(claims.appUid).toBe(appUid); expect(claims.userUid).toBe(userUid); expect(claims.sessionUuid).toBe(sessionUuid); + expect(claims.subdomain).toBe(subdomain); expect(typeof claims.exp).toBe('number'); }); @@ -77,6 +105,7 @@ describe('AuthService private asset token helpers', () => { const token = authService.createPrivateAssetToken({ appUid: 'app-9f1c10e3-9a7f-43fb-8671-af4918e65407', userUid: '9885b80e-1a14-4c8d-9e3f-4fa5915b1136', + subdomain: 'beans', }); expect(() => authService.verifyPrivateAssetToken(token, { @@ -86,6 +115,10 @@ describe('AuthService private asset token helpers', () => { expect(() => authService.verifyPrivateAssetToken(token, { expectedUserUid: 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', })).toThrow(); + + expect(() => authService.verifyPrivateAssetToken(token, { + expectedSubdomain: 'other-app', + })).toThrow(); }); it('rejects non private-asset tokens', () => { @@ -99,6 +132,17 @@ describe('AuthService private asset token helpers', () => { expect(() => authService.verifyPrivateAssetToken(token)).toThrow(); }); + it('rejects private asset tokens with tampered signatures', () => { + const authService = createAuthService(); + const token = authService.createPrivateAssetToken({ + appUid: 'app-9f1c10e3-9a7f-43fb-8671-af4918e65407', + userUid: '9885b80e-1a14-4c8d-9e3f-4fa5915b1136', + }); + const tampered = tamperTokenSignature(token); + + expect(() => authService.verifyPrivateAssetToken(tampered)).toThrow(); + }); + it('returns hardened cookie options with config-driven ttl and domain', () => { const authService = createAuthService(); const options = authService.getPrivateAssetCookieOptions(); @@ -148,7 +192,6 @@ describe('AuthService private asset token helpers', () => { session: sessionUuid, }, authService.global_config.jwt_secret, { expiresIn: 60 }); - // @ts-expect-error test-only lightweight stub authService.get_session_ = vi.fn().mockResolvedValue({ uuid: sessionUuid, user_uid: userUid, @@ -160,7 +203,6 @@ describe('AuthService private asset token helpers', () => { userUid, sessionUuid, }); - // @ts-expect-error test-only lightweight stub expect(authService.get_session_).toHaveBeenCalledWith(sessionUuid); }); @@ -174,7 +216,6 @@ describe('AuthService private asset token helpers', () => { session: 'f9000804-2fd3-4da5-819b-afc5296f90f7', }, authService.global_config.jwt_secret, { expiresIn: 60 }); - // @ts-expect-error test-only lightweight stub authService.get_session_ = vi.fn().mockResolvedValue({ uuid: 'f9000804-2fd3-4da5-819b-afc5296f90f7', user_uid: '9885b80e-1a14-4c8d-9e3f-4fa5915b1136', @@ -184,4 +225,22 @@ describe('AuthService private asset token helpers', () => { .rejects .toThrow(); }); + + it('rejects bootstrap identity token when signature is tampered', async () => { + const authService = createAuthService(); + const userUid = '4b0cecf8-dd6a-4eb5-bcc4-c76cc7e8d7f0'; + const sessionUuid = 'f9000804-2fd3-4da5-819b-afc5296f90f7'; + const token = jwt.sign({ + type: 'app-under-user', + version: '0.0.0', + user_uid: userUid, + app_uid: 'app-7e2d3016-8d36-456a-9dc7-b75b0f4f7683', + session: sessionUuid, + }, authService.global_config.jwt_secret, { expiresIn: 60 }); + const tampered = tamperTokenSignature(token); + + await expect(authService.resolvePrivateBootstrapIdentityFromToken(tampered)) + .rejects + .toThrow(); + }); });