fix(auth): let the OAuth provider resume authorization after sign-in - #46
Merged
Merged
Conversation
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
An MCP client sending a user through
/oauth2/authorizegot them as far as the login page, then dropped them at/chatinstead of the consent screen — so the client waited on a callback that never arrived. Plus one unrelated install fix.Why it broke
better-auth's
oauth-providerresumes an interrupted authorization on its own. Per the plugin docs:The mechanism, in the installed 1.6.22:
oauthProviderClient()attaches the signed authorization query to non-GET auth requests asoauth_query(dist/client.mjs).beforehook verifies it and stashes it (dist/index.mjs:2992); for/sign-in/socialit also copies it intoadditionalData.queryso it survives the trip through Google.afterhook fires on any response that sets a session cookie, re-runsrunOAuth2Authorize, and returns{redirect, url}pointing at consent (dist/index.mjs:3019).redirectPluginnavigates on that shape (better-auth/dist/client/fetch-plugins.mjs).The sign-in form bypassed all of it: no
callbackURL, so responses came backredirect: false, and it hand-rolled navigation withrouter.push('/chat'). When the plugin did resume, that push raced it.What changed
Use
callbackURLinstead of hand-rolled navigation.signIn.emailpopulates{redirect, url}straight from it (api/routes/sign-in.mjs:253), so one mechanism covers both normal logins and MCP consent.followOAuthResumeandredirectAfterAuthare gone.Resume across a social login. The Google button is the only sign-in control production renders (see Reachability below), and it named a fixed destination. A social login leaves for Google before there's a response for the plugin to rewrite, so
callbackURLis now computed: when the signed query is still on the URL it points back at the authorize endpoint, otherwise/chat.Decide where a login belongs at the app's door.
callbackURLmust be a fixed URL, but the destination depends on state that exists only once there's a session.chat/layout.tsxalready validated the session server-side; it now also checksactiveOrganizationIdand routes to invitations or organization creation. This closes a gap that predates the PR: someone with no organization navigating straight to/chatwas let in, and every request they made failed withNO_ACTIVE_ORGANIZATION.No loop —
activeOrganizationIdis stamped at session creation and refreshed bysetActiveon organization create (create-org.tsx) and invitation accept (user-invitations.tsx).Carry the authorization query across the sign-up link.
oauthProviderhas onlyloginPage, so the signed query lands on/sign-in; the link to/sign-upwas bare and dropped it, leaving a new user unable to resume. Also stops the form re-enabling its submit button mid-navigation.Remove
COOKIE_DOMAIN. Added in 0498e59 so the direct browser → backend authorize hit would see the session — unnecessary, since an unauthenticated authorize request is the expected state and bounces to the login page by design. It could not have worked anyway:fly.devis a public suffix, so browsers reject theDomainattribute, and the/authproxy stripsdomain=from everySet-Cookie. A comment points atadvanced.crossSubDomainCookiesfor the day web and api share a registrable domain.Describe every scope on the consent screen. It described
relations:*, which aren't inMCP_SCOPES, while ten scopes that are — icps, leads, campaigns, comments, prospects — rendered as raw strings likeprospects:writeon the screen where users decide what to grant. All 19 now have descriptions, verified in both directions.Stop sharp attempting a source build (unrelated). Its install script is
node install/check.js || npm run build, andcheck.jsexits 1 whenever it detects a system libvips (lib/libvips.js:176) — true on any host withlibvips-devor Homebrew libvips. That exit selects the source-build fallback, which dies onsharp: Please add node-gyp to your dependencies. Cosmetic so far, since the binary comes from the@img/sharp-*optional deps, but noise on every install and a hard failure on any platform without a prebuild.sharp: falseinallowBuilds, matching the existing@scarf/scarf: falseprecedent.Reachability — read before assuming this unblocks the reported bug
The email/password form is gated behind
NEXT_PUBLIC_BETTER_AUTH_INCLUDE_EMAILS_AUTH(flags.ts,defaultValue: false).NEXT_PUBLIC_*is inlined at build time, andapps/web/Dockerfilepasses no build arg and copies no.env— the local build output showsdecidebaked to a constant. So in the Fly image it isfalsepermanently, and checking the prod env var tells you nothing; enabling it needs a DockerfileARG, not a secret.That makes Google the only live sign-in path, which is what the social-login commit addresses.
Testing
The sharp change is verified: frozen-lockfile install, no build attempt, no ignored-scripts warning,
pendingBuildsempty, lockfile unchanged,require('sharp')still loading libvips 8.17.3. The original failure was reproduced beforehand withSHARP_FORCE_GLOBAL_LIBVIPS=1.The auth changes are not verified end-to-end. A full local run of the MCP flow (discovery → registration → authorize → sign-in → consent → token →
tools/call) passed on an earlier commit of this branch, but only via email/password; the Google path has not been exercised.npx tsc --noEmit -p apps/web/tsconfig.jsonwas OOM-killed in the dev environment, so changed files were verified to transform cleanly under esbuild instead.CI has skipped every run on this branch. The
cijob is gated onauthor_associationbeing OWNER/MEMBER/COLLABORATOR; the REST API reports this PR's author as MEMBER, but the webhook payload reflects public org membership, so a bot with private membership reads as CONTRIBUTOR and the job never runs. Worth fixing separately — either make the membership public or add an explicit actor check.Given all of the above, exercise normal sign-in, invited-user, and no-organization-yet flows before merging.
[AUTH PROXY] Redirect to:in the web app's logs shows what the Google callback actually resolves to.Known gaps, not addressed here
NO_ACTIVE_ORGANIZATION. Gating consent behind organization creation needs a resume-after-create mechanism that doesn't exist yet.resourceparam the provider mints an opaque token rather than a JWT (dist/index.mjs:510), andMcpBearerGuardrejects it with a misleading "Invalid or expired access token". Worth distinguishing "not a JWT" from "JWT failed verification".🤖 Generated with Claude Code