fix(auth): link SSO sign-in to existing same-email accounts#4866
Conversation
SSO sign-ins failed with "account not linked" (then a cascading "Invalid callbackURL") when an account with the same email already existed. Better Auth's `@better-auth/sso` plugin hardcodes the provisioned user's `emailVerified: options?.trustEmailVerified ? <claim> : false`, so with the option unset every SSO login arrived unverified and tripped the account linking gate `(!isTrustedProvider && !userInfo.emailVerified)` whenever the provider was not in `accountLinking.trustedProviders`. - Set `trustEmailVerified: true` on the SSO plugin so the IdP's verified-email claim is honored (Okta, Entra ID, Google Workspace, Auth0 all assert it). - Trust the operator's configured provider for linking: merge `SSO_PROVIDER_ID` (when present in the app env) plus a new `SSO_TRUSTED_PROVIDER_IDS` list into `trustedProviders`. Empty/unset => no-op, so existing deployments are unchanged. - Invite callback URL: return a clean `/invite/<id>` (token already persists in sessionStorage) so an appended `?error=` cannot produce a malformed URL. - Document `SSO_TRUSTED_PROVIDER_IDS` in SSO docs, Helm values, and schema. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
PR SummaryHigh Risk Overview Auth: Enables Config & docs: Adds Reviewed by Cursor Bugbot for commit b08e6cf. Configure here. |
Greptile SummaryThis PR fixes the
Confidence Score: 5/5Safe to merge — changes are tightly scoped to the SSO code path and have no effect on non-SSO deployments. Both new config levers are guarded by the SSO_ENABLED flag; empty or unset SSO_TRUSTED_PROVIDER_IDS produces an empty array and leaves the trusted-providers list unchanged. The trustEmailVerified option is a well-targeted single-line addition inside the existing SSO plugin block. No auth logic is modified outside the SSO path, and the input-parsing code handles whitespace and empty strings correctly. No files require special attention. Important Files Changed
Sequence DiagramsequenceDiagram
participant User
participant BetterAuth
participant SSOPlugin
participant IdP
User->>BetterAuth: SSO sign-in attempt
BetterAuth->>IdP: OIDC/SAML auth request
IdP-->>BetterAuth: token with email + email_verified claim
BetterAuth->>SSOPlugin: handleOAuthUserInfo(userInfo)
Note over SSOPlugin: trustEmailVerified: true<br/>→ userInfo.emailVerified = idp_claim
SSOPlugin->>BetterAuth: check accountLinking gate
Note over BetterAuth: Gate passes if:<br/>• isTrustedProvider (SSO_PROVIDER_ID / SSO_TRUSTED_PROVIDER_IDS)<br/>• OR userInfo.emailVerified == true (via trustEmailVerified)
alt Gate passes
BetterAuth->>BetterAuth: link SSO identity to existing account
BetterAuth-->>User: signed in ✓
else Gate blocked (SAML / no email_verified + untrusted provider)
BetterAuth-->>User: account not linked error
Note over User: Operator must add provider ID<br/>to SSO_TRUSTED_PROVIDER_IDS
end
Reviews (4): Last reviewed commit: "Merge remote-tracking branch 'origin/sta..." | Re-trigger Greptile |
…e callback - Only compute additionalTrustedSsoProviders when SSO_ENABLED, so trustedProviders is exactly unchanged for non-SSO deployments. - Revert the invite getCallbackUrl change: keep the token in the callback URL (with sessionStorage/searchParams fallback) so the token survives when sessionStorage is unavailable. The account-linking fix removes the "account not linked" error that caused the malformed callback URL, so the callback cleanup is unnecessary. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
@greptile |
|
@cursor review |
env.SSO_ENABLED can be the string "false" (t3-env returns strings for booleans), which is truthy in JS. Use the canonical isSsoEnabled flag (isTruthy(env.SSO_ENABLED)) so SSO_ENABLED="false"/"0" correctly yields an empty trusted-provider list, matching how SSO is gated elsewhere. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
@greptile |
|
@cursor review |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 481c66d. Configure here.
|
@greptile |
|
@cursor review |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit b08e6cf. Configure here.
Problem
Self-hosted SSO sign-ins failed with
account not linked, followed by a cascadingERROR [Better Auth]: Invalid callbackURLthat left the user stuck. It surfaced after thebetter-auth 1.3.12 → 1.6.11upgrade (#4766).Root cause (verified against the 1.6.11 source):
@better-auth/ssoprovisions the user withemailVerified: options?.trustEmailVerified ? <idp-claim> : false. WithtrustEmailVerifiedunset, every SSO login arrivesemailVerified: false, which trips the account-linking gate inhandleOAuthUserInfo:For Sim's config only the first term can fire — so when the configured SSO
providerIdisn't inaccountLinking.trustedProvidersand the IdP email isn't treated as verified, an existing same-email account (e.g. prior email/password signup) can't be linked. The literal-spaces error string (account not linked, notaccount_not_linked) pins the failure to the OIDC callback.Fix
trustEmailVerified: trueon the SSO plugin — honor the IdP's verified-email claim (Okta, Entra ID, Google Workspace, Auth0 all assert it). Closes the gate viauserInfo.emailVerified.SSO_PROVIDER_ID(when present in the app environment) plus a new optionalSSO_TRUSTED_PROVIDER_IDS(comma-separated) intotrustedProviders. Closes the gate viaisTrustedProvider, unconditionally, for IdPs that omitemail_verified. Computed only whenSSO_ENABLEDand empty/unset ⇒[]⇒ no behavior change for non-SSO / unconfigured deployments.SSO_TRUSTED_PROVIDER_IDSdocumented in the SSO docs, Helmvalues.yaml, andvalues.schema.json; new FAQ + callout for the same-email linking behavior.Safety / regressions
trustEmailVerifiedis evaluated live per sign-in (covers any provider, incl. SAML, which runs the same gate in 1.6.11).SSO_ENABLEDoff), the trusted-provider list is[]andtrustEmailVerifiedlives inside theSSO_ENABLEDblock — byte-for-byte equivalent config for non-SSO / unconfigured deployments.trustEmailVerifiedis strictly more conservative than the pre-existing unconditional trust of common provider IDs.check:api-validationat baseline.Notes
An earlier revision also simplified the invite
getCallbackUrl()(dropping?token=); that was reverted after review — it removed thesessionStorage-unavailable fallback for no real benefit, since the account-linking fix removes the error that produced the malformed callback URL, and the success path keeps the token-bearing URL working. The middleware (proxy.ts) still carries the invite token in the logincallbackUrl, which is the existing, working behavior.