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