Skip to content

Commit 4d5e4aa

Browse files
waleedlatif1claude
andauthored
feat(sso): several identity providers per organization (#7652)
* feat(sso): several identity providers per organization An organization can now run more than one identity provider, one per verified domain: Okta for eng.acme.com and Entra for acme.com, for example. The schema already allowed several rows; the product could not show, pick, or remove them. - Sign-in names the provider explicitly. A new POST /api/auth/sso/resolve maps an email domain to the provider that serves it, preferring a verified domain and breaking ties on provider id, so routing no longer depends on the SSO plugin's unordered domain lookup. - Registration refuses a second provider on a domain the caller already routes (SSO_DOMAIN_ALREADY_ROUTED), keeping email-domain routing deterministic. The check shares the existing cross-tenant domain read. - DELETE /api/auth/sso/providers/[providerId], owned by Sim since the plugin's delete-provider stays blocked: organization owners and admins remove an organization provider, the creator a personal one. Accounts and memberships the provider admitted are untouched. - The provider list is ordered by provider id; the settings sign-in tab lists providers, opens one by URL (`provider=`), adds another, and deletes with confirmation. JIT admissions record the provider id in their audit metadata. - Docs describe multiple providers, domain routing, and deletion. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CkZjSKgtFdE5mzsQ31gGLY * improvement(sso): verified multi-provider flow and cleaner provider settings - Registration guard uses the same domain expression as sign-in resolution and domain verification - Self-host registration script stores the normalized provider domain - Sign-in reports "no provider" only on 404; other failures keep the generic message - Settings: canonical back slot and title on provider detail and form, Edit then Delete, no duplicate Open in the row menu, awaited list refetch before navigating, replace-history on close, unsaved-changes guard on back - Docs: state that an organization can use more than one identity provider * fix(sso): make one provider per domain hold at the database and resolve only verified providers - Partial unique index on sso_provider (organization_id, normalized domain), built concurrently after a duplicate pre-check; a lost race maps to the same 409 as the pre-check - Sign-in resolution returns only providers whose domain is verified - Settings controls follow the organization owner/admin model the server enforces, not the creator * fix(sso): support registered provider IDs in settings * refactor(sso): keep provider deletion in details * chore(db): remove SSO migration before staging update * chore(db): regenerate SSO migration after staging --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 9a10d35 commit 4d5e4aa

23 files changed

Lines changed: 27433 additions & 611 deletions

File tree

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

Lines changed: 12 additions & 6 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.
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.
1212

1313
---
1414

@@ -36,7 +36,9 @@ Go to **Settings → Organization → Single sign-on**. The page has three tabs:
3636
| **Domains** | DNS verification shared by SSO and SCIM |
3737
| **Provisioning** | SCIM connection, tokens, rules, group mappings, and activity |
3838

39-
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 is included in the URL, so it can be bookmarked or shared. On self-hosted deployments, Provisioning appears when SCIM is enabled.
39+
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.
40+
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.
4042

4143
### 2. Choose a protocol
4244

@@ -99,7 +101,7 @@ Click **Save**. To test, sign out and use the **Sign in with SSO** button on the
99101

100102
## Editing and advanced configuration
101103

102-
For a saved connection, open **Sign-in** and select **Edit**. The Provider ID remains fixed. 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, 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.
103105

104106
**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.
105107

@@ -285,7 +287,7 @@ Once SSO is configured, users with your domain (`company.com`) can sign in throu
285287

286288
1. User goes to `sim.ai` and clicks **Sign in with SSO**
287289
2. They enter their work email (e.g. `alice@company.com`)
288-
3. Sim redirects them to your identity provider
290+
3. Sim looks up the provider that serves `company.com` and redirects them to it
289291
4. After authenticating, they are returned to Sim
290292
5. If **First sign-in** is **Automatic**, Sim adds them to the organization as a Member, growing a Team seat count or validating available fixed-seat capacity
291293
6. They land in an accessible workspace, or see a clear no-access state until an admin grants workspace access
@@ -311,7 +313,11 @@ SSO provisioning creates internal organization members but does not grant worksp
311313
},
312314
{
313315
question: "What is the Domain field used for?",
314-
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 a registered SSO provider and redirects them there."
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."
317+
},
318+
{
319+
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."
315321
},
316322
{
317323
question: "Do I need to provide OIDC endpoints manually?",
@@ -343,7 +349,7 @@ SSO provisioning creates internal organization members but does not grant worksp
343349
},
344350
{
345351
question: "How do I update or replace an existing SSO configuration?",
346-
answer: "Open Settings → Organization → Single sign-on → Sign-in and select Edit. Change the fields and select Update. The Provider ID cannot be changed; replacing it requires deleting the provider and creating a new one."
352+
answer: "Open Settings → Organization → Single sign-on → Sign-in, select the provider, and select Edit. Change the fields and select Update. The Provider ID cannot be changed; replacing it means deleting the provider and adding a new one."
347353
}
348354
]} />
349355

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import {
5+
createMockRequest,
6+
dbChainMock,
7+
dbChainMockFns,
8+
queueTableRows,
9+
resetDbChainMock,
10+
schemaMock,
11+
} from '@sim/testing'
12+
import { beforeEach, describe, expect, it, vi } from 'vitest'
13+
14+
const { mockGetSession } = vi.hoisted(() => ({ mockGetSession: vi.fn() }))
15+
16+
vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock }))
17+
vi.mock('@/lib/auth', () => ({ getSession: mockGetSession }))
18+
19+
import { DELETE } from '@/app/api/auth/sso/providers/[providerId]/route'
20+
21+
const context = { params: Promise.resolve({ providerId: 'acme-okta' }) }
22+
const request = () => createMockRequest('DELETE')
23+
24+
describe('DELETE /api/auth/sso/providers/[providerId]', () => {
25+
beforeEach(() => {
26+
vi.clearAllMocks()
27+
resetDbChainMock()
28+
mockGetSession.mockResolvedValue({ user: { id: 'u1' } })
29+
dbChainMockFns.returning.mockResolvedValue([{ id: 'row-1' }])
30+
})
31+
32+
it('requires a session', async () => {
33+
mockGetSession.mockResolvedValue(null)
34+
const res = await DELETE(request(), context)
35+
expect(res.status).toBe(401)
36+
expect(dbChainMockFns.delete).not.toHaveBeenCalled()
37+
})
38+
39+
it('answers 404 for an unknown provider', async () => {
40+
queueTableRows(schemaMock.ssoProvider, [])
41+
const res = await DELETE(request(), context)
42+
expect(res.status).toBe(404)
43+
expect(dbChainMockFns.delete).not.toHaveBeenCalled()
44+
})
45+
46+
it("refuses an organization provider to a member who is not the organization's admin", async () => {
47+
queueTableRows(schemaMock.ssoProvider, [
48+
{ id: 'row-1', organizationId: 'org1', userId: 'u-other', domain: 'acme.com' },
49+
])
50+
queueTableRows(schemaMock.member, [{ role: 'member' }])
51+
const res = await DELETE(request(), context)
52+
expect(res.status).toBe(403)
53+
expect(dbChainMockFns.delete).not.toHaveBeenCalled()
54+
})
55+
56+
it('lets an organization admin delete a provider another admin created', async () => {
57+
queueTableRows(schemaMock.ssoProvider, [
58+
{ id: 'row-1', organizationId: 'org1', userId: 'u-other', domain: 'acme.com' },
59+
])
60+
queueTableRows(schemaMock.member, [{ role: 'admin' }])
61+
const res = await DELETE(request(), context)
62+
expect(res.status).toBe(200)
63+
await expect(res.json()).resolves.toEqual({ success: true, providerId: 'acme-okta' })
64+
expect(dbChainMockFns.delete).toHaveBeenCalledWith(schemaMock.ssoProvider)
65+
})
66+
67+
it('lets only the creator delete a personal provider', async () => {
68+
queueTableRows(schemaMock.ssoProvider, [
69+
{ id: 'row-1', organizationId: null, userId: 'u-other', domain: 'acme.com' },
70+
])
71+
const refused = await DELETE(request(), context)
72+
expect(refused.status).toBe(403)
73+
74+
resetDbChainMock()
75+
dbChainMockFns.returning.mockResolvedValue([{ id: 'row-1' }])
76+
queueTableRows(schemaMock.ssoProvider, [
77+
{ id: 'row-1', organizationId: null, userId: 'u1', domain: 'acme.com' },
78+
])
79+
const allowed = await DELETE(request(), context)
80+
expect(allowed.status).toBe(200)
81+
})
82+
83+
it.each([129, 256])('deletes a provider with a %i-character ID', async (length) => {
84+
const providerId = 'a'.repeat(length)
85+
queueTableRows(schemaMock.ssoProvider, [
86+
{ id: 'row-1', organizationId: 'org1', userId: 'u1', domain: 'acme.com' },
87+
])
88+
queueTableRows(schemaMock.member, [{ role: 'owner' }])
89+
90+
const res = await DELETE(request(), { params: Promise.resolve({ providerId }) })
91+
92+
expect(res.status).toBe(200)
93+
await expect(res.json()).resolves.toEqual({ success: true, providerId })
94+
expect(dbChainMockFns.delete).toHaveBeenCalledWith(schemaMock.ssoProvider)
95+
})
96+
97+
it('answers 404 when the row vanished between the check and the delete', async () => {
98+
queueTableRows(schemaMock.ssoProvider, [
99+
{ id: 'row-1', organizationId: 'org1', userId: 'u1', domain: 'acme.com' },
100+
])
101+
queueTableRows(schemaMock.member, [{ role: 'owner' }])
102+
dbChainMockFns.returning.mockResolvedValue([])
103+
const res = await DELETE(request(), context)
104+
expect(res.status).toBe(404)
105+
})
106+
})
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { db, member, ssoProvider } from '@sim/db'
2+
import { createLogger } from '@sim/logger'
3+
import { and, eq, isNull } from 'drizzle-orm'
4+
import { type NextRequest, NextResponse } from 'next/server'
5+
import { deleteSsoProviderContract } from '@/lib/api/contracts/auth'
6+
import { parseRequest } from '@/lib/api/server'
7+
import { getSession } from '@/lib/auth'
8+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
9+
10+
const logger = createLogger('SSOProviderRoute')
11+
12+
/**
13+
* Removes one identity provider.
14+
*
15+
* Sim owns this rather than exposing the SSO plugin's `delete-provider`, which
16+
* `/api/auth/[...all]` blocks by design: the plugin gates only on the row's
17+
* creator, while an organization's providers belong to the organization and
18+
* are removed by its owners and admins. Accounts and memberships the provider
19+
* admitted are untouched; only the sign-in path goes.
20+
*/
21+
export const DELETE = withRouteHandler(
22+
async (request: NextRequest, context: { params: Promise<{ providerId: string }> }) => {
23+
const session = await getSession()
24+
if (!session?.user?.id) {
25+
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
26+
}
27+
28+
const parsed = await parseRequest(deleteSsoProviderContract, request, context)
29+
if (!parsed.success) return parsed.response
30+
const { providerId } = parsed.data.params
31+
32+
const [provider] = await db
33+
.select({
34+
id: ssoProvider.id,
35+
organizationId: ssoProvider.organizationId,
36+
userId: ssoProvider.userId,
37+
domain: ssoProvider.domain,
38+
})
39+
.from(ssoProvider)
40+
.where(eq(ssoProvider.providerId, providerId))
41+
.limit(1)
42+
if (!provider) return NextResponse.json({ error: 'Provider not found' }, { status: 404 })
43+
44+
if (provider.organizationId) {
45+
const [membership] = await db
46+
.select({ role: member.role })
47+
.from(member)
48+
.where(
49+
and(
50+
eq(member.userId, session.user.id),
51+
eq(member.organizationId, provider.organizationId)
52+
)
53+
)
54+
.limit(1)
55+
if (!membership || (membership.role !== 'owner' && membership.role !== 'admin')) {
56+
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
57+
}
58+
} else if (provider.userId !== session.user.id) {
59+
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
60+
}
61+
62+
/**
63+
* Deleted by primary key under the same ownership the check established,
64+
* so a concurrent re-registration of the providerId cannot be the row
65+
* removed.
66+
*/
67+
const ownerClause = provider.organizationId
68+
? eq(ssoProvider.organizationId, provider.organizationId)
69+
: and(eq(ssoProvider.userId, session.user.id), isNull(ssoProvider.organizationId))
70+
const removed = await db
71+
.delete(ssoProvider)
72+
.where(and(eq(ssoProvider.id, provider.id), ownerClause))
73+
.returning({ id: ssoProvider.id })
74+
if (removed.length === 0) {
75+
return NextResponse.json({ error: 'Provider not found' }, { status: 404 })
76+
}
77+
78+
logger.info('Deleted SSO provider', {
79+
providerId,
80+
organizationId: provider.organizationId,
81+
domain: provider.domain,
82+
userId: session.user.id,
83+
})
84+
return NextResponse.json({ success: true, providerId })
85+
}
86+
)

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { db, member, ssoProvider } from '@sim/db'
22
import { createLogger } from '@sim/logger'
3-
import { and, eq } from 'drizzle-orm'
3+
import { and, asc, eq } from 'drizzle-orm'
44
import { type NextRequest, NextResponse } from 'next/server'
55
import { listSsoProvidersContract } from '@/lib/api/contracts/auth'
66
import { parseRequest } from '@/lib/api/server'
@@ -79,6 +79,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
7979
})
8080
.from(ssoProvider)
8181
.where(whereClause)
82+
.orderBy(asc(ssoProvider.providerId))
8283

