Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
b6eb0c8
fix(sso): link Entra sign-ins to existing accounts and enforce unique…
waleedlatif1 Aug 6, 2026
e4b9fcc
fix(sso): revoke domain trust when verification is removed mid-update
waleedlatif1 Aug 6, 2026
a0bd392
fix(sso): make domain-trust grants atomic and propagate revocation
waleedlatif1 Aug 6, 2026
4dea2d4
fix(sso): report a refused domain-trust grant instead of returning su…
waleedlatif1 Aug 6, 2026
016dbba
feat(sso): let admins map IdP claims, and trim setup comments
waleedlatif1 Aug 6, 2026
1a0203e
feat(sso): expose the standard enterprise IdP options in the setup form
waleedlatif1 Aug 6, 2026
ec38a1a
fix(sso): withhold domain trust from personal providers on the hosted…
waleedlatif1 Aug 6, 2026
7c3aa3b
docs(sso): correct the personal-provider trust note after the hosted …
waleedlatif1 Aug 6, 2026
15cc971
fix(sso): drop the inert SAML algorithm selects, make NameID format c…
waleedlatif1 Aug 6, 2026
715c6ec
docs(sso): tighten the personal-provider note to the self-host path i…
waleedlatif1 Aug 6, 2026
91332e2
fix(sso): forward an empty SAML NameID format so the provider default…
waleedlatif1 Aug 6, 2026
33e2cee
fix(sso): revoke trust for providers whose domain is spelled with a w…
waleedlatif1 Aug 6, 2026
afd7e27
fix(db): give the SSO index migration the concurrent-build convention…
waleedlatif1 Aug 6, 2026
db92d43
fix(sso): give the Enter shortcut the same guard as the Add domain bu…
waleedlatif1 Aug 6, 2026
833932a
fix(sso): stop the provider ID being editable after it is saved
waleedlatif1 Aug 6, 2026
2318480
fix(sso): stop persisting generated IdP metadata so SAML cert rotatio…
waleedlatif1 Aug 6, 2026
e9949e9
fix(sso): always write SAML IdP metadata so clearing it takes effect …
waleedlatif1 Aug 6, 2026
c564441
fix(sso): hold the domain proof under a row lock while granting trust
waleedlatif1 Aug 6, 2026
eedc242
refactor(sso): write the two merge-sensitive SAML fields the same way
waleedlatif1 Aug 6, 2026
ee5fd2d
fix(sso): report the row the trust grant actually matched
waleedlatif1 Aug 6, 2026
9fff2d3
fix(sso): restore provider domain trust when a domain is re-verified
waleedlatif1 Aug 6, 2026
c6b9d66
fix(sso): correct the domain-removal warning now that it disables sig…
waleedlatif1 Aug 6, 2026
27ade42
chore(sso): trim verbose comments
waleedlatif1 Aug 6, 2026
7b735e4
fix(sso): revert a rejected SSO update instead of leaving it stored
waleedlatif1 Aug 6, 2026
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
42 changes: 25 additions & 17 deletions apps/docs/content/docs/en/platform/enterprise/sso.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,21 @@ Single Sign-On lets your team sign in to Sim through your company's identity pro

---

## Before you start

<Callout type="warning">
[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.
</Callout>

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.

---

## Setup

### 1. Open SSO settings

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

### 2. Choose a protocol

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

| Field | What to enter |
|-------|--------------|
| **Provider ID** | A short slug identifying this connection, e.g. `okta` or `azure-ad`. Letters, numbers, and dashes only. |
| **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. |
| **Issuer URL** | The identity provider's issuer URL. Must be HTTPS. |
| **Domain** | Your organization's email domain, e.g. `company.com`. Users with this domain will be routed through SSO at sign-in. |

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

1. Go to **Microsoft Entra ID → App registrations → New registration**
2. Under **Redirect URI**, select **Web** and enter your Sim callback URL:
2. Under **Redirect URI**, select **Web** and enter your Sim callback URL, using the Provider ID you chose:
```
https://sim.ai/api/auth/sso/callback/azure-ad
https://sim.ai/api/auth/sso/callback/azure-ad-acme
```
3. After registration, go to **Certificates & secrets → New client secret** and copy the value immediately — it won't be shown again
4. Go to **Overview** and copy the **Application (client) ID** and **Directory (tenant) ID**
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
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

**In Sim:**

| Field | Value |
|-------|-------|
| Provider Type | OIDC |
| Provider ID | `azure-ad` |
| Provider ID | `azure-ad-acme` (must be globally unique) |
| Issuer URL | `https://login.microsoftonline.com/{tenant-id}/v2.0` |
| Domain | `company.com` |
| Client ID | Application (client) ID |
Expand Down Expand Up @@ -252,7 +264,7 @@ SSO provisioning creates internal organization members. External workspace membe
},
{
question: "A user already has an account with the same email — what happens when they sign in with SSO?",
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."
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."
},
{
question: "Who can configure SSO on Sim Cloud?",
Expand All @@ -264,7 +276,7 @@ SSO provisioning creates internal organization members. External workspace membe
},
{
question: "How do I update or replace an existing SSO configuration?",
answer: "Open Settings → Enterprise → Single Sign-On and click Edit. Update the fields and save. The existing provider configuration is replaced."
answer: "Open Settings → Security → Single sign-on and click Edit. Update the fields and save. The existing provider configuration is replaced."
}
]} />

Expand All @@ -285,22 +297,18 @@ NEXT_PUBLIC_SSO_ENABLED=true
ORGANIZATIONS_ENABLED=true
NEXT_PUBLIC_ORGANIZATIONS_ENABLED=true

# Optional: comma-separated SSO provider IDs to trust for automatic account linking
# (links an SSO sign-in to an existing account with the same email). Needed when your
# IdP does not assert email_verified — typically SAML providers, or OIDC providers that
# omit the claim. Set it to the Provider ID you registered, then restart.
# (If you also keep SSO_PROVIDER_ID in the app's environment, that provider is trusted
# without listing it here.)
# Optional: comma-separated provider IDs to trust for automatic account linking.
# This applies to non-SSO providers only — SSO linking is authorized by the
# verified domain on the provider itself, not by this list.
SSO_TRUSTED_PROVIDER_IDS=custom-oidc,partner-saml
```

<Callout type="info">
When someone signs in with SSO and an account with the same email already exists
(for example, they previously signed up with email/password), Sim links the SSO
identity to that account automatically as long as your IdP reports the email as
verified, or the provider is trusted. If you hit an `account not linked` error,
either confirm your IdP sends `email_verified`, or add the provider's ID to
`SSO_TRUSTED_PROVIDER_IDS` and restart.
identity to that account automatically. That linking is authorized by the verified
domain attached to the provider, so it works for both OIDC and SAML and does not
depend on your IdP asserting `email_verified`.
</Callout>

You can register providers through the **Settings UI** (same as cloud) or by running the registration script directly against your database.
Expand Down
232 changes: 219 additions & 13 deletions apps/sim/app/api/auth/sso/register/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,19 @@ function queueMembers(rows: Array<Record<string, unknown>>) {
}

/**
* Queues existing SSO provider rows for BOTH domain-conflict lookups (the
* pre-registration check and the post-registration re-check).
* Queues the sso_provider lookups a registration performs, in route order:
* providerId conflict then domain conflict, once before OIDC discovery and again
* immediately before the write. `providerIdRows` defaults to empty so
* domain-conflict tests are unaffected by the providerId check.
*/
function queueProviders(rows: Array<Record<string, unknown>>) {
queueTableRows(schemaMock.ssoProvider, rows)
queueTableRows(schemaMock.ssoProvider, rows)
function queueProviders(
domainRows: Array<Record<string, unknown>>,
providerIdRows: Array<Record<string, unknown>> = []
) {
queueTableRows(schemaMock.ssoProvider, providerIdRows)
queueTableRows(schemaMock.ssoProvider, domainRows)
queueTableRows(schemaMock.ssoProvider, providerIdRows)
queueTableRows(schemaMock.ssoProvider, domainRows)
}

vi.mock('@/lib/auth', () => ({
Expand Down Expand Up @@ -109,12 +116,14 @@ describe('POST /api/auth/sso/register', () => {
mockSecureFetchWithPinnedIP.mockRejectedValue(new Error('discovery not mocked for this test'))
mockRegisterSSOProvider.mockResolvedValue({ id: 'row-1', providerId: 'acme-oidc' })
mockUpdateSSOProvider.mockResolvedValue({ providerId: 'acme-oidc' })
// The trust UPDATE reports the row it matched; by default the provider exists.
dbChainMockFns.returning.mockResolvedValue([{ id: 'provider-row' }])
// Default: the org has already verified the domain, so the ownership gate
// passes and each test exercises the logic beyond it. The gate is checked
// three times for a successful org-scoped registration (fail-fast entry +
// authoritative re-check before the write + compensating re-check after the
// write), so queue three rows. Gate-specific tests reset the queue to assert
// the unverified paths.
// passes and each test exercises the logic beyond it. A successful org-scoped
// registration reads it three times: the fail-fast entry gate, the
// authoritative re-check before the write, and the locking read inside the
// trust transaction. Gate-specific tests reset the queue to assert the
// unverified paths.
queueTableRows(schemaMock.ssoDomain, [{ id: 'verified-domain' }])
queueTableRows(schemaMock.ssoDomain, [{ id: 'verified-domain' }])
queueTableRows(schemaMock.ssoDomain, [{ id: 'verified-domain' }])
Expand Down Expand Up @@ -175,7 +184,7 @@ describe('POST /api/auth/sso/register', () => {
queueMembers([{ organizationId: 'org1', role: 'owner' }])
queueTableRows(schemaMock.ssoDomain, [{ id: 'v' }]) // entry gate: verified
queueTableRows(schemaMock.ssoDomain, [{ id: 'v' }]) // pre-write re-check: verified
queueTableRows(schemaMock.ssoDomain, []) // post-write compensating check: revoked
queueTableRows(schemaMock.ssoDomain, []) // locking read in the grant: proof gone
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
const json = await res.json()
expect(res.status).toBe(403)
Expand Down Expand Up @@ -214,6 +223,204 @@ describe('POST /api/auth/sso/register', () => {
expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1)
})

/**
* Better Auth scopes providerId uniqueness globally, not per tenant, and would
* otherwise reject this with an opaque 422 that reads like a bug. Sim catches
* it first and returns a 409 naming a free id.
*/
it('rejects a providerId already taken by another organization', async () => {
queueMembers([{ organizationId: 'org-b', role: 'owner' }])
queueProviders([], [{ domain: 'other.com', userId: 'u-other', organizationId: 'org-other' }])
const res = await POST(request({ ...OIDC_BODY, orgId: 'org-b' }))
const json = await res.json()
expect(res.status).toBe(409)
expect(json.code).toBe('SSO_PROVIDER_ID_TAKEN')
expect(mockRegisterSSOProvider).not.toHaveBeenCalled()
})

it('suggests a free, domain-scoped providerId when the requested one is taken', async () => {
queueMembers([{ organizationId: 'org-b', role: 'owner' }])
queueProviders([], [{ domain: 'other.com', userId: 'u-other', organizationId: 'org-other' }])
const res = await POST(request({ ...OIDC_BODY, orgId: 'org-b' }))
const json = await res.json()
expect(json.error).toContain('acme-oidc-acme')
})

it('does not treat the caller’s own provider as a providerId conflict', async () => {
queueMembers([{ organizationId: 'org1', role: 'owner' }])
queueProviders([], [{ domain: 'acme.com', userId: 'u1', organizationId: 'org1' }])
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
expect(res.status).toBe(200)
})

/**
* Better Auth's `isTrustedProvider` reads this flag, and it is the only thing
* that lets an SSO sign-in link to a pre-existing same-email account once the
* plugin stopped honouring `trustedProviders` for SSO. `registerSSOProvider`
* always persists `false`, so the route must set it after the write.
*/
it('marks the provider domain-verified after registering', async () => {
queueMembers([{ organizationId: 'org1', role: 'owner' }])
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
expect(res.status).toBe(200)
expect(dbChainMockFns.set).toHaveBeenCalledWith({ domainVerified: true })
})

/** updateSSOProvider resets domainVerified to false whenever the domain changes. */
it('re-marks the provider domain-verified after an update', async () => {
queueMembers([{ organizationId: 'org1', role: 'owner' }])
queueProviders([])
queueTableRows(schemaMock.ssoProvider, [{ id: 'p1' }])
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
expect(res.status).toBe(200)
expect(mockUpdateSSOProvider).toHaveBeenCalledTimes(1)
expect(dbChainMockFns.set).toHaveBeenCalledWith({ domainVerified: true })
})

/**
* The create path rolls the provider back when verification is revoked during the
* write. The update path has no new row to delete, so it restores the pre-update
* config and clears the trust flag together. Clearing alone would leave the
* rejected config stored, and re-verifying the domain regrants trust
* automatically — silently activating a config the caller was told had failed.
*/
it('reverts the config and revokes trust when verification is removed mid-update', async () => {
queueMembers([{ organizationId: 'org1', role: 'owner' }])
resetDbChainMock()
queueMembers([{ organizationId: 'org1', role: 'owner' }])
queueTableRows(schemaMock.ssoDomain, [{ id: 'v' }]) // entry gate
queueTableRows(schemaMock.ssoDomain, [{ id: 'v' }]) // pre-write re-check
queueTableRows(schemaMock.ssoDomain, []) // locking read in the grant: proof gone
queueProviders([])
queueTableRows(schemaMock.ssoProvider, [
{
id: 'p1',
issuer: 'https://old-issuer.example.com',
domain: 'acme.com',
oidcConfig: '{"stored":"oidc"}',
samlConfig: null,
},
]) // provider already owned → update path

const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
expect(res.status).toBe(403)
expect(mockUpdateSSOProvider).toHaveBeenCalledTimes(1)
// The conditional grant UPDATE is still issued — it simply matches no rows once
// the proof is gone — so the signal is the restoring write plus the 403.
expect(dbChainMockFns.set).toHaveBeenCalledWith({
issuer: 'https://old-issuer.example.com',
domain: 'acme.com',
oidcConfig: '{"stored":"oidc"}',
samlConfig: null,
domainVerified: false,
})
})

it('does not mark domain-verified when the registration is rolled back', async () => {
queueMembers([{ organizationId: 'org1', role: 'owner' }])
resetDbChainMock()
queueMembers([{ organizationId: 'org1', role: 'owner' }])
queueTableRows(schemaMock.ssoDomain, [{ id: 'verified-domain' }])
queueTableRows(schemaMock.ssoDomain, [{ id: 'verified-domain' }])
queueTableRows(schemaMock.ssoDomain, []) // locking read in the grant: proof gone
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
expect(res.status).toBe(403)
expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1) // it was created…
expect(dbChainMockFns.delete).toHaveBeenCalled() // …then rolled back
})

/**
* A personal provider has no verified domain behind it. On the hosted
* multi-tenant deployment that must grant no linking authority, or anyone able
* to register one could claim a domain they do not own and have their own IdP
* auto-link to existing accounts on it.
*/
it('does not grant domain trust to a personal provider when hosted', async () => {
setEnvFlags({ isSsoEnabled: true, isHosted: true })
const res = await POST(request(OIDC_BODY))
expect(res.status).toBe(200)
expect(dbChainMockFns.set).toHaveBeenCalledWith({ domainVerified: false })
})

it('grants domain trust to a personal provider when self-hosted', async () => {
setEnvFlags({ isSsoEnabled: true, isHosted: false })
const res = await POST(request(OIDC_BODY))
expect(res.status).toBe(200)
expect(dbChainMockFns.set).toHaveBeenCalledWith({ domainVerified: true })
})

/**
* Better Auth merges SAML config with `??`, so dropping an empty identifierFormat
* would silently retain a previously stored NameID format while the admin had
* selected the provider default.
*/
it('forwards an empty SAML identifierFormat so the provider default can be restored', async () => {
queueMembers([{ organizationId: 'org1', role: 'owner' }])
queueProviders([])
await POST(
request({
providerType: 'saml',
providerId: 'acme-saml',
issuer: 'https://idp.acme.com',
domain: 'acme.com',
orgId: 'org1',
entryPoint: 'https://idp.acme.com/sso',
cert: 'CERT',
identifierFormat: '',
})
)
expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1)
const sent = mockRegisterSSOProvider.mock.calls[0][0].body
expect(sent.samlConfig).toHaveProperty('identifierFormat', '')
})

/**
* Persisting generated IdP metadata made re-saving destructive: the form loaded
* it back, resent it, and it then won over the certificate — so rotating a SAML
* cert through the form silently did nothing.
*/
it('writes empty IdP metadata when the admin supplied none, so a stored one clears', async () => {
queueMembers([{ organizationId: 'org1', role: 'owner' }])
queueProviders([])
await POST(
request({
providerType: 'saml',
providerId: 'acme-saml',
issuer: 'https://idp.acme.com',
domain: 'acme.com',
orgId: 'org1',
entryPoint: 'https://idp.acme.com/sso',
cert: 'ORIGINAL-CERT',
})
)
const sent = mockRegisterSSOProvider.mock.calls[0][0].body
// Written as empty rather than omitted: Better Auth merges with `??`, so an
// omitted key would retain a previously stored document on update.
expect(sent.samlConfig.idpMetadata).toEqual({ metadata: '' })
expect(sent.samlConfig.cert).toBe('ORIGINAL-CERT')
})

it('persists IdP metadata the admin did supply', async () => {
queueMembers([{ organizationId: 'org1', role: 'owner' }])
queueProviders([])
await POST(
request({
providerType: 'saml',
providerId: 'acme-saml',
issuer: 'https://idp.acme.com',
domain: 'acme.com',
orgId: 'org1',
entryPoint: 'https://idp.acme.com/sso',
cert: 'CERT',
idpMetadata: '<EntityDescriptor>supplied</EntityDescriptor>',
})
)
const sent = mockRegisterSSOProvider.mock.calls[0][0].body
expect(sent.samlConfig.idpMetadata).toEqual({
metadata: '<EntityDescriptor>supplied</EntityDescriptor>',
})
})

it('nests the attribute mapping inside oidcConfig (Better Auth reads it there)', async () => {
queueMembers([{ organizationId: 'org1', role: 'owner' }])
await POST(
Expand All @@ -227,8 +434,7 @@ describe('POST /api/auth/sso/register', () => {

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