Skip to content

Commit b1f22a3

Browse files
committed
feat(sso): DNS domain verification gating org SSO registration
Add org-scoped domain ownership verification (DNS TXT challenge) as the security precondition for configuring SSO. Closes the first-come domain-claim vuln where any org could wire another company's domain to its own IdP. - New sso_domain table + migration 0266; existing org SSO domains are grandfathered as verified so live tenants are unaffected - Verified-domains settings UI (enterprise-gated) with add/verify/remove - Register route now requires a verified domain for org-scoped registration; personal SSO and already-grandfathered domains are unaffected - Self-host register script writes the verified sso_domain row directly, so script-driven registration stays backwards compatible
1 parent dbbfce8 commit b1f22a3

27 files changed

Lines changed: 19724 additions & 10 deletions

File tree

apps/docs/content/docs/en/platform/enterprise/meta.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"pages": [
44
"index",
55
"sso",
6+
"verified-domains",
67
"session-policies",
78
"access-control",
89
"custom-blocks",
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
---
2+
title: Verified Domains
3+
description: Prove ownership of your email domains to unlock SSO and domain-based provisioning
4+
---
5+
6+
import { Callout } from 'fumadocs-ui/components/callout'
7+
import { FAQ } from '@/components/ui/faq'
8+
9+
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 — and, in a later release, for enforcing SSO and auto-joining members from that domain.
10+
11+
<Callout type="warning">
12+
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.
13+
</Callout>
14+
15+
---
16+
17+
## Verify a domain
18+
19+
Go to **Settings → Security → Verified domains** in your organization settings.
20+
21+
1. Enter the domain, for example `acme.com`, and click **Add domain**.
22+
2. Sim shows a DNS **TXT record** to publish — a host (`_sim-challenge.acme.com`) and a unique value (`sim-domain-verification=…`).
23+
3. Add that TXT record at your DNS provider.
24+
4. Click **Verify**. Sim looks up the record; on success the domain is marked **Verified**.
25+
26+
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.
27+
28+
Add each domain you own separately. Subdomains (`eng.acme.com`) are verified independently of the apex.
29+
30+
---
31+
32+
## FAQ
33+
34+
<FAQ
35+
items={[
36+
{
37+
question: 'Where does the TXT record go?',
38+
answer:
39+
'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.',
40+
},
41+
{
42+
question: 'What happens to domains we already use for SSO?',
43+
answer:
44+
'They are automatically treated as verified, so existing single sign-on keeps working with no action needed.',
45+
},
46+
{
47+
question: 'Can two organizations verify the same domain?',
48+
answer:
49+
'No. A verified domain belongs to exactly one organization. Once verified, another organization cannot claim it.',
50+
},
51+
{
52+
question: 'What if I remove a verified domain?',
53+
answer:
54+
'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.',
55+
},
56+
]}
57+
/>

apps/sim/app/api/auth/sso/register/route.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,10 @@ describe('POST /api/auth/sso/register', () => {
9494
mockValidateUrlWithDNS.mockResolvedValue({ isValid: true, resolvedIP: '1.2.3.4' })
9595
mockSecureFetchWithPinnedIP.mockRejectedValue(new Error('discovery not mocked for this test'))
9696
mockRegisterSSOProvider.mockResolvedValue({ providerId: 'acme-oidc' })
97+
// Default: the org has already verified the domain, so the ownership gate
98+
// passes and each test exercises the logic beyond it. Gate-specific tests
99+
// reset the queue to assert the unverified path.
100+
queueTableRows(schemaMock.ssoDomain, [{ id: 'verified-domain' }])
97101
})
98102

99103
afterAll(() => {
@@ -122,6 +126,17 @@ describe('POST /api/auth/sso/register', () => {
122126
expect(mockRegisterSSOProvider).not.toHaveBeenCalled()
123127
})
124128

129+
it('rejects configuring org SSO for a domain the org has not verified', async () => {
130+
resetDbChainMock()
131+
queueMembers([{ organizationId: 'org1', role: 'owner' }])
132+
queueTableRows(schemaMock.ssoDomain, []) // no verified sso_domain row
133+
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
134+
const json = await res.json()
135+
expect(res.status).toBe(403)
136+
expect(json.code).toBe('SSO_DOMAIN_NOT_VERIFIED')
137+
expect(mockRegisterSSOProvider).not.toHaveBeenCalled()
138+
})
139+
125140
it('rejects a domain already registered by another organization', async () => {
126141
queueMembers([{ organizationId: 'org-attacker', role: 'owner' }])
127142
queueProviders([{ domain: 'acme.com', userId: 'u-victim', organizationId: 'org-victim' }])

apps/sim/app/api/auth/sso/register/route.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { db, member, ssoProvider } from '@sim/db'
1+
import { db, member, ssoDomain, ssoProvider } from '@sim/db'
22
import { createLogger } from '@sim/logger'
33
import { getErrorMessage } from '@sim/utils/errors'
44
import { and, eq, sql } from 'drizzle-orm'
@@ -120,6 +120,34 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
120120
return NextResponse.json({ error: 'Enter a valid domain like company.com' }, { status: 400 })
121121
}
122122

123+
// Security gate: configuring org SSO for a domain requires the org to have
124+
// proven ownership of it (DNS TXT verification). Without this, the old
125+
// first-come claim let any org wire another company's domain to their own
126+
// IdP — an account-takeover primitive. Existing domains were grandfathered
127+
// as verified by migration 0266, so live tenants are unaffected.
128+
if (orgId) {
129+
const [verified] = await db
130+
.select({ id: ssoDomain.id })
131+
.from(ssoDomain)
132+
.where(
133+
and(
134+
eq(ssoDomain.organizationId, orgId),
135+
eq(ssoDomain.domain, domain),
136+
eq(ssoDomain.status, 'verified')
137+
)
138+
)
139+
.limit(1)
140+
if (!verified) {
141+
return NextResponse.json(
142+
{
143+
error: `Verify ownership of ${domain} under Settings → Verified domains before configuring SSO for it.`,
144+
code: 'SSO_DOMAIN_NOT_VERIFIED',
145+
},
146+
{ status: 403 }
147+
)
148+
}
149+
}
150+
123151
const isOwnedByCaller = (provider: {
124152
userId: string | null
125153
organizationId: string | null
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
2+
import { db } from '@sim/db'
3+
import { member, ssoDomain } from '@sim/db/schema'
4+
import { createLogger } from '@sim/logger'
5+
import { isOrgAdminRole } from '@sim/platform-authz/workspace'
6+
import { and, eq } from 'drizzle-orm'
7+
import { type NextRequest, NextResponse } from 'next/server'
8+
import { removeOrganizationDomainContract } from '@/lib/api/contracts/organization'
9+
import { parseRequest } from '@/lib/api/server'
10+
import { getSession } from '@/lib/auth'
11+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
12+
13+
const logger = createLogger('OrgDomainDeleteAPI')
14+
15+
/**
16+
* DELETE /api/organizations/[id]/domains/[domainId]
17+
* Removes a claimed/verified domain. Requires owner/admin role. Removing a
18+
* verified domain drops the ownership proof, so SSO can no longer be configured
19+
* for it until it is re-verified. It does not retroactively un-register an
20+
* already-configured SSO provider — that flows through the SSO provider itself.
21+
*/
22+
export const DELETE = withRouteHandler(
23+
async (request: NextRequest, context: { params: Promise<{ id: string; domainId: string }> }) => {
24+
const session = await getSession()
25+
if (!session?.user?.id) {
26+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
27+
}
28+
29+
const parsed = await parseRequest(removeOrganizationDomainContract, request, context)
30+
if (!parsed.success) return parsed.response
31+
const { id: organizationId, domainId } = parsed.data.params
32+
33+
const [memberEntry] = await db
34+
.select({ role: member.role })
35+
.from(member)
36+
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
37+
.limit(1)
38+
39+
if (!memberEntry) {
40+
return NextResponse.json(
41+
{ error: 'Forbidden - Not a member of this organization' },
42+
{ status: 403 }
43+
)
44+
}
45+
if (!isOrgAdminRole(memberEntry.role)) {
46+
return NextResponse.json(
47+
{ error: 'Forbidden - Only organization owners and admins can remove domains' },
48+
{ status: 403 }
49+
)
50+
}
51+
52+
const [removed] = await db
53+
.delete(ssoDomain)
54+
.where(and(eq(ssoDomain.id, domainId), eq(ssoDomain.organizationId, organizationId)))
55+
.returning({ domain: ssoDomain.domain })
56+
57+
if (!removed) {
58+
return NextResponse.json({ error: 'Domain not found' }, { status: 404 })
59+
}
60+
61+
logger.info('Domain removed', { organizationId, domain: removed.domain })
62+
recordAudit({
63+
workspaceId: null,
64+
actorId: session.user.id,
65+
action: AuditAction.ORGANIZATION_DOMAIN_REMOVED,
66+
resourceType: AuditResourceType.ORGANIZATION,
67+
resourceId: organizationId,
68+
actorName: session.user.name ?? undefined,
69+
actorEmail: session.user.email ?? undefined,
70+
description: `Removed domain ${removed.domain}`,
71+
metadata: { domain: removed.domain },
72+
request,
73+
})
74+
75+
return NextResponse.json({ success: true })
76+
}
77+
)
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
2+
import { db } from '@sim/db'
3+
import { member, ssoDomain } from '@sim/db/schema'
4+
import { createLogger } from '@sim/logger'
5+
import { isOrgAdminRole } from '@sim/platform-authz/workspace'
6+
import { and, eq } from 'drizzle-orm'
7+
import { type NextRequest, NextResponse } from 'next/server'
8+
import { verifyOrganizationDomainContract } from '@/lib/api/contracts/organization'
9+
import { parseRequest } from '@/lib/api/server'
10+
import { getSession } from '@/lib/auth'
11+
import { checkDomainTxtRecord, toDomainResponse } from '@/lib/auth/sso/domain-verification'
12+
import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription'
13+
import { isBillingEnabled } from '@/lib/core/config/env-flags'
14+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
15+
16+
const logger = createLogger('OrgDomainVerifyAPI')
17+
18+
/**
19+
* POST /api/organizations/[id]/domains/[domainId]/verify
20+
* Checks the domain's DNS TXT challenge record; on success flips it to
21+
* `verified`. Requires enterprise plan and owner/admin role. A domain already
22+
* verified by another org is refused (the partial unique index also guards it).
23+
*/
24+
export const POST = withRouteHandler(
25+
async (request: NextRequest, context: { params: Promise<{ id: string; domainId: string }> }) => {
26+
const session = await getSession()
27+
if (!session?.user?.id) {
28+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
29+
}
30+
31+
const parsed = await parseRequest(verifyOrganizationDomainContract, request, context)
32+
if (!parsed.success) return parsed.response
33+
const { id: organizationId, domainId } = parsed.data.params
34+
35+
const [memberEntry] = await db
36+
.select({ role: member.role })
37+
.from(member)
38+
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
39+
.limit(1)
40+
41+
if (!memberEntry) {
42+
return NextResponse.json(
43+
{ error: 'Forbidden - Not a member of this organization' },
44+
{ status: 403 }
45+
)
46+
}
47+
if (!isOrgAdminRole(memberEntry.role)) {
48+
return NextResponse.json(
49+
{ error: 'Forbidden - Only organization owners and admins can verify domains' },
50+
{ status: 403 }
51+
)
52+
}
53+
if (isBillingEnabled && !(await isOrganizationOnEnterprisePlan(organizationId))) {
54+
return NextResponse.json(
55+
{ error: 'Domain verification is available on Enterprise plans only' },
56+
{ status: 403 }
57+
)
58+
}
59+
60+
const [row] = await db
61+
.select()
62+
.from(ssoDomain)
63+
.where(and(eq(ssoDomain.id, domainId), eq(ssoDomain.organizationId, organizationId)))
64+
.limit(1)
65+
if (!row) {
66+
return NextResponse.json({ error: 'Domain not found' }, { status: 404 })
67+
}
68+
if (row.status === 'verified') {
69+
return NextResponse.json({ success: true, data: { domain: toDomainResponse(row) } })
70+
}
71+
72+
const recordPresent = await checkDomainTxtRecord(row.domain, row.verificationToken)
73+
if (!recordPresent) {
74+
return NextResponse.json(
75+
{
76+
error:
77+
'The verification TXT record was not found yet. DNS changes can take up to 48 hours to propagate — add the record shown and try again.',
78+
},
79+
{ status: 422 }
80+
)
81+
}
82+
83+
// Guard the race where another org verified the same domain between claim
84+
// and verify (the partial unique index would also reject the update).
85+
const [verifiedElsewhere] = await db
86+
.select({ organizationId: ssoDomain.organizationId })
87+
.from(ssoDomain)
88+
.where(and(eq(ssoDomain.domain, row.domain), eq(ssoDomain.status, 'verified')))
89+
.limit(1)
90+
if (verifiedElsewhere && verifiedElsewhere.organizationId !== organizationId) {
91+
return NextResponse.json(
92+
{ error: 'This domain was verified by another organization' },
93+
{ status: 409 }
94+
)
95+
}
96+
97+
const [updated] = await db
98+
.update(ssoDomain)
99+
.set({ status: 'verified', verifiedAt: new Date(), updatedAt: new Date() })
100+
.where(eq(ssoDomain.id, domainId))
101+
.returning()
102+
103+
logger.info('Domain verified', { organizationId, domain: row.domain })
104+
recordAudit({
105+
workspaceId: null,
106+
actorId: session.user.id,
107+
action: AuditAction.ORGANIZATION_DOMAIN_VERIFIED,
108+
resourceType: AuditResourceType.ORGANIZATION,
109+
resourceId: organizationId,
110+
actorName: session.user.name ?? undefined,
111+
actorEmail: session.user.email ?? undefined,
112+
description: `Verified domain ${row.domain}`,
113+
metadata: { domain: row.domain },
114+
request,
115+
})
116+
117+
return NextResponse.json({ success: true, data: { domain: toDomainResponse(updated) } })
118+
}
119+
)

0 commit comments

Comments
 (0)