Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions packages/owletto-backend/src/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Comment on lines +135 to +136
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include non-HTTPS wildcard in cookie-domain trusted origins

This adds only https://*.{domain} and https://{domain} when AUTH_COOKIE_DOMAIN is set, so cross-subdomain auth still fails for HTTP deployments (for example self-hosted/staging environments that intentionally run without TLS) because Origin: http://tenant.example.com can never match the new entries. Since this block is meant to make cross-subdomain requests work generically, derive the scheme from the resolved base URL or use a protocol-agnostic wildcard so non-HTTPS environments don't regress.

Useful? React with 👍 / 👎.

}
}

const auth = betterAuth({
secret: env.BETTER_AUTH_SECRET,
database: pool,
Expand Down
19 changes: 17 additions & 2 deletions packages/owletto-backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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);
}
}
Expand Down
15 changes: 15 additions & 0 deletions packages/owletto-backend/src/utils/__tests__/public-origin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
12 changes: 10 additions & 2 deletions packages/owletto-backend/src/utils/public-origin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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}`;
}
Loading