Repository navigation
Conversation
…cial
The login page rendered the social buttons as links to
/api/auth/sign-in/social?provider=...&callbackURL=/auth/complete. A link
click is a GET, but better-auth 1.4.18 registers /sign-in/social as
POST-only, so every click ended on a 404 and the buttons could never start
a sign-in on an instance with GOOGLE_CLIENT_ID/SECRET or
GITHUB_CLIENT_ID/SECRET set.
Render them as <button type="button" data-social-provider="google|github">
and add one click handler to the existing page script: it POSTs
{ provider, callbackURL: '/auth/complete' } to authBasePath +
'/sign-in/social' (same origin, so already inside connect-src 'self') and
follows the provider URL better-auth returns. On any failure it shows the
page's standard error flash and re-enables the button. The button stays
disabled while the POST is in flight, because a second POST would replace
the first one's OAuth state cookie, and is re-enabled when the page comes
back from the back/forward cache.
No new endpoint, configuration or dependency; /auth/complete is unchanged.
Tests: new login-page-social-buttons.test.ts runs the page's real inline
script in a node:vm context against a stand-in DOM and clicks the buttons
(all 10 cases fail against main). The documentation-style assertions in
social-providers.test.ts that still described a GET link now describe the
POST.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A provider's button now renders only when the operator configured it and the OAuth client lists it in epds_social_providers, the same way epds_handle_login_url gates the ATProto/Bluesky button. Configuring Google on a shared PDS therefore leaves the login page of every other client unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The login page script assigned window.location to whatever URL /sign-in/social returned. Follow it only when it is an https URL whose origin is that provider's authorization server (accounts.google.com for Google, github.com for GitHub); any other response shows the standard "Could not start sign-in" error and re-enables the button. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tative better-auth 1.4.18's Google provider copies the ID token's email_verified claim into user.emailVerified unchanged, and handleOAuthUserInfo tests it for truthiness when it decides whether to create a verified user or to link the Google identity to an existing user with the same email. Two problems follow: - the string "false" is truthy, so a token carrying email_verified: "false" is treated as verified (and "true" is stored as a string); - Google only vouches for addresses it hosts. For any other address the claim means "verified once, when the Google account was created", and Google's guidance is to treat it as unverified (https://developers.google.com/identity/gsi/web/guides/verify-google-id-token). Today such a sign-in links to, and so signs into, an existing user with that email. Add lib/google-email-verified.ts and pass its mapper as the Google provider's mapProfileToUser, which better-auth spreads over its own user info. A Google sign-in now counts as email-verified only when the claim is boolean true or the exact string "true" AND the address is Gmail (@gmail.com / @googlemail.com) or the token carries an hd (Workspace) claim. Everything else reaches better-auth as emailVerified: false, so it is not linked to an existing user ("account not linked"). buildSocialProviders() is exported (as docs/design/testing-gaps.md suggested) and social-providers.test.ts now tests it instead of a copy. New better-auth-social-sign-in.test.ts drives a real better-auth instance on in-memory SQLite through the ID-token sign-in path; 4 of its 8 cases fail without the mapper. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…in page The social buttons passed only callbackURL to better-auth, so any error in the OAuth callback - the user pressing Cancel on Google's consent screen (error=access_denied), better-auth refusing to link the identity to an existing user (account_not_linked), the provider returning no email - ended on better-auth's bare /api/auth/error page, outside the login flow, with no way back to the client app. - The page script now also sends errorCallbackURL: '/auth/complete'. - /auth/complete checks ?error= right after loading the flow, before it reads any session (so a session an earlier sign-in left in the browser is never resumed by a cancelled one), reduces the code to one of cancelled / email_unverified / no_email / failed, and 303s to /oauth/authorize?request_uri=...&client_id=...&epds_social_error=<code>, the same login-page URL it already uses when it finds no session. - GET /oauth/authorize maps epds_social_error to a fixed message and the page shows it in the standard flash region on load. Unknown values show nothing; request text is never reflected. - With epds_social_error present, GET /oauth/authorize renders the login page even when the browser holds a device session. Handing the flow to pds-core's account chooser (session reuse) would drop the message, and could auto-select the RP's hinted account for someone who had just chosen "Another account" (whose prompt=login and epds_skip_par_hint the redirect cannot carry back). For the same reason the PAR login_hint is ignored on that return, so the email step renders, which is what the message asks for. The mapping lives in lib/social-sign-in-error.ts. Tests: unit tests for the mapping, route tests for /auth/complete?error= (all 5 fail without the route change) and for the login page with epds_social_error, and the page-script tests now pin errorCallbackURL and the on-load message. The login page route test runs the script of the page it was served, so it shows that each code's message reaches the flash region; for that the node:vm page-script harness moves from login-page-social-buttons.test.ts to __tests__/__helpers__/login-page-script.ts (with an explicit return type, so tsc --build can emit it). With dev-id/ses-id cookies the route test also shows the page instead of a 302 to pds-core, and the email step when the PAR hint matches an account bound to the device; 2 of those 3 cases fail without the change (the third is the control without the error). State errors that better-auth raises before it can read the stored errorCallbackURL (an expired or mismatched state cookie) still go to its default error page; that would need onAPIError.errorURL, left out here. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…n account ePDS keys a sign-in to a PDS account by email alone (/auth/complete calls getDidByEmail on the session's address), and better-auth creates a user for a social sign-in whatever its emailVerified says. With Google's claim now strict, that leaves three ways an address nobody proved could still act as its owner: 1. /auth/complete signed it into the existing PDS account for that address (it only withheld email_verified on the callback), or created a new account with email_verified=0. /auth/choose-handle could be POSTed directly to do the latter. 2. The unverified user row better-auth created stays linked to the social identity. When the address's real owner later signs in with an email code, better-auth's email-otp marks that existing user verified, and from then on the social identity signs straight in as them (pre-account-takeover). 3. Account settings accepted any better-auth session, so the same unverified session could manage the PDS account that owns the address. Fixes: - better-auth databaseHooks.user.create.before (refuseUnverifiedUser) refuses to create a user whose emailVerified is not true. Email OTP always creates verified users, so this only stops social sign-ins the provider did not vouch for; the redirect flow then ends on /auth/complete?error=email_not_verified (mapped to the email_unverified message) and the ID-token flow gets a 401. - /auth/complete refuses a session whose email is not verified, for new and existing accounts alike, and returns to the login page with epds_social_error=email_unverified. /auth/choose-handle bounces such a session to /auth/complete. - requireBetterAuth (account settings) and GET /account/login treat a session as signed out unless its email is verified. - All of them, and the hook, use one check, hasVerifiedEmail() in lib/verified-email.ts: only a boolean true counts. An earlier release could store Google's claim as the text 'true' or 'false', which better-auth's SQLite adapter reads back as a string, and the text 'false' is truthy; with a looser check in one route and a strict one in another, /account and /account/login would redirect to each other forever for such a row. The /auth/complete, choose-handle and account guards are defence in depth for user rows created before the hook existed. Two kinds of row an earlier release left are beyond them: an unverified user with a linked social identity, which comes back to life once its owner proves the address (the next commit removes such links at that moment), and a social identity an earlier release linked to a user who is verified, which better-auth signs straight in without looking at the email again. The docs commit therefore tells operators who had GOOGLE_* or GITHUB_* set to delete the social links and unverified users an earlier release left. Tests: better-auth-social-sign-in.test.ts now runs with ePDS's databaseHooks and also drives the real redirect flow (POST /sign-in/social, GET /callback/google with Google's token endpoint stubbed): a cancel lands on /auth/complete?error=access_denied, an unverified address on ?error=email_not_verified with no user created, a verified Gmail address on /auth/complete with a session. New route tests cover /auth/complete, choose-handle and account settings with unverified sessions, including emailVerified stored as the text 'true' or 'false' (they fail without the route changes), and verified-email.test.ts pins the check itself. callback-handle-mode's session fixture now carries emailVerified: true, as an email-OTP session does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rified The user-creation guard stops new unverified users, but an instance that had Google or GitHub configured before it can hold unverified users an earlier release created, each with a social identity linked. They cannot sign in now. better-auth, however, marks such a user verified as soon as the address's owner proves the address: email-otp's sign-in (and its verify-email) does it for an email code, and handleOAuthUserInfo does it when a social sign-in the provider vouches for (the owner's own Gmail identity, say) links to the user. From then on better-auth signs the linked identity straight in without looking at the email again, so it signs in as the owner, and an old session of it starts counting as signed in too. Add databaseHooks.user.update before/after hooks: when an update marks a user's email verified (in better-auth 1.4.18 only a user who was not verified gets one; overrideUserInfoOnSignIn, which ePDS does not set, would also write it for verified users), remove every social identity linked to the user and sign out its sessions. This runs before the sign-in that verified the address creates its own session, so that sign-in completes as normal. A Gmail or Workspace identity links again by itself on its next sign-in; the one a social sign-in has just linked is dropped as well, which costs one extra link. Still left to the operator cleanup in the docs: a social identity an earlier release linked to a user who was already verified (this release cannot tell it from a good link), and a user whose emailVerified an earlier release stored as the text 'true' or 'false' (email-otp only updates a falsy value, so that user's owner stays locked out until the row is removed). Tests: create-better-auth.test.ts runs createBetterAuth() itself, as index.ts does (in-memory SQLite, Google's token endpoint stubbed, email codes captured from a stub EmailSender, PDS lookups mocked), so it also covers production wiring that no test reached before. It checks that the hooks and the Google claim mapper are the ones passed to better-auth and that a non-authoritative Google address is refused with no user, and it drives both revival paths with rows seeded as an earlier release left them: an unverified user with a Google link loses the link and its old session when the owner signs in by email code, after which the Google identity gets account_not_linked; an authoritative Google sign-in drops a GitHub link from such a user and links itself again next time. A verified user's links and sessions survive an email-code sign-in. Removing databaseHooks from createBetterAuth fails 4 of its 6 tests, removing the update hooks 3, removing the mapper 3. The Google redirect-flow helpers move from better-auth-social-sign-in.test.ts to __tests__/__helpers__/google-sign-in.ts so both files use them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sted docs/configuration.md's social providers section only listed the four env vars. Add: - that the buttons are instance-wide (every OAuth client of the ePDS); - the redirect URIs to register, https://<AUTH_HOSTNAME>/api/auth/callback/google and .../callback/github, one per auth hostname; - Google setup: a Web application OAuth client, a consent screen with only openid/email/profile, publishing status In production (Testing limits sign-in to at most 100 listed test users); - which social sign-ins are trusted (Google: strict email_verified plus Gmail/Workspace; GitHub: the /user/emails verified flag, with the caveat that GitHub does not host addresses, so the flag can be stale) and what happens to the rest; - for instances that had Google or GitHub configured before: which rows an earlier release left this release handles by itself (an unverified user's links and sessions go once its owner proves the address) and which still have to be removed by hand (a social identity linked to a verified user, which still signs in without the email being checked again; a user whose emailVerified was stored as text, which locks its owner out), a read-only query to look first, and the DELETEs. They use emailVerified IS NOT 1, which also catches the text 'true'/'false' an earlier release could store. Checked against the schema better-auth 1.4.18 creates. Both .env.example files now name the redirect URIs next to the variables. features/social-login.feature (@pending) now matches: the "same email" scenario uses a Gmail address, and new scenarios cover a Google address Google does not host (back to the login page, asked for an email code), a Workspace address (trusted), and Cancel at Google. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
pnpm test:coverage on this branch: statements 64.62, branches 63.59, functions 77.22, lines 63.51 (main: 61.68 / 60.64 / 74.39 / 60.49). Raise the floors to 64 / 63 / 77 / 63 per the ratcheting policy, and record in testing-gaps.md that better-auth.ts is now at ~76%, with create-better-auth.test.ts running the production instance. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…per client Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
isGoogleAuthoritativeFor treated any non-empty hd as proof that Google hosts the address, so person@example.com with hd "*" or "attacker.example" counted as verified. hd names a hosted domain: Google is now authoritative only for @gmail.com / @googlemail.com, or when hd equals the address's domain (both lower-cased). Workspace secondary domains no longer count. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
hasVerifiedEmail accepted only a boolean true, so users an earlier release stored with Google's claim as the text 'true' failed /auth/complete, /auth/choose-handle and /account until an operator ran the manual SQL cleanup. Accept the exact text 'true' as well; 'false' and every other value still count as unverified, and the hook that drops links made before verification is unchanged. The cleanup in docs/configuration.md no longer removes 'true' users (NOT IN (1, 'true') instead of IS NOT 1); it is still needed for social links on verified users and for users stored as the text 'false', which an email code cannot mark verified. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The social buttons only disabled the button that was clicked, so a click on the other provider, an email code send, a verify or a handle hand-off could start while the first sign-in was still in flight; a second /sign-in/social replaces the first one's OAuth state. One page-wide guard now covers the social buttons, sending and resending an email code, verifying it, and the ATProto/Bluesky handle hand-off. Each handler claims it before starting a request, releases it when the attempt fails, and keeps it while the page navigates away. Sending a code releases it once the code is sent, so the verify that follows can run. The verify form's own double-submit latch is folded into it. A page restored from the back/forward cache releases it. The login page script harness moves to __tests__/__helpers__ so the new guard tests and the social button tests share it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Also tell end users that only one sign-in runs at a time. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts: # packages/auth-service/src/__tests__/__helpers__/login-page-script.ts # packages/auth-service/src/__tests__/login-page-social-buttons.test.ts
Google and GitHub sign-in were never enabled on any deployment, so there are no social links or sessions from an earlier release to repair. Remove noteEmailVerification, dropLinksMadeBeforeVerification, their context tracking and the user.update hooks, along with the earlier-release fixtures and tests, the SQL clean-up section in docs/configuration.md and the release-note text about it. The user-creation guard stays. The update hook also fired on /email-otp/verify-email for a user who was already verified, which signed out that user's existing (for example Google) session. With it gone, a new test pins that re-verifying an address keeps the session and the Google link, and that an email-code sign-in after a Google sign-up lands on the same user. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No release stored emailVerified as text, so hasVerifiedEmail is now user?.emailVerified === true and the string-valued cases go from its tests and the account route tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The redirect URI table, the trust notes and the .env.example comments added for this change now cover Google only. The GitHub provider itself and its existing variables are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sonar flagged 3.3% duplication on new code: complete-social-sign-in copied the mocks and app setup of callback-handle-mode, and login-page-social-error copied the config and server setup of login-page-prompt-login. The /auth/complete social cases move into callback-handle-mode, whose stand-in session now takes a user, and both login page route tests start their app from __helpers__/login-page-app. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sign-in guard and social button tests rendered the page with the same option block; it now lives next to the script harness. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts: # packages/auth-service/src/__tests__/login-page-social-buttons.test.ts
features/social-login.feature was @pending and never ran. It is now Google-only, tagged @fake-google, and runs in the private Atmosphere stack: - The browser's trip to accounts.google.com is intercepted with page.route and redirected straight back to /api/auth/callback/google, with the scenario's ID token claims as the authorization code (or error=access_denied for a cancel). - With EPDS_TEST_HOOKS=1 and EPDS_TEST_FAKE_GOOGLE=1, auth-service answers better-auth's token request with an ID token for those claims (lib/test-google-token.ts). It refuses to install when NODE_ENV=production. Everything after it, including the Google claim check, runs as in production. - The demo client lists google in epds_social_providers; the stack sets placeholder Google credentials and E2E_FAKE_GOOGLE=1 on the runner, and e2e/cucumber.mjs excludes @fake-google where that flag is unset. Scenarios: an account created with Google is the same PDS account for a later email-code sign-in; a Google sign-in refused for hd other.example recovers with an email code to the existing account; a cancel at Google returns to the login page. No session is created by the refused or cancelled sign-ins. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This branch had ratcheted the thresholds to 65/64/77/64 with the earlier-release repair code and its tests included. Removing that code leaves 64.76/63.75/77.31/63.61, so the floors become 64/63/77/63, still above main's 61/60/74/60. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ding without a fake Google Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…in flight Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The social button passes its provider in the errorCallbackURL, and /auth/complete carries it back to the login page, so the message names the provider that was used. Only names from a fixed list are shown; any other value falls back to the neutral wording. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Extract the request-body parsing into requestForm() instead of a nested ternary. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Set prompt=select_account on the Google provider, so a browser already signed in to another Google account cannot silently pick it. Also export socialProviders as a const filled in place by createBetterAuth (Sonar S6861), so importers still see the running instance's providers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A client can add epds_social_provider=google to /oauth/authorize (for example from a "Sign in with Google" button on its own page). The login page then hands off to Google on load and sends no email code; a login_hint email becomes Google's login hint. The parameter only counts for a provider the client offers, never on a social error return (which carries it to name the failed provider), and, like prompt=login, skips device-session reuse. The login page's Google button also sends an email address typed into the field as Google's login hint. The demo gets a "Sign in with Google" button under "Sign in with ATProto/Bluesky", with an e2e scenario that checks Google is asked for the account chooser and no code is sent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Decide epds_social_provider against the client's offered providers before the device-session reuse check, so an unlisted or unconfigured provider no longer skips reuse (Greptile, CodeRabbit). - Log why a social sign-in could not start instead of an empty catch. - Demo: with social_provider, drop a malformed email instead of refusing the sign-in. - Tests: the controls now assert the code is sent on load, and reuse still happens for an unoffered provider. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rdening Brings in #265 (keep a handle/DID-resolved email server-side) and the 0.10.1 release. Conflicts: - login-page.ts: keep main's renderTermsHtml/renderSocialButtonsHtml split, with the helper rendering this branch's POST buttons for the client's offered providers (main still rendered GET links, which 404); the auto-sent code keeps both the in-flight guard and main's flow-email fallback. - vitest.config.ts, testing-gaps.md: main's numbers, both texts. - login-page-prompt-login.test.ts: #265's new describe reuses the shared test app, which now exposes its context. - The social auto-start loop only runs when a provider was requested, so main's flow-email page-script test (whose fake DOM has no getAttribute) passes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Google is the only social provider. GITHUB_CLIENT_ID/SECRET are no longer read, "github" in epds_social_providers is ignored, and the GitHub button, provider origin, display name, docs, env examples and tests are gone. The GitHub button never worked (its GET link was a 404), so no sign-in is lost. A few tests keep "github" as input to show it stays ignored. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With ?epds_social_provider=google, GET /oauth/authorize now starts the sign-in on the server (better-auth signInSocial with returnHeaders) and redirects to Google, instead of rendering the login page and pressing its button from the page script. Nothing shows before Google's own page, and Back returns to the app rather than restarting Google. - The state cookie better-auth issues is appended to the response, next to the auth_flow cookie and any orphan device-cookie clears. - Only an email hint the caller supplied goes to Google; an address resolved from a handle/DID never reaches the redirect. - The provider URL gets the same https + origin check as the button. - If the start fails, the login page renders with "Could not start sign-in" and no code is sent. - The client-side auto-start (and the renderLoginPage option) is gone. - Tests: route tests for the redirect, cookies, handle hints, failures and session reuse; create-better-auth runs the start against the real instance through to the callback. The e2e fake for Google follows the redirect chain from the demo's sign-in route, since Playwright routes only the first request of a chain, and the scenario now checks the auth service redirected straight to Google. - Coverage thresholds ratcheted to 66/65/79/65. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
How a client opts in (epds_social_providers), how to start Google from its own page (epds_social_provider=google on the returned authorization URL, with an optional email login_hint), what users and the callback see, how to verify the redirect, and the two common failures. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Only the first GET for a flow redirects to the provider. better-auth keeps one OAuth state cookie per browser, so a duplicate GET (extension re-request, prefetch, second tab) starting again would replace the state of the sign-in already at Google and make it fail. A duplicate gets the login page, which starts nothing on load, like the OTP path's otpAlreadySent. - request_uri is narrowed to a string, like login_hint, so a repeated or bracketed query key gets the existing 400 instead of reaching the database lookup. - Tests: the duplicate GET, a non-string request_uri, Google listed by a client on a server without Google configured, and direct cases for the server-side provider URL check (http, look-alike host, userinfo, port, relative, non-string, other providers). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- remove-github-sign-in changeset: operators only; epds_social_providers was never released with "github". - epds-login skill and verified-email changeset: Workspace addresses count only on the organisation's primary (hd) domain. - e2e README: how the fake reaches Google when the auth service redirects. - IMPLEMENTATION-PLAN: social-login.feature has 4 scenarios. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fix(auth): make social sign-in safe to switch on (verified emails only, errors back to the login page)
Send social callbacks that lost their state cookie to /auth/complete instead of better-auth's bare error page, only treat known epds_social_error codes as a sign-in return, bind the login-page test server to 127.0.0.1, and tighten the related docs and changeset. Closes #278
…-278 auth: address CodeRabbit follow-ups from #260
|
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the
|
Coverage Report for CI Build 41988417676Warning No base build found for commit Coverage: 65.693%Details
Uncovered Changes
Coverage RegressionsRequires a base build to compare against. How to fix this → Coverage Stats💛 - Coveralls |
prepare for release v0.11.0
|



Syncs
mainintodev: #260 (Google sign-in hardening, server-side Google start, GitHub sign-in removed) and #279 (CodeRabbit follow-ups from #260).