Skip to content

Commit d8ba4bf

Browse files
authored
feat(sso): allow self-hosted DNS verification bypass (#8681)
* feat(sso): allow self-hosted DNS verification bypass * fix(helm): document image requirement for DNS bypass
1 parent c9c2c4e commit d8ba4bf

10 files changed

Lines changed: 166 additions & 40 deletions

File tree

‎apps/docs/content/docs/platform/enterprise/sso.mdx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,8 @@ SSO_TRUSTED_PROVIDER_IDS=custom-oidc,partner-saml
419419

420420
You can register providers through the **Settings UI** (same as cloud) or by running the registration script directly against your database.
421421

422+
Restricted networks can opt out of the DNS challenge with the server-only `SSO_SKIP_DOMAIN_VERIFICATION=true` setting. Add the domain and click **Verify** before saving SSO. This trusts administrator claims across the instance and is ignored on Sim Cloud. See [skipping DNS verification](/platform/enterprise/verified-domains#skip-dns-verification) for the trust implications and [DNS network requirements](/platform/enterprise/verified-domains#restricted-networks) for the default setup.
423+
422424
### Script-based registration
423425

424426
Use this when you need to register an SSO provider without going through the UI — for example, during initial deployment or CI/CD automation.

‎apps/docs/content/docs/platform/enterprise/verified-domains.mdx‎

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ Add each domain you own separately. Subdomains (`eng.acme.com`) are verified ind
6767

6868
## Self-hosted setup
6969

70-
Domain verification has no flag of its own. It lives on the single sign-on page, so it appears exactly when SSO does:
70+
Domain verification appears on the single sign-on page when SSO is enabled:
7171

7272
```bash
7373
SSO_ENABLED=true
@@ -77,3 +77,35 @@ NEXT_PUBLIC_SSO_ENABLED=true
7777
`ENTERPRISE_ENABLED` turns both on together, but it needs its own browser twin — set `NEXT_PUBLIC_ENTERPRISE_ENABLED` alongside it, or the server and the settings page enable SSO while the login page still hides its SSO entry point. See the [self-hosted enterprise guide](/platform/enterprise/self-hosted).
7878

7979
Once enabled, verify domains from **Settings → Organization → Single sign-on → Domains**. The older `/workspace/<workspaceId>/settings/domains` path still resolves to the same page.
80+
81+
### Restricted networks
82+
83+
Sim queries `1.1.1.1` and `8.8.8.8` directly from the application server. Allow outbound DNS on UDP and TCP port 53 to these addresses. The lookup uses public DNS rather than the server's local resolver or an HTTP proxy, so the TXT record must be published in a publicly delegated DNS zone. A Route 53 private hosted zone is not visible to these resolvers.
84+
85+
### Skip DNS verification
86+
87+
For a deployment where the operator trusts every organization owner and admin to claim email domains, set this server-only variable and restart the application:
88+
89+
```bash
90+
SSO_SKIP_DOMAIN_VERIFICATION=true
91+
```
92+
93+
For Helm deployments, use an application image built from a revision that includes this setting. Upgrading the chart alone does not make an older application image support the variable. Override `app.image.tag` with a compatible released image or a build you publish yourself; set `app.image.repository` as well for a custom repository:
94+
95+
```yaml
96+
app:
97+
image:
98+
tag: "<image-tag-with-domain-verification-bypass>"
99+
env:
100+
SSO_SKIP_DOMAIN_VERIFICATION: "true"
101+
```
102+
103+
If `app.image.digest` is set, replace it with a compatible image digest instead — it takes precedence over the tag.
104+
105+
Add the domain under **Domains**, then click **Verify**. No TXT record or DNS access is needed. The domain is recorded as verified for SSO account linking and SCIM provisioning, and another organization still cannot claim the same domain. Existing pending domains can be verified the same way.
106+
107+
<Callout type="warn">
108+
This option is off by default and ignored on Sim Cloud. It applies to every organization in the self-hosted instance: an organization administrator can authorize their identity provider for any unclaimed email domain without proving ownership. Enable it only when those administrators are trusted to make that decision.
109+
</Callout>
110+
111+
Removing the variable or setting it to `false` restores DNS verification for pending and future claims. Domains already verified remain verified; remove a domain in **Domains** to revoke its trust.

‎apps/docs/content/docs/platform/self-hosting/environment-variables.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,7 @@ Enterprise features are unlocked by configuration rather than billing on self-ho
248248
|----------|-------------|
249249
| `ENTERPRISE_ENABLED`, `NEXT_PUBLIC_ENTERPRISE_ENABLED` | Enable the whole enterprise feature set |
250250
| `SSO_ENABLED`, `NEXT_PUBLIC_SSO_ENABLED` | Enable SAML and OIDC single sign-on on its own. See [Authentication](/platform/self-hosting/authentication#sso-saml-and-oidc) |
251+
| `SSO_SKIP_DOMAIN_VERIFICATION` | Skip the DNS challenge when an administrator clicks Verify on a domain. Off by default; self-hosted only. Trusts administrator claims for SSO and SCIM across the instance. See [Verified Domains](/platform/enterprise/verified-domains#skip-dns-verification) |
251252
| `SCIM_ENABLED`, `NEXT_PUBLIC_SCIM_ENABLED` | Enable directory provisioning on its own. Needs SSO. See [Directory provisioning](/platform/enterprise/scim) |
252253
| `INSTANCE_ORG_NAME` | Name of the organization every user joins automatically at signup |
253254
| `INSTANCE_ORG_SLUG` | Slug for that organization (derived from the name when omitted) |

‎apps/sim/.env.example‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,11 @@ CRON_SECRET=your_cron_secret # Use `openssl rand -hex 32` to generate. Authentic
218218
# KNOWLEDGE_MEMBER_ACCESS= # Per-member knowledge connectors and hybrid-by-default retrieval
219219
# ORGANIZATIONS_ENABLED= / NEXT_PUBLIC_ORGANIZATIONS_ENABLED= # Organizations only
220220

221+
# SSO domain verification (Optional - self-hosted, off by default). Trust every organization
222+
# owner and admin to claim email domains without DNS proof. Add a domain and click Verify.
223+
# Docs: https://docs.sim.ai/platform/enterprise/verified-domains#skip-dns-verification
224+
# SSO_SKIP_DOMAIN_VERIFICATION=true
225+
221226
# Instance organization (Optional). Most enterprise features read their settings from the
222227
# organization that owns a workspace, so a deployment needs an organization for them to
223228
# apply. Setting a name puts every user in one shared org at signup and makes their

‎apps/sim/lib/core/config/env.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -671,6 +671,8 @@ export const env = createEnv({
671671

672672
// SSO Configuration (for script-based registration)
673673
SSO_ENABLED: z.boolean().optional(), // Enable SSO functionality
674+
/** Trust administrator domain claims without DNS proof on self-hosted deployments only. */
675+
SSO_SKIP_DOMAIN_VERIFICATION: z.boolean().optional(),
674676
SCIM_ENABLED: z.boolean().optional(), // Enable SCIM directory provisioning
675677
USAGE_MONITORING_ENABLED: z.boolean().optional(), // Enable organization usage monitoring on self-hosted (bypasses hosted requirements)
676678
SSO_PROVIDER_TYPE: z.enum(['oidc', 'saml']).optional(), // [REQUIRED] SSO provider type

‎apps/sim/lib/organizations/application/domain-settings.ts‎

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ import {
1414
} from '@/lib/auth/sso/domain-verification'
1515
import { invalidateSsoPolicyCache } from '@/lib/auth/sso-policy'
1616
import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription'
17-
import { isBillingEnabled } from '@/lib/core/config/env-flags'
17+
import { env, isTruthy } from '@/lib/core/config/env'
18+
import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags'
1819
import { OrchestrationError } from '@/lib/core/orchestration/types'
1920
import { defineOrganizationConfigurationUseCase } from '@/lib/organizations/application/authorized-configuration-use-case'
2021
import { organizationSecurityOperations } from '@/lib/organizations/application/security-operations'
@@ -60,6 +61,9 @@ function domainConflict(): never {
6061
'This domain is already verified by another organization'
6162
)
6263
}
64+
function requiresDomainDnsVerification(): boolean {
65+
return isHosted || !isTruthy(env.SSO_SKIP_DOMAIN_VERIFICATION)
66+
}
6367
function providersOnDomain(organizationId: string, domain: string) {
6468
return and(
6569
eq(ssoProvider.organizationId, organizationId),
@@ -231,17 +235,19 @@ export const verifyOrganizationDomain = defineOrganizationConfigurationUseCase({
231235
if (!row) throw new OrchestrationError('not_found', 'Domain not found')
232236
if (row.status === 'verified')
233237
return { domain: domainValue(row, principal, true), verified: false }
234-
const lookup = await checkDomainTxtRecord(row.domain, row.verificationToken)
235-
if (lookup === 'unavailable')
236-
throw new DomainVerificationLookupError(
237-
503,
238-
"We couldn't complete the DNS lookup, so we can't tell yet whether your record is published. Try again in a few minutes — if it keeps failing, check that your domain's nameservers are responding."
239-
)
240-
if (lookup === 'absent')
241-
throw new DomainVerificationLookupError(
242-
422,
243-
'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.'
244-
)
238+
if (requiresDomainDnsVerification()) {
239+
const lookup = await checkDomainTxtRecord(row.domain, row.verificationToken)
240+
if (lookup === 'unavailable')
241+
throw new DomainVerificationLookupError(
242+
503,
243+
"We couldn't complete the DNS lookup, so we can't tell yet whether your record is published. Try again in a few minutes — if it keeps failing, check that your domain's nameservers are responding."
244+
)
245+
if (lookup === 'absent')
246+
throw new DomainVerificationLookupError(
247+
422,
248+
'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.'
249+
)
250+
}
245251
const [verifiedElsewhere] = await db
246252
.select({ organizationId: ssoDomain.organizationId })
247253
.from(ssoDomain)
@@ -306,7 +312,10 @@ export const verifyOrganizationDomain = defineOrganizationConfigurationUseCase({
306312
resourceType: AuditResourceType.ORGANIZATION,
307313
resourceId: input.organizationId,
308314
description: `Verified domain ${result.domain.domain}`,
309-
metadata: { domain: result.domain.domain },
315+
metadata: {
316+
domain: result.domain.domain,
317+
verificationMethod: requiresDomainDnsVerification() ? 'dns' : 'operator',
318+
},
310319
}
311320
: undefined,
312321
})

‎apps/sim/lib/organizations/application/security-settings.test.ts‎

Lines changed: 93 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
billingSubscriptionMock,
1414
billingSubscriptionMockFns,
1515
} from '@sim/testing/mocks/billing-subscription.mock'
16+
import { resetEnvMock, setEnv } from '@sim/testing/mocks/env.mock'
1617
import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing/mocks/env-flags.mock'
1718
import { permissionGroupsResolveMock } from '@sim/testing/mocks/permission-groups-resolve.mock'
1819
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -55,6 +56,7 @@ const mocks = {
5556

5657
setEnvFlags({ isBillingEnabled: true })
5758
afterAll(resetEnvFlagsMock)
59+
afterAll(resetEnvMock)
5860

5961
const delegated: OrganizationDelegatedPrincipal = {
6062
kind: 'organization_delegated',
@@ -81,6 +83,8 @@ const row = {
8183
}
8284
beforeEach(() => {
8385
resetDbChainMock()
86+
setEnvFlags({ isHosted: false, isBillingEnabled: true })
87+
setEnv({ SSO_SKIP_DOMAIN_VERIFICATION: undefined })
8488
mocks.enterprise.mockResolvedValue(true)
8589
mocks.dns.mockResolvedValue('present')
8690
})
@@ -123,31 +127,35 @@ describe('organization domain Settings operations', () => {
123127
).rejects.toThrow()
124128
expect(dbChainMockFns.select).not.toHaveBeenCalled()
125129
})
126-
it('keeps all mutations administrator-only', async () => {
127-
queueTableRows(member, [{ role: 'member' }])
128-
queueTableRows(member, [{ role: 'member' }])
129-
queueTableRows(member, [{ role: 'member' }])
130-
await expect(
131-
addOrganizationDomain.execute({
132-
principal: delegated,
133-
input: { organizationId: 'org', domain: 'example.com' },
134-
})
135-
).rejects.toThrow('administrator')
136-
await expect(
137-
verifyOrganizationDomain.execute({
138-
principal: delegated,
139-
input: { organizationId: 'org', domainId: 'domain' },
140-
})
141-
).rejects.toThrow('administrator')
142-
await expect(
143-
removeOrganizationDomain.execute({
144-
principal: delegated,
145-
input: { organizationId: 'org', domainId: 'domain' },
146-
})
147-
).rejects.toThrow('administrator')
148-
expect(mocks.dns).not.toHaveBeenCalled()
149-
expect(dbChainMockFns.insert).not.toHaveBeenCalled()
150-
})
130+
it.each([undefined, 'true'])(
131+
'keeps all mutations administrator-only with DNS bypass %s',
132+
async (skip) => {
133+
setEnv({ SSO_SKIP_DOMAIN_VERIFICATION: skip })
134+
queueTableRows(member, [{ role: 'member' }])
135+
queueTableRows(member, [{ role: 'member' }])
136+
queueTableRows(member, [{ role: 'member' }])
137+
await expect(
138+
addOrganizationDomain.execute({
139+
principal: delegated,
140+
input: { organizationId: 'org', domain: 'example.com' },
141+
})
142+
).rejects.toThrow('administrator')
143+
await expect(
144+
verifyOrganizationDomain.execute({
145+
principal: delegated,
146+
input: { organizationId: 'org', domainId: 'domain' },
147+
})
148+
).rejects.toThrow('administrator')
149+
await expect(
150+
removeOrganizationDomain.execute({
151+
principal: delegated,
152+
input: { organizationId: 'org', domainId: 'domain' },
153+
})
154+
).rejects.toThrow('administrator')
155+
expect(mocks.dns).not.toHaveBeenCalled()
156+
expect(dbChainMockFns.insert).not.toHaveBeenCalled()
157+
}
158+
)
151159
it('does not give non-enterprise members any domains or proof', async () => {
152160
queueTableRows(member, [{ role: 'member' }])
153161
mocks.enterprise.mockResolvedValue(false)
@@ -196,6 +204,66 @@ describe('organization domain Settings operations', () => {
196204
expect(dbChainMockFns.update).not.toHaveBeenCalled()
197205
expect(mocks.audit).not.toHaveBeenCalled()
198206
})
207+
it.each(['absent', 'unavailable'])(
208+
'accepts a self-hosted administrator claim without %s DNS',
209+
async (lookup) => {
210+
setEnv({ SSO_SKIP_DOMAIN_VERIFICATION: 'true' })
211+
queueTableRows(member, [{ role: 'admin' }])
212+
queueTableRows(ssoDomain, [row])
213+
queueTableRows(ssoDomain, [])
214+
mocks.dns.mockResolvedValue(lookup)
215+
dbChainMockFns.returning.mockResolvedValueOnce([{ ...row, status: 'verified' }])
216+
217+
const result = await verifyOrganizationDomain.execute({
218+
principal,
219+
input: { organizationId: 'org', domainId: 'domain' },
220+
})
221+
222+
expect(result.verified).toBe(true)
223+
expect(mocks.dns).not.toHaveBeenCalled()
224+
}
225+
)
226+
it.each([
227+
{ hosted: true, skip: 'true', lookup: 'absent', status: 422 },
228+
{ hosted: true, skip: 'true', lookup: 'unavailable', status: 503 },
229+
{ hosted: false, skip: undefined, lookup: 'absent', status: 422 },
230+
{ hosted: false, skip: 'false', lookup: 'unavailable', status: 503 },
231+
])(
232+
'requires DNS proof with hosted=$hosted and skip=$skip',
233+
async ({ hosted, skip, lookup, status }) => {
234+
setEnvFlags({ isHosted: hosted })
235+
setEnv({ SSO_SKIP_DOMAIN_VERIFICATION: skip })
236+
queueTableRows(member, [{ role: 'admin' }])
237+
queueTableRows(ssoDomain, [row])
238+
queueTableRows(ssoDomain, [])
239+
dbChainMockFns.returning.mockResolvedValueOnce([{ ...row, status: 'verified' }])
240+
mocks.dns.mockResolvedValue(lookup)
241+
242+
await expect(
243+
verifyOrganizationDomain.execute({
244+
principal,
245+
input: { organizationId: 'org', domainId: 'domain' },
246+
})
247+
).rejects.toMatchObject({ status })
248+
expect(dbChainMockFns.update).not.toHaveBeenCalled()
249+
expect(mocks.audit).not.toHaveBeenCalled()
250+
}
251+
)
252+
it('refuses a domain owned by another organization even with DNS bypass', async () => {
253+
setEnv({ SSO_SKIP_DOMAIN_VERIFICATION: 'true' })
254+
queueTableRows(member, [{ role: 'admin' }])
255+
queueTableRows(ssoDomain, [row])
256+
queueTableRows(ssoDomain, [{ organizationId: 'other' }])
257+
258+
await expect(
259+
verifyOrganizationDomain.execute({
260+
principal,
261+
input: { organizationId: 'org', domainId: 'domain' },
262+
})
263+
).rejects.toThrow('already verified by another organization')
264+
expect(dbChainMockFns.update).not.toHaveBeenCalled()
265+
expect(mocks.audit).not.toHaveBeenCalled()
266+
})
199267
})
200268

201269
describe('organization session revocation', () => {

‎helm/sim/Chart.yaml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ apiVersion: v2
22
name: sim
33
description: A Helm chart for Sim - the open-source AI workspace where teams build, deploy, and manage AI agents
44
type: application
5-
version: 1.11.5
5+
version: 1.11.6
66
appVersion: "v0.8.26"
77
kubeVersion: ">=1.25.0-0"
88
home: https://sim.ai

‎helm/sim/values.schema.json‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,10 @@
167167
"type": "string",
168168
"description": "Comma-separated CIDRs or IPs on a private network that outbound requests may reach (e.g. '10.0.0.0/8'). Cloud metadata endpoints stay blocked regardless. Ignored on the hosted platform."
169169
},
170+
"SSO_SKIP_DOMAIN_VERIFICATION": {
171+
"type": "string",
172+
"description": "Set to 'true' to trust organization administrator domain claims without DNS proof on self-hosted deployments. Off by default and ignored on Sim Cloud."
173+
},
170174
"SSO_TRUSTED_PROVIDER_IDS": {
171175
"type": "string",
172176
"description": "Comma-separated SSO provider IDs to trust for automatic account linking when an SSO sign-in matches an existing account's email. Only needed for IdPs that do not assert email_verified. Merged into Better Auth accountLinking.trustedProviders."

‎helm/sim/values.yaml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,9 @@ app:
254254
# Set to "true" AFTER running the SSO registration script
255255
SSO_ENABLED: "" # Enable SSO authentication ("true" to enable)
256256
NEXT_PUBLIC_SSO_ENABLED: "" # Show SSO login button in UI ("true" to enable)
257+
# Self-hosted only: trust every organization owner/admin to verify domains without DNS proof.
258+
# Requires a compatible app image; see https://docs.sim.ai/platform/enterprise/verified-domains#skip-dns-verification
259+
SSO_SKIP_DOMAIN_VERIFICATION: "" # Opt in with "true"; unset/"false" requires DNS
257260
# SSO_TRUSTED_PROVIDER_IDS: comma-separated SSO provider IDs to trust for automatic account linking when a
258261
# user signs in via SSO and an account with the same email already exists. Only needed for IdPs that do NOT
259262
# assert email_verified (trustEmailVerified already handles those that do). Resolved at startup — restart after editing.

0 commit comments

Comments
 (0)