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
This commit is contained in:
Daniel Salazar
2026-02-26 14:19:59 -08:00
committed by GitHub
parent f8560cf0f9
commit 15e7a3503b
3 changed files with 294 additions and 30 deletions
+4 -2
View File
@@ -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.
};
+180 -28
View File
@@ -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<Actor>} 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;
@@ -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');
});
});