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();
+ });
});