Skip to content

Commit d4dafff

Browse files
committed
feat(sso): let an organization keep several identity providers on one domain and choose the primary
1 parent 95e0607 commit d4dafff

31 files changed

Lines changed: 27972 additions & 190 deletions

File tree

.github/workflows/test-build.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ jobs:
9696
lib/auth/sim-auth-adapter.postgres.test.ts
9797
ee/scim/lib/managed-membership.postgres.test.ts
9898
lib/auth/sso/application/admit-sso-user.postgres.test.ts
99+
lib/auth/sso/primary-provider.postgres.test.ts
99100
100101
- name: Verify cumulative billing timeout recovery in PostgreSQL
101102
working-directory: apps/sim

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

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
88
import { FAQ } from '@/components/ui/faq'
99
import { Image } from '@/components/ui/image'
1010

11-
Single Sign-On lets your team sign in to Sim through your company's identity provider instead of managing separate passwords. Sim supports both OIDC and SAML 2.0, and an organization can use more than one identity provider at a time, one per verified domain.
11+
Single Sign-On lets your team sign in to Sim through your company's identity provider instead of managing separate passwords. Sim supports both OIDC and SAML 2.0, and an organization can use more than one identity provider at a time.
1212

1313
---
1414

@@ -38,7 +38,7 @@ Go to **Settings → Organization → Single sign-on**. The page has three tabs:
3838

3939
Use **Domains** to verify ownership, then return to **Sign-in** to configure your provider. Switching tabs preserves an unsaved sign-in draft while you stay on this page; use **Save** or **Update** to commit it. The selected tab and provider are included in the URL, so they can be bookmarked or shared. On self-hosted deployments, Provisioning appears when SCIM is enabled.
4040

