Skip to content

Commit 01f4b43

Browse files
authored
fix(sso): re-grant provider trust when an already-verified domain is re-submitted (#6320)
* fix(sso): re-grant provider trust when an already-verified domain is re-submitted * fix(sso): distinguish a failed DNS lookup from a missing record, and label the domain fields * chore(sso): tighten the re-grant rationale comment * fix(sso): state what a failed DNS lookup tells us instead of assigning blame
1 parent ab25755 commit 01f4b43

5 files changed

Lines changed: 125 additions & 49 deletions

File tree

apps/sim/app/api/organizations/[id]/domains/[domainId]/verify/route.test.ts

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -68,17 +68,32 @@ describe('verify org domain route', () => {
6868
user: { id: 'user-1', name: 'Admin', email: 'admin@acme.dev' },
6969
})
7070
mockIsEnterprise.mockResolvedValue(true)
71-
mockCheckDomainTxtRecord.mockResolvedValue(true)
71+
mockCheckDomainTxtRecord.mockResolvedValue('present')
7272
})
7373

7474
it('422s when the TXT record is not found', async () => {
7575
queueAdminWithPendingRow()
76-
mockCheckDomainTxtRecord.mockResolvedValue(false)
76+
mockCheckDomainTxtRecord.mockResolvedValue('absent')
7777
const res = await POST(createMockRequest('POST'), routeContext)
7878
expect(res.status).toBe(422)
7979
expect(mockRecordAudit).not.toHaveBeenCalled()
8080
})
8181

