From 70e84051f7fc1b7d4089dd3155e6749729337c2d Mon Sep 17 00:00:00 2001 From: c9s-ai Date: Sun, 20 Sep 2026 13:41:19 +0530 Subject: [PATCH 1/9] fix(auth): let the OAuth provider resume authorization after sign-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An MCP client sending a user through /oauth2/authorize landed them on /chat instead of the consent screen, so the client sat waiting on a callback that never arrived. better-auth's oauth-provider resumes the authorization on its own: the client plugin attaches the signed authorization query to the sign-in request, and a server after-hook re-runs the authorize step as soon as a session cookie is set, returning the consent URL on the sign-in response. EmailPasswordAuth discarded that response and routed to /chat unconditionally. Follow the returned URL when there is one, on both sign-in and sign-up. Also drop COOKIE_DOMAIN. It was added so the direct browser -> backend authorize hit would see the session, which the resume above makes unnecessary — an unauthenticated authorize request is the expected state and bounces to the login page by design. It could not have worked regardless: web and api are sibling *.fly.dev hosts, and fly.dev is a public suffix, so browsers reject the Domain attribute outright. The /auth proxy also strips domain= from every Set-Cookie, so it never reached a browser on the login path. Left a comment in its place pointing at advanced.crossSubDomainCookies, which is the supported option if web and api ever share a registrable domain. Co-Authored-By: Claude Opus 5 (1M context) --- apps/backend/src/libs/better-auth/auth.ts | 19 +++++++++++-------- .../components/auth/email-password-auth.tsx | 18 ++++++++++++++++-- 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/apps/backend/src/libs/better-auth/auth.ts b/apps/backend/src/libs/better-auth/auth.ts index a39fea6c..e77cc5c6 100644 --- a/apps/backend/src/libs/better-auth/auth.ts +++ b/apps/backend/src/libs/better-auth/auth.ts @@ -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 web/api hosts so the + // direct browser -> backend /oauth2/authorize hit would see it. + // That was 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. It also could not work on *.fly.dev + // (a public suffix — browsers reject `Domain=fly.dev`), and the + // proxy strips `domain=` from every Set-Cookie anyway. If web and + // api ever become real siblings of a domain we own, use + // better-auth's `advanced.crossSubDomainCookies` instead. } : { sameSite: 'lax', // Standard for same-origin diff --git a/apps/web/src/components/auth/email-password-auth.tsx b/apps/web/src/components/auth/email-password-auth.tsx index 99a70aa2..bce70ee2 100644 --- a/apps/web/src/components/auth/email-password-auth.tsx +++ b/apps/web/src/components/auth/email-password-auth.tsx @@ -27,6 +27,20 @@ export function EmailPasswordAuth({ const isSignup = mode === 'signup'; + /** + * When an MCP client sent the user here via /oauth2/authorize, the + * oauth-provider plugin resumes that authorization the moment a session + * cookie is set and hands the next hop (the consent page) back on the + * sign-in response. Follow it instead of falling through to the default + * post-login routing, which would strand the client waiting on a callback. + */ + const followOAuthResume = (data: unknown) => { + const resume = data as { redirect?: boolean; url?: string } | null; + if (!resume?.redirect || !resume.url) return false; + window.location.href = resume.url; + return true; + }; + /** Shared post-auth redirect: org → chat, invitations → settings, else → create org */ const redirectAfterAuth = async () => { const { data } = await authClient.organization.list(); @@ -69,7 +83,7 @@ export function EmailPasswordAuth({ result.error.message || 'Failed to create account. Please try again.', ); - } else { + } else if (!followOAuthResume(result.data)) { await redirectAfterAuth(); } } else { @@ -83,7 +97,7 @@ export function EmailPasswordAuth({ result.error.message || 'Failed to sign in. Please check your credentials.', ); - } else { + } else if (!followOAuthResume(result.data)) { await redirectAfterAuth(); } } From 6a9e89dd432400f3754eabf2e64a149a5dea44c7 Mon Sep 17 00:00:00 2001 From: c9s-ai Date: Sun, 20 Sep 2026 14:54:47 +0530 Subject: [PATCH 2/9] chore: stop sharp attempting a source build on install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every install on a machine with a system libvips printed: sharp: Attempting to build from source via node-gyp sharp: Please add node-gyp to your dependencies sharp 0.34's install script is `node install/check.js || npm run build`, and check.js exits 1 whenever useGlobalLibvips() finds a compatible system libvips (lib/libvips.js:176) — true on any host with libvips-dev or Homebrew libvips. That exit is what selects the source-build fallback, which then dies because node-gyp is not a dependency. It was cosmetic so far: the binary comes from the @img/sharp-* optional deps, which pnpm installs as ordinary packages and which are what require('sharp') actually loads. But it is noise on every install and would be a hard failure on a platform with no prebuild. We never want a source build, so deny the script. check.js does nothing on success, so skipping it loses nothing. Verified with a frozen-lockfile install: no build attempt, no ignored-scripts warning, pendingBuilds empty, lockfile unchanged, and require('sharp') still loads libvips 8.17.3. Co-Authored-By: Claude Opus 5 (1M context) --- pnpm-workspace.yaml | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 9630527e..9a7da407 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -30,7 +30,7 @@ overrides: # pnpm blocks dependency install/postinstall scripts unless they are listed # here (`pnpm approve-builds` maintains this). @scarf/scarf is denied on -# purpose: it is analytics, not a build. +# purpose: it is analytics, not a build. sharp is denied too — see below. allowBuilds: '@parcel/watcher': true '@prisma/engines': true @@ -40,5 +40,14 @@ allowBuilds: esbuild: true nx: true prisma: true - sharp: true + # sharp's install script is `node install/check.js || npm run build`, and + # check.js exits 1 whenever it detects a compatible system libvips + # (lib/libvips.js useGlobalLibvips) — true on any machine with libvips-dev + # or Homebrew libvips. That exit triggers the source-build fallback, which + # dies on `sharp: Please add node-gyp to your dependencies` and prints a + # scary error on every install. We never want a source build: sharp 0.34 + # ships its binary as the @img/sharp-* optional deps, which pnpm installs + # as ordinary packages and which are what require('sharp') loads. Skipping + # the script loses nothing (check.js does nothing on success). + sharp: false unrs-resolver: true From 06586193de1b4dacf8e0f49fd2f031a9e5eb316e Mon Sep 17 00:00:00 2001 From: c9s-ai Date: Sun, 20 Sep 2026 16:01:31 +0530 Subject: [PATCH 3/9] fix(auth): carry the authorization query across the sign-up link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on the sign-in resume. The signed authorization query lands on whichever of /sign-in or /sign-up oauthProvider redirected to, but the cross-link between the two pages was a bare href. A user who arrived mid-authorization and clicked through to sign up lost the query, so the client plugin had nothing to attach and the resume guard on that branch could never fire — a new user still stranded the MCP client. Carry window.location.search on both links, read after mount so the server and first client render agree. Also stop the finally block re-enabling the submit button while a full-page navigation is already in flight. No behaviour change to followOAuthResume, but its comment was misleading: better-auth's own redirectPlugin (client/fetch-plugins.mjs) already navigates on { redirect, url }, so the assignment is belt-and-braces. What the function is actually for is suppressing the racing router.push('/chat'). Co-Authored-By: Claude Opus 5 (1M context) --- .../components/auth/email-password-auth.tsx | 39 ++++++++++++++----- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/auth/email-password-auth.tsx b/apps/web/src/components/auth/email-password-auth.tsx index bce70ee2..ac873bfc 100644 --- a/apps/web/src/components/auth/email-password-auth.tsx +++ b/apps/web/src/components/auth/email-password-auth.tsx @@ -1,7 +1,7 @@ 'use client'; import Image from 'next/image'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; import { AuthLayout, Button, Field, Label, Input } from '@zuko/ui-kit'; import { authClient } from '@/lib/auth-client'; @@ -27,12 +27,25 @@ export function EmailPasswordAuth({ const isSignup = mode === 'signup'; + // A signed authorization query lands on whichever of /sign-in or /sign-up + // oauthProvider sent the user to. The cross-link between the two pages has + // to carry it, or a user who signs up mid-authorization arrives with no + // query for the client plugin to attach and the MCP client is left waiting. + // Read after mount so the server render and first client render agree. + const [authQuery, setAuthQuery] = useState(''); + useEffect(() => setAuthQuery(window.location.search), []); + /** * When an MCP client sent the user here via /oauth2/authorize, the * oauth-provider plugin resumes that authorization the moment a session * cookie is set and hands the next hop (the consent page) back on the - * sign-in response. Follow it instead of falling through to the default - * post-login routing, which would strand the client waiting on a callback. + * sign-in response as { redirect, url }. + * + * better-auth's own redirectPlugin (client/fetch-plugins.mjs) already + * navigates on that shape, so the assignment below is belt-and-braces. The + * load-bearing part is the return value: it stops redirectAfterAuth() from + * racing the plugin with a router.push('/chat'), which is what stranded the + * MCP client waiting on a callback. */ const followOAuthResume = (data: unknown) => { const resume = data as { redirect?: boolean; url?: string } | null; @@ -70,6 +83,10 @@ export function EmailPasswordAuth({ setIsLoading(true); setError(null); + // Set once we hand off to a full-page navigation; the button must stay + // disabled for the round trip rather than flicking back to enabled. + let leaving = false; + try { if (isSignup) { const result = await authClient.signUp.email({ @@ -83,8 +100,9 @@ export function EmailPasswordAuth({ result.error.message || 'Failed to create account. Please try again.', ); - } else if (!followOAuthResume(result.data)) { - await redirectAfterAuth(); + } else { + leaving = followOAuthResume(result.data); + if (!leaving) await redirectAfterAuth(); } } else { const result = await authClient.signIn.email({ @@ -97,15 +115,16 @@ export function EmailPasswordAuth({ result.error.message || 'Failed to sign in. Please check your credentials.', ); - } else if (!followOAuthResume(result.data)) { - await redirectAfterAuth(); + } else { + leaving = followOAuthResume(result.data); + if (!leaving) await redirectAfterAuth(); } } } catch (err) { setError('An unexpected error occurred. Please try again.'); console.error('Email/password auth error:', err); } finally { - setIsLoading(false); + if (!leaving) setIsLoading(false); } }; @@ -222,7 +241,7 @@ export function EmailPasswordAuth({ <> Already have an account?{' '} Sign in @@ -232,7 +251,7 @@ export function EmailPasswordAuth({ <> Don't have an account?{' '} Sign up From 250c1b09c72bd86ccf37704e0cd2fddcf74fb5a0 Mon Sep 17 00:00:00 2001 From: c9s-ai Date: Sun, 20 Sep 2026 16:05:52 +0530 Subject: [PATCH 4/9] refactor(auth): route post-login through callbackURL instead of by hand The sign-in handler was doing better-auth's job. better-auth returns { redirect, url } and its own redirectPlugin navigates on it (client/fetch-plugins.mjs); signIn.email populates those fields straight from callbackURL (api/routes/sign-in.mjs:253). The component passed no callbackURL, so the response came back redirect:false, and it hand-rolled the navigation with router.push instead. That is why the MCP flow broke: when oauthProvider resumed an authorization it rewrote the response to point at the consent screen, the plugin started navigating there, and the hand-rolled push to /chat raced it. Move the routing to app/post-login and make it the callbackURL for signIn.email and signIn.social. It has to be a route rather than a static URL because the destination depends on a lookup that needs a live session (members to the app, invitees to invitations, everyone else to organization creation). Off the response cycle it cannot race anything, and when an authorization is in flight the browser never reaches it. signUp.email is the exception: it returns { token, user } and ignores callbackURL except for the verification link, so that branch still navigates itself, guarded by a check that the plugin is not already doing so. Removes followOAuthResume and redirectAfterAuth. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/src/app/post-login/page.tsx | 64 +++++++++++++++++++ .../components/auth/email-password-auth.tsx | 62 ++++++------------ 2 files changed, 82 insertions(+), 44 deletions(-) create mode 100644 apps/web/src/app/post-login/page.tsx diff --git a/apps/web/src/app/post-login/page.tsx b/apps/web/src/app/post-login/page.tsx new file mode 100644 index 00000000..7921157d --- /dev/null +++ b/apps/web/src/app/post-login/page.tsx @@ -0,0 +1,64 @@ +'use client'; + +import { useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { AuthLayout, Text } from '@zuko/ui-kit'; +import { authClient } from '@/lib/auth-client'; + +/** + * Where better-auth lands every successful login, via `callbackURL`. + * + * The destination depends on a lookup that can only run once a session + * exists — members go to the app, invitees to their invitations, and everyone + * else has to create an organization first — so it cannot be expressed as a + * static callbackURL. Doing it on a route of its own instead of in the + * sign-in handler keeps it out of the response cycle: when an MCP client has + * an authorization in flight, oauthProvider rewrites the sign-in response to + * point at the consent screen and the browser never reaches this page at all. + */ +export default function PostLoginPage() { + const router = useRouter(); + + useEffect(() => { + let cancelled = false; + + const route = async () => { + const { data: organizations } = await authClient.organization.list(); + if (cancelled) return; + + if (organizations && organizations.length > 0) { + router.replace('/chat'); + return; + } + + // 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)); + const { data: invitations } = + await authClient.organization.listUserInvitations(); + if (cancelled) return; + + if (invitations && invitations.length > 0) { + router.replace('/settings?tab=invitations'); + return; + } + } + + router.replace('/organization/create'); + }; + + void route(); + + return () => { + cancelled = true; + }; + }, [router]); + + return ( + +
+ Signing you in… +
+
+ ); +} diff --git a/apps/web/src/components/auth/email-password-auth.tsx b/apps/web/src/components/auth/email-password-auth.tsx index ac873bfc..ed46b855 100644 --- a/apps/web/src/components/auth/email-password-auth.tsx +++ b/apps/web/src/components/auth/email-password-auth.tsx @@ -35,48 +35,22 @@ export function EmailPasswordAuth({ const [authQuery, setAuthQuery] = useState(''); useEffect(() => setAuthQuery(window.location.search), []); + /** Where better-auth sends every successful login; see app/post-login. */ + const postLoginURL = () => `${window.location.origin}/post-login`; + /** - * When an MCP client sent the user here via /oauth2/authorize, the - * oauth-provider plugin resumes that authorization the moment a session - * cookie is set and hands the next hop (the consent page) back on the - * sign-in response as { redirect, url }. + * better-auth's redirectPlugin (client/fetch-plugins.mjs) navigates by + * itself whenever a response comes back as { redirect, url } — which is + * both how `callbackURL` is honoured and how oauthProvider hands back the + * consent screen after resuming an MCP authorization. * - * better-auth's own redirectPlugin (client/fetch-plugins.mjs) already - * navigates on that shape, so the assignment below is belt-and-braces. The - * load-bearing part is the return value: it stops redirectAfterAuth() from - * racing the plugin with a router.push('/chat'), which is what stranded the - * MCP client waiting on a callback. + * signUp.email is the one endpoint that never sets those fields: it returns + * { token, user } and uses callbackURL only for the verification link + * (api/routes/sign-up.mjs). So that branch still has to navigate on its + * own, and has to check first or it races the plugin. */ - const followOAuthResume = (data: unknown) => { - const resume = data as { redirect?: boolean; url?: string } | null; - if (!resume?.redirect || !resume.url) return false; - window.location.href = resume.url; - return true; - }; - - /** Shared post-auth redirect: org → chat, invitations → settings, else → create org */ - const redirectAfterAuth = async () => { - const { data } = await authClient.organization.list(); - if (data && data.length > 0) { - router.push('/chat'); - } else { - // Retry a few times to handle session propagation timing for new accounts - let invitations = null; - for (let attempt = 0; attempt < 3; attempt++) { - if (attempt > 0) await new Promise((r) => setTimeout(r, 500)); - const { data } = await authClient.organization.listUserInvitations(); - if (data && data.length > 0) { - invitations = data; - break; - } - } - if (invitations && invitations.length > 0) { - router.push('/settings?tab=invitations'); - } else { - router.push('/organization/create'); - } - } - }; + const pluginWillRedirect = (data: unknown) => + Boolean((data as { redirect?: boolean } | null)?.redirect); const handleEmailPasswordSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -101,13 +75,14 @@ export function EmailPasswordAuth({ 'Failed to create account. Please try again.', ); } else { - leaving = followOAuthResume(result.data); - if (!leaving) await redirectAfterAuth(); + leaving = true; + if (!pluginWillRedirect(result.data)) router.push('/post-login'); } } else { const result = await authClient.signIn.email({ email, password, + callbackURL: postLoginURL(), }); if (result.error) { @@ -116,8 +91,7 @@ export function EmailPasswordAuth({ 'Failed to sign in. Please check your credentials.', ); } else { - leaving = followOAuthResume(result.data); - if (!leaving) await redirectAfterAuth(); + leaving = true; } } } catch (err) { @@ -131,7 +105,7 @@ export function EmailPasswordAuth({ const handleGoogleSignIn = () => { authClient.signIn.social({ provider: 'google', - callbackURL: `${window.location.origin}/chat`, + callbackURL: postLoginURL(), }); }; From 068afa915dd37b33b76e0e6f576dc09addb16fd7 Mon Sep 17 00:00:00 2001 From: c9s-ai Date: Sun, 20 Sep 2026 16:11:43 +0530 Subject: [PATCH 5/9] fix(auth): resume the authorization across a social login too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Google button is the only sign-in control production renders, and it named a fixed post-login destination. oauthProvider normally resumes an interrupted authorization by rewriting the sign-in response, but a social login leaves for Google before there is a response to rewrite, so the MCP flow still ended up at the app instead of the consent screen. Compute callbackURL instead: when the signed authorization query is still on the URL, point it back at the authorize endpoint; otherwise /post-login as before. This is the same callbackURL mechanism, just with the right value, so it composes with the plugin's own resume rather than competing with it. The URL is the same-origin /auth proxy, not BACKEND_URL. web and api are sibling *.fly.dev hosts with no shared cookie domain, so hitting the backend authorize endpoint directly would carry no session and bounce straight back to the login page — the loop COOKIE_DOMAIN was once meant to paper over. The proxy forwards the cookie. Re-encoding the query in transit is safe: the signature is verified over a canonicalised, re-sorted URLSearchParams on both sides (version-DaSfXJQ1.mjs:5). Sign-up takes the same destination for the same reason. useRouter is no longer needed. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/auth/email-password-auth.tsx | 35 +++++++++++++++---- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/auth/email-password-auth.tsx b/apps/web/src/components/auth/email-password-auth.tsx index ed46b855..45f631df 100644 --- a/apps/web/src/components/auth/email-password-auth.tsx +++ b/apps/web/src/components/auth/email-password-auth.tsx @@ -2,7 +2,6 @@ import Image from 'next/image'; import { useEffect, useState } from 'react'; -import { useRouter } from 'next/navigation'; import { AuthLayout, Button, Field, Label, Input } from '@zuko/ui-kit'; import { authClient } from '@/lib/auth-client'; import Link from 'next/link'; @@ -16,7 +15,6 @@ export function EmailPasswordAuth({ mode = 'signin', emailPasswordEnabled = false, }: EmailPasswordAuthProps) { - const router = useRouter(); const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(false); @@ -35,8 +33,30 @@ export function EmailPasswordAuth({ const [authQuery, setAuthQuery] = useState(''); useEffect(() => setAuthQuery(window.location.search), []); - /** Where better-auth sends every successful login; see app/post-login. */ - const postLoginURL = () => `${window.location.origin}/post-login`; + /** + * Where better-auth sends a successful login, via `callbackURL`. + * + * Normally /post-login. But when oauthProvider bounced an MCP client's + * authorization here, the signed query is still on the URL and the login + * has to return to the authorize endpoint before a code can be minted. + * oauthProvider resumes that itself on any response it can rewrite, which + * covers email sign-in — a social login round-trips through Google first, + * so name the destination explicitly and let whichever lands first win. + * + * Deliberately the same-origin /auth proxy and not BACKEND_URL: web and api + * are sibling *.fly.dev hosts with no shared cookie domain, so hitting the + * backend authorize endpoint directly would carry no session and bounce + * straight back to this page. The proxy forwards the cookie. Re-encoding + * the query on the way through is safe — the signature is verified over a + * canonicalised, re-sorted URLSearchParams on both sides + * (@better-auth/oauth-provider/dist/version-DaSfXJQ1.mjs:5). + */ + const postAuthURL = () => { + const search = window.location.search; + return new URLSearchParams(search).has('sig') + ? `${window.location.origin}/auth/oauth2/authorize${search}` + : `${window.location.origin}/post-login`; + }; /** * better-auth's redirectPlugin (client/fetch-plugins.mjs) navigates by @@ -76,13 +96,14 @@ export function EmailPasswordAuth({ ); } else { leaving = true; - if (!pluginWillRedirect(result.data)) router.push('/post-login'); + if (!pluginWillRedirect(result.data)) + window.location.href = postAuthURL(); } } else { const result = await authClient.signIn.email({ email, password, - callbackURL: postLoginURL(), + callbackURL: postAuthURL(), }); if (result.error) { @@ -105,7 +126,7 @@ export function EmailPasswordAuth({ const handleGoogleSignIn = () => { authClient.signIn.social({ provider: 'google', - callbackURL: postLoginURL(), + callbackURL: postAuthURL(), }); }; From dc8fd32298c4c74b633fead978a36db071c7090d Mon Sep 17 00:00:00 2001 From: c9s-ai Date: Sun, 20 Sep 2026 16:11:43 +0530 Subject: [PATCH 6/9] fix(auth): describe every scope on the consent screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The consent screen described relations:read and relations:write, which are not in MCP_SCOPES, while ten scopes that are — icps, leads, campaigns, comments and prospects — had no entry and rendered to the user as raw strings like "prospects:write" on the screen where they decide what to grant. All 19 MCP_SCOPES now have a description and no description is left without a scope. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/src/components/auth/oauth-consent.tsx | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/auth/oauth-consent.tsx b/apps/web/src/components/auth/oauth-consent.tsx index 022ab69b..d1319ad0 100644 --- a/apps/web/src/components/auth/oauth-consent.tsx +++ b/apps/web/src/components/auth/oauth-consent.tsx @@ -35,9 +35,19 @@ const SCOPE_DESCRIPTIONS: Record = { // CRM - Tasks 'tasks:read': 'Read your tasks', 'tasks:write': 'Create and update tasks', - // CRM - Relations - 'relations:read': 'Read entity relations', - 'relations:write': 'Manage entity relations', + // CRM - Prospects and leads + 'prospects:read': 'Read your prospects', + 'prospects:write': 'Create and update prospects', + 'leads:read': 'Read your leads', + 'leads:write': 'Create and update leads', + // CRM - Targeting + 'icps:read': 'Read your ideal customer profiles', + 'icps:write': 'Create and update ideal customer profiles', + 'campaigns:read': 'Read your campaigns', + 'campaigns:write': 'Create and update campaigns', + // CRM - Comments + 'comments:read': 'Read comments', + 'comments:write': 'Write comments', }; /** From 38a61061e96e7d0e81b9ad4c7087a783d3355e67 Mon Sep 17 00:00:00 2001 From: c9s-ai Date: Sun, 20 Sep 2026 16:25:04 +0530 Subject: [PATCH 7/9] refactor(auth): decide where a login belongs at the app's door MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the /post-login interstitial added a commit ago. better-auth can only redirect to a fixed callbackURL, so the routing had to live behind some URL — but a page whose whole job is to bounce you elsewhere is a worse home for it than the guard that already exists on the way in. chat/layout.tsx already validated the session server-side and sent anyone without one to /sign-in. It now also checks activeOrganizationId and sends anyone without an organization to their invitations, or to organization creation. callbackURL becomes plain /chat. That closes a gap the interstitial never covered: until now someone with no organization who navigated straight to /chat was let in, and every request they made failed with NO_ACTIVE_ORGANIZATION. Only the login hop was guarded. No loop: activeOrganizationId is stamped on the session at creation (databaseHooks.session.create.before) and refreshed by setActive when an organization is created (create-org.tsx) or an invitation accepted (user-invitations.tsx), so it flips as soon as they qualify. The invitation lookup keeps the retry the old client-side code had, for session propagation on fresh accounts, and only runs for users with no organization. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/src/app/(app)/chat/layout.tsx | 77 ++++++++++++++----- apps/web/src/app/post-login/page.tsx | 64 --------------- .../components/auth/email-password-auth.tsx | 10 ++- 3 files changed, 65 insertions(+), 86 deletions(-) delete mode 100644 apps/web/src/app/post-login/page.tsx diff --git a/apps/web/src/app/(app)/chat/layout.tsx b/apps/web/src/app/(app)/chat/layout.tsx index 24e81b57..4cde1974 100644 --- a/apps/web/src/app/(app)/chat/layout.tsx +++ b/apps/web/src/app/(app)/chat/layout.tsx @@ -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, }: { @@ -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}; } diff --git a/apps/web/src/app/post-login/page.tsx b/apps/web/src/app/post-login/page.tsx deleted file mode 100644 index 7921157d..00000000 --- a/apps/web/src/app/post-login/page.tsx +++ /dev/null @@ -1,64 +0,0 @@ -'use client'; - -import { useEffect } from 'react'; -import { useRouter } from 'next/navigation'; -import { AuthLayout, Text } from '@zuko/ui-kit'; -import { authClient } from '@/lib/auth-client'; - -/** - * Where better-auth lands every successful login, via `callbackURL`. - * - * The destination depends on a lookup that can only run once a session - * exists — members go to the app, invitees to their invitations, and everyone - * else has to create an organization first — so it cannot be expressed as a - * static callbackURL. Doing it on a route of its own instead of in the - * sign-in handler keeps it out of the response cycle: when an MCP client has - * an authorization in flight, oauthProvider rewrites the sign-in response to - * point at the consent screen and the browser never reaches this page at all. - */ -export default function PostLoginPage() { - const router = useRouter(); - - useEffect(() => { - let cancelled = false; - - const route = async () => { - const { data: organizations } = await authClient.organization.list(); - if (cancelled) return; - - if (organizations && organizations.length > 0) { - router.replace('/chat'); - return; - } - - // 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)); - const { data: invitations } = - await authClient.organization.listUserInvitations(); - if (cancelled) return; - - if (invitations && invitations.length > 0) { - router.replace('/settings?tab=invitations'); - return; - } - } - - router.replace('/organization/create'); - }; - - void route(); - - return () => { - cancelled = true; - }; - }, [router]); - - return ( - -
- Signing you in… -
-
- ); -} diff --git a/apps/web/src/components/auth/email-password-auth.tsx b/apps/web/src/components/auth/email-password-auth.tsx index 45f631df..f7465a66 100644 --- a/apps/web/src/components/auth/email-password-auth.tsx +++ b/apps/web/src/components/auth/email-password-auth.tsx @@ -36,9 +36,11 @@ export function EmailPasswordAuth({ /** * Where better-auth sends a successful login, via `callbackURL`. * - * Normally /post-login. But when oauthProvider bounced an MCP client's - * authorization here, the signed query is still on the URL and the login - * has to return to the authorize endpoint before a code can be minted. + * Normally /chat, whose layout decides whether this user actually belongs + * there or needs to create an organization first. But when oauthProvider + * bounced an MCP client's authorization here, the signed query is still on + * the URL and the login has to return to the authorize endpoint before a + * code can be minted. * oauthProvider resumes that itself on any response it can rewrite, which * covers email sign-in — a social login round-trips through Google first, * so name the destination explicitly and let whichever lands first win. @@ -55,7 +57,7 @@ export function EmailPasswordAuth({ const search = window.location.search; return new URLSearchParams(search).has('sig') ? `${window.location.origin}/auth/oauth2/authorize${search}` - : `${window.location.origin}/post-login`; + : `${window.location.origin}/chat`; }; /** From 1d36f672fcd4a5d0619923f441b6dfb8ff4985d5 Mon Sep 17 00:00:00 2001 From: c9s-ai Date: Sun, 20 Sep 2026 17:26:04 +0530 Subject: [PATCH 8/9] fix(auth): turn the proxy's JSON redirects back into real ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Returning to /oauth2/authorize through the /auth proxy after login rendered raw JSON in the browser — {"redirect":true,"url":"…/callback?code=…"} — with the authorization code sitting unused on screen and the MCP client waiting on a callback that never came. oauth-provider content-negotiates its redirects: a browser fetch gets { redirect, url } to act on, anything else gets a 302 (handleRedirect in dist/index.mjs). It decides with sec-fetch-mode === 'cors', and Node's fetch sets that header on every request and refuses to let it be overridden — passing sec-fetch-mode: navigate explicitly still goes out as cors. So the backend cannot tell this proxy apart from an XHR, and curl gets a 302 where the proxy gets JSON. That is harmless when the browser really is doing an XHR, since better-auth's redirectPlugin acts on the body. It is fatal on a document navigation. So convert it back: on a GET the browser is navigating, a { redirect, url } body becomes the 302 the backend meant, carrying the same rewritten cookies the existing 302 path already applies. Helpers live in lib/auth-proxy.ts because a Next route module can only export its handlers, and this logic is worth testing: 11 cases covering navigation detection, body sniffing, cookie rewriting, and that peeking at the body leaves it readable for the pass-through path. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/__tests__/auth-proxy.test.ts | 111 +++++++++++++++++++++++ apps/web/src/app/auth/[...auth]/route.ts | 86 +++++++++--------- apps/web/src/lib/auth-proxy.ts | 58 ++++++++++++ 3 files changed, 210 insertions(+), 45 deletions(-) create mode 100644 apps/web/__tests__/auth-proxy.test.ts create mode 100644 apps/web/src/lib/auth-proxy.ts diff --git a/apps/web/__tests__/auth-proxy.test.ts b/apps/web/__tests__/auth-proxy.test.ts new file mode 100644 index 00000000..592b0df1 --- /dev/null +++ b/apps/web/__tests__/auth-proxy.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect } from 'vitest'; +import { + isTopLevelNavigation, + redirectTargetFromBody, + rewriteCookieForProxy, +} from '@/lib/auth-proxy'; + +const request = (method: string, headers: Record) => ({ + 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', + ); + }); +}); diff --git a/apps/web/src/app/auth/[...auth]/route.ts b/apps/web/src/app/auth/[...auth]/route.ts index c56a5fd0..45e2682f 100644 --- a/apps/web/src/app/auth/[...auth]/route.ts +++ b/apps/web/src/app/auth/[...auth]/route.ts @@ -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'; @@ -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[] }> }, @@ -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, { @@ -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) { diff --git a/apps/web/src/lib/auth-proxy.ts b/apps/web/src/lib/auth-proxy.ts new file mode 100644 index 00000000..14b68e5f --- /dev/null +++ b/apps/web/src/lib/auth-proxy.ts @@ -0,0 +1,58 @@ +/** + * Helpers for the /auth proxy (app/auth/[...auth]/route.ts). + * + * Kept out of the route module so they can be unit tested: Next only lets a + * route file export its HTTP handlers. + */ + +/** + * A document navigation, as opposed to an XHR the page will handle itself. + * Mirrors the test better-auth makes internally: trust sec-fetch-mode when the + * browser sends it, otherwise fall back to the Accept header. + */ +export function isTopLevelNavigation(request: { + method: string; + headers: Headers; +}) { + if (request.method !== 'GET') return false; + + const mode = request.headers.get('sec-fetch-mode')?.toLowerCase(); + if (mode) return mode === 'navigate'; + + const accept = request.headers.get('accept')?.toLowerCase() ?? ''; + return ( + accept.includes('text/html') || accept.includes('application/xhtml+xml') + ); +} + +/** + * The `url` of a `{ redirect: true, url }` body, or null for anything else. + * + * Reads a clone so the caller can still stream the original body through. + */ +export async function redirectTargetFromBody(response: Response) { + if (!response.headers.get('content-type')?.includes('application/json')) { + return null; + } + + try { + const body = await response.clone().json(); + return body?.redirect === true && typeof body.url === 'string' + ? body.url + : null; + } catch { + return null; + } +} + +/** Re-scope a backend cookie onto this origin. */ +export function rewriteCookieForProxy(cookie: string) { + return cookie + .split(';') + .map((part) => part.trim()) + .filter((part) => !part.toLowerCase().startsWith('domain=')) + .map((part) => + part.toLowerCase() === 'samesite=none' ? 'SameSite=Lax' : part, + ) + .join('; '); +} From 33760884568566fa94ce428813c9deb60d0a7bbb Mon Sep 17 00:00:00 2001 From: c9s-ai Date: Sun, 20 Sep 2026 19:27:05 +0530 Subject: [PATCH 9/9] docs(auth): explain the cookie constraint without naming a host The reason the authorization resume goes through the same-origin /auth proxy is that web and api are separate origins with no shared cookie domain. That is true of the current hosting, but it is not about the current hosting, and pinning the explanation to one provider's domain would leave the comments wrong the moment we move. Comment-only. Co-Authored-By: Claude Opus 5 (1M context) --- apps/backend/src/libs/better-auth/auth.ts | 16 ++++++++-------- .../src/components/auth/email-password-auth.tsx | 6 +++--- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/backend/src/libs/better-auth/auth.ts b/apps/backend/src/libs/better-auth/auth.ts index e77cc5c6..c716cc2f 100644 --- a/apps/backend/src/libs/better-auth/auth.ts +++ b/apps/backend/src/libs/better-auth/auth.ts @@ -320,16 +320,16 @@ const authInstance: any = betterAuth({ sameSite: 'none', // Allow cross-origin requests secure: true, // HTTPS only // No `domain` here on purpose. A previous COOKIE_DOMAIN env var - // tried to share the session cookie across web/api hosts so the - // direct browser -> backend /oauth2/authorize hit would see it. - // That was never needed: oauthProvider bounces an unauthenticated + // 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. It also could not work on *.fly.dev - // (a public suffix — browsers reject `Domain=fly.dev`), and the - // proxy strips `domain=` from every Set-Cookie anyway. If web and - // api ever become real siblings of a domain we own, use - // better-auth's `advanced.crossSubDomainCookies` instead. + // 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 diff --git a/apps/web/src/components/auth/email-password-auth.tsx b/apps/web/src/components/auth/email-password-auth.tsx index f7465a66..722450f7 100644 --- a/apps/web/src/components/auth/email-password-auth.tsx +++ b/apps/web/src/components/auth/email-password-auth.tsx @@ -46,9 +46,9 @@ export function EmailPasswordAuth({ * so name the destination explicitly and let whichever lands first win. * * Deliberately the same-origin /auth proxy and not BACKEND_URL: web and api - * are sibling *.fly.dev hosts with no shared cookie domain, so hitting the - * backend authorize endpoint directly would carry no session and bounce - * straight back to this page. The proxy forwards the cookie. Re-encoding + * are separate origins with no shared cookie domain, so hitting the backend + * authorize endpoint directly would carry no session and bounce straight + * back to this page. The proxy forwards the cookie. Re-encoding * the query on the way through is safe — the signature is verified over a * canonicalised, re-sorted URLSearchParams on both sides * (@better-auth/oauth-provider/dist/version-DaSfXJQ1.mjs:5).