41-
An organization can run several identity providers at once, each serving a different verified domain: Okta for `eng.acme.com` and Microsoft Entra ID for `acme.com`, for example. **Sign-in** lists them; select **Add identity provider** for another, or a row to view, edit, or delete one. Sim routes each sign-in by the email domain, so a domain routes to exactly one provider.
41+
An organization can run several identity providers at once: Okta for `eng.acme.com` and Microsoft Entra ID for `acme.com`, for example. **Sign-in** lists them; select **Add identity provider** for another, or a row to view, edit, or delete one. Sim routes each sign-in by the email domain. When a domain has more than one provider, the one marked **Primary** is used, which is how you [switch identity providers](#switching-identity-providers) without downtime.
4242

4343
### 2. Choose a protocol
4444

@@ -101,14 +101,27 @@ Click **Save**. To test, sign out and use the **Sign in with SSO** button on the
101101

102102
## Editing and advanced configuration
103103

104-
For a saved connection, open **Sign-in**, select the provider, and select **Edit**. The Provider ID remains fixed. **Delete** removes that sign-in path only: accounts and memberships it admitted stay, and people at its domain sign in another way until a provider serves the domain again. A saved OIDC client secret appears as a mask with a suffix when available; **Replace** lets you enter a new secret, and **Keep saved** cancels that replacement. Select **Update** to save the provider, or **Discard** to abandon changes.
104+
For a saved connection, open **Sign-in**, select the provider, and select **Edit**. The Provider ID remains fixed. **Delete** removes that sign-in path only: accounts and memberships it admitted stay. If the domain has another provider, it becomes primary; otherwise people at the domain sign in another way until a provider serves it again. A saved OIDC client secret appears as a mask with a suffix when available; **Replace** lets you enter a new secret, and **Keep saved** cancels that replacement. Select **Update** to save the provider, or **Discard** to abandon changes.
105105

106106
**Advanced options** contains OIDC scopes and optional authorization, token, and JWKS endpoint overrides. For SAML, it contains Audience, Callback URL override, signed-assertion requirements, NameID format, and optional IdP metadata XML. **Attribute mapping** lets either protocol override the email, name, and stable user-ID claim names. Leave a mapping blank to use the protocol default.
107107

108108
SCIM settings save immediately in the **Provisioning** tab. Its **Disable just-in-time provisioning** rule overrides Automatic first-sign-in membership while the connection is active and entitled. Existing members can still sign in. See [directory provisioning](/platform/enterprise/scim#provisioning-and-sso-together).
109109

110110
Follow the [Okta](/platform/enterprise/scim/okta) or [Microsoft Entra](/platform/enterprise/scim/entra) provisioning walkthrough to connect a SCIM app and verify synchronization separately from sign-in.
111111

112+
## Switching identity providers
113+
114+
To move a domain from one identity provider to another, for example from Microsoft Entra ID to Okta, set up the new provider next to the current one and switch when it works. Nobody loses access along the way.
115+
116+
1. **Add the new provider** under **Sign-in** with the same verified domain. It is saved alongside the current provider, which stays **Primary**, so sign-in does not change yet.
117+
2. **Test it.** Open the new provider and copy its **Test sign-in link**. Anyone at the domain who opens the link signs in through the new provider; everyone else still uses the current one.
118+
3. **Make it primary.** Select **Make primary** on the new provider. From their next sign-in, everyone at the domain goes through it. People already signed in stay signed in.
119+
4. **Remove the old provider** once you are confident, or keep it to switch back with **Make primary**.
120+
121+
The first time someone signs in through the new provider, Sim links it to their existing account by email address, so their workspaces and membership carry over. This requires the new provider to send the same email addresses as the old one.
122+
123+
---
124+
112125
## Provider Guides
113126

114127
<Tabs items={['Okta', 'Microsoft Entra ID', 'Microsoft Entra ID (SAML)', 'Google Workspace', 'ADFS']}>
@@ -313,11 +326,11 @@ SSO provisioning creates internal organization members but does not grant worksp
313326
},
314327
{
315328
question: "What is the Domain field used for?",
316-
answer: "The domain (e.g. company.com) is how Sim routes users to the right identity provider. When a user enters their email on the SSO sign-in page, Sim matches their email domain to the provider that serves it and redirects them there. Each verified domain routes to one provider, and an organization can serve different domains with different providers."
329+
answer: "The domain (e.g. company.com) is how Sim routes users to the right identity provider. When a user enters their email on the SSO sign-in page, Sim matches their email domain to the provider that serves it and redirects them there. If a domain has more than one provider, Sim uses the one marked Primary."
317330
},
318331
{
319332
question: "Can we use more than one identity provider?",
320-
answer: "Yes. Add one provider per verified domain: for example Okta for eng.acme.com and Microsoft Entra ID for acme.com. Sign-in routes by email domain, so a single domain cannot be split across two providers. SCIM provisioning stays organization-wide and works alongside any number of providers."
333+
answer: "Yes. Different domains can use different providers, for example Okta for eng.acme.com and Microsoft Entra ID for acme.com. A domain can also have several providers while you move between them: sign-in uses its primary provider, and the others are reachable through a test sign-in link. SCIM provisioning stays organization-wide and works alongside any number of providers."
321334
},
322335
{
323336
question: "Do I need to provide OIDC endpoints manually?",

apps/sim/app/api/auth/sso/providers/[providerId]/route.test.ts

Lines changed: 95 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,14 @@ import {
1111
} from '@sim/testing'
1212
import { beforeEach, describe, expect, it, vi } from 'vitest'
1313

14-
const { mockGetSession } = vi.hoisted(() => ({ mockGetSession: vi.fn() }))
14+
const { mockGetSession } = vi.hoisted(() => ({
15+
mockGetSession: vi.fn(),
16+
}))
1517

1618
vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock }))
1719
vi.mock('@/lib/auth', () => ({ getSession: mockGetSession }))
1820

19-
import { DELETE } from '@/app/api/auth/sso/providers/[providerId]/route'
21+
import { DELETE, PATCH } from '@/app/api/auth/sso/providers/[providerId]/route'
2022

2123
const context = { params: Promise.resolve({ providerId: 'acme-okta' }) }
2224
const request = () => createMockRequest('DELETE')
@@ -29,6 +31,39 @@ describe('DELETE /api/auth/sso/providers/[providerId]', () => {
2931
dbChainMockFns.returning.mockResolvedValue([{ id: 'row-1' }])
3032
})
3133

