fix: keep one Better Auth instance so provider sign-in returns to the app - #9
Conversation
… 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>
|
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 configurationConfiguration used: Organization UI Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (4)
✨ Finishing Touches📝 Generate docstrings
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. Comment |
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
left a comment
There was a problem hiding this comment.
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.
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>
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>
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/authorizeskips 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:
So on
/sign-in/socialthe OAuth provider plugin stored the pending authorization under one set of keys, and Better Auth'sgenerateState()read another and found nothing. The sign-in state persisted for the provider round trip therefore had noserverContext:{ "callbackURL": "/", "codeVerifier": "…", "idTokenNonce": "…", "oauthState": "…" }With nothing recorded to resume, the provider callback just honoured
callbackURLand dropped the user on/. The same split broke the other half of the handoff — the plugin's post-callback hook readgetOAuthState()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 buildnow also runsscripts/check-server-bundle.mjs. It finds everydefineRequestState()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-authexternal 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-startimports@tanstack/react-start/server). Cookies still work only because TanStack happens to keep its request store on a global key. TanStack Start bundles packages likebetter-authon 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 (externalwins forbetter-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./, application gets nothing/consent, then…/cb?code=…&state=…Also confirmed in
vp dev, which had the same split: onmainthe Better Auth state module runs twice, with this change once. Withmain's config, the new check fails and names each duplicated piece of state.vp checkandvp testpass.🤖 Generated with Claude Code