diff --git a/src/backend/src/routers/hosting/puterSiteMiddleware.js b/src/backend/src/routers/hosting/puterSiteMiddleware.js index d207a757f..0da172f6b 100644 --- a/src/backend/src/routers/hosting/puterSiteMiddleware.js +++ b/src/backend/src/routers/hosting/puterSiteMiddleware.js @@ -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; + } })(); @@ -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; diff --git a/src/backend/src/routers/hosting/puterSiteMiddleware.test.js b/src/backend/src/routers/hosting/puterSiteMiddleware.test.js index 69be52f23..c7db2f45e 100644 --- a/src/backend/src/routers/hosting/puterSiteMiddleware.test.js +++ b/src/backend/src/routers/hosting/puterSiteMiddleware.test.js @@ -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; diff --git a/src/backend/src/services/auth/AuthService.js b/src/backend/src/services/auth/AuthService.js index 5efd39c76..13d609c9c 100644 --- a/src/backend/src/services/auth/AuthService.js +++ b/src/backend/src/services/auth/AuthService.js @@ -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. * diff --git a/src/backend/src/services/auth/AuthService.privateAssetToken.test.ts b/src/backend/src/services/auth/AuthService.privateAssetToken.test.ts index bc59cf41d..95ac14ef0 100644 --- a/src/backend/src/services/auth/AuthService.privateAssetToken.test.ts +++ b/src/backend/src/services/auth/AuthService.privateAssetToken.test.ts @@ -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(); + }); });