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
16 changes: 11 additions & 5 deletions apps/docs/content/docs/platform/enterprise/sso.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ Go to **Settings → Security → Single sign-on** in your organization settings
| **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. |
| **Member provisioning** | **Automatic** adds a user authenticated through this verified SSO connection to the organization as a Member and consumes a billed seat. Team seat counts grow with membership; fixed-seat plans require available capacity. **Invite only** authenticates the user without creating organization membership. Neither mode grants workspace access automatically. |

**OIDC additional fields:**

Expand Down Expand Up @@ -267,16 +268,17 @@ 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
4. After authenticating, they are returned to Sim and added to your organization automatically
5. They land in the workspace
4. After authenticating, they are returned to Sim
5. If **Member provisioning** 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

Users who sign in via SSO for the first time are automatically provisioned and added to your organization — no manual invite required.
With **Automatic** provisioning, no invitation is required for organization membership. The join follows the organization's seat policy and does not infer a role from IdP claims: every newly provisioned user starts as a Member. Team subscriptions grow their billed seat count with membership; fixed-seat plans reject the join when capacity is full. With **Invite only**, SSO proves identity but does not create new membership or workspace access; new access must be granted separately, while existing organization membership and workspace access remain available.

<Callout type="warning">
Sign-in must start from Sim. Launching from your identity provider's app portal (Microsoft's **My Apps**, Okta's dashboard tile) sends an unsolicited assertion, which Sim rejects. This is deliberate — accepting them would let anyone replay an assertion into your tenant — but it means an IdP-initiated test fails even when the configuration is correct.
</Callout>

SSO provisioning creates internal organization members. External workspace members are different: they are invited to a specific workspace without joining your organization or consuming one of your seats.
SSO provisioning creates internal organization members but does not grant workspace access. External workspace members are different: they are invited to a specific workspace without joining your organization or consuming one of your seats. Existing invitations and external access take precedence over automatic provisioning so their intended role and workspace grants are preserved.

<Callout type="info">
Password-based login remains available. Forcing all organization members to use SSO exclusively is not yet supported.
Expand All @@ -299,7 +301,11 @@ SSO provisioning creates internal organization members. External workspace membe
},
{
question: "What happens when a user signs in with SSO for the first time?",
answer: "Sim creates an account for them automatically and adds them to your organization. No manual invite is needed. They are assigned the member role by default. External workspace members are not provisioned through SSO into your organization; they are invited directly to a workspace and remain outside your org roster."
answer: "Sim creates or links their account. If Member provisioning is Automatic and a seat is available, Sim adds them to your organization as a Member; no manual organization invite is needed. Workspace access is always granted separately. If provisioning is Invite only, or the user already has a pending invitation or external workspace access, Sim preserves that flow instead of creating membership automatically."
},
{
question: "Does disabling someone in the identity provider remove their Sim access?",
answer: "No. Disabling the IdP account blocks future SSO authentication, but Sim does not currently receive SCIM deprovisioning or IdP logout events to remove membership or revoke active Sim sessions. Remove or suspend the user in Sim as part of offboarding."
},
{
question: "Can I still use email/password login after enabling SSO?",
Expand Down
1 change: 1 addition & 0 deletions apps/sim/app/api/auth/sso/providers/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
samlConfig: ssoProvider.samlConfig,
userId: ssoProvider.userId,
organizationId: ssoProvider.organizationId,
jitProvisioningEnabled: ssoProvider.jitProvisioningEnabled,
})
.from(ssoProvider)
.where(whereClause)
Expand Down
64 changes: 60 additions & 4 deletions apps/sim/app/api/auth/sso/register/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,10 @@ describe('POST /api/auth/sso/register', () => {
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 })
expect(dbChainMockFns.set).toHaveBeenCalledWith({
domainVerified: true,
jitProvisioningEnabled: true,
})
})

/** updateSSOProvider resets domainVerified to false whenever the domain changes. */
Expand All @@ -274,7 +277,10 @@ describe('POST /api/auth/sso/register', () => {
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
expect(res.status).toBe(200)
expect(mockUpdateSSOProvider).toHaveBeenCalledTimes(1)
expect(dbChainMockFns.set).toHaveBeenCalledWith({ domainVerified: true })
expect(dbChainMockFns.set).toHaveBeenCalledWith({
domainVerified: true,
jitProvisioningEnabled: true,
})
})