8384
providers = results.map((provider) => {
8485
let oidcConfig = provider.oidcConfig

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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,43 @@ describe('POST /api/auth/sso/register', () => {
216216
expect(conflictWhere?.[0]?.values).toContain('acme.com')
217217
})
218218

219+
it('refuses a second provider on a domain the organization already routes', async () => {
220+
queueMembers([{ organizationId: 'org1', role: 'owner' }])
221+
queueProviders([
222+
{ domain: 'acme.com', userId: 'u1', organizationId: 'org1', providerId: 'acme-saml' },
223+
])
224+
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
225+
const json = await res.json()
226+
expect(res.status).toBe(409)
227+
expect(json.code).toBe('SSO_DOMAIN_ALREADY_ROUTED')
228+
expect(json.error).toContain('acme-saml')
229+
expect(mockRegisterSSOProvider).not.toHaveBeenCalled()
230+
})
231+
232+
it('turns a lost race on the domain index into the same 409 as the pre-check', async () => {
233+
queueMembers([{ organizationId: 'org1', role: 'owner' }])
234+
queueProviders([])
235+
mockRegisterSSOProvider.mockRejectedValue(
236+
Object.assign(new Error('duplicate key value violates unique constraint'), {
237+
code: '23505',
238+
constraint_name: 'sso_provider_org_domain_unique',
239+
})
240+
)
241+
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
242+
const json = await res.json()
243+
expect(res.status).toBe(409)
244+
expect(json.code).toBe('SSO_DOMAIN_ALREADY_ROUTED')
245+
expect(json.error).toContain('acme.com')
246+
})
247+
248+
it('lets the organization add a provider for a different verified domain', async () => {
249+
queueMembers([{ organizationId: 'org1', role: 'owner' }])
250+
queueProviders([])
251+
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1', domain: 'eng.acme.com' }))
252+
expect(res.status).toBe(200)
253+
expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1)
254+
})
255+
219256
it('registers when the domain is unclaimed', async () => {
220257
queueMembers([{ organizationId: 'org1', role: 'owner' }])
221258
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))

0 commit comments

Comments
 (0)