Skip to content

fix: keep one Better Auth instance so provider sign-in returns to the app - #9

Merged
lorenzocorallo merged 2 commits into
mainfrom
fix/oidc-login-never-returns-to-app
Sep 25, 2026
Merged

lorenzocorallo merged 2 commits into
mainfrom
fix/oidc-login-never-returns-to-app

Conversation

@viganogabriele

@viganogabriele viganogabriele commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

The bug

Opening an application that signs in through this IdP, then clicking Continue with PoliNetwork APS (Microsoft), left the user back on our own page instead of returning them to the application. The sign-in itself worked — a session was created — but the OpenID Connect authorization it was meant to complete was silently dropped, so the application never received its authorization code and kept asking the user to log in.

Clearing browser storage looked like a fix, but it was a coincidence: it only changes which path the second attempt takes. Once a session cookie exists, /oauth2/authorize skips the sign-in page and goes straight to consent — the broken step never runs.

The cause

Better Auth carries the in-flight authorization across the provider redirect in per-request state. defineRequestState() keys that state with a module-private token minted once per module evaluation.

The server build was producing two evaluations of the modules that own those tokens:

_ssr/router-*.mjs                            ← better-auth inlined into the SSR bundle
_libs/@better-auth/oauth-provider+[...].mjs  ← a second copy inlined into the plugin chunk
_libs/@better-auth/core+[...].mjs            ← and a second copy of the core request-state module

So on /sign-in/social the OAuth provider plugin stored the pending authorization under one set of keys, and Better Auth's generateState() read another and found nothing. The sign-in state persisted for the provider round trip therefore had no serverContext:

{ "callbackURL": "/", "codeVerifier": "…", "idTokenNonce": "…", "oauthState": "…" }

With nothing recorded to resume, the provider callback just honoured callbackURL and dropped the user on /. The same split broke the other half of the handoff — the plugin's post-callback hook read getOAuthState() from its own copy and also saw nothing. No error was raised on either path.

The fix

Bundle every Better Auth package together via ssr.noExternal, so one module instance — and one set of keys — serves the whole flow. The packages are matched by name pattern (better-auth, @better-auth/*), so a plugin added later is covered without editing the list. After the change, every module owning per-request state appears exactly once in .output/server.

Because the failure is silent and the cause is a bundler setting that can shift on any dependency upgrade (several are pinned to latest), pnpm build now also runs scripts/check-server-bundle.mjs. It finds every defineRequestState() call in the built server and fails the build — and so the image build in CI — if any of them, or the core request-state module, appears more than once. Because it looks at every call rather than a fixed list, new request state added by a Better Auth upgrade is checked too. The check has its own tests.

Why not keep better-auth external instead (#8)

#8 fixes the same bug the other way round, with ssr.external: ["better-auth"]. That also works today, but it makes the build carry a second copy of TanStack Start's server code (better-auth/tanstack-start imports @tanstack/react-start/server). Cookies still work only because TanStack happens to keep its request store on a global key. TanStack Start bundles packages like better-auth on purpose to avoid exactly this, so this PR works with that instead of against it. Putting both settings in at once brings the bug back (external wins for better-auth, and the OAuth plugin's own state gets two copies), so only one of them can land.

Verification

Reproduced and verified end to end against a production build (pnpm build + scripts/start.mjs), a real Postgres, a stub Entra-shaped OpenID Provider, a registered OIDC client, and a real Chromium driving the actual pages.

before after
first sign-in, fresh browser lands on /, application gets nothing lands on /consent, then …/cb?code=…&state=…
build with the packages split — build fails with an actionable message

Also confirmed in vp dev, which had the same split: on main the Better Auth state module runs twice, with this change once. With main's config, the new check fails and names each duplicated piece of state. vp check and vp test pass.

🤖 Generated with Claude Code

… app

Signing in to an application with Microsoft (or Google) left the user on the
auth site's own page instead of sending them back: the sign-in itself worked,
but the OpenID Connect authorization it was supposed to complete was dropped,
so the application never received its code. Clearing site data appeared to fix
it only because a second attempt reuses the session cookie and skips the sign-in
step entirely.

Better Auth carries the pending authorization across the provider redirect in
per-request state, keyed by module-private tokens `defineRequestState()` mints
once per module evaluation. The server build inlined `better-auth` into the SSR
bundle and gave each `@better-auth/*` plugin its own chunk with a second copy
inlined, so the OAuth provider plugin stored the pending request under one set
of keys while Better Auth read another and found nothing. Neither half of the
resume ran, and nothing logged an error.

Bundle the Better Auth packages together so one module instance, and one set of
keys, serves the whole flow. Because the failure is silent and the cause is a
bundler heuristic that shifts with dependency upgrades, the build now also fails
if either module that owns per-request state is emitted more than once.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

Note

Currently processing new changes in this PR. This may take a few minutes, please wait...

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: 04568252-7d93-4bc7-8bde-47b94c9ca55a

📥 Commits

Reviewing files that changed from the base of the PR and between 5b70bfd and 0360e0a.

📒 Files selected for processing (4)
  • package.json
  • scripts/check-server-bundle.mjs
  • scripts/check-server-bundle.test.mjs
  • vite.config.ts
✨ Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

The fix only kept four named Better Auth packages together, and the build
check only looked for two hand-picked text markers. Adding another
`@better-auth/*` plugin, or a Better Auth upgrade that adds new request
state, could bring the lost-authorization bug back without the check
noticing.

Match the packages by name pattern instead, and make the check find every
`defineRequestState()` call in the built server and fail if any of them
appears more than once. The check now has tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@lorenzocorallo lorenzocorallo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Checked by building main, #8 and #9 and counting copies of Better Auth's request state in dev and in the built server. main has two copies (the bug), this PR has one. I pushed one commit: the packages are now matched by pattern, and the build check now looks at every defineRequestState() call instead of two fixed text markers, with tests.

@lorenzocorallo
lorenzocorallo merged commit 134635e into main Sep 25, 2026
1 of 2 checks passed
lorenzocorallo pushed a commit that referenced this pull request Sep 25, 2026
Signing in to an application through this provider dropped the caller when
the person was not already signed in here: after authenticating with Google
or PoliNetwork APS they landed on this app's home page instead of returning
to the application that sent them. The cause was two copies of Better Auth
in the server bundle, each with its own keys for the request state that
carries the pending authorization across the provider redirect.

The fix landed in #9, which bundles every `better-auth` and `@better-auth/*`
package together and fails the build if any request state is duplicated.
That check only runs on a build, so add a unit test that reads the Better
Auth packages from package.json and checks that `ssr.noExternal` bundles
each of them and their subpaths, and that none is marked external. It
fails on the old config and on keeping `better-auth` external.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
lorenzocorallo pushed a commit that referenced this pull request Sep 25, 2026
Signing in to an application through this provider dropped the caller when
the person was not already signed in here: after authenticating with Google
or PoliNetwork APS they landed on this app's home page instead of returning
to the application that sent them. The cause was two copies of Better Auth
in the server bundle, each with its own keys for the request state that
carries the pending authorization across the provider redirect.

The fix landed in #9, which bundles every `better-auth` and `@better-auth/*`
package together and fails the build if any request state is duplicated.
That check only runs on a build, so add a unit test that reads the Better
Auth packages from package.json and checks that `ssr.noExternal` bundles
each of them and their subpaths, and that none is marked external. It
fails on the old config and on keeping `better-auth` external.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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.

2 participants