Skip to content

Commit fcfda44

Browse files
committed
fix(sso): link Entra sign-ins to existing accounts and enforce unique provider IDs
Better Auth 1.6.23 calls the account-linking handler with trustProviderByName: false, which disables the trustedProviders allowlist for SSO entirely. Trust now comes only from the provider's domainVerified flag, which Sim never set — so any user who already had a Sim account was stranded on "account not linked". Entra never sends email_verified, so this hit every Microsoft tenant. Sim already proves domain ownership via sso_domain before a provider can be registered, so the register route mirrors that decision onto domainVerified. The column defaults to true so existing providers keep signing in across the deploy, since enabling the option turns sign-in into a hard gate. Also enforces the providerId uniqueness Better Auth already assumes: it rejects any id that exists in any tenant and resolves providers by that column alone, so a second customer picking "azure-ad" could not register at all and got an opaque 422. Sim now returns a 409 naming a free id, and a unique index makes the duplicate-row state unreachable.
1 parent 721b471 commit fcfda44

10 files changed

Lines changed: 19074 additions & 34 deletions

File tree

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

Lines changed: 25 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,21 @@ Single Sign-On lets your team sign in to Sim through your company's identity pro
1212

1313
---
1414

15+
## Before you start
16+
17+
<Callout type="warning">
18+
[Verify your email domain](/platform/enterprise/verified-domains) first. SSO cannot be saved until the domain shows as **Verified**, and DNS changes take time to propagate.
19+
</Callout>
20+
21+
Decide your **Provider ID** before configuring your identity provider — it becomes part of the callback URL you register there, so changing it later means redoing that step.
22+
23+
---
24+
1525
## Setup
1626

1727
### 1. Open SSO settings
1828

19-
Go to **Settings → Enterprise → Single Sign-On** in your workspace.
29+
Go to **Settings → Security → Single sign-on** in your organization settings.
2030

2131
### 2. Choose a protocol
2232

@@ -33,7 +43,7 @@ Go to **Settings → Enterprise → Single Sign-On** in your workspace.
3343

3444
| Field | What to enter |
3545
|-------|--------------|
36-
| **Provider ID** | A short slug identifying this connection, e.g. `okta` or `azure-ad`. Letters, numbers, and dashes only. |
46+
| **Provider ID** | A short slug identifying this connection. Letters, numbers, and dashes only. It must be **unique across every Sim organization**, so include something specific to you — `azure-ad-acme`, not `azure-ad`. If the ID is taken, Sim tells you and suggests a free one. |
3747
| **Issuer URL** | The identity provider's issuer URL. Must be HTTPS. |
3848
| **Domain** | Your organization's email domain, e.g. `company.com`. Users with this domain will be routed through SSO at sign-in. |
3949

@@ -118,19 +128,21 @@ The issuer URL uses Okta's default authorization server, which is pre-configured
118128
**In Azure** ([official docs](https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-register-app)):
119129

120130
1. Go to **Microsoft Entra ID → App registrations → New registration**
121-
2. Under **Redirect URI**, select **Web** and enter your Sim callback URL:
131+
2. Under **Redirect URI**, select **Web** and enter your Sim callback URL, using the Provider ID you chose:
122132
```
123-
https://sim.ai/api/auth/sso/callback/azure-ad
133+
https://sim.ai/api/auth/sso/callback/azure-ad-acme
124134
```
125135
3. After registration, go to **Certificates & secrets → New client secret** and copy the value immediately — it won't be shown again
126136
4. Go to **Overview** and copy the **Application (client) ID** and **Directory (tenant) ID**
137+
5. Go to **Token configuration → Add optional claim**, choose **ID**, and add **email**. Entra omits the email address for managed users without this claim, and sign-in then fails with a missing-user-info error
138+
6. If **Enterprise applications → Sim → Properties → Assignment required** is **Yes**, assign the users or groups who should sign in. Microsoft rejects unassigned users before they reach Sim
127139

128140
**In Sim:**
129141

130142
| Field | Value |
131143
|-------|-------|
132144
| Provider Type | OIDC |
133-
| Provider ID | `azure-ad` |
145+
| Provider ID | `azure-ad-acme` (must be globally unique) |
134146
| Issuer URL | `https://login.microsoftonline.com/{tenant-id}/v2.0` |
135147
| Domain | `company.com` |
136148
| Client ID | Application (client) ID |
@@ -252,7 +264,7 @@ SSO provisioning creates internal organization members. External workspace membe
252264
},
253265
{
254266
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."
267+
answer: "Sim links the SSO identity to that account automatically. Linking is authorized by your verified domain: because you proved ownership of the domain before configuring SSO, Sim treats your identity provider as authoritative for email addresses on it. This works the same for OIDC and SAML, and does not depend on your IdP sending an email_verified claim — Microsoft Entra, for example, never sends one."
256268
},
257269
{
258270
question: "Who can configure SSO on Sim Cloud?",
@@ -264,7 +276,7 @@ SSO provisioning creates internal organization members. External workspace membe
264276
},
265277
{
266278
question: "How do I update or replace an existing SSO configuration?",
267-
answer: "Open Settings → Enterprise → Single Sign-On and click Edit. Update the fields and save. The existing provider configuration is replaced."
279+
answer: "Open Settings → Security → Single sign-on and click Edit. Update the fields and save. The existing provider configuration is replaced."
268280
}
269281
]} />
270282

@@ -285,22 +297,18 @@ NEXT_PUBLIC_SSO_ENABLED=true
285297
ORGANIZATIONS_ENABLED=true
286298
NEXT_PUBLIC_ORGANIZATIONS_ENABLED=true
287299

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.)
300+
# Optional: comma-separated provider IDs to trust for automatic account linking.
301+
# This applies to non-SSO providers only — SSO linking is authorized by the
302+
# verified domain on the provider itself, not by this list.
294303
SSO_TRUSTED_PROVIDER_IDS=custom-oidc,partner-saml
295304
```
296305

297306
<Callout type="info">
298307
When someone signs in with SSO and an account with the same email already exists
299308
(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.
309+
identity to that account automatically. That linking is authorized by the verified
310+
domain attached to the provider, so it works for both OIDC and SAML and does not
311+
depend on your IdP asserting `email_verified`.
304312
</Callout>
305313

306314
You can register providers through the **Settings UI** (same as cloud) or by running the registration script directly against your database.

apps/sim/app/api/auth/sso/register/route.test.ts

Lines changed: 80 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,19 @@ function queueMembers(rows: Array<Record<string, unknown>>) {
3939
}
4040

4141
/**
42-
* Queues existing SSO provider rows for BOTH domain-conflict lookups (the
43-
* pre-registration check and the post-registration re-check).
42+
* Queues the sso_provider lookups a registration performs, in route order:
43+
* providerId conflict then domain conflict, once before OIDC discovery and again
44+
* immediately before the write. `providerIdRows` defaults to empty so
45+
* domain-conflict tests are unaffected by the providerId check.
4446
*/
45-
function queueProviders(rows: Array<Record<string, unknown>>) {
46-
queueTableRows(schemaMock.ssoProvider, rows)
47-
queueTableRows(schemaMock.ssoProvider, rows)
47+
function queueProviders(
48+
domainRows: Array<Record<string, unknown>>,
49+
providerIdRows: Array<Record<string, unknown>> = []
50+
) {
51+
queueTableRows(schemaMock.ssoProvider, providerIdRows)
52+
queueTableRows(schemaMock.ssoProvider, domainRows)
53+
queueTableRows(schemaMock.ssoProvider, providerIdRows)
54+
queueTableRows(schemaMock.ssoProvider, domainRows)
4855
}
4956

5057
vi.mock('@/lib/auth', () => ({
@@ -214,6 +221,73 @@ describe('POST /api/auth/sso/register', () => {
214221
expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1)
215222
})
216223

224+
/**
225+
* Better Auth scopes providerId uniqueness globally, not per tenant, and would
226+
* otherwise reject this with an opaque 422 that reads like a bug. Sim catches
227+
* it first and returns a 409 naming a free id.
228+
*/
229+
it('rejects a providerId already taken by another organization', async () => {
230+
queueMembers([{ organizationId: 'org-b', role: 'owner' }])
231+
queueProviders([], [{ domain: 'other.com', userId: 'u-other', organizationId: 'org-other' }])
232+
const res = await POST(request({ ...OIDC_BODY, orgId: 'org-b' }))
233+
const json = await res.json()
234+
expect(res.status).toBe(409)
235+
expect(json.code).toBe('SSO_PROVIDER_ID_TAKEN')
236+
expect(mockRegisterSSOProvider).not.toHaveBeenCalled()
237+
})
238+
239+
it('suggests a free, domain-scoped providerId when the requested one is taken', async () => {
240+
queueMembers([{ organizationId: 'org-b', role: 'owner' }])
241+
queueProviders([], [{ domain: 'other.com', userId: 'u-other', organizationId: 'org-other' }])
242+
const res = await POST(request({ ...OIDC_BODY, orgId: 'org-b' }))
243+
const json = await res.json()
244+
expect(json.error).toContain('acme-oidc-acme')
245+
})
246+
247+
it('does not treat the caller’s own provider as a providerId conflict', async () => {
248+
queueMembers([{ organizationId: 'org1', role: 'owner' }])
249+
queueProviders([], [{ domain: 'acme.com', userId: 'u1', organizationId: 'org1' }])
250+
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
251+
expect(res.status).toBe(200)
252+
})
253+
254+
/**
255+
* Better Auth's `isTrustedProvider` reads this flag, and it is the only thing
256+
* that lets an SSO sign-in link to a pre-existing same-email account once the
257+
* plugin stopped honouring `trustedProviders` for SSO. `registerSSOProvider`
258+
* always persists `false`, so the route must set it after the write.
259+
*/
260+
it('marks the provider domain-verified after registering', async () => {
261+
queueMembers([{ organizationId: 'org1', role: 'owner' }])
262+
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
263+
expect(res.status).toBe(200)
264+
expect(dbChainMockFns.set).toHaveBeenCalledWith({ domainVerified: true })
265+
})
266+
267+
/** updateSSOProvider resets domainVerified to false whenever the domain changes. */
268+
it('re-marks the provider domain-verified after an update', async () => {
269+
queueMembers([{ organizationId: 'org1', role: 'owner' }])
270+
queueProviders([])
271+
queueTableRows(schemaMock.ssoProvider, [{ id: 'p1' }])
272+
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
273+
expect(res.status).toBe(200)
274+
expect(mockUpdateSSOProvider).toHaveBeenCalledTimes(1)
275+
expect(dbChainMockFns.set).toHaveBeenCalledWith({ domainVerified: true })
276+
})
277+
278+
it('does not mark domain-verified when the registration is rolled back', async () => {
279+
queueMembers([{ organizationId: 'org1', role: 'owner' }])
280+
resetDbChainMock()
281+
queueMembers([{ organizationId: 'org1', role: 'owner' }])
282+
// Verified for both pre-write gates, then revoked for the post-write re-check.
283+
queueTableRows(schemaMock.ssoDomain, [{ id: 'verified-domain' }])
284+
queueTableRows(schemaMock.ssoDomain, [{ id: 'verified-domain' }])
285+
queueTableRows(schemaMock.ssoDomain, [])
286+
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
287+
expect(res.status).toBe(403)
288+
expect(dbChainMockFns.set).not.toHaveBeenCalledWith({ domainVerified: true })
289+
})
290+
217291
it('nests the attribute mapping inside oidcConfig (Better Auth reads it there)', async () => {
218292
queueMembers([{ organizationId: 'org1', role: 'owner' }])
219293
await POST(
@@ -227,8 +301,7 @@ describe('POST /api/auth/sso/register', () => {
227301

228302
it('routes an edit of an existing owned provider through updateSSOProvider', async () => {
229303
queueMembers([{ organizationId: 'org1', role: 'owner' }])
230-
queueTableRows(schemaMock.ssoProvider, []) // findDomainConflict #1 → no conflict
231-
queueTableRows(schemaMock.ssoProvider, []) // findDomainConflict #2 → no conflict
304+
queueProviders([]) // no providerId or domain conflicts on either pass
232305
queueTableRows(schemaMock.ssoProvider, [{ id: 'p1' }]) // provider already owned → edit
233306
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
234307
expect(res.status).toBe(200)

apps/sim/app/api/auth/sso/register/route.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,18 @@ function selectTokenEndpointAuthMethod(
4040
return 'client_secret_post'
4141
}
4242

43+
/**
44+
* Proposes a free, tenant-scoped provider ID by suffixing the domain's first
45+
* label (`azure-ad` + `acme.com` -> `azure-ad-acme`), so a caller who hit the
46+
* global-uniqueness collision is handed something concrete to type rather than
47+
* being asked to invent a name. Callers pass a domain that already went through
48+
* `normalizeSSODomain`, whose `^[a-z0-9-]+(\.[a-z0-9-]+)+$` shape guarantees a
49+
* non-empty first label needing no further sanitizing.
50+
*/
51+
function suggestProviderId(providerId: string, domain: string): string {
52+
return `${providerId}-${domain.split('.')[0]}`
53+
}
54+
4355
type DiscoveryResult =
4456
| { ok: true; discovery: Record<string, unknown> }
4557
| { ok: false; error: string }
@@ -187,6 +199,43 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
187199
{ status: 409 }
188200
)
189201

202+
/**
203+
* Better Auth treats `providerId` as globally unique, not per-tenant:
204+
* `registerSSOProvider` rejects any id already present regardless of owner,
205+
* and `checkProviderAccess` resolves providers by that column alone. Catch
206+
* the cross-tenant collision here so the caller gets an actionable 409 that
207+
* names a free id, instead of Better Auth's opaque 422 ("SSO provider with
208+
* this providerId already exists") that gives no hint anything can be done.
209+
*/
210+
const findProviderIdConflict = async () =>
211+
(
212+
await db
213+
.select({
214+
userId: ssoProvider.userId,
215+
organizationId: ssoProvider.organizationId,
216+
})
217+
.from(ssoProvider)
218+
.where(eq(ssoProvider.providerId, providerId))
219+
).find((provider) => !isOwnedByCaller(provider))
220+
221+
const providerIdConflictResponse = () =>
222+
NextResponse.json(
223+
{
224+
error: `The provider ID "${providerId}" is already taken by another organization. Provider IDs are global, so pick a unique one — for example "${suggestProviderId(providerId, domain)}". It appears in the redirect URL you register with your identity provider, so choose it before configuring the IdP.`,
225+
code: 'SSO_PROVIDER_ID_TAKEN',
226+
},
227+
{ status: 409 }
228+
)
229+
230+
if (await findProviderIdConflict()) {
231+
logger.warn('Rejected SSO registration for providerId owned by another tenant', {
232+
providerId,
233+
orgId,
234+
userId: session.user.id,
235+
})
236+
return providerIdConflictResponse()
237+
}
238+
190239
if (await findDomainConflict()) {
191240
logger.warn('Rejected SSO registration for domain owned by another tenant', {
192241
domain,
@@ -537,6 +586,15 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
537586
),
538587
})
539588

589+
if (await findProviderIdConflict()) {
590+
logger.warn('Rejected SSO registration: providerId was claimed during registration', {
591+
providerId,
592+
orgId,
593+
userId: session.user.id,
594+
})
595+
return providerIdConflictResponse()
596+
}
597+
540598
if (await findDomainConflict()) {
541599
logger.warn('Rejected SSO registration: domain was claimed during registration', {
542600
domain,
@@ -584,6 +642,20 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
584642
.where(ownerClause)
585643
.limit(1)
586644

645+
/**
646+
* Mirrors Sim's own domain-ownership proof onto Better Auth's `domainVerified`
647+
* flag, which is what lets an SSO sign-in auto-link to an existing same-email
648+
* account. Must run after every write, not just on create: `registerSSOProvider`
649+
* always persists `false`, and `updateSSOProvider` resets it to `false` whenever
650+
* the domain changes. Only ever called once the verification gate above has
651+
* passed for this exact domain, so it can never mark an unproven domain as
652+
* verified. Org-less (personal) SSO is not domain-gated by Sim and keeps its
653+
* pre-existing trust here, matching how it behaved before the flag existed.
654+
*/
655+
const markProviderDomainVerified = async () => {
656+
await db.update(ssoProvider).set({ domainVerified: true }).where(ownerClause)
657+
}
658+
587659
if (existingOwnedProvider) {
588660
await auth.api.updateSSOProvider({
589661
body: {
@@ -595,6 +667,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
595667
},
596668
headers,
597669
})
670+
await markProviderDomainVerified()
598671
logger.info('SSO provider updated successfully', { providerId, providerType, domain })
599672
return NextResponse.json({
600673
success: true,
@@ -644,6 +717,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
644717
return domainNotVerifiedResponse()
645718
}
646719

720+
await markProviderDomainVerified()
721+
647722
logger.info('SSO provider registered successfully', {
648723
providerId,
649724
providerType,

apps/sim/ee/sso/components/sso-settings.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -516,6 +516,10 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) {
516516
placeholder='Select or enter a provider ID'
517517
editable
518518
/>
519+
<p className='text-[var(--text-muted)] text-small'>
520+
Must be unique across all Sim organizations — include something specific to you,
521+
like <span className='font-mono'>azure-ad-acme</span>.
522+
</p>
519523
</SettingRow>
520524

521525
<SettingRow

0 commit comments

Comments
 (0)