feat: resolve private app hosts by index_url fallback (#2583)

* feat: resolve private app hosts by index_url fallback

Adds a private-app lookup fallback for hosted subdomains without associated_app_id by matching owner-scoped index_url candidates built from request host and configured protocol.

* fix: redirect path

* fix: add new domains too

* fix, bootstrap url

* fix: bootstrap url

* fix: auto sign in puter pirvate app
This commit is contained in:
Daniel Salazar
2026-03-03 13:52:12 -08:00
committed by GitHub
parent 930cbfb770
commit 4f5fec5ee4
4 changed files with 656 additions and 25 deletions
@@ -28,6 +28,7 @@ import selectors from '../../filesystem/node/selectors.js';
import { get_app, get_user } from '../../helpers.js';
import api_error_handler from '../../modules/web/lib/api_error_handler.js';
import { Actor, SiteActorType, UserActorType } from '../../services/auth/Actor.js';
import { DB_READ } from '../../services/database/consts.js';
import { PermissionUtil } from '../../services/auth/permissionUtils.mjs';
import { Context } from '../../util/context.js';
import { stream_to_buffer as streamToBuffer } from '../../util/streamutil.js';
@@ -97,18 +98,125 @@ function getSubdomainFromHostedRequest (req) {
}
function buildPrivateHostRedirectUrl (req, app) {
if ( !app?.index_url || typeof app.index_url !== 'string' ) {
if ( ! app ) {
return null;
}
try {
const redirectUrl = new URL(req.originalUrl || '/', app.index_url);
const privateHostingDomain = `${privateAppHostingDomain ?? 'puter.dev'}`
.trim()
.toLowerCase()
.replace(/^\./, '');
if ( ! privateHostingDomain ) {
return null;
}
const subdomain = req.subdomains?.[0] || getSubdomainFromHostedRequest(req);
if ( ! subdomain ) {
return null;
}
const protocol = `${config.protocol ?? 'https'}`
.trim()
.replace(/:$/, '') || 'https';
const requestUrl = `${req.originalUrl || '/'}`.startsWith('/')
? req.originalUrl || '/'
: `/${req.originalUrl}`;
const privateHostOrigin = `${protocol}://${subdomain}.${privateHostingDomain}`;
const redirectUrl = new URL(requestUrl, privateHostOrigin);
return redirectUrl.toString();
} catch {
return null;
}
}
function normalizeHostFromHeader (hostValue) {
if ( typeof hostValue !== 'string' ) return null;
const normalizedHost = hostValue.trim().toLowerCase();
if ( ! normalizedHost ) return null;
try {
return new URL(`http://${normalizedHost}`).host;
} catch {
return normalizedHost;
}
}
function normalizeConfiguredHost (hostValue) {
if ( typeof hostValue !== 'string' ) return null;
const normalizedHost = hostValue.trim().toLowerCase().replace(/^\./, '');
if ( ! normalizedHost ) return null;
return normalizedHost;
}
function buildPrivateAppIndexUrlCandidates (req) {
const protocol = `${config.protocol ?? 'https'}`.trim().replace(/:$/, '') || 'https';
const hostCandidates = new Set();
const hostnameCandidate = normalizeHostFromHeader(req.hostname);
if ( hostnameCandidate ) {
hostCandidates.add(hostnameCandidate);
}
const headerHostCandidate = normalizeHostFromHeader(req.headers?.host);
if ( headerHostCandidate ) {
hostCandidates.add(headerHostCandidate);
}
const hostedSubdomain = getSubdomainFromHostedRequest(req);
if ( hostedSubdomain ) {
const staticHostingDomainCandidate = normalizeConfiguredHost(staticHostingDomain);
const staticHostingDomainAltCandidate = normalizeConfiguredHost(staticHostingDomainAlt);
const privateHostingDomainCandidate = normalizeConfiguredHost(privateAppHostingDomain);
if ( staticHostingDomainCandidate ) {
hostCandidates.add(`${hostedSubdomain}.${staticHostingDomainCandidate}`);
}
if ( staticHostingDomainAltCandidate ) {
hostCandidates.add(`${hostedSubdomain}.${staticHostingDomainAltCandidate}`);
}
if ( privateHostingDomainCandidate ) {
hostCandidates.add(`${hostedSubdomain}.${privateHostingDomainCandidate}`);
}
}
const candidates = [];
for ( const host of hostCandidates ) {
const base = `${protocol}://${host}`;
candidates.push(base);
candidates.push(`${base}/`);
candidates.push(`${base}/index.html`);
}
return [...new Set(candidates)];
}
async function resolvePrivateAppForHostedSite ({ req, site, services, associatedApp }) {
if ( associatedApp ) return associatedApp;
if ( ! site?.user_id ) return null;
const indexUrlCandidates = buildPrivateAppIndexUrlCandidates(req);
if ( indexUrlCandidates.length === 0 ) return null;
const databaseService = services.get('database');
const dbService = databaseService.get(DB_READ, 'apps');
const placeholders = indexUrlCandidates.map(() => '?').join(', ');
const apps = await dbService.read(
`SELECT * FROM apps WHERE owner_user_id = ? AND is_private = 1 AND index_url IN (${placeholders}) LIMIT 2`,
[site.user_id, ...indexUrlCandidates],
);
if ( apps.length > 1 ) {
logPrivateAccessEvent('private_access.host_match_ambiguous', {
requestHost: req.hostname,
siteOwnerUserId: site.user_id,
matchCount: apps.length,
});
}
return apps[0] || null;
}
function getPrivateDeniedRedirectUrl (app, denyRedirectUrl) {
if ( typeof denyRedirectUrl === 'string' && denyRedirectUrl.trim() ) {
return denyRedirectUrl.trim();
@@ -159,9 +267,15 @@ function getBootstrapPrivateToken (req) {
const authorizationToken = getTokenFromAuthorizationHeader(req);
if ( authorizationToken ) return authorizationToken;
const queryToken = req.query?.['puter.auth.token'];
if ( typeof queryToken === 'string' && queryToken.trim() ) {
return queryToken.trim();
const queryTokenCandidates = [
req.query?.['puter.auth.token'],
req.query?.puter?.auth?.token,
req.query?.auth_token,
];
for ( const queryTokenCandidate of queryTokenCandidates ) {
if ( typeof queryTokenCandidate === 'string' && queryTokenCandidate.trim() ) {
return queryTokenCandidate.trim();
}
}
const headerToken = req.headers?.['x-puter-auth-token'];
@@ -249,6 +363,7 @@ async function resolvePrivateIdentity ({ req, services, appUid }) {
const bootstrapToken = getBootstrapPrivateToken(req);
if ( typeof bootstrapToken === 'string' && bootstrapToken ) {
let strictAuthError;
try {
const actor = await authService.authenticate_from_token(bootstrapToken);
const identity = actorToPrivateIdentity(actor);
@@ -261,8 +376,37 @@ async function resolvePrivateIdentity ({ req, services, appUid }) {
hasInvalidPrivateCookie,
};
}
} catch {
// no valid identity from bootstrap token
} catch (e) {
strictAuthError = e;
}
if ( typeof authService.resolvePrivateBootstrapIdentityFromToken === 'function' ) {
try {
const identity = await authService.resolvePrivateBootstrapIdentityFromToken(bootstrapToken);
if ( identity ) {
logPrivateAccessEvent('private_access.bootstrap_fallback_allowed', {
appUid,
userUid: identity.userUid ?? null,
requestHost: req.hostname,
source: 'bootstrap-token',
});
return {
source: 'bootstrap-token',
...identity,
hasValidPrivateCookie: false,
hasPrivateCookie,
hasInvalidPrivateCookie,
};
}
} catch (e) {
logPrivateAccessEvent('private_access.bootstrap_fallback_rejected', {
appUid,
requestHost: req.hostname,
source: 'bootstrap-token',
reason: e?.code || e?.message || 'unknown',
strictReason: strictAuthError?.code || strictAuthError?.message || null,
});
}
}
}
@@ -377,34 +521,57 @@ function respondPrivateLoginBootstrap ({ res, app }) {
const statusNode = document.getElementById('status');
const loginButton = document.getElementById('loginButton');
const retryButton = document.getElementById('retryButton');
const attemptedTokenStorageKey = 'puter.privateAppBootstrap.lastAttemptedToken';
const setStatus = (message) => {
statusNode.textContent = message;
};
const getStoredAuthToken = () => {
return globalThis.puter?.authToken
|| localStorage.getItem('auth_token')
|| localStorage.getItem('puter.auth.token');
};
const redirectWithToken = (token) => {
if ( typeof token !== 'string' || !token ) {
throw new Error('missing_auth_token');
}
sessionStorage.setItem(attemptedTokenStorageKey, token);
const url = new URL(window.location.href);
url.searchParams.set('puter.auth.token', token);
window.location.replace(url.toString());
};
const tryStoredTokenBootstrap = () => {
const currentUrl = new URL(window.location.href);
const currentUrlToken = currentUrl.searchParams.get('puter.auth.token');
const storedToken = getStoredAuthToken();
if ( typeof storedToken !== 'string' || !storedToken ) return false;
// Avoid looping on the exact same token if backend already rejected it.
if ( currentUrlToken && currentUrlToken === storedToken ) return false;
const lastAttemptedToken = sessionStorage.getItem(attemptedTokenStorageKey);
if ( lastAttemptedToken && lastAttemptedToken === storedToken ) return false;
setStatus('Using saved Puter session...');
redirectWithToken(storedToken);
return true;
};
const authenticate = async () => {
loginButton.disabled = true;
setStatus('Authenticating with Puter...');
try {
if ( globalThis.puter?.authToken ) {
redirectWithToken(globalThis.puter.authToken);
if ( tryStoredTokenBootstrap() ) {
return;
}
const result = await globalThis.puter.auth.signIn();
const authToken =
result?.token
|| globalThis.puter?.authToken
|| localStorage.getItem('puter.auth.token');
|| getStoredAuthToken();
redirectWithToken(authToken);
} catch (error) {
console.error('private app sign in failed', error);
@@ -420,6 +587,10 @@ function respondPrivateLoginBootstrap ({ res, app }) {
retryButton.addEventListener('click', () => {
window.location.reload();
});
if ( tryStoredTokenBootstrap() ) {
return;
}
})();
</script>
</body>
@@ -591,7 +762,13 @@ async function runInternal (req, res, next) {
const associatedApp = site.associated_app_id
? await get_app({ id: site.associated_app_id })
: null;
const privateAppEnabled = isPrivateApp(associatedApp);
const privateApp = await resolvePrivateAppForHostedSite({
req,
site,
services,
associatedApp,
});
const privateAppEnabled = isPrivateApp(privateApp);
const privateAccessGateEnabled = isPrivateAccessGateEnabled();
if (
@@ -599,10 +776,10 @@ async function runInternal (req, res, next) {
&& privateAppEnabled
&& !hostMatchesPrivateDomain(req.hostname)
) {
const privateHostRedirect = buildPrivateHostRedirectUrl(req, associatedApp);
const privateHostRedirect = buildPrivateHostRedirectUrl(req, privateApp);
if ( privateHostRedirect ) {
logPrivateAccessEvent('private_access.host_redirect', {
appUid: associatedApp?.uid ?? null,
appUid: privateApp?.uid ?? null,
requestHost: req.hostname,
requestPath: req.path,
redirectUrl: privateHostRedirect,
@@ -614,6 +791,7 @@ async function runInternal (req, res, next) {
if (
site.associated_app_id &&
!privateAppEnabled &&
!req.query['puter.app_instance_id'] &&
( path === '' || path.endsWith('/') )
) {
@@ -669,7 +847,7 @@ async function runInternal (req, res, next) {
req,
res,
services,
app: associatedApp,
app: privateApp,
requestPath: req.path,
});
if ( ! accessAllowed ) return;
@@ -261,7 +261,7 @@ describe('PuterSiteMiddleware', () => {
capturedMiddleware = puterSiteMiddleware;
});
it('redirects private app assets to puter.dev host', async () => {
it('redirects private app assets to puter.dev host even before index_url migration', async () => {
const mockServices = {
get: vi.fn().mockImplementation((serviceName) => {
if ( serviceName === 'puter-site' ) {
@@ -285,7 +285,7 @@ describe('PuterSiteMiddleware', () => {
uid: 'app-11111111-1111-1111-1111-111111111111',
name: 'paid-app',
is_private: 1,
index_url: 'https://paid.puter.dev/',
index_url: 'https://paid.site.puter.localhost/',
});
const mockReq = {
@@ -315,6 +315,130 @@ describe('PuterSiteMiddleware', () => {
});
it('serves login bootstrap html when private app identity is missing', async () => {
const eventEmit = vi.fn().mockImplementation(async (_eventName, event) => {
event.result.allowed = false;
event.result.redirectUrl = 'https://puter.com/app/app-center/?item=app-11111111-1111-1111-1111-111111111111';
});
const dbRead = vi.fn().mockResolvedValue([
{
uid: 'app-11111111-1111-1111-1111-111111111111',
name: 'paid-app',
is_private: 1,
index_url: 'https://paid.puter.dev/',
owner_user_id: 101,
},
]);
const authService = {
getPrivateAssetCookieName: vi.fn().mockReturnValue('puter.private.asset.token'),
verifyPrivateAssetToken: vi.fn().mockImplementation(() => {
throw new Error('invalid');
}),
authenticate_from_token: vi.fn().mockImplementation(() => {
throw new Error('invalid');
}),
createPrivateAssetToken: vi.fn().mockReturnValue('private-token'),
getPrivateAssetCookieOptions: vi.fn().mockReturnValue({}),
};
const mockServices = {
get: vi.fn().mockImplementation((serviceName) => {
if ( serviceName === 'puter-site' ) {
return {
get_subdomain: vi.fn().mockResolvedValue({
user_id: 101,
associated_app_id: null,
root_dir_id: 303,
}),
};
}
if ( serviceName === 'filesystem' ) {
return {
node: vi.fn().mockResolvedValue({
exists: vi.fn().mockResolvedValue(true),
get: vi.fn().mockImplementation(async (fieldName) => {
if ( fieldName === 'type' ) return 'directory';
if ( fieldName === 'path' ) return '/alice/Public';
return null;
}),
}),
};
}
if ( serviceName === 'acl' ) {
return {
check: vi.fn().mockResolvedValue(true),
};
}
if ( serviceName === 'database' ) {
return {
get: vi.fn().mockReturnValue({
read: dbRead,
}),
};
}
if ( serviceName === 'event' ) return { emit: eventEmit };
if ( serviceName === 'auth' ) return authService;
return {};
}),
};
mockContextInstance.get.mockImplementation((key) => {
if ( key === 'services' ) return mockServices;
return null;
});
getUserMockImpl = async () => ({ id: 101, suspended: false });
getAppMockImpl = async () => ({
uid: 'app-11111111-1111-1111-1111-111111111111',
name: 'paid-app',
is_private: 1,
index_url: 'https://paid.puter.dev/',
});
const mockReq = {
hostname: 'paid.puter.dev',
subdomains: [],
is_custom_domain: false,
baseUrl: '',
path: '/index.html',
originalUrl: '/index.html',
cookies: {},
headers: {},
query: {},
ctx: mockContextInstance,
};
const mockRes = {
redirect: vi.fn(),
cookie: vi.fn(),
set: vi.fn().mockReturnThis(),
setHeader: vi.fn(),
status: vi.fn().mockReturnThis(),
send: vi.fn(),
};
const mockNext = vi.fn();
await capturedMiddleware(mockReq, mockRes, mockNext);
expect(eventEmit).not.toHaveBeenCalled();
expect(dbRead).toHaveBeenCalledWith(
expect.stringContaining('index_url IN'),
expect.arrayContaining([
101,
'https://paid.puter.dev',
'https://paid.puter.dev/',
'https://paid.puter.dev/index.html',
'https://paid.site.puter.localhost',
'https://paid.site.puter.localhost/',
'https://paid.site.puter.localhost/index.html',
]),
);
expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.send).toHaveBeenCalledWith(expect.stringContaining('https://js.puter.com/v2/'));
expect(mockRes.send).toHaveBeenCalledWith(expect.stringContaining('puter.auth.signIn()'));
expect(mockRes.send).toHaveBeenCalledWith(expect.stringContaining('localStorage.getItem(\'auth_token\')'));
expect(mockRes.send).toHaveBeenCalledWith(expect.stringContaining('tryStoredTokenBootstrap'));
expect(mockRes.redirect).not.toHaveBeenCalled();
expect(mockRes.cookie).not.toHaveBeenCalled();
expect(mockNext).not.toHaveBeenCalled();
});
it('does not redirect private root requests to puter.com app route before access bootstrap', async () => {
const eventEmit = vi.fn().mockImplementation(async (_eventName, event) => {
event.result.allowed = false;
event.result.redirectUrl = 'https://puter.com/app/app-center/?item=app-11111111-1111-1111-1111-111111111111';
@@ -372,7 +496,7 @@ describe('PuterSiteMiddleware', () => {
uid: 'app-11111111-1111-1111-1111-111111111111',
name: 'paid-app',
is_private: 1,
index_url: 'https://paid.puter.dev/',
index_url: 'https://paid.site.puter.localhost/',
});
const mockReq = {
@@ -380,11 +504,13 @@ describe('PuterSiteMiddleware', () => {
subdomains: [],
is_custom_domain: false,
baseUrl: '',
path: '/index.html',
originalUrl: '/index.html',
path: '/',
originalUrl: '/?puter.auth.token=abc',
cookies: {},
headers: {},
query: {},
query: {
'puter.auth.token': 'abc',
},
ctx: mockContextInstance,
};
const mockRes = {
@@ -399,12 +525,10 @@ describe('PuterSiteMiddleware', () => {
await capturedMiddleware(mockReq, mockRes, mockNext);
expect(eventEmit).not.toHaveBeenCalled();
expect(mockRes.redirect).not.toHaveBeenCalledWith('https://puter.com/app/paid-app/');
expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.send).toHaveBeenCalledWith(expect.stringContaining('https://js.puter.com/v2/'));
expect(mockRes.send).toHaveBeenCalledWith(expect.stringContaining('puter.auth.signIn()'));
expect(mockRes.redirect).not.toHaveBeenCalled();
expect(mockRes.cookie).not.toHaveBeenCalled();
expect(mockNext).not.toHaveBeenCalled();
});
@@ -512,6 +636,215 @@ describe('PuterSiteMiddleware', () => {
expect(mockNext).not.toHaveBeenCalled();
});
it('uses bootstrap fallback identity when strict bootstrap auth fails', async () => {
const eventEmit = vi.fn().mockImplementation(async (_eventName, event) => {
event.result.allowed = false;
event.result.redirectUrl = 'https://apps.puter.com/app/paid-app';
});
const authService = {
getPrivateAssetCookieName: vi.fn().mockReturnValue('puter.private.asset.token'),
verifyPrivateAssetToken: vi.fn().mockImplementation(() => {
throw new Error('invalid');
}),
authenticate_from_token: vi.fn().mockImplementation(() => {
throw new Error('token_auth_failed');
}),
resolvePrivateBootstrapIdentityFromToken: vi.fn().mockResolvedValue({
userUid: 'user-111',
sessionUuid: 'session-111',
}),
createPrivateAssetToken: vi.fn().mockReturnValue('private-token'),
getPrivateAssetCookieOptions: vi.fn().mockReturnValue({}),
};
const mockServices = {
get: vi.fn().mockImplementation((serviceName) => {
if ( serviceName === 'puter-site' ) {
return {
get_subdomain: vi.fn().mockResolvedValue({
user_id: 101,
associated_app_id: 202,
root_dir_id: 303,
}),
};
}
if ( serviceName === 'filesystem' ) {
return {
node: vi.fn().mockResolvedValue({
exists: vi.fn().mockResolvedValue(true),
get: vi.fn().mockImplementation(async (fieldName) => {
if ( fieldName === 'type' ) return 'directory';
if ( fieldName === 'path' ) return '/alice/Public';
return null;
}),
}),
};
}
if ( serviceName === 'acl' ) {
return {
check: vi.fn().mockResolvedValue(true),
};
}
if ( serviceName === 'event' ) return { emit: eventEmit };
if ( serviceName === 'auth' ) return authService;
return {};
}),
};
mockContextInstance.get.mockImplementation((key) => {
if ( key === 'services' ) return mockServices;
return null;
});
getUserMockImpl = async () => ({ id: 101, suspended: false });
getAppMockImpl = async () => ({
uid: 'app-11111111-1111-1111-1111-111111111111',
name: 'paid-app',
is_private: 1,
index_url: 'https://paid.puter.dev/',
});
const mockReq = {
hostname: 'paid.puter.dev',
subdomains: [],
is_custom_domain: false,
baseUrl: '',
path: '/index.html',
originalUrl: '/index.html?puter.auth.token=bootstrap-token',
cookies: {},
headers: {},
query: {
'puter.auth.token': 'bootstrap-token',
},
ctx: mockContextInstance,
};
const mockRes = {
redirect: vi.fn(),
cookie: vi.fn(),
setHeader: vi.fn(),
status: vi.fn().mockReturnThis(),
send: vi.fn(),
};
const mockNext = vi.fn();
await capturedMiddleware(mockReq, mockRes, mockNext);
expect(authService.authenticate_from_token).toHaveBeenCalledWith('bootstrap-token');
expect(authService.resolvePrivateBootstrapIdentityFromToken)
.toHaveBeenCalledWith('bootstrap-token');
expect(eventEmit).toHaveBeenCalledWith(
'app.privateAccess.check',
expect.objectContaining({
appUid: 'app-11111111-1111-1111-1111-111111111111',
userUid: 'user-111',
}),
);
expect(mockRes.redirect).toHaveBeenCalledWith('https://apps.puter.com/app/paid-app');
expect(mockRes.send).not.toHaveBeenCalled();
expect(mockRes.cookie).not.toHaveBeenCalled();
expect(mockNext).not.toHaveBeenCalled();
});
it('accepts nested query token key for bootstrap auth', async () => {
const eventEmit = vi.fn().mockImplementation(async (_eventName, event) => {
event.result.allowed = false;
event.result.redirectUrl = 'https://apps.puter.com/app/paid-app';
});
const authService = {
getPrivateAssetCookieName: vi.fn().mockReturnValue('puter.private.asset.token'),
verifyPrivateAssetToken: vi.fn().mockImplementation(() => {
throw new Error('invalid');
}),
authenticate_from_token: vi.fn().mockImplementation(() => {
throw new Error('token_auth_failed');
}),
resolvePrivateBootstrapIdentityFromToken: vi.fn().mockResolvedValue({
userUid: 'user-111',
sessionUuid: 'session-111',
}),
createPrivateAssetToken: vi.fn().mockReturnValue('private-token'),
getPrivateAssetCookieOptions: vi.fn().mockReturnValue({}),
};
const mockServices = {
get: vi.fn().mockImplementation((serviceName) => {
if ( serviceName === 'puter-site' ) {
return {
get_subdomain: vi.fn().mockResolvedValue({
user_id: 101,
associated_app_id: 202,
root_dir_id: 303,
}),
};
}
if ( serviceName === 'filesystem' ) {
return {
node: vi.fn().mockResolvedValue({
exists: vi.fn().mockResolvedValue(true),
get: vi.fn().mockImplementation(async (fieldName) => {
if ( fieldName === 'type' ) return 'directory';
if ( fieldName === 'path' ) return '/alice/Public';
return null;
}),
}),
};
}
if ( serviceName === 'acl' ) {
return {
check: vi.fn().mockResolvedValue(true),
};
}
if ( serviceName === 'event' ) return { emit: eventEmit };
if ( serviceName === 'auth' ) return authService;
return {};
}),
};
mockContextInstance.get.mockImplementation((key) => {
if ( key === 'services' ) return mockServices;
return null;
});
getUserMockImpl = async () => ({ id: 101, suspended: false });
getAppMockImpl = async () => ({
uid: 'app-11111111-1111-1111-1111-111111111111',
name: 'paid-app',
is_private: 1,
index_url: 'https://paid.puter.dev/',
});
const mockReq = {
hostname: 'paid.puter.dev',
subdomains: [],
is_custom_domain: false,
baseUrl: '',
path: '/index.html',
originalUrl: '/index.html?puter.auth.token=bootstrap-token',
cookies: {},
headers: {},
query: {
puter: {
auth: {
token: 'bootstrap-token',
},
},
},
ctx: mockContextInstance,
};
const mockRes = {
redirect: vi.fn(),
cookie: vi.fn(),
setHeader: vi.fn(),
status: vi.fn().mockReturnThis(),
send: vi.fn(),
};
const mockNext = vi.fn();
await capturedMiddleware(mockReq, mockRes, mockNext);
expect(authService.authenticate_from_token).toHaveBeenCalledWith('bootstrap-token');
expect(authService.resolvePrivateBootstrapIdentityFromToken)
.toHaveBeenCalledWith('bootstrap-token');
expect(mockRes.redirect).toHaveBeenCalledWith('https://apps.puter.com/app/paid-app');
expect(mockRes.send).not.toHaveBeenCalled();
expect(mockRes.cookie).not.toHaveBeenCalled();
expect(mockNext).not.toHaveBeenCalled();
});
it('skips private app gate when feature flag is disabled', async () => {
config.enable_private_app_access_gate = false;
@@ -400,6 +400,75 @@ class AuthService extends BaseService {
};
}
resolvePrivateBootstrapSessionUuid (decoded) {
if ( !decoded || typeof decoded !== 'object' ) {
return null;
}
if ( decoded.type === 'session' || decoded.type === 'gui' ) {
if ( typeof decoded.uuid !== 'string' || !decoded.uuid ) {
return null;
}
return decoded.uuid;
}
if ( decoded.type === 'app-under-user' ) {
if ( typeof decoded.session !== 'string' || !decoded.session ) {
return null;
}
try {
return this.uuid_fpe.decrypt(decoded.session);
} catch (e) {
return null;
}
}
return null;
}
async resolvePrivateBootstrapIdentityFromToken (token) {
let decoded;
try {
decoded = this.modules.jwt.verify(token, this.global_config.jwt_secret);
} catch (e) {
throw APIError.create('token_auth_failed');
}
const userUid = typeof decoded?.user_uid === 'string'
? decoded.user_uid
: null;
if ( ! userUid ) {
throw APIError.create('token_auth_failed');
}
const allowedTypes = new Set(['session', 'gui', 'app-under-user']);
if ( ! allowedTypes.has(decoded.type) ) {
throw APIError.create('token_auth_failed');
}
const sessionUuid = this.resolvePrivateBootstrapSessionUuid(decoded);
if ( ! sessionUuid ) {
throw APIError.create('token_auth_failed');
}
const session = await this.get_session_(sessionUuid);
if ( ! session ) {
throw APIError.create('token_auth_failed');
}
const sessionUserUid = typeof session.user_uid === 'string'
? session.user_uid
: null;
if ( !sessionUserUid || sessionUserUid !== userUid ) {
throw APIError.create('token_auth_failed');
}
return {
userUid,
sessionUuid: session.uuid || sessionUuid,
};
}
/**
* Internal method for creating a session.
*
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import * as jwt from 'jsonwebtoken';
import { AuthService } from './AuthService.js';
@@ -39,6 +39,8 @@ const createAuthService = (): AuthServiceForPrivateTokenTests => {
encrypt: (value) => value,
decrypt: (value) => value,
};
// @ts-expect-error test-only lightweight stub
authService.get_session_ = vi.fn().mockResolvedValue(undefined);
return authService;
};
@@ -107,4 +109,53 @@ describe('AuthService private asset token helpers', () => {
expect(options.maxAge).toBe(3_600_000);
expect(options.domain).toBe('.app.puter.localhost');
});
it('resolves bootstrap identity from app-under-user token without app lookup', 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 });
// @ts-expect-error test-only lightweight stub
authService.get_session_ = vi.fn().mockResolvedValue({
uuid: sessionUuid,
user_uid: userUid,
});
const identity = await authService.resolvePrivateBootstrapIdentityFromToken(token);
expect(identity).toEqual({
userUid,
sessionUuid,
});
// @ts-expect-error test-only lightweight stub
expect(authService.get_session_).toHaveBeenCalledWith(sessionUuid);
});
it('rejects bootstrap identity when session owner does not match token user', async () => {
const authService = createAuthService();
const token = jwt.sign({
type: 'app-under-user',
version: '0.0.0',
user_uid: '4b0cecf8-dd6a-4eb5-bcc4-c76cc7e8d7f0',
app_uid: 'app-7e2d3016-8d36-456a-9dc7-b75b0f4f7683',
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',
});
await expect(authService.resolvePrivateBootstrapIdentityFromToken(token))
.rejects
.toThrow();
});
});