Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/test-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ jobs:
lib/auth/sim-auth-adapter.postgres.test.ts
ee/scim/lib/managed-membership.postgres.test.ts
lib/auth/sso/application/admit-sso-user.postgres.test.ts
lib/auth/sso/primary-provider.postgres.test.ts

- name: Verify cumulative billing timeout recovery in PostgreSQL
working-directory: apps/sim
Expand Down
23 changes: 18 additions & 5 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, and an organization can use more than one identity provider at a time, one per verified domain.
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.

---

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

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

### 2. Choose a protocol

Expand Down Expand Up @@ -101,14 +101,27 @@ 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**, 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.
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 you delete the primary provider and the domain has another verified provider, that one 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.

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

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

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.

## Switching identity providers

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.

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.
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.
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.
4. **Remove the old provider** once you are confident, or keep it to switch back with **Make primary**.

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.

---

## Provider Guides

<Tabs items={['Okta', 'Microsoft Entra ID', 'Microsoft Entra ID (SAML)', 'Google Workspace', 'ADFS']}>
Expand Down Expand Up @@ -313,11 +326,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 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."
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."
},
{
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."
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."
},
{
question: "Do I need to provide OIDC endpoints manually?",
Expand Down
104 changes: 94 additions & 10 deletions apps/sim/app/api/auth/sso/providers/[providerId]/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,25 @@ import {
schemaMock,
} from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { OrchestrationError } from '@/lib/core/orchestration/types'

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

vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock }))
vi.mock('@/lib/auth', () => ({ getSession: mockGetSession }))
/** Authorization and the primary switch are the use case's; its own tests and the PostgreSQL suite cover them. */
vi.mock('@/lib/auth/sso/application/set-primary-provider', () => ({
setPrimarySsoProviderOperation: { id: 'organization.sso.set_primary_provider' },
setPrimarySsoProvider: {
operation: { id: 'organization.sso.set_primary_provider' },
execute: mockSetPrimary,
},
}))

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

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

it('clears the name of a domain that made the deleted provider primary', async () => {
queueTableRows(schemaMock.ssoProvider, [
{ id: 'row-1', organizationId: 'org1', userId: 'u1', domain: 'acme.com' },
])
queueTableRows(schemaMock.member, [{ role: 'owner' }])
const res = await DELETE(request(), context)
expect(res.status).toBe(200)
expect(dbChainMockFns.transaction).toHaveBeenCalledTimes(1)
expect(dbChainMockFns.update).toHaveBeenCalledWith(schemaMock.ssoDomain)
expect(dbChainMockFns.set).toHaveBeenCalledWith(
expect.objectContaining({ primaryProviderId: null })
)
})

it('leaves domains alone when deleting a personal provider', async () => {
queueTableRows(schemaMock.ssoProvider, [
{ id: 'row-1', organizationId: null, userId: 'u1', domain: 'acme.com' },
])
const res = await DELETE(request(), context)
expect(res.status).toBe(200)
expect(dbChainMockFns.update).not.toHaveBeenCalled()
})

it('leaves domains alone when nothing was deleted', async () => {
queueTableRows(schemaMock.ssoProvider, [
{ id: 'row-1', organizationId: 'org1', userId: 'u1', domain: 'acme.com' },
])
queueTableRows(schemaMock.member, [{ role: 'owner' }])
dbChainMockFns.returning.mockResolvedValue([])
expect((await DELETE(request(), context)).status).toBe(404)
expect(dbChainMockFns.update).not.toHaveBeenCalled()
})

it('requires a session', async () => {
mockGetSession.mockResolvedValue(null)
const res = await DELETE(request(), context)
Expand Down Expand Up @@ -93,14 +138,53 @@ describe('DELETE /api/auth/sso/providers/[providerId]', () => {
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)
describe('PATCH /api/auth/sso/providers/[providerId]', () => {
const patch = (body: Record<string, unknown> = { isPrimary: true }) =>
PATCH(createMockRequest('PATCH', body), context)

beforeEach(() => {
vi.clearAllMocks()
mockGetSession.mockResolvedValue({ user: { id: 'u1' }, session: { id: 's1' } })
mockSetPrimary.mockResolvedValue({
providerId: 'acme-okta',
organizationId: 'org1',
domain: 'acme.com',
})
})

it('requires a session before the use case runs', async () => {
mockGetSession.mockResolvedValue(null)
expect((await patch()).status).toBe(401)
expect(mockSetPrimary).not.toHaveBeenCalled()
})

it('only accepts making a provider primary', async () => {
expect((await patch({ isPrimary: false })).status).toBe(400)
expect(mockSetPrimary).not.toHaveBeenCalled()
})

it('passes the routed provider to the use case and presents its result', async () => {
const res = await patch()
expect(res.status).toBe(200)
await expect(res.json()).resolves.toEqual({ success: true, providerId: 'acme-okta' })
expect(mockSetPrimary).toHaveBeenCalledWith(
expect.objectContaining({
principal: expect.objectContaining({ kind: 'session', userId: 'u1' }),
input: { providerId: 'acme-okta' },
})
)
})

it.each([
['conflict', 409, 'Verify acme.com before making this provider primary.'],
['forbidden', 403, 'Organization administrator access is required'],
['not_found', 404, 'Provider not found'],
] as const)('projects a %s refusal with its message', async (code, status, message) => {
mockSetPrimary.mockRejectedValue(new OrchestrationError(code, message))
const res = await patch()
expect(res.status).toBe(status)
await expect(res.json()).resolves.toEqual({ error: message })
})
})
Loading
Loading