34+
it('clears the name of a domain that made the deleted provider primary', async () => {
35+
queueTableRows(schemaMock.ssoProvider, [
36+
{ id: 'row-1', organizationId: 'org1', userId: 'u1', domain: 'acme.com' },
37+
])
38+
queueTableRows(schemaMock.member, [{ role: 'owner' }])
39+
const res = await DELETE(request(), context)
40+
expect(res.status).toBe(200)
41+
expect(dbChainMockFns.transaction).toHaveBeenCalledTimes(1)
42+
expect(dbChainMockFns.update).toHaveBeenCalledWith(schemaMock.ssoDomain)
43+
expect(dbChainMockFns.set).toHaveBeenCalledWith(
44+
expect.objectContaining({ primaryProviderId: null })
45+
)
46+
})
47+
48+
it('leaves domains alone when deleting a personal provider', async () => {
49+
queueTableRows(schemaMock.ssoProvider, [
50+
{ id: 'row-1', organizationId: null, userId: 'u1', domain: 'acme.com' },
51+
])
52+
const res = await DELETE(request(), context)
53+
expect(res.status).toBe(200)
54+
expect(dbChainMockFns.update).not.toHaveBeenCalled()
55+
})
56+
57+
it('leaves domains alone when nothing was deleted', async () => {
58+
queueTableRows(schemaMock.ssoProvider, [
59+
{ id: 'row-1', organizationId: 'org1', userId: 'u1', domain: 'acme.com' },
60+
])
61+
queueTableRows(schemaMock.member, [{ role: 'owner' }])
62+
dbChainMockFns.returning.mockResolvedValue([])
63+
expect((await DELETE(request(), context)).status).toBe(404)
64+
expect(dbChainMockFns.update).not.toHaveBeenCalled()
65+
})
66+
3267
it('requires a session', async () => {
3368
mockGetSession.mockResolvedValue(null)
3469
const res = await DELETE(request(), context)
@@ -93,14 +128,67 @@ describe('DELETE /api/auth/sso/providers/[providerId]', () => {
93128
await expect(res.json()).resolves.toEqual({ success: true, providerId })
94129
expect(dbChainMockFns.delete).toHaveBeenCalledWith(schemaMock.ssoProvider)
95130
})
131+
})
132+
133+
describe('PATCH /api/auth/sso/providers/[providerId]', () => {
134+
const patch = (body: Record<string, unknown> = { isPrimary: true }) =>
135+
PATCH(createMockRequest('PATCH', body), context)
96136

97-
it('answers 404 when the row vanished between the check and the delete', async () => {
137+
beforeEach(() => {
138+
vi.clearAllMocks()
139+
resetDbChainMock()
140+
mockGetSession.mockResolvedValue({ user: { id: 'u1' } })
141+
dbChainMockFns.returning.mockResolvedValue([{ id: 'domain-1' }])
142+
})
143+
144+
function queueOrgProvider(role: string) {
98145
queueTableRows(schemaMock.ssoProvider, [
99-
{ id: 'row-1', organizationId: 'org1', userId: 'u1', domain: 'acme.com' },
146+
{ id: 'row-1', organizationId: 'org1', userId: 'u-other', domain: 'acme.com' },
100147
])
101-
queueTableRows(schemaMock.member, [{ role: 'owner' }])
148+
queueTableRows(schemaMock.member, [{ role }])
149+
}
150+
151+
it('requires a session', async () => {
152+
mockGetSession.mockResolvedValue(null)
153+
expect((await patch()).status).toBe(401)
154+
expect(dbChainMockFns.update).not.toHaveBeenCalled()
155+
})
156+
157+
it('only accepts making a provider primary', async () => {
158+
expect((await patch({ isPrimary: false })).status).toBe(400)
159+
expect(dbChainMockFns.update).not.toHaveBeenCalled()
160+
})
161+
162+
it("refuses a member who is not the organization's owner or admin", async () => {
163+
queueOrgProvider('member')
164+
expect((await patch()).status).toBe(403)
165+
expect(dbChainMockFns.update).not.toHaveBeenCalled()
166+
})
167+
168+
it('names the provider on its verified domain', async () => {
169+
queueOrgProvider('admin')
170+
const res = await patch()
171+
expect(res.status).toBe(200)
172+
await expect(res.json()).resolves.toEqual({ success: true, providerId: 'acme-okta' })
173+
expect(dbChainMockFns.update).toHaveBeenCalledWith(schemaMock.ssoDomain)
174+
expect(dbChainMockFns.set).toHaveBeenCalledWith(
175+
expect.objectContaining({ primaryProviderId: 'acme-okta' })
176+
)
177+
})
178+
179+
it('refuses a provider whose domain is not verified', async () => {
180+
queueOrgProvider('owner')
102181
dbChainMockFns.returning.mockResolvedValue([])
103-
const res = await DELETE(request(), context)
104-
expect(res.status).toBe(404)
182+
const res = await patch()
183+
expect(res.status).toBe(409)
184+
await expect(res.json()).resolves.toMatchObject({ error: expect.stringContaining('acme.com') })
185+
})
186+
187+
it('refuses a personal provider, which has no primary', async () => {
188+
queueTableRows(schemaMock.ssoProvider, [
189+
{ id: 'row-1', organizationId: null, userId: 'u1', domain: 'acme.com' },
190+
])
191+
expect((await patch()).status).toBe(400)
192+
expect(dbChainMockFns.update).not.toHaveBeenCalled()
105193
})
106194
})

0 commit comments

Comments
 (0)