Skip to content

Commit 5a8d219

Browse files
authored
fix(sso): surface DNS verification failures and the provider auto-append gotcha (#5931)
* fix(sso): surface DNS verification failures and the provider auto-append gotcha Post-merge audit follow-ups for verified domains (#5909): - The host field handed admins the FQDN `_sim-challenge.acme.com`. GoDaddy, Namecheap, Hover and most cPanel panels append the zone to whatever is typed, yielding `_sim-challenge.acme.com.acme.com` — the record looks right in their panel but never verifies, and our 422 tells them to wait 48 hours. Add a hint under the field (and a docs callout) telling those admins to enter just the label. - DNS failures were logged at debug, but production log level is ERROR, so a blocked-egress or SERVFAIL condition was invisible to us and misreported to the admin as "record not found yet". Log infrastructure-class failures at warn with the DNS error code; keep the genuinely-absent codes at debug. - Trim the joined TXT value before comparing: several DNS panels pad the stored string, which otherwise fails an exact match forever. - Lower the resolver to 2s/1 try. c-ares multiplies timeout across servers and retries by ~7x, so the previous 5s/2-try config could block a verify request for ~35s when resolvers are unreachable. - Cover `checkDomainTxtRecord` — the function that decides whether the gate opens had no tests. Adds exact match, chunk-joined value, match among unrelated records, padded value, near-miss, another org's token, absent record, infrastructure failure, and empty-response cases. * fix(sso): log DNS infrastructure failures at error so prod actually surfaces them Production's default minimum log level is ERROR, so the warn introduced in the previous commit was still filtered out — the fault stayed invisible exactly as before. A resolver failure that is not 'record absent' is a genuine infrastructure error, so ERROR is both the visible and the honest severity. * fix(sso): state the zone-removal rule instead of a wrong subdomain hint The hint computed the bare label as the first segment of the challenge host, so for a subdomain like eng.acme.com it advised entering `_sim-challenge` when the host relative to the acme.com zone is `_sim-challenge.eng` — following it would publish the record on the wrong name and verification would never succeed, the exact failure the hint exists to prevent. Deriving the real zone needs the Public Suffix List (acme.co.uk defeats naive label-stripping), so state the rule instead: enter the host with the trailing zone removed. Docs show both the apex and the subdomain form.
1 parent 6105376 commit 5a8d219

4 files changed

Lines changed: 132 additions & 12 deletions

File tree

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,11 @@ Go to **Settings → Security → Verified domains** in your organization settin
2323
3. Add that TXT record at your DNS provider.
2424
4. Click **Verify**. Sim looks up the record; on success the domain is marked **Verified**.
2525

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.
26+
<Callout type="warning">
27+
Some DNS providers — GoDaddy, Namecheap, Hover, and most cPanel panels — append your zone to whatever you type in the host field. If yours does, enter the host with the trailing zone removed, or you will end up with `_sim-challenge.acme.com.acme.com` and verification will never succeed. Managing the `acme.com` zone, `_sim-challenge.acme.com` becomes `_sim-challenge`; verifying the subdomain `eng.acme.com` from that same zone, `_sim-challenge.eng.acme.com` becomes `_sim-challenge.eng`. Cloudflare and Route 53 take the full host as shown.
28+
</Callout>
29+
30+
DNS changes can take up to 48 hours to propagate — if verification does not succeed immediately, wait and retry. Keep the TXT record published: leaving it in place means the domain stays verifiable if you ever need to verify it again.
2731

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

apps/sim/ee/sso/components/domain-settings.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,15 @@ interface DomainSettingsProps {
2121
interface CopyFieldProps {
2222
label: string
2323
value: string
24+
hint?: string
2425
}
2526

26-
function CopyField({ label, value }: CopyFieldProps) {
27+
function CopyField({ label, value, hint }: CopyFieldProps) {
2728
return (
2829
<div className='flex flex-col gap-1'>
2930
<span className='text-[var(--text-muted)] text-caption'>{label}</span>
3031
<ChipCopyInput value={value} copyLabel={`Copy ${label}`} inputClassName='font-mono' />
32+
{hint ? <span className='text-[var(--text-muted)] text-caption'>{hint}</span> : null}
3133
</div>
3234
)
3335
}
@@ -71,7 +73,11 @@ function DomainRow({ organizationId, domain, onRemove }: DomainRowProps) {
7173
Add this TXT record at your DNS provider, then verify. DNS changes can take up to 48
7274
hours to propagate.
7375
</p>
74-
<CopyField label='Host / name' value={domain.challengeHost} />
76+
<CopyField
77+
label='Host / name'
78+
value={domain.challengeHost}
79+
hint='Some DNS providers append your zone automatically. If yours does, enter this host with the trailing zone removed.'
80+
/>
7581
<CopyField label='Value' value={domain.txtRecordValue} />
7682
<div>
7783
<Button size='sm' onClick={handleVerify} disabled={verifyDomain.isPending}>

apps/sim/lib/auth/sso/domain-verification.test.ts

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,24 @@
11
/**
22
* @vitest-environment node
33
*/
4-
import { describe, expect, it } from 'vitest'
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
const { mockResolveTxt, mockSetServers } = vi.hoisted(() => ({
7+
mockResolveTxt: vi.fn(),
8+
mockSetServers: vi.fn(),
9+
}))
10+
11+
vi.mock('node:dns/promises', () => ({
12+
Resolver: class {
13+
resolveTxt = mockResolveTxt
14+
setServers = mockSetServers
15+
},
16+
}))
17+
518
import {
619
buildChallengeHost,
720
buildTxtRecordValue,
21+
checkDomainTxtRecord,
822
generateVerificationToken,
923
SSO_CHALLENGE_HOST_PREFIX,
1024
toDomainResponse,
@@ -76,4 +90,69 @@ describe('domain-verification helpers', () => {
7690
expect(verified.verifiedAt).toBe(verifiedAt.toISOString())
7791
})
7892
})
93+
94+
describe('checkDomainTxtRecord', () => {
95+
const TOKEN = 'tok-123'
96+
const EXPECTED = buildTxtRecordValue(TOKEN)
97+
98+
beforeEach(() => {
99+
vi.clearAllMocks()
100+
})
101+
102+
it('queries the challenge host for the domain', async () => {
103+
mockResolveTxt.mockResolvedValue([[EXPECTED]])
104+
await checkDomainTxtRecord('acme.com', TOKEN)
105+
expect(mockResolveTxt).toHaveBeenCalledWith('_sim-challenge.acme.com')
106+
})
107+
108+
it('verifies when the exact value is published', async () => {
109+
mockResolveTxt.mockResolvedValue([[EXPECTED]])
110+
await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe(true)
111+
})
112+
113+
it('joins a value split across 255-char chunks before comparing', async () => {
114+
const midpoint = Math.floor(EXPECTED.length / 2)
115+
mockResolveTxt.mockResolvedValue([[EXPECTED.slice(0, midpoint), EXPECTED.slice(midpoint)]])
116+
await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe(true)
117+
})
118+
119+
it('finds the match among unrelated TXT records on the same host', async () => {
120+
mockResolveTxt.mockResolvedValue([
121+
['v=spf1 include:_spf.google.com ~all'],
122+
['facebook-domain-verification=abc123'],
123+
[EXPECTED],
124+
])
125+
await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe(true)
126+
})
127+
128+
it('tolerates padding a DNS panel added around the value', async () => {
129+
mockResolveTxt.mockResolvedValue([[` ${EXPECTED} `]])
130+
await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe(true)
131+
})
132+
133+
it('rejects a near-miss value (no partial or prefix match)', async () => {
134+
mockResolveTxt.mockResolvedValue([[`${EXPECTED}extra`], [EXPECTED.slice(0, -1)]])
135+
await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe(false)
136+
})
137+
138+
it('rejects another org token published on the same host', async () => {
139+
mockResolveTxt.mockResolvedValue([[buildTxtRecordValue('someone-elses-token')]])
140+
await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe(false)
141+
})
142+
143+
it('returns false (never throws) when the record is absent', async () => {
144+
mockResolveTxt.mockRejectedValue(Object.assign(new Error('no data'), { code: 'ENODATA' }))
145+
await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe(false)
146+
})
147+
148+
it('returns false (never throws) when resolution fails for an infrastructure reason', async () => {
149+
mockResolveTxt.mockRejectedValue(Object.assign(new Error('timeout'), { code: 'ETIMEOUT' }))
150+
await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe(false)
151+
})
152+
153+
it('returns false when the host has no TXT records at all', async () => {
154+
mockResolveTxt.mockResolvedValue([])
155+
await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe(false)
156+
})
157+
})
79158
})

apps/sim/lib/auth/sso/domain-verification.ts

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -54,14 +54,29 @@ const TXT_VALUE_PREFIX = 'sim-domain-verification='
5454
* depend on (or get poisoned by) the host's local resolver/split-horizon DNS. */
5555
const VERIFICATION_NAMESERVERS = ['1.1.1.1', '8.8.8.8']
5656

57-
const DNS_TIMEOUT_MS = 5000
57+
/**
58+
* Per-attempt timeout. c-ares multiplies this across servers and retries by
59+
* more than the nominal `tries` (measured ~7x with two servers), so keep the
60+
* base low: 2s x 1 try over two servers bounds a fully-unreachable-resolver
61+
* lookup at a few seconds rather than the ~35s a 5s/2-try config produced.
62+
*/
63+
const DNS_TIMEOUT_MS = 2000
64+
65+
/**
66+
* DNS error codes that genuinely mean "the record is not published yet" — the
67+
* expected state while an admin is still adding it. Anything else (timeout,
68+
* refused, SERVFAIL) indicates an infrastructure problem on our side and is
69+
* logged loudly, because it is otherwise indistinguishable to the admin from a
70+
* missing record.
71+
*/
72+
const RECORD_ABSENT_DNS_CODES = new Set(['ENODATA', 'ENOTFOUND', 'NXDOMAIN'])
5873

5974
/**
6075
* Shared resolver pinned to the public nameservers. Its config is fully static
6176
* and `resolveTxt` is safe to call concurrently, so a single module-scope
6277
* instance avoids re-allocating one per verification.
6378
*/
64-
const verificationResolver = new Resolver({ timeout: DNS_TIMEOUT_MS, tries: 2 })
79+
const verificationResolver = new Resolver({ timeout: DNS_TIMEOUT_MS, tries: 1 })
6580
verificationResolver.setServers(VERIFICATION_NAMESERVERS)
6681

6782
/** The fully-qualified host an org must create the TXT record on. */
@@ -95,13 +110,29 @@ export async function checkDomainTxtRecord(domain: string, token: string): Promi
95110

96111
try {
97112
const records = await verificationResolver.resolveTxt(host)
98-
// Each TXT record may be split into multiple strings — join the chunks.
99-
return records.some((chunks) => chunks.join('') === expected)
113+
// Each TXT record may be split into 255-char chunks — join before comparing.
114+
// Trim the joined value: several DNS panels pad the stored string, which
115+
// would otherwise fail an exact match forever with no way for the admin to
116+
// tell why. Concatenation happens first, so trimming cannot corrupt a
117+
// legitimate chunk boundary.
118+
return records.some((chunks) => chunks.join('').trim() === expected)
100119
} catch (error) {
101-
logger.debug('TXT verification lookup failed (treated as unverified)', {
102-
host,
103-
error: getErrorMessage(error),
104-
})
120+
const code = (error as NodeJS.ErrnoException)?.code
121+
if (code && RECORD_ABSENT_DNS_CODES.has(code)) {
122+
logger.debug('TXT verification record not published yet', { host, code })
123+
} else {
124+
// Not a missing record — our resolver path itself is failing (blocked
125+
// egress, timeout, SERVFAIL). Log at ERROR, not warn: the default minimum
126+
// level in production is ERROR, so anything below it is dropped and the
127+
// fault stays invisible while the admin is told their record "isn't
128+
// published yet". This is a genuine infrastructure fault, so ERROR is also
129+
// the honest severity.
130+
logger.error('TXT verification lookup failed for an infrastructure reason', {
131+
host,
132+
code,
133+
error: getErrorMessage(error),
134+
})
135+
}
105136
return false
106137
}
107138
}

0 commit comments

Comments
 (0)