Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
5f9f3e6
feat(sso): DNS domain verification gating org SSO registration
waleedlatif1 Jul 24, 2026
c27a28a
fix(sso): harden domain verification against concurrency + fix CI lint
waleedlatif1 Jul 24, 2026
6fc3224
fix(sso): re-check domain verification before provider write (TOCTOU)
waleedlatif1 Jul 24, 2026
16f92b5
fix(sso): stop rotating verification token on idempotent re-add
waleedlatif1 Jul 24, 2026
1995af7
fix(sso): close register TOCTOU with compensating delete + harden edges
waleedlatif1 Jul 24, 2026
99b4f13
fix(sso): redact domain tokens from non-admins + fix script stale-update
waleedlatif1 Jul 24, 2026
64fceab
docs(sso): drop unshipped enforce-SSO / auto-join copy from verified …
waleedlatif1 Jul 24, 2026
1e33e89
fix(sso): guard rollback to new providers only + Enterprise-gate doma…
waleedlatif1 Jul 24, 2026
44bf72c
fix(sso): roll back the SSO provider by row id, not logical keys
waleedlatif1 Jul 24, 2026
98c54f6
chore(sso): final-review polish — trim script read, unify copy, doc m…
waleedlatif1 Jul 24, 2026
b4c7a34
fix(sso): apply attribute mapping + make SSO edit work; drop dead guard
waleedlatif1 Jul 24, 2026
bb9637a
fix(sso): require null org on personal-mode provider lookups (gate by…
waleedlatif1 Jul 24, 2026
464e7e0
fix(sso): script updates the observed provider by id, not providerId
waleedlatif1 Jul 24, 2026
b37655e
fix(sso): script upserts provider via delete-then-insert (no unique c…
waleedlatif1 Jul 24, 2026
c106d87
fix(sso): guard compensating-delete row id so rollback can't silently…
waleedlatif1 Jul 24, 2026
c03f389
Merge remote-tracking branch 'origin/staging' into feat/sso-domain-ve…
waleedlatif1 Jul 24, 2026
dce6d63
chore(sso): regenerate migration as 0268 after merging staging
waleedlatif1 Jul 24, 2026
085a962
refactor(sso): share normalizeSSODomain via @sim/utils so script matc…
waleedlatif1 Jul 24, 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
1 change: 1 addition & 0 deletions apps/docs/content/docs/en/platform/enterprise/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"pages": [
"index",
"sso",
"verified-domains",
"session-policies",
"access-control",
"custom-blocks",
Expand Down
57 changes: 57 additions & 0 deletions apps/docs/content/docs/en/platform/enterprise/verified-domains.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
---
title: Verified Domains
description: Prove ownership of your email domains before configuring single sign-on
---

import { Callout } from 'fumadocs-ui/components/callout'
import { FAQ } from '@/components/ui/faq'

Verified Domains let organization owners and admins on Enterprise plans prove they control an email domain (like `acme.com`) with a DNS TXT record. Verifying a domain is the security precondition for configuring single sign-on for it.

<Callout type="warning">
Configuring SSO for a domain requires it to be verified first. Verifying proves your organization controls the domain — without it, anyone could point another company's domain at their own identity provider. Domains you had already configured for SSO are automatically treated as verified.
</Callout>

---

## Verify a domain

Go to **Settings → Security → Verified domains** in your organization settings.

1. Enter the domain, for example `acme.com`, and click **Add domain**.
2. Sim shows a DNS **TXT record** to publish — a host (`_sim-challenge.acme.com`) and a unique value (`sim-domain-verification=…`).
3. Add that TXT record at your DNS provider.
4. Click **Verify**. Sim looks up the record; on success the domain is marked **Verified**.

DNS changes can take up to 48 hours to propagate — if verification does not succeed immediately, wait and retry. You can remove the TXT record after the domain is verified; the verification persists.

Add each domain you own separately. Subdomains (`eng.acme.com`) are verified independently of the apex.

---

## FAQ

<FAQ
items={[
{
question: 'Where does the TXT record go?',
answer:
'On a dedicated host, _sim-challenge.<your-domain>, rather than the root of your domain — this avoids colliding with your SPF, DMARC, or other root TXT records.',
},
{
question: 'What happens to domains we already use for SSO?',
answer:
'They are automatically treated as verified, so existing single sign-on keeps working with no action needed.',
},
{
question: 'Can two organizations verify the same domain?',
answer:
'No. A verified domain belongs to exactly one organization. Once verified, another organization cannot claim it.',
},
{
question: 'What if I remove a verified domain?',
answer:
'You lose the ownership proof, so you cannot configure SSO for that domain until you re-add and re-verify it. Removing it does not sign anyone out — an already-configured SSO provider keeps working.',
},
]}
/>
84 changes: 81 additions & 3 deletions apps/sim/app/api/auth/sso/register/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,14 @@ import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockGetSession,
mockRegisterSSOProvider,
mockUpdateSSOProvider,
mockHasSSOAccess,
mockValidateUrlWithDNS,
mockSecureFetchWithPinnedIP,
} = vi.hoisted(() => ({
mockGetSession: vi.fn(),
mockRegisterSSOProvider: vi.fn(),
mockUpdateSSOProvider: vi.fn(),
mockHasSSOAccess: vi.fn(),
mockValidateUrlWithDNS: vi.fn(),
mockSecureFetchWithPinnedIP: vi.fn(),
Expand All @@ -45,14 +47,19 @@ function queueProviders(rows: Array<Record<string, unknown>>) {

vi.mock('@/lib/auth', () => ({
getSession: mockGetSession,
auth: { api: { registerSSOProvider: mockRegisterSSOProvider } },
auth: {
api: {
registerSSOProvider: mockRegisterSSOProvider,
updateSSOProvider: mockUpdateSSOProvider,
},
},
}))

vi.mock('@/lib/billing', () => ({
hasSSOAccess: mockHasSSOAccess,
}))

vi.mock('@/lib/auth/sso/domain', () => ({
vi.mock('@sim/utils/sso-domain', () => ({
normalizeSSODomain: (input: unknown): string | null => {
if (typeof input !== 'string') return null
const value = input.trim().toLowerCase()
Expand Down Expand Up @@ -93,7 +100,17 @@ describe('POST /api/auth/sso/register', () => {
mockHasSSOAccess.mockResolvedValue(true)
mockValidateUrlWithDNS.mockResolvedValue({ isValid: true, resolvedIP: '1.2.3.4' })
mockSecureFetchWithPinnedIP.mockRejectedValue(new Error('discovery not mocked for this test'))
mockRegisterSSOProvider.mockResolvedValue({ providerId: 'acme-oidc' })
mockRegisterSSOProvider.mockResolvedValue({ id: 'row-1', providerId: 'acme-oidc' })
mockUpdateSSOProvider.mockResolvedValue({ providerId: 'acme-oidc' })
// 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.
queueTableRows(schemaMock.ssoDomain, [{ id: 'verified-domain' }])
queueTableRows(schemaMock.ssoDomain, [{ id: 'verified-domain' }])
queueTableRows(schemaMock.ssoDomain, [{ id: 'verified-domain' }])
})

afterAll(() => {
Expand Down Expand Up @@ -122,6 +139,43 @@ describe('POST /api/auth/sso/register', () => {
expect(mockRegisterSSOProvider).not.toHaveBeenCalled()
})

it('rejects configuring org SSO for a domain the org has not verified', async () => {
resetDbChainMock()
queueMembers([{ organizationId: 'org1', role: 'owner' }])
queueTableRows(schemaMock.ssoDomain, []) // no verified sso_domain row
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
const json = await res.json()
expect(res.status).toBe(403)
expect(json.code).toBe('SSO_DOMAIN_NOT_VERIFIED')
expect(mockRegisterSSOProvider).not.toHaveBeenCalled()
})

it('re-checks verification before the write and 403s if it was revoked mid-registration', async () => {
resetDbChainMock()
queueMembers([{ organizationId: 'org1', role: 'owner' }])
queueTableRows(schemaMock.ssoDomain, [{ id: 'v' }]) // entry gate: verified
queueTableRows(schemaMock.ssoDomain, []) // re-check before write: revoked
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
const json = await res.json()
expect(res.status).toBe(403)
expect(json.code).toBe('SSO_DOMAIN_NOT_VERIFIED')
expect(mockRegisterSSOProvider).not.toHaveBeenCalled()
})

it('rolls back the newly-created provider if verification is revoked after the write', async () => {
resetDbChainMock()
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
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
const json = await res.json()
expect(res.status).toBe(403)
expect(json.code).toBe('SSO_DOMAIN_NOT_VERIFIED')
expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1) // it was created…
expect(dbChainMockFns.delete).toHaveBeenCalled() // …then rolled back
})

it('rejects a domain already registered by another organization', async () => {
queueMembers([{ organizationId: 'org-attacker', role: 'owner' }])
queueProviders([{ domain: 'acme.com', userId: 'u-victim', organizationId: 'org-victim' }])
Expand Down Expand Up @@ -152,6 +206,30 @@ describe('POST /api/auth/sso/register', () => {
expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1)
})

it('nests the attribute mapping inside oidcConfig (Better Auth reads it there)', async () => {
queueMembers([{ organizationId: 'org1', role: 'owner' }])
await POST(
request({ ...OIDC_BODY, orgId: 'org1', mapping: { id: 'oid', email: 'upn', name: 'name' } })
)
expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1)
const sent = mockRegisterSSOProvider.mock.calls[0][0].body
expect(sent.mapping).toBeUndefined() // not passed at the top level (silently ignored there)
expect(sent.oidcConfig.mapping).toMatchObject({ id: 'oid', email: 'upn', name: 'name' })
})

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
queueTableRows(schemaMock.ssoProvider, [{ id: 'p1' }]) // provider already owned → edit
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
expect(res.status).toBe(200)
const body = await res.json()
expect(body.message).toContain('updated')
expect(mockUpdateSSOProvider).toHaveBeenCalledTimes(1)
expect(mockRegisterSSOProvider).not.toHaveBeenCalled()
})

it('allows the owning tenant to update its own provider for the same domain', async () => {
queueMembers([{ organizationId: 'org1', role: 'owner' }])
queueProviders([{ domain: 'acme.com', userId: 'u1', organizationId: 'org1' }])
Expand Down
Loading
Loading