diff --git a/packages/owletto-backend/src/auth/index.ts b/packages/owletto-backend/src/auth/index.ts index 97fd0b019..ca7c62454 100644 --- a/packages/owletto-backend/src/auth/index.ts +++ b/packages/owletto-backend/src/auth/index.ts @@ -126,6 +126,17 @@ export async function createAuth(env: Env, request?: Request) { // Also trust the baseURL (resolves from PUBLIC_WEB_URL, forwarded headers, or request URL) addTrustedOriginVariants(resolveBaseUrl({ request })); + // When AUTH_COOKIE_DOMAIN is set (e.g. ".lobu.ai"), trust all subdomains so + // session cookies travel across {org}.lobu.ai → lobu.ai cross-origin requests. + const cookieDomain = process.env.AUTH_COOKIE_DOMAIN?.trim(); + if (cookieDomain) { + const normalized = cookieDomain.startsWith('.') ? cookieDomain.slice(1) : cookieDomain; + if (normalized) { + trustedOriginSet.add(`https://*.${normalized}`); + trustedOriginSet.add(`https://${normalized}`); + } + } + const auth = betterAuth({ secret: env.BETTER_AUTH_SECRET, database: pool, diff --git a/packages/owletto-backend/src/index.ts b/packages/owletto-backend/src/index.ts index d182e5fda..a55a15949 100644 --- a/packages/owletto-backend/src/index.ts +++ b/packages/owletto-backend/src/index.ts @@ -264,8 +264,23 @@ app.use('/*', async (c, next) => { /** * Subdomain org extraction middleware - * Parses Host header for {org}.{baseDomain} pattern and sets subdomainOrg + * Parses Host header for {org}.{baseDomain} pattern and sets subdomainOrg. + * Reserved subdomains (www, api, app, admin, etc.) are not treated as orgs. */ +const RESERVED_SUBDOMAINS = new Set([ + 'www', + 'api', + 'app', + 'admin', + 'auth', + 'mcp', + 'static', + 'assets', + 'cdn', + 'docs', + 'mail', +]); + app.use('/*', async (c, next) => { c.set('subdomainOrg', null); const baseUrlValue = getConfiguredPublicOrigin(); @@ -275,7 +290,7 @@ app.use('/*', async (c, next) => { const host = c.req.header('host')?.split(':')[0]; if (host?.endsWith(`.${baseDomain}`)) { const sub = host.slice(0, -(baseDomain.length + 1)); - if (sub && !sub.includes('.')) { + if (sub && !sub.includes('.') && !RESERVED_SUBDOMAINS.has(sub.toLowerCase())) { c.set('subdomainOrg', sub); } } diff --git a/packages/owletto-backend/src/utils/__tests__/public-origin.test.ts b/packages/owletto-backend/src/utils/__tests__/public-origin.test.ts index ea15f8252..e3212c0fb 100644 --- a/packages/owletto-backend/src/utils/__tests__/public-origin.test.ts +++ b/packages/owletto-backend/src/utils/__tests__/public-origin.test.ts @@ -31,4 +31,19 @@ describe('getCanonicalRedirectUrl', () => { getCanonicalRedirectUrl('http://localhost:8787/brand/acme', 'https://community.lobu.ai') ).toBeNull(); }); + + it('preserves sibling subdomains under the auth cookie zone', () => { + expect( + getCanonicalRedirectUrl('https://acme.lobu.ai/dashboard', 'https://app.lobu.ai', '.lobu.ai') + ).toBeNull(); + expect( + getCanonicalRedirectUrl('https://lobu.ai/marketing', 'https://app.lobu.ai', '.lobu.ai') + ).toBeNull(); + }); + + it('still redirects unrelated hosts when a cookie zone is set', () => { + expect( + getCanonicalRedirectUrl('https://owletto.com/foo', 'https://app.lobu.ai', '.lobu.ai') + ).toBe('https://app.lobu.ai/foo'); + }); }); diff --git a/packages/owletto-backend/src/utils/public-origin.ts b/packages/owletto-backend/src/utils/public-origin.ts index 80bf88967..22cbdc573 100644 --- a/packages/owletto-backend/src/utils/public-origin.ts +++ b/packages/owletto-backend/src/utils/public-origin.ts @@ -75,11 +75,14 @@ const LOCALHOST_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', '[::1]']); /** * Returns a canonical redirect URL when browser traffic lands on a non-canonical * host while a public origin is configured. Subdomains of the canonical host are - * preserved so workspace routing keeps working. + * preserved so workspace routing keeps working. When AUTH_COOKIE_DOMAIN is set + * (e.g. ".lobu.ai") sibling subdomains of that zone are also preserved so + * per-org subdomains like acme.lobu.ai are not bounced to app.lobu.ai. */ export function getCanonicalRedirectUrl( requestUrl: string, - configuredOrigin = getConfiguredPublicOrigin() + configuredOrigin = getConfiguredPublicOrigin(), + cookieDomain = process.env.AUTH_COOKIE_DOMAIN ): string | null { if (!configuredOrigin) return null; @@ -102,5 +105,10 @@ export function getCanonicalRedirectUrl( return null; } + const cookieZone = cookieDomain?.trim().replace(/^\./, '').toLowerCase(); + if (cookieZone && (requestHost === cookieZone || requestHost.endsWith(`.${cookieZone}`))) { + return null; + } + return `${canonical.origin}${request.pathname}${request.search}`; }