Skip to content

fix(auth): let the OAuth provider resume authorization after sign-in - #46

Merged
codemancers-bot merged 9 commits into
mainfrom
fix/mcp-oauth-signin-resume
Sep 20, 2026
Merged

codemancers-bot merged 9 commits into
mainfrom
fix/mcp-oauth-signin-resume

Conversation

@codemancers-bot

@codemancers-bot codemancers-bot commented Sep 20, 2026

Copy link
Copy Markdown
Contributor

An MCP client sending a user through /oauth2/authorize got them as far as the login page, then dropped them at /chat instead 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-provider resumes an interrupted authorization on its own. Per the plugin docs:

You don't need to handle anything from your side; when a new session is created, the plugin will handle continuing the authorization flow.

The mechanism, in the installed 1.6.22:

  1. oauthProviderClient() attaches the signed authorization query to non-GET auth requests as oauth_query (dist/client.mjs).
  2. A server before hook verifies it and stashes it (dist/index.mjs:2992); for /sign-in/social it also copies it into additionalData.query so it survives the trip through Google.
  3. A server after hook fires on any response that sets a session cookie, re-runs runOAuth2Authorize, and returns {redirect, url} pointing at consent (dist/index.mjs:3019).
  4. The client's own redirectPlugin navigates on that shape (better-auth/dist/client/fetch-plugins.mjs).

The sign-in form bypassed all of it: no callbackURL, so responses came back redirect: false, and it hand-rolled navigation with router.push('/chat'). When the plugin did resume, that push raced it.

What changed

Use callbackURL instead of hand-rolled navigation. signIn.email populates {redirect, url} straight from it (api/routes/sign-in.mjs:253), so one mechanism covers both normal logins and MCP consent. followOAuthResume and redirectAfterAuth are 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 callbackURL is now computed: when the signed query is still on the URL it points back at the authorize endpoint, otherwise /chat.

It points at 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 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).

Decide where a login belongs at the app's door. callbackURL must be a fixed URL, but the destination depends on state that exists only once there's a session. chat/layout.tsx already validated the session server-side; it now also checks activeOrganizationId and routes to invitations or organization creation. This closes a gap that predates the PR: someone with no organization navigating straight to /chat was let in, and every request they made failed with NO_ACTIVE_ORGANIZATION.

No loop — activeOrganizationId is stamped at session creation and refreshed by setActive on organization create (create-org.tsx) and invitation accept (user-invitations.tsx).

Carry the authorization query across the sign-up link. oauthProvider has only loginPage, so the signed query lands on /sign-in; the link to /sign-up was 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.dev is a public suffix, so browsers reject the Domain attribute, and the /auth proxy strips domain= from every Set-Cookie. A comment points at advanced.crossSubDomainCookies for the day web and api share a registrable domain.

Describe every scope on the consent screen. It described relations:*, which aren't in MCP_SCOPES, while ten scopes that are — icps, leads, campaigns, comments, prospects — rendered as raw strings like prospects:write on 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, and check.js exits 1 whenever it detects a system libvips (lib/libvips.js:176) — true on any host with libvips-dev or Homebrew libvips. That exit selects the source-build fallback, which dies on sharp: 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: false in allowBuilds, matching the existing @scarf/scarf: false precedent.

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, and apps/web/Dockerfile passes no build arg and copies no .env — the local build output shows decide baked to a constant. So in the Fly image it is false permanently, and checking the prod env var tells you nothing; enabling it needs a Dockerfile ARG, 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, pendingBuilds empty, lockfile unchanged, require('sharp') still loading libvips 8.17.3. The original failure was reproduced beforehand with SHARP_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.json was 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 ci job is gated on author_association being 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

  • A brand-new user completing consent still has no organization, so every MCP tool call fails with NO_ACTIVE_ORGANIZATION. Gating consent behind organization creation needs a resume-after-create mechanism that doesn't exist yet.
  • When a client omits the RFC 8707 resource param the provider mints an opaque token rather than a JWT (dist/index.mjs:510), and McpBearerGuard rejects it with a misleading "Invalid or expired access token". Worth distinguishing "not a JWT" from "JWT failed verification".

🤖 Generated with Claude Code

codemancers-bot and others added 9 commits September 20, 2026 14:51
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>
@codemancers-bot
codemancers-bot merged commit b425dda into main Sep 20, 2026
1 check passed
@codemancers-bot
codemancers-bot deleted the fix/mcp-oauth-signin-resume branch September 20, 2026 19:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant