Skip to content
18 changes: 12 additions & 6 deletions apps/docs/content/docs/platform/enterprise/sso.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
import { FAQ } from '@/components/ui/faq'
import { Image } from '@/components/ui/image'

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.
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.

---

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

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.
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.

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.

### 2. Choose a protocol

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

## Editing and advanced configuration

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.
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.

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

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

1. User goes to `sim.ai` and clicks **Sign in with SSO**
2. They enter their work email (e.g. `alice@company.com`)
3. Sim redirects them to your identity provider
3. Sim looks up the provider that serves `company.com` and redirects them to it
4. After authenticating, they are returned to Sim
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
6. They land in an accessible workspace, or see a clear no-access state until an admin grants workspace access
Expand All @@ -311,7 +313,11 @@ SSO provisioning creates internal organization members but does not grant worksp
},
{
question: "What is the Domain field used for?",
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."
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."
},
{
question: "Can we use more than one identity provider?",
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."
},
{
question: "Do I need to provide OIDC endpoints manually?",
Expand Down Expand Up @@ -343,7 +349,7 @@ SSO provisioning creates internal organization members but does not grant worksp
},
{
question: "How do I update or replace an existing SSO configuration?",
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."
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."
}
]} />

Expand Down
106 changes: 106 additions & 0 deletions apps/sim/app/api/auth/sso/providers/[providerId]/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/**
* @vitest-environment node
*/
import {
createMockRequest,
dbChainMock,
dbChainMockFns,
queueTableRows,
resetDbChainMock,
schemaMock,
} from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { mockGetSession } = vi.hoisted(() => ({ mockGetSession: vi.fn() }))

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

import { DELETE } from '@/app/api/auth/sso/providers/[providerId]/route'

const context = { params: Promise.resolve({ providerId: 'acme-okta' }) }
const request = () => createMockRequest('DELETE')

describe('DELETE /api/auth/sso/providers/[providerId]', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
mockGetSession.mockResolvedValue({ user: { id: 'u1' } })
dbChainMockFns.returning.mockResolvedValue([{ id: 'row-1' }])
})

it('requires a session', async () => {
mockGetSession.mockResolvedValue(null)
const res = await DELETE(request(), context)
expect(res.status).toBe(401)
expect(dbChainMockFns.delete).not.toHaveBeenCalled()
})

it('answers 404 for an unknown provider', async () => {
queueTableRows(schemaMock.ssoProvider, [])
const res = await DELETE(request(), context)
expect(res.status).toBe(404)
expect(dbChainMockFns.delete).not.toHaveBeenCalled()
})

it("refuses an organization provider to a member who is not the organization's admin", async () => {
queueTableRows(schemaMock.ssoProvider, [
{ id: 'row-1', organizationId: 'org1', userId: 'u-other', domain: 'acme.com' },
])
queueTableRows(schemaMock.member, [{ role: 'member' }])
const res = await DELETE(request(), context)
expect(res.status).toBe(403)
expect(dbChainMockFns.delete).not.toHaveBeenCalled()
})

it('lets an organization admin delete a provider another admin created', async () => {
queueTableRows(schemaMock.ssoProvider, [
{ id: 'row-1', organizationId: 'org1', userId: 'u-other', domain: 'acme.com' },
])
queueTableRows(schemaMock.member, [{ role: 'admin' }])
const res = await DELETE(request(), context)
expect(res.status).toBe(200)
await expect(res.json()).resolves.toEqual({ success: true, providerId: 'acme-okta' })
expect(dbChainMockFns.delete).toHaveBeenCalledWith(schemaMock.ssoProvider)
})

it('lets only the creator delete a personal provider', async () => {
queueTableRows(schemaMock.ssoProvider, [
{ id: 'row-1', organizationId: null, userId: 'u-other', domain: 'acme.com' },
])
const refused = await DELETE(request(), context)
expect(refused.status).toBe(403)

resetDbChainMock()
dbChainMockFns.returning.mockResolvedValue([{ id: 'row-1' }])
queueTableRows(schemaMock.ssoProvider, [
{ id: 'row-1', organizationId: null, userId: 'u1', domain: 'acme.com' },
])
const allowed = await DELETE(request(), context)
expect(allowed.status).toBe(200)
})

it.each([129, 256])('deletes a provider with a %i-character ID', async (length) => {
const providerId = 'a'.repeat(length)
queueTableRows(schemaMock.ssoProvider, [
{ id: 'row-1', organizationId: 'org1', userId: 'u1', domain: 'acme.com' },
])
queueTableRows(schemaMock.member, [{ role: 'owner' }])

const res = await DELETE(request(), { params: Promise.resolve({ providerId }) })

expect(res.status).toBe(200)
await expect(res.json()).resolves.toEqual({ success: true, providerId })
expect(dbChainMockFns.delete).toHaveBeenCalledWith(schemaMock.ssoProvider)
})

it('answers 404 when the row vanished between the check and the delete', async () => {
queueTableRows(schemaMock.ssoProvider, [
{ id: 'row-1', organizationId: 'org1', userId: 'u1', domain: 'acme.com' },
])
queueTableRows(schemaMock.member, [{ role: 'owner' }])
dbChainMockFns.returning.mockResolvedValue([])
const res = await DELETE(request(), context)
expect(res.status).toBe(404)
})
})
86 changes: 86 additions & 0 deletions apps/sim/app/api/auth/sso/providers/[providerId]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { db, member, ssoProvider } from '@sim/db'
import { createLogger } from '@sim/logger'
import { and, eq, isNull } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { deleteSsoProviderContract } from '@/lib/api/contracts/auth'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'

const logger = createLogger('SSOProviderRoute')

/**
* Removes one identity provider.
*
* Sim owns this rather than exposing the SSO plugin's `delete-provider`, which
* `/api/auth/[...all]` blocks by design: the plugin gates only on the row's
* creator, while an organization's providers belong to the organization and
* are removed by its owners and admins. Accounts and memberships the provider
* admitted are untouched; only the sign-in path goes.
*/
export const DELETE = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ providerId: string }> }) => {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}

const parsed = await parseRequest(deleteSsoProviderContract, request, context)
if (!parsed.success) return parsed.response
const { providerId } = parsed.data.params

const [provider] = await db
.select({
id: ssoProvider.id,
organizationId: ssoProvider.organizationId,
userId: ssoProvider.userId,
domain: ssoProvider.domain,
})
.from(ssoProvider)
.where(eq(ssoProvider.providerId, providerId))
.limit(1)
if (!provider) return NextResponse.json({ error: 'Provider not found' }, { status: 404 })

if (provider.organizationId) {
const [membership] = await db
.select({ role: member.role })
.from(member)
.where(
and(
eq(member.userId, session.user.id),
eq(member.organizationId, provider.organizationId)
)
)
.limit(1)
if (!membership || (membership.role !== 'owner' && membership.role !== 'admin')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
} else if (provider.userId !== session.user.id) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}

/**
* Deleted by primary key under the same ownership the check established,
* so a concurrent re-registration of the providerId cannot be the row
* removed.
*/
const ownerClause = provider.organizationId
? eq(ssoProvider.organizationId, provider.organizationId)
: and(eq(ssoProvider.userId, session.user.id), isNull(ssoProvider.organizationId))
const removed = await db
.delete(ssoProvider)
.where(and(eq(ssoProvider.id, provider.id), ownerClause))
Comment thread
waleedlatif1 marked this conversation as resolved.
.returning({ id: ssoProvider.id })
if (removed.length === 0) {
return NextResponse.json({ error: 'Provider not found' }, { status: 404 })
}

logger.info('Deleted SSO provider', {
providerId,
organizationId: provider.organizationId,
domain: provider.domain,
userId: session.user.id,
})
return NextResponse.json({ success: true, providerId })
}
)
3 changes: 2 additions & 1 deletion apps/sim/app/api/auth/sso/providers/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { db, member, ssoProvider } from '@sim/db'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { and, asc, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { listSsoProvidersContract } from '@/lib/api/contracts/auth'
import { parseRequest } from '@/lib/api/server'
Expand Down Expand Up @@ -79,6 +79,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
})
.from(ssoProvider)
.where(whereClause)
.orderBy(asc(ssoProvider.providerId))

providers = results.map((provider) => {
let oidcConfig = provider.oidcConfig
Expand Down
37 changes: 37 additions & 0 deletions apps/sim/app/api/auth/sso/register/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,43 @@ describe('POST /api/auth/sso/register', () => {
expect(conflictWhere?.[0]?.values).toContain('acme.com')
})

it('refuses a second provider on a domain the organization already routes', async () => {
queueMembers([{ organizationId: 'org1', role: 'owner' }])
queueProviders([
{ domain: 'acme.com', userId: 'u1', organizationId: 'org1', providerId: 'acme-saml' },
])
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
const json = await res.json()
expect(res.status).toBe(409)
expect(json.code).toBe('SSO_DOMAIN_ALREADY_ROUTED')
expect(json.error).toContain('acme-saml')
expect(mockRegisterSSOProvider).not.toHaveBeenCalled()
})

it('turns a lost race on the domain index into the same 409 as the pre-check', async () => {
queueMembers([{ organizationId: 'org1', role: 'owner' }])
queueProviders([])
mockRegisterSSOProvider.mockRejectedValue(
Object.assign(new Error('duplicate key value violates unique constraint'), {
code: '23505',
constraint_name: 'sso_provider_org_domain_unique',
})
)
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
const json = await res.json()
expect(res.status).toBe(409)
expect(json.code).toBe('SSO_DOMAIN_ALREADY_ROUTED')
expect(json.error).toContain('acme.com')
})

it('lets the organization add a provider for a different verified domain', async () => {
queueMembers([{ organizationId: 'org1', role: 'owner' }])
queueProviders([])
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1', domain: 'eng.acme.com' }))
expect(res.status).toBe(200)
expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1)
})

it('registers when the domain is unclaimed', async () => {
queueMembers([{ organizationId: 'org1', role: 'owner' }])
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
Expand Down
Loading
Loading