82+
/**
83+
* A failed lookup says nothing about the admin's DNS, so it must not be reported
84+
* as a missing record — that sends them hunting through their zone for our fault.
85+
*/
86+
it('503s (not 422) when the DNS lookup itself could not complete', async () => {
87+
queueAdminWithPendingRow()
88+
mockCheckDomainTxtRecord.mockResolvedValue('unavailable')
89+
const res = await POST(createMockRequest('POST'), routeContext)
90+
expect(res.status).toBe(503)
91+
expect(await res.json()).toMatchObject({
92+
error: expect.stringContaining("couldn't complete the DNS lookup"),
93+
})
94+
expect(mockRecordAudit).not.toHaveBeenCalled()
95+
})
96+
8297
it('verifies the domain and records an audit event', async () => {
8398
queueAdminWithPendingRow()
8499
queueTableRows(ssoDomain, []) // verified-elsewhere check → none
@@ -143,12 +158,29 @@ describe('verify org domain route', () => {
143158
expect(grantWhere).toBeDefined()
144159
})
145160

146-
it('does not grant trust when the conditional update matched no row', async () => {
161+
/**
162+
* A provider can hold a verified domain while its own trust flag is off, after
163+
* an update whose grant was refused reverted the config and cleared it. Re-running
164+
* verification is the obvious recovery, so an already-verified domain must still
165+
* re-grant instead of returning success having done nothing.
166+
*/
167+
it('re-grants trust when the domain is already verified', async () => {
168+
queueAdminWithPendingRow()
169+
queueTableRows(ssoDomain, [])
170+
dbChainMockFns.returning.mockResolvedValueOnce([]) // conditional update matched nothing
171+
queueTableRows(ssoDomain, [{ ...PENDING_ROW, status: 'verified' }]) // re-read: verified
172+
const res = await POST(createMockRequest('POST'), routeContext)
173+
expect(res.status).toBe(200)
174+
expect(dbChainMockFns.set).toHaveBeenCalledWith({ domainVerified: true })
175+
})
176+
177+
it('does not grant trust when the challenge is genuinely stale', async () => {
147178
queueAdminWithPendingRow()
148179
queueTableRows(ssoDomain, [])
149-
dbChainMockFns.returning.mockResolvedValueOnce([]) // lost the race
150-
queueTableRows(ssoDomain, [{ ...PENDING_ROW, status: 'verified' }])
151-
await POST(createMockRequest('POST'), routeContext)
180+
dbChainMockFns.returning.mockResolvedValueOnce([]) // conditional update matched nothing
181+
queueTableRows(ssoDomain, []) // re-read: row deleted or re-tokenized
182+
const res = await POST(createMockRequest('POST'), routeContext)
183+
expect(res.status).toBe(409)
152184
expect(dbChainMockFns.set).not.toHaveBeenCalledWith({ domainVerified: true })
153185
})
154186

apps/sim/app/api/organizations/[id]/domains/[domainId]/verify/route.ts

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,20 @@ export const POST = withRouteHandler(
7070
return NextResponse.json({ success: true, data: { domain: toDomainResponse(row) } })
7171
}
7272

73-
const recordPresent = await checkDomainTxtRecord(row.domain, row.verificationToken)
74-
if (!recordPresent) {
73+
const lookup = await checkDomainTxtRecord(row.domain, row.verificationToken)
74+
// 503, not 422: we learned nothing about their record, so this must not read
75+
// as a missing one. SERVFAIL can mean either a fault of ours or a broken zone
76+
// of theirs, so the message states what we know rather than assigning blame.
77+
if (lookup === 'unavailable') {
78+
return NextResponse.json(
79+
{
80+
error:
81+
"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.",
82+
},
83+
{ status: 503 }
84+
)
85+
}
86+
if (lookup === 'absent') {
7587
return NextResponse.json(
7688
{
7789
error:
@@ -101,6 +113,17 @@ export const POST = withRouteHandler(
101113
// instead of mapping an undefined row or trusting a superseded challenge. A
102114
// concurrent cross-org verification trips the partial unique index; surface
103115
// that as a 409 rather than an unhandled 500.
116+
/**
117+
* Providers this proof covers. Normalized the way migration 0268 stored these
118+
* rows (lower, trimmed, leading `*.` dropped) and identical to the expression
119+
* the deletion path revokes with, so granting and revoking can never diverge.
120+
*/
121+
const providersOnDomain = (verifiedDomain: string) =>
122+
and(
123+
eq(ssoProvider.organizationId, organizationId),
124+
sql`lower(regexp_replace(btrim(${ssoProvider.domain}), '^\\*\\.', '')) = ${verifiedDomain}`
125+
)
126+
104127
let updated: (typeof row)[]
105128
try {
106129
updated = await db.transaction(async (tx) => {
@@ -118,18 +141,12 @@ export const POST = withRouteHandler(
118141

119142
// Restore trust this proof covers, mirroring the revocation on delete.
120143
// Without it a delete-then-reverify leaves the provider untrusted, and
121-
// since that flag gates sign-in the org sits in a silent SSO outage. The
122-
// comparison matches the revoking one exactly so the two stay symmetric.
144+
// since that flag gates sign-in the org sits in a silent SSO outage.
123145
if (flipped.length > 0) {
124146
await tx
125147
.update(ssoProvider)
126148
.set({ domainVerified: true })
127-
.where(
128-
and(
129-
eq(ssoProvider.organizationId, organizationId),
130-
sql`lower(regexp_replace(btrim(${ssoProvider.domain}), '^\\*\\.', '')) = ${flipped[0].domain}`
131-
)
132-
)
149+
.where(providersOnDomain(flipped[0].domain))
133150
}
134151

135152
return flipped
@@ -155,6 +172,13 @@ export const POST = withRouteHandler(
155172
.where(and(eq(ssoDomain.id, domainId), eq(ssoDomain.organizationId, organizationId)))
156173
.limit(1)
157174
if (current?.status === 'verified') {
175+
// Re-grant rather than returning early: a provider can hold a verified
176+
// domain with its own flag off, after an update whose grant was refused
177+
// reverted the config. The proof is present, which authorizes this.
178+
await db
179+
.update(ssoProvider)
180+
.set({ domainVerified: true })
181+
.where(providersOnDomain(current.domain))
158182
return NextResponse.json({ success: true, data: { domain: toDomainResponse(current) } })
159183
}
160184
return NextResponse.json(

apps/sim/ee/sso/components/verified-domains-section.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ import {
1717
useVerifyOrganizationDomain,
1818
} from '@/ee/sso/hooks/domains'
1919

20+
/** Ties the "Add a domain" label to its input, so clicking the label focuses it. */
21+
const ADD_DOMAIN_FIELD_ID = 'sso-add-domain'
22+
2023
interface VerifiedDomainsSectionProps {
2124
organizationId: string
2225
}
@@ -65,16 +68,19 @@ function DomainRow({ organizationId, domain, onRemove }: DomainRowProps) {
6568
<SettingRow
6669
label='Host / name'
6770
description='Some DNS providers append your zone automatically. If yours does, enter this host with the trailing zone removed.'
71+
htmlFor={`${domain.id}-challenge-host`}
6872
>
6973
<ChipCopyInput
74+
id={`${domain.id}-challenge-host`}
7075
value={domain.challengeHost}
7176
copyLabel='Copy host'
7277
inputClassName='font-mono'
7378
/>
7479
</SettingRow>
7580

76-
<SettingRow label='Value'>
81+
<SettingRow label='Value' htmlFor={`${domain.id}-challenge-value`}>
7782
<ChipCopyInput
83+
id={`${domain.id}-challenge-value`}
7884
value={domain.txtRecordValue}
7985
copyLabel='Copy value'
8086
inputClassName='font-mono'
@@ -139,9 +145,11 @@ export function VerifiedDomainsSection({ organizationId }: VerifiedDomainsSectio
139145
<SettingRow
140146
label='Add a domain'
141147
description='Verify a domain your organization owns before configuring SSO for it. Verifying proves you control the domain, so no one else can point it at their identity provider.'
148+
htmlFor={ADD_DOMAIN_FIELD_ID}
142149
>
143150
<div className='flex items-center gap-2'>
144151
<ChipInput
152+
id={ADD_DOMAIN_FIELD_ID}
145153
value={newDomain}
146154
onChange={(event) => setNewDomain(event.target.value)}
147155
onKeyDown={(event) => {

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

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -107,13 +107,13 @@ describe('domain-verification helpers', () => {
107107

108108
it('verifies when the exact value is published', async () => {
109109
mockResolveTxt.mockResolvedValue([[EXPECTED]])
110-
await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe(true)
110+
await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe('present')
111111
})
112112

113113
it('joins a value split across 255-char chunks before comparing', async () => {
114114
const midpoint = Math.floor(EXPECTED.length / 2)
115115
mockResolveTxt.mockResolvedValue([[EXPECTED.slice(0, midpoint), EXPECTED.slice(midpoint)]])
116-
await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe(true)
116+
await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe('present')
117117
})
118118

119119
it('finds the match among unrelated TXT records on the same host', async () => {
@@ -122,37 +122,41 @@ describe('domain-verification helpers', () => {
122122
['facebook-domain-verification=abc123'],
123123
[EXPECTED],
124124
])
125-
await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe(true)
125+
await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe('present')
126126
})
127127

128128
it('tolerates padding a DNS panel added around the value', async () => {
129129
mockResolveTxt.mockResolvedValue([[` ${EXPECTED} `]])
130-
await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe(true)
130+
await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe('present')
131131
})
132132

133133
it('rejects a near-miss value (no partial or prefix match)', async () => {
134134
mockResolveTxt.mockResolvedValue([[`${EXPECTED}extra`], [EXPECTED.slice(0, -1)]])
135-
await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe(false)
135+
await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe('absent')
136136
})
137137

138138
it('rejects another org token published on the same host', async () => {
139139
mockResolveTxt.mockResolvedValue([[buildTxtRecordValue('someone-elses-token')]])
140-
await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe(false)
140+
await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe('absent')
141141
})
142142

143-
it('returns false (never throws) when the record is absent', async () => {
143+
it('reports absent (never throws) when the record is not published', async () => {
144144
mockResolveTxt.mockRejectedValue(Object.assign(new Error('no data'), { code: 'ENODATA' }))
145-
await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe(false)
145+
await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe('absent')
146146
})
147147

148-
it('returns false (never throws) when resolution fails for an infrastructure reason', async () => {
148+
/**
149+
* Distinct from `absent`: our resolver failed, so we learned nothing about the
150+
* admin's DNS and must not tell them their record is missing.
151+
*/
152+
it('reports unavailable when resolution fails for an infrastructure reason', async () => {
149153
mockResolveTxt.mockRejectedValue(Object.assign(new Error('timeout'), { code: 'ETIMEOUT' }))
150-
await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe(false)
154+
await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe('unavailable')
151155
})
152156

153-
it('returns false when the host has no TXT records at all', async () => {
157+
it('reports absent when the host has no TXT records at all', async () => {
154158
mockResolveTxt.mockResolvedValue([])
155-
await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe(false)
159+
await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe('absent')
156160
})
157161
})
158162
})

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

Lines changed: 28 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -98,13 +98,25 @@ export function generateVerificationToken(): string {
9898
}
9999

100100
/**
101-
* Resolves the challenge host's TXT records against public nameservers and
102-
* returns true when the expected `sim-domain-verification=<token>` value is
103-
* present. Never throws — resolution failures (NXDOMAIN, timeout, missing
104-
* record) resolve to `false` so a not-yet-propagated record simply reads as
105-
* unverified.
101+
* Outcome of a TXT challenge lookup.
102+
*
103+
* `absent` and `unavailable` are kept apart because they place the fault on
104+
* opposite sides: the first means the admin's record is not published yet, the
105+
* second means our own resolver path failed and we learned nothing about their
106+
* DNS. Collapsing both to "not found" tells an admin to fix a record that may
107+
* already be correct.
106108
*/
107-
export async function checkDomainTxtRecord(domain: string, token: string): Promise<boolean> {
109+
export type DomainTxtLookup = 'present' | 'absent' | 'unavailable'
110+
111+
/**
112+
* Resolves the challenge host's TXT records against public nameservers. Never
113+
* throws: a missing record resolves to `absent`, and an infrastructure failure
114+
* (blocked egress, timeout, SERVFAIL) to `unavailable`.
115+
*/
116+
export async function checkDomainTxtRecord(
117+
domain: string,
118+
token: string
119+
): Promise<DomainTxtLookup> {
108120
const host = buildChallengeHost(domain)
109121
const expected = buildTxtRecordValue(token)
110122

@@ -115,24 +127,20 @@ export async function checkDomainTxtRecord(domain: string, token: string): Promi
115127
// would otherwise fail an exact match forever with no way for the admin to
116128
// tell why. Concatenation happens first, so trimming cannot corrupt a
117129
// legitimate chunk boundary.
118-
return records.some((chunks) => chunks.join('').trim() === expected)
130+
return records.some((chunks) => chunks.join('').trim() === expected) ? 'present' : 'absent'
119131
} catch (error) {
120132
const code = (error as NodeJS.ErrnoException)?.code
121133
if (code && RECORD_ABSENT_DNS_CODES.has(code)) {
122134
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+
return 'absent'
135136
}
136-
return false
137+
// Our resolver path itself is failing. Log at ERROR: production's minimum
138+
// level drops anything lower, so a warn would keep the fault invisible.
139+
logger.error('TXT verification lookup failed for an infrastructure reason', {
140+
host,
141+
code,
142+
error: getErrorMessage(error),
143+
})
144+
return 'unavailable'
137145
}
138146
}

0 commit comments

Comments
 (0)