mirror of
https://github.com/HeyPuter/puter.git
synced 2026-05-04 08:30:39 +00:00
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:
@@ -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.
|
||||
|
||||
};
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user