Skip to content

Commit b07ee18

Browse files
waleedlatif1claude
andcommitted
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>
1 parent 928bd91 commit b07ee18

6 files changed

Lines changed: 58 additions & 5 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/app/invite/[id]/invite.tsx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -255,11 +255,13 @@ export default function Invite() {
255255
}
256256
}
257257

258-
const getCallbackUrl = () => {
259-
const effectiveToken =
260-
token || sessionStorage.getItem(inviteTokenStorageKey) || searchParams.get('token')
261-
return `/invite/${inviteId}${effectiveToken ? `?token=${effectiveToken}` : ''}`
262-
}
258+
/**
259+
* Post-authentication return URL. Omits the token query string: Better Auth
260+
* appends `?error=<message>` onto callbackURL unescaped, producing a malformed
261+
* URL that fails its callbackURL validation. The token is persisted to
262+
* sessionStorage on mount and rehydrated on return, so it need not ride in the URL.
263+
*/
264+
const getCallbackUrl = () => `/invite/${inviteId}`
263265

264266
if (!session?.user && !isPending) {
265267
const callbackUrl = encodeURIComponent(getCallbackUrl())

apps/sim/lib/auth/auth.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,20 @@ const additionalTrustedOrigins = parseOriginList(env.TRUSTED_ORIGINS, (value) =>
164164
logger.warn('Ignoring invalid entry in TRUSTED_ORIGINS', { value })
165165
)
166166

167+
/**
168+
* SSO provider IDs to trust for automatic account linking when an SSO sign-in
169+
* matches an existing account's email. Includes `SSO_PROVIDER_ID` when it is set
170+
* in the app environment, plus any IDs from `SSO_TRUSTED_PROVIDER_IDS`. Resolved
171+
* once at startup; `trustEmailVerified` on the SSO plugin handles IdPs that assert
172+
* `email_verified` live, so this is only needed for IdPs that omit that claim.
173+
*/
174+
const additionalTrustedSsoProviders = [
175+
env.SSO_PROVIDER_ID,
176+
...(env.SSO_TRUSTED_PROVIDER_IDS?.split(',') ?? []),
177+
]
178+
.map((id) => id?.trim())
179+
.filter((id): id is string => Boolean(id))
180+
167181
if (env.NODE_ENV === 'production') {
168182
const baseUrl = getBaseUrl()
169183
if (isLocalhostUrl(baseUrl)) {
@@ -685,6 +699,7 @@ export const auth = betterAuth({
685699
'calcom',
686700
'docusign',
687701
...SSO_TRUSTED_PROVIDERS,
702+
...additionalTrustedSsoProviders,
688703
],
689704
},
690705
},
@@ -2916,6 +2931,12 @@ export const auth = betterAuth({
29162931
...(env.SSO_ENABLED
29172932
? [
29182933
sso({
2934+
/**
2935+
* Honor the IdP's verified-email claim. Without this the SSO plugin
2936+
* forces `emailVerified: false`, blocking automatic linking of an SSO
2937+
* login to an existing same-email account (Better Auth "account not linked").
2938+
*/
2939+
trustEmailVerified: true,
29192940
organizationProvisioning: {
29202941
disabled: false,
29212942
defaultRole: 'member',

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,7 @@ export const env = createEnv({
404404
SSO_DOMAIN: z.string().optional(), // [REQUIRED] SSO email domain
405405
SSO_USER_EMAIL: z.string().optional(), // [REQUIRED] User email for SSO registration
406406
SSO_ORGANIZATION_ID: z.string().optional(), // Organization ID for SSO registration (optional)
407+
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.
407408

408409
// SSO Mapping Configuration (optional - sensible defaults provided)
409410
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)