feat: add subdomain to private asset tokens (#2591)

This commit is contained in:
Daniel Salazar
2026-03-03 20:37:17 -08:00
committed by GitHub
parent 468558f8dc
commit adf034b120
5 changed files with 136 additions and 42 deletions
@@ -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(),
@@ -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',
+2
View File
@@ -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 {
+59 -39
View File
@@ -17,13 +17,14 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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<Actor>} 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<void>}
*/
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}`;
}
@@ -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<typeof jwt.sign>[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();
});
});