Skip to content

fix(auth): link SSO sign-in to existing same-email accounts#4866

Merged
waleedlatif1 merged 4 commits into
stagingfrom
waleedlatif1/-none
Jun 3, 2026
Merged

fix(auth): link SSO sign-in to existing same-email accounts#4866
waleedlatif1 merged 4 commits into
stagingfrom
waleedlatif1/-none

Conversation

@waleedlatif1
Copy link
Copy Markdown
Collaborator

@waleedlatif1 waleedlatif1 commented Jun 3, 2026

Problem

Self-hosted SSO sign-ins failed with account not linked, followed by a cascading ERROR [Better Auth]: Invalid callbackURL that left the user stuck. It surfaced after the better-auth 1.3.12 → 1.6.11 upgrade (#4766).

Root cause (verified against the 1.6.11 source): @better-auth/sso provisions the user with emailVerified: options?.trustEmailVerified ? <idp-claim> : false. With trustEmailVerified unset, every SSO login arrives emailVerified: false, which trips the account-linking gate in handleOAuthUserInfo:

(!isTrustedProvider && !userInfo.emailVerified) || (requireLocalEmailVerified && !dbUser.emailVerified) || enabled === false || disableImplicitLinking === true

For Sim's config only the first term can fire — so when the configured SSO providerId isn't in accountLinking.trustedProviders and 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, not account_not_linked) pins the failure to the OIDC callback.

Fix

  • trustEmailVerified: true on the SSO plugin — honor the IdP's verified-email claim (Okta, Entra ID, Google Workspace, Auth0 all assert it). Closes the gate via userInfo.emailVerified.
  • Trust the operator's provider — merge SSO_PROVIDER_ID (when present in the app environment) plus a new optional SSO_TRUSTED_PROVIDER_IDS (comma-separated) into trustedProviders. Closes the gate via isTrustedProvider, unconditionally, for IdPs that omit email_verified. Computed only when SSO_ENABLED and empty/unset ⇒ []no behavior change for non-SSO / unconfigured deployments.
  • DocsSSO_TRUSTED_PROVIDER_IDS documented in the SSO docs, Helm values.yaml, and values.schema.json; new FAQ + callout for the same-email linking behavior.

Safety / regressions

  • The two levers each independently close the gate; either one fixes the bug. trustEmailVerified is evaluated live per sign-in (covers any provider, incl. SAML, which runs the same gate in 1.6.11).
  • With no SSO env vars set (or SSO_ENABLED off), the trusted-provider list is [] and trustEmailVerified lives inside the SSO_ENABLED block — byte-for-byte equivalent config for non-SSO / unconfigured deployments.
  • No new attack surface: linking remains domain-scoped (a provider only handles its registered, conflict-checked email domain). trustEmailVerified is strictly more conservative than the pre-existing unconditional trust of common provider IDs.
  • No tests assert the prior behavior; Biome clean; check:api-validation at baseline.

Notes

An earlier revision also simplified the invite getCallbackUrl() (dropping ?token=); that was reverted after review — it removed the sessionStorage-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 login callbackUrl, which is the existing, working behavior.

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>
@vercel
Copy link
Copy Markdown

vercel Bot commented Jun 3, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
docs Ready Ready Preview, Comment Jun 3, 2026 6:52pm

Request Review

@cursor
Copy link
Copy Markdown

cursor Bot commented Jun 3, 2026

PR Summary

High Risk
Changes authentication account-linking behavior for SSO; misconfigured trusted provider IDs could weaken linking guarantees for IdPs that omit email_verified.

Overview
Fixes SSO sign-in failing with account not linked when a user already has an account with the same email (e.g. prior email/password signup), especially after the Better Auth upgrade.

Auth: Enables trustEmailVerified: true on the SSO plugin so IdP email_verified claims are honored. Merges SSO_PROVIDER_ID and optional comma-separated SSO_TRUSTED_PROVIDER_IDS into Better Auth accountLinking.trustedProviders when SSO is enabled (empty when SSO is off).

Config & docs: Adds SSO_TRUSTED_PROVIDER_IDS to env schema, Helm values.yaml / schema, enterprise SSO docs (FAQ, callout, self-hosted env example).

Reviewed by Cursor Bugbot for commit b08e6cf. Configure here.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Jun 3, 2026

Greptile Summary

This PR fixes the account not linked error introduced by the better-auth 1.3.12 → 1.6.11 upgrade, where the SSO plugin forced emailVerified: false on every sign-in (absent trustEmailVerified), blocking same-email account linking. The fix adds trustEmailVerified: true to the SSO plugin config and a new SSO_TRUSTED_PROVIDER_IDS env var as a fallback for IdPs that don't assert email_verified (e.g. SAML).

  • trustEmailVerified: true is added inside the SSO_ENABLED guard, so it only activates for SSO deployments and has zero impact on non-SSO configs.
  • additionalTrustedSsoProviders is gated by isSsoEnabled and handles empty/whitespace values safely via .trim() + Boolean filter; empty SSO_TRUSTED_PROVIDER_IDS: "" from the Helm default produces [] after filtering.
  • Docs, Helm values.yaml, and values.schema.json are updated consistently.

Confidence Score: 5/5

Safe 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

Filename Overview
apps/sim/lib/auth/auth.ts Adds trustEmailVerified: true to the SSO plugin (inside SSO_ENABLED guard) and spreads additionalTrustedSsoProviders (gated by isSsoEnabled) into accountLinking.trustedProviders; both changes are safely scoped and handle edge-case inputs correctly.
apps/sim/lib/core/config/env.ts Adds SSO_TRUSTED_PROVIDER_IDS as z.string().optional(); consistent with existing optional env var patterns; skipValidation: true means empty string from Helm is handled gracefully by the caller's filter.
apps/docs/content/docs/en/enterprise/sso.mdx Adds FAQ entry and callout accurately describing the email-linking behavior and when SSO_TRUSTED_PROVIDER_IDS is needed.
helm/sim/values.yaml Adds SSO_TRUSTED_PROVIDER_IDS with an empty default; consistent with other optional env var defaults in the Helm chart.
helm/sim/values.schema.json Schema entry for SSO_TRUSTED_PROVIDER_IDS added as type string with an accurate description; matches values.yaml.

Sequence Diagram

sequenceDiagram
    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
Loading

Reviews (4): Last reviewed commit: "Merge remote-tracking branch 'origin/sta..." | Re-trigger Greptile

Comment thread apps/sim/lib/auth/auth.ts Outdated
Comment thread apps/sim/app/invite/[id]/invite.tsx Outdated
…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>
@waleedlatif1
Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1
Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/lib/auth/auth.ts
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>
@waleedlatif1
Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1
Copy link
Copy Markdown
Collaborator Author

@cursor review

Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

✅ 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.

@waleedlatif1
Copy link
Copy Markdown
Collaborator Author

@greptile

@waleedlatif1
Copy link
Copy Markdown
Collaborator Author

@cursor review

Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

✅ 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.

@waleedlatif1 waleedlatif1 merged commit b329c36 into staging Jun 3, 2026
14 checks passed
@waleedlatif1 waleedlatif1 deleted the waleedlatif1/-none branch June 3, 2026 19:00
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