/**
Expand All @@ -299,6 +305,7 @@ describe('POST /api/auth/sso/register', () => {
domain: 'acme.com',
oidcConfig: '{"stored":"oidc"}',
samlConfig: null,
jitProvisioningEnabled: false,
},
]) // provider already owned → update path

Expand All @@ -313,6 +320,36 @@ describe('POST /api/auth/sso/register', () => {
oidcConfig: '{"stored":"oidc"}',
samlConfig: null,
domainVerified: false,
jitProvisioningEnabled: false,
})
})

it('reverts the config and provisioning mode when the trust write fails', async () => {
queueMembers([{ organizationId: 'org1', role: 'owner' }])
queueProviders([])
queueTableRows(schemaMock.ssoProvider, [
{
id: 'p1',
issuer: 'https://old-issuer.example.com',
domain: 'acme.com',
oidcConfig: '{"stored":"oidc"}',
samlConfig: null,
jitProvisioningEnabled: true,
},
])
dbChainMockFns.returning.mockRejectedValueOnce(new Error('trust write failed'))

const res = await POST(request({ ...OIDC_BODY, orgId: 'org1', jitProvisioningEnabled: false }))

expect(res.status).toBe(500)
expect(mockUpdateSSOProvider).toHaveBeenCalledTimes(1)
expect(dbChainMockFns.set).toHaveBeenCalledWith({
issuer: 'https://old-issuer.example.com',
domain: 'acme.com',
oidcConfig: '{"stored":"oidc"}',
samlConfig: null,
domainVerified: false,
jitProvisioningEnabled: true,
})
})

Expand All @@ -339,14 +376,33 @@ describe('POST /api/auth/sso/register', () => {
setEnvFlags({ isSsoEnabled: true, isHosted: true })
const res = await POST(request(OIDC_BODY))
expect(res.status).toBe(200)
expect(dbChainMockFns.set).toHaveBeenCalledWith({ domainVerified: false })
expect(dbChainMockFns.set).toHaveBeenCalledWith({
domainVerified: false,
jitProvisioningEnabled: true,
})
})

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 })
expect(dbChainMockFns.set).toHaveBeenCalledWith({
domainVerified: true,
jitProvisioningEnabled: true,
})
})

it('persists invite-only provisioning without changing Better Auth provider config', async () => {
queueMembers([{ organizationId: 'org1', role: 'owner' }])
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1', jitProvisioningEnabled: false }))
expect(res.status).toBe(200)
expect(dbChainMockFns.set).toHaveBeenCalledWith({
domainVerified: true,
jitProvisioningEnabled: false,
})
expect(mockRegisterSSOProvider.mock.calls[0][0].body).not.toHaveProperty(
'jitProvisioningEnabled'
)
})

/**
Expand Down
56 changes: 42 additions & 14 deletions apps/sim/app/api/auth/sso/register/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
if (!parsed.success) return parsed.response

const body = parsed.data.body
const { providerId, issuer, providerType, mapping, orgId } = body
const { providerId, issuer, providerType, mapping, orgId, jitProvisioningEnabled } = body

if (orgId) {
const [membership] = await db
Expand Down Expand Up @@ -624,6 +624,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
domain: ssoProvider.domain,
oidcConfig: ssoProvider.oidcConfig,
samlConfig: ssoProvider.samlConfig,
jitProvisioningEnabled: ssoProvider.jitProvisioningEnabled,
})
.from(ssoProvider)
.where(ownerClause)
Expand All @@ -643,7 +644,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
*/
const grantProviderDomainTrust = async (): Promise<boolean> => {
if (!orgId) {
await db.update(ssoProvider).set({ domainVerified: !isHosted }).where(ownerClause)
await db
.update(ssoProvider)
.set({ domainVerified: !isHosted, jitProvisioningEnabled })
.where(ownerClause)
return true
}
return db.transaction(async (tx) => {
Expand All @@ -663,14 +667,28 @@ export const POST = withRouteHandler(async (request: NextRequest) => {

const granted = await tx
.update(ssoProvider)
.set({ domainVerified: true })
.set({ domainVerified: true, jitProvisioningEnabled })
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
.where(ownerClause)
.returning({ id: ssoProvider.id })
return granted.length > 0
})
}

if (existingOwnedProvider) {
const revertProviderUpdate = async (): Promise<void> => {
await db
.update(ssoProvider)
.set({
issuer: existingOwnedProvider.issuer,
domain: existingOwnedProvider.domain,
oidcConfig: existingOwnedProvider.oidcConfig,
samlConfig: existingOwnedProvider.samlConfig,
domainVerified: false,
jitProvisioningEnabled: existingOwnedProvider.jitProvisioningEnabled,
})
.where(eq(ssoProvider.id, existingOwnedProvider.id))
}

await auth.api.updateSSOProvider({
body: {
providerId,
Expand All @@ -682,20 +700,30 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
headers,
})

let domainTrustGranted: boolean
try {
domainTrustGranted = await grantProviderDomainTrust()
} catch (error) {
try {
await revertProviderUpdate()
} catch (rollbackError) {
logger.error('Failed to revert SSO provider after domain trust write failed', {
domain,
orgId,
providerId,
userId: session.user.id,
error,
rollbackError,
})
}
throw error
}

// Restore the pre-update config and clear the flag together. Clearing alone
// is not enough: re-verifying the domain now regrants trust automatically,
// which would activate the very config this request reported as rejected.
if (!(await grantProviderDomainTrust())) {
await db
.update(ssoProvider)
.set({
issuer: existingOwnedProvider.issuer,
domain: existingOwnedProvider.domain,
oidcConfig: existingOwnedProvider.oidcConfig,
samlConfig: existingOwnedProvider.samlConfig,
domainVerified: false,
})
.where(eq(ssoProvider.id, existingOwnedProvider.id))
if (!domainTrustGranted) {
await revertProviderUpdate()
logger.warn('Reverted SSO update: domain verification was removed mid-write', {
domain,
orgId,
Expand Down
8 changes: 4 additions & 4 deletions apps/sim/content/blog/enterprise/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ slug: enterprise
title: 'Sim for Enterprise'
description: 'Access control, BYOK, self-hosted deployments, on-prem Copilot, SSO & SAML, whitelabeling, Admin API, and flexible data retention—enterprise features for teams with strict security and compliance requirements.'
date: 2026-02-11
updated: 2026-08-27
updated: 2026-08-31
authors:
- vik
readingTime: 10
Expand Down Expand Up @@ -35,7 +35,7 @@ faq:
- q: "Can Copilot be used without sending workflow data to an external AI service?"
a: "Yes. Copilot can run entirely within a self-hosted deployment using your own LLM keys, so prompts containing context from your workflows, execution logs, and workspace configuration route directly to your chosen provider and never leave your network."
- q: "What identity providers does Sim support for SSO, and what happens when an employee is deprovisioned?"
a: "Sim integrates with Okta, Azure AD (Entra ID), Google Workspace, OneLogin, Auth0, JumpCloud, Ping Identity, ADFS, and any SAML 2.0 or OIDC compliant identity provider. Session management ties to your IdP, so logging out there terminates Sim sessions, and account deprovisioning immediately revokes access."
a: "Sim integrates with Okta, Azure AD (Entra ID), Google Workspace, OneLogin, Auth0, JumpCloud, Ping Identity, ADFS, and any SAML 2.0 or OIDC compliant identity provider. IdP deprovisioning blocks future SSO authentication but does not currently remove Sim membership or revoke active Sim sessions, so offboarding must also remove or suspend access in Sim."
---

We've been working with security teams at larger organizations to bring Sim into environments with strict compliance and data handling requirements. This post covers the enterprise capabilities we've built: granular access control, bring-your-own-keys, self-hosted deployments, on-prem Copilot, SSO & SAML, whitelabeling, compliance, and programmatic management via the Admin API.
Expand Down Expand Up @@ -121,9 +121,9 @@ This is particularly relevant for organizations where the context Copilot needs

Integrate with your existing identity provider through SAML 2.0 or OIDC. We support Okta, Azure AD (Entra ID), Google Workspace, OneLogin, Auth0, JumpCloud, Ping Identity, ADFS, and any compliant identity provider.

Once enabled, users authenticate through your IdP instead of Sim credentials. Your MFA policies apply automatically. Session management ties to your IdPlogout there terminates Sim sessions. Account deprovisioning immediately revokes access.
Once enabled, users authenticate through your IdP, so its MFA and sign-in policies apply to the authentication event. Sim sessions have their own lifecycle: IdP logout or deprovisioning does not currently revoke an active Sim session or remove organization membership, so those are explicit Sim admin steps during offboarding.

New users are provisioned on first SSO login based on IdP attributes. No invitation emails, no password setup, no manual account creation required.
Administrators choose how first-time users enter the organization. For a user without a pending invitation or existing external workspace access, **Automatic** provisioning adds them through the verified SSO connection as a Member and consumes a billed seat; Team seat counts grow with membership, while fixed-seat plans require available capacity. **Invite only** authenticates the user but requires access to be granted separately. Existing membership and workspace access remain available. Automatic provisioning never promotes IdP claims into Sim roles and never grants workspace access implicitly.

This centralizes your authentication and audit trail. Your security team's policies apply to Sim access through the same system that tracks everything else.

Expand Down
15 changes: 15 additions & 0 deletions apps/sim/ee/sso/components/sso-form.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ vi.mock('@/lib/auth/auth-client', () => ({
}))

vi.mock('@/app/(auth)/components', () => ({
AuthFormMessage: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
AuthSubmitButton: ({
children,
disabled = false,
Expand Down Expand Up @@ -149,6 +150,20 @@ describe('SSOForm sign-in errors', () => {
container.remove()
})

it('shows an actionable seat message after a successful IdP login cannot provision access', async () => {
renderInteractive('error=sso_no_seats')

await act(async () => {})

expect(container).toHaveTextContent('Your organization has no available seat capacity.')
expect(container).toHaveTextContent('Ask an administrator to increase capacity')
expect(container.querySelector('[role="alert"]')).toHaveTextContent(
'Your organization has no available seat capacity.'
)
expect(container.querySelector('#email')).not.toHaveAttribute('aria-invalid')
expect(container.querySelector('#email')).not.toHaveAttribute('aria-describedby')
})

it('shows a generic retryable error when Better Auth resolves with a 404', async () => {
mockSsoSignIn.mockResolvedValue({
data: null,
Expand Down
Loading
Loading