Skip to content

Commit b329c36

Browse files
waleedlatif1claude
andauthored
fix(auth): link SSO sign-in to existing same-email accounts (#4866)
* fix(auth): link SSO sign-in to existing same-email accounts 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> * fix(auth): address review — guard trusted SSO providers, revert invite 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> * fix(auth): guard trusted SSO providers with isSsoEnabled (isTruthy) 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> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent b98fbfe commit b329c36

5 files changed

Lines changed: 52 additions & 0 deletions

File tree

apps/docs/content/docs/en/enterprise/sso.mdx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,10 @@ SSO provisioning creates internal organization members. External workspace membe
250250
question: "Can I still use email/password login after enabling SSO?",
251251
answer: "Yes. Enabling SSO does not disable password-based login. Users can still sign in with their email and password if they have one. Forced SSO (requiring all users on the domain to use SSO) is not yet supported."
252252
},
253+
{
254+
question: "A user already has an account with the same email — what happens when they sign in with SSO?",
255+
answer: "Sim links the SSO identity to the existing account automatically, as long as your identity provider reports the email as verified (email_verified) or the provider is trusted. Most OIDC providers (Okta, Google Workspace, Auth0) assert email_verified, so linking just works. If sign-in fails with 'account not linked' — common with SAML providers that omit the claim — add the provider's ID to SSO_TRUSTED_PROVIDER_IDS on self-hosted and restart."
256+
},
253257
{
254258
question: "Who can configure SSO on Sim Cloud?",
255259
answer: "Organization owners and admins can configure SSO. You must be on the Enterprise plan."
@@ -280,8 +284,25 @@ NEXT_PUBLIC_SSO_ENABLED=true
280284
# Required if you want users auto-added to your organization on first SSO sign-in
281285
ORGANIZATIONS_ENABLED=true
282286
NEXT_PUBLIC_ORGANIZATIONS_ENABLED=true
287+
288+
# Optional: comma-separated SSO provider IDs to trust for automatic account linking
289+
# (links an SSO sign-in to an existing account with the same email). Needed when your
290+
# IdP does not assert email_verified — typically SAML providers, or OIDC providers that
291+
# omit the claim. Set it to the Provider ID you registered, then restart.
292+
# (If you also keep SSO_PROVIDER_ID in the app's environment, that provider is trusted
293+
# without listing it here.)
294+
SSO_TRUSTED_PROVIDER_IDS=custom-oidc,partner-saml
283295
```
284296

297+
<Callout type="info">
298+
When someone signs in with SSO and an account with the same email already exists
299+
(for example, they previously signed up with email/password), Sim links the SSO
300+
identity to that account automatically as long as your IdP reports the email as
301+
verified, or the provider is trusted. If you hit an `account not linked` error,
302+
either confirm your IdP sends `email_verified`, or add the provider's ID to
303+
`SSO_TRUSTED_PROVIDER_IDS` and restart.
304+
</Callout>
305+
285306
You can register providers through the **Settings UI** (same as cloud) or by running the registration script directly against your database.
286307

287308
### Script-based registration

apps/sim/lib/auth/auth.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ import {
7171
isRegistrationDisabled,
7272
isSignupEmailValidationEnabled,
7373
isSignupMxValidationEnabled,
74+
isSsoEnabled,
7475
} from '@/lib/core/config/feature-flags'
7576
import { PlatformEvents } from '@/lib/core/telemetry'
7677
import { getBaseUrl, isLocalhostUrl, parseOriginList } from '@/lib/core/utils/urls'
@@ -164,6 +165,20 @@ const additionalTrustedOrigins = parseOriginList(env.TRUSTED_ORIGINS, (value) =>
164165
logger.warn('Ignoring invalid entry in TRUSTED_ORIGINS', { value })
165166
)
166167

168+
/**
169+
* SSO provider IDs to trust for automatic account linking when an SSO sign-in
170+
* matches an existing account's email. Includes `SSO_PROVIDER_ID` when it is set
171+
* in the app environment, plus any IDs from `SSO_TRUSTED_PROVIDER_IDS`. Empty when
172+
* SSO is disabled, so `trustedProviders` is unchanged for non-SSO deployments.
173+
* Resolved once at startup; `trustEmailVerified` on the SSO plugin handles IdPs
174+
* that assert `email_verified` live, so this is only needed for IdPs that omit it.
175+
*/
176+
const additionalTrustedSsoProviders = isSsoEnabled
177+
? [env.SSO_PROVIDER_ID, ...(env.SSO_TRUSTED_PROVIDER_IDS?.split(',') ?? [])]
178+
.map((id) => id?.trim())
179+
.filter((id): id is string => Boolean(id))
180+
: []
181+
167182
if (env.NODE_ENV === 'production') {
168183
const baseUrl = getBaseUrl()
169184
if (isLocalhostUrl(baseUrl)) {
@@ -685,6 +700,7 @@ export const auth = betterAuth({
685700
'calcom',
686701
'docusign',
687702
...SSO_TRUSTED_PROVIDERS,
703+
...additionalTrustedSsoProviders,
688704
],
689705
},
690706
},
@@ -2916,6 +2932,12 @@ export const auth = betterAuth({
29162932
...(env.SSO_ENABLED
29172933
? [
29182934
sso({
2935+
/**
2936+
* Honor the IdP's verified-email claim. Without this the SSO plugin
2937+
* forces `emailVerified: false`, blocking automatic linking of an SSO
2938+
* login to an existing same-email account (Better Auth "account not linked").
2939+
*/
2940+
trustEmailVerified: true,
29192941
organizationProvisioning: {
29202942
disabled: false,
29212943
defaultRole: 'member',

apps/sim/lib/core/config/env.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,7 @@ export const env = createEnv({
406406
SSO_DOMAIN: z.string().optional(), // [REQUIRED] SSO email domain
407407
SSO_USER_EMAIL: z.string().optional(), // [REQUIRED] User email for SSO registration
408408
SSO_ORGANIZATION_ID: z.string().optional(), // Organization ID for SSO registration (optional)
409+
SSO_TRUSTED_PROVIDER_IDS: z.string().optional(), // Comma-separated SSO provider IDs to trust for automatic account linking when an existing account shares the same email. Use for IdPs that do not assert email_verified. Merged into Better Auth accountLinking.trustedProviders.
409410

410411
// SSO Mapping Configuration (optional - sensible defaults provided)
411412
SSO_MAPPING_ID: z.string().optional(), // Custom ID claim mapping (default: sub for OIDC, nameidentifier for SAML)

helm/sim/values.schema.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,10 @@
157157
"type": "string",
158158
"description": "Comma-separated additional public origins to trust for auth (e.g. 'https://app.example.com,https://www.example.com'). Merged into Better Auth trustedOrigins."
159159
},
160+
"SSO_TRUSTED_PROVIDER_IDS": {
161+
"type": "string",
162+
"description": "Comma-separated SSO provider IDs to trust for automatic account linking when an SSO sign-in matches an existing account's email. Only needed for IdPs that do not assert email_verified. Merged into Better Auth accountLinking.trustedProviders."
163+
},
160164
"NODE_ENV": {
161165
"type": "string",
162166
"enum": ["development", "test", "production"],

helm/sim/values.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,10 @@ app:
216216
# Set to "true" AFTER running the SSO registration script
217217
SSO_ENABLED: "" # Enable SSO authentication ("true" to enable)
218218
NEXT_PUBLIC_SSO_ENABLED: "" # Show SSO login button in UI ("true" to enable)
219+
# SSO_TRUSTED_PROVIDER_IDS: comma-separated SSO provider IDs to trust for automatic account linking when a
220+
# user signs in via SSO and an account with the same email already exists. Only needed for IdPs that do NOT
221+
# assert email_verified (trustEmailVerified already handles those that do). Resolved at startup — restart after editing.
222+
SSO_TRUSTED_PROVIDER_IDS: ""
219223

220224
# Enterprise Feature Overrides (self-hosted)
221225
CREDENTIAL_SETS_ENABLED: "" # Enable credential sets (email polling) on self-hosted ("true" to enable)

0 commit comments

Comments
 (0)