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
19 changes: 11 additions & 8 deletions apps/backend/src/libs/better-auth/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,14 +319,17 @@ const authInstance: any = betterAuth({
? {
sameSite: 'none', // Allow cross-origin requests
secure: true, // HTTPS only
// Shares the session cookie across sibling subdomains (e.g.
// web and api hosts) so the direct browser -> backend OAuth
// authorize/consent redirect (MCP login flow) can see the
// session set on the frontend host. Unset in envs where web
// and api share one origin.
...(process.env.COOKIE_DOMAIN
? { domain: process.env.COOKIE_DOMAIN }
: {}),
// No `domain` here on purpose. A previous COOKIE_DOMAIN env var
// tried to share the session cookie across the web and api hosts
// so the direct browser -> backend /oauth2/authorize hit would
// see it. Never needed: oauthProvider bounces an unauthenticated
// authorize request to `loginPage` and resumes the flow itself
// once a session cookie is set, which happens on the frontend
// origin via the /auth proxy — which also strips `domain=` from
// every Set-Cookie, so it never reached a browser anyway. If web
// and api ever share a registrable domain, reach for
// better-auth's `advanced.crossSubDomainCookies` rather than
// setting `domain` by hand.
}
: {
sameSite: 'lax', // Standard for same-origin
Expand Down
111 changes: 111 additions & 0 deletions apps/web/__tests__/auth-proxy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { describe, it, expect } from 'vitest';
import {
isTopLevelNavigation,
redirectTargetFromBody,
rewriteCookieForProxy,
} from '@/lib/auth-proxy';

const request = (method: string, headers: Record<string, string>) => ({
method,
headers: new Headers(headers),
});

const jsonResponse = (body: unknown) =>
new Response(JSON.stringify(body), {
headers: { 'content-type': 'application/json' },
});

describe('isTopLevelNavigation', () => {
it('recognises a document navigation', () => {
expect(
isTopLevelNavigation(
request('GET', {
'sec-fetch-mode': 'navigate',
accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
}),
),
).toBe(true);
});

it('leaves an XHR alone, which handles its own redirect', () => {
expect(
isTopLevelNavigation(
request('GET', { 'sec-fetch-mode': 'cors', accept: '*/*' }),
),
).toBe(false);
});

it('never treats a POST as a navigation', () => {
expect(
isTopLevelNavigation(request('POST', { 'sec-fetch-mode': 'navigate' })),
).toBe(false);
});

it('falls back to Accept when sec-fetch-mode is absent', () => {
expect(isTopLevelNavigation(request('GET', { accept: 'text/html' }))).toBe(
true,
);
expect(
isTopLevelNavigation(request('GET', { accept: 'application/json' })),
).toBe(false);
});
});

describe('redirectTargetFromBody', () => {
it("picks up oauth-provider's JSON redirect", async () => {
await expect(
redirectTargetFromBody(
jsonResponse({
redirect: true,
url: 'http://localhost:9876/callback?code=abc',
}),
),
).resolves.toBe('http://localhost:9876/callback?code=abc');
});

it('ignores a sign-in response that carries no redirect', async () => {
await expect(
redirectTargetFromBody(
jsonResponse({ redirect: false, token: 't', user: {} }),
),
).resolves.toBeNull();
});

it('ignores an ordinary payload', async () => {
await expect(
redirectTargetFromBody(jsonResponse({ session: { id: 1 } })),
).resolves.toBeNull();
});

it('ignores a non-JSON body', async () => {
await expect(
redirectTargetFromBody(
new Response('hi', { headers: { 'content-type': 'text/html' } }),
),
).resolves.toBeNull();
});

it('leaves the body readable for the pass-through path', async () => {
const response = jsonResponse({ redirect: false, token: 'abc' });
await redirectTargetFromBody(response);
await expect(response.text()).resolves.toBe(
JSON.stringify({ redirect: false, token: 'abc' }),
);
});
});

describe('rewriteCookieForProxy', () => {
it('drops Domain so the cookie lands on this origin', () => {
expect(
rewriteCookieForProxy(
'session=abc; Path=/; Domain=.example.com; HttpOnly; Secure',
),
).toBe('session=abc; Path=/; HttpOnly; Secure');
});

it('relaxes SameSite=None now that it is same-site', () => {
expect(rewriteCookieForProxy('session=abc; SameSite=None; Secure')).toBe(
'session=abc; SameSite=Lax; Secure',
);
});
});
77 changes: 59 additions & 18 deletions apps/web/src/app/(app)/chat/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,33 +5,64 @@ export const metadata = {
title: 'Chat',
};

// Use backend URL from environment, fallback to localhost for development
// Use NEXT_PUBLIC_BACKEND_URL for client-side consistency
const backendUrl = () =>
process.env.NEXT_PUBLIC_BACKEND_URL ||
process.env.BACKEND_URL ||
'http://localhost:3001';

async function authFetch(path: string) {
const headersList = await headers();
const response = await fetch(`${backendUrl()}${path}`, {
headers: Object.fromEntries(headersList.entries()),
cache: 'no-store',
});

return response.ok ? await response.json() : null;
}

// Server Component layout guard - performs real session validation
async function getSession() {
try {
const headersList = await headers();
// Use backend URL from environment, fallback to localhost for development
// Use NEXT_PUBLIC_BACKEND_URL for client-side consistency
const backendUrl =
process.env.NEXT_PUBLIC_BACKEND_URL ||
process.env.BACKEND_URL ||
'http://localhost:3001';
const response = await fetch(`${backendUrl}/auth/get-session`, {
headers: Object.fromEntries(headersList.entries()),
cache: 'no-store',
});

if (!response.ok) {
return null;
}

const data = await response.json();
return data.session || null;
const data = await authFetch('/auth/get-session');
return data?.session || null;
} catch (error) {
console.error('Failed to get session:', error);
return null;
}
}

/**
* Where to send someone who reaches the app without an organization.
*
* activeOrganizationId is stamped on the session when it is created
* (databaseHooks.session.create.before in the backend's better-auth config)
* and refreshed by setActive when an organization is created or an invitation
* accepted. So its absence is a dependable "no organization yet", and it
* flips the moment they have one — which is what stops this bouncing them
* straight back here.
*/
async function organizationlessDestination() {
// Retry a few times to handle session propagation timing for new accounts
for (let attempt = 0; attempt < 3; attempt++) {
if (attempt > 0) await new Promise((r) => setTimeout(r, 500));

try {
const invitations = await authFetch(
'/auth/organization/list-user-invitations',
);
if (Array.isArray(invitations) && invitations.length > 0) {
return '/settings?tab=invitations';
}
} catch (error) {
console.error('Failed to list invitations:', error);
}
}

return '/organization/create';
}

export default async function ChatLayout({
children,
}: {
Expand All @@ -44,5 +75,15 @@ export default async function ChatLayout({
redirect('/sign-in');
}

// better-auth can only redirect to a fixed callbackURL, but where a login
// belongs depends on state that exists only once there is a session. So
// every login lands here and the decision is made at the door. This also
// catches anyone navigating straight to /chat without an organization, who
// previously reached an app where every request failed with
// NO_ACTIVE_ORGANIZATION.
if (!session.activeOrganizationId) {
redirect(await organizationlessDestination());
}

return <>{children}</>;
}
86 changes: 41 additions & 45 deletions apps/web/src/app/auth/[...auth]/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import {
isTopLevelNavigation,
redirectTargetFromBody,
rewriteCookieForProxy,
} from '@/lib/auth-proxy';

const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3001';

Expand Down Expand Up @@ -40,6 +45,19 @@ export async function PATCH(
return proxyToBackend(request, context);
}

/** Copy the backend's cookies across, re-scoped onto this origin. */
function applyCookies(from: Response, to: NextResponse, label: string) {
from.headers.getSetCookie().forEach((cookie) => {
const rewrittenCookie = rewriteCookieForProxy(cookie);

console.log(`[AUTH PROXY] Rewriting cookie on ${label}:`, {
original: cookie,
rewritten: rewrittenCookie,
});
to.headers.append('Set-Cookie', rewrittenCookie);
});
}

async function proxyToBackend(
request: NextRequest,
context: { params: Promise<{ auth: string[] }> },
Expand Down Expand Up @@ -130,32 +148,33 @@ async function proxyToBackend(
response.status,
);

// Set cookies on redirect response
const cookies = response.headers.getSetCookie();
cookies.forEach((cookie) => {
const rewrittenCookie = cookie
.split(';')
.map((part) => part.trim())
.filter((part) => !part.toLowerCase().startsWith('domain='))
.map((part) => {
if (part.toLowerCase() === 'samesite=none') {
return 'SameSite=Lax';
}
return part;
})
.join('; ');

console.log('[AUTH PROXY] Rewriting cookie on redirect:', {
original: cookie,
rewritten: rewrittenCookie,
});
redirectResponse.headers.append('Set-Cookie', rewrittenCookie);
});
applyCookies(response, redirectResponse, 'redirect');

return redirectResponse;
}
}

// better-auth's oauth-provider content-negotiates its redirects: a browser
// fetch gets { redirect, url } to act on, anything else gets a real 302
// (oauth-provider/dist/index.mjs handleRedirect). It decides by
// sec-fetch-mode === 'cors' — and Node's fetch sets that header on every
// request and will not let us override it, so the backend cannot tell this
// proxy apart from an XHR. Harmless when the browser is doing an XHR and
// will act on the JSON itself. Fatal on a top-level navigation, such as
// returning to /oauth2/authorize after login: the browser renders the JSON
// as text, the authorization code sits unused on screen, and the MCP
// client waits forever. So turn it back into the 302 the backend meant.
if (response.ok && isTopLevelNavigation(request)) {
const location = await redirectTargetFromBody(response);

if (location) {
console.log('[AUTH PROXY] JSON redirect → 302:', location);
const redirectResponse = NextResponse.redirect(location, 302);
applyCookies(response, redirectResponse, 'json redirect');
return redirectResponse;
}
}

// Forward the response
const responseBody = await response.text();
const nextResponse = new NextResponse(responseBody, {
Expand All @@ -179,30 +198,7 @@ async function proxyToBackend(
});

// Forward all Set-Cookie headers, rewriting for same-site usage
const cookies = response.headers.getSetCookie();
cookies.forEach((cookie) => {
// Parse cookie and:
// 1. Remove Domain attribute so it defaults to current domain (frontend)
// 2. Change SameSite=None to SameSite=Lax (we're now same-site, not cross-site)
const rewrittenCookie = cookie
.split(';')
.map((part) => part.trim())
.filter((part) => !part.toLowerCase().startsWith('domain='))
.map((part) => {
// Change SameSite=None to SameSite=Lax
if (part.toLowerCase() === 'samesite=none') {
return 'SameSite=Lax';
}
return part;
})
.join('; ');

console.log('[AUTH PROXY] Rewriting cookie:', {
original: cookie,
rewritten: rewrittenCookie,
});
nextResponse.headers.append('Set-Cookie', rewrittenCookie);
});
applyCookies(response, nextResponse, 'response');

return nextResponse;
} catch (error) {
Expand Down
Loading