Skip to content

Commit 9fff2d3

Browse files
committed
fix(sso): restore provider domain trust when a domain is re-verified
1 parent ee5fd2d commit 9fff2d3

2 files changed

Lines changed: 73 additions & 12 deletions

File tree

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

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,45 @@ describe('verify org domain route', () => {
113113
expect(mockRecordAudit).not.toHaveBeenCalled()
114114
})
115115

116+
/**
117+
* Deleting a verified domain revokes `domainVerified` on the providers it covered.
118+
* Re-verifying has to restore it, or the provider stays untrusted — and since that
119+
* flag gates sign-in rather than only linking, the org would sit in a silent SSO
120+
* outage until an admin happened to re-save the SSO config.
121+
*/
122+
it('restores SSO domain trust for providers on the verified domain', async () => {
123+
queueAdminWithPendingRow()
124+
queueTableRows(ssoDomain, []) // verified-elsewhere check → none
125+
dbChainMockFns.returning.mockResolvedValueOnce([{ ...PENDING_ROW, status: 'verified' }])
126+
const res = await POST(createMockRequest('POST'), routeContext)
127+
expect(res.status).toBe(200)
128+
expect(dbChainMockFns.set).toHaveBeenCalledWith({ domainVerified: true })
129+
})
130+
131+
/**
132+
* Mirrors the revocation's wildcard-tolerant comparison: a provider grandfathered
133+
* as `*.acme.com` must be re-trusted by a proof row holding `acme.com`.
134+
*/
135+
it('matches the provider domain wildcard-tolerantly, as the revocation does', async () => {
136+
queueAdminWithPendingRow()
137+
queueTableRows(ssoDomain, [])
138+
dbChainMockFns.returning.mockResolvedValueOnce([{ ...PENDING_ROW, status: 'verified' }])
139+
await POST(createMockRequest('POST'), routeContext)
140+
const grantWhere = dbChainMockFns.where.mock.calls.find(([condition]) =>
141+
JSON.stringify(condition ?? '').includes('regexp_replace')
142+
)
143+
expect(grantWhere).toBeDefined()
144+
})
145+
146+
it('does not grant trust when the conditional update matched no row', async () => {
147+
queueAdminWithPendingRow()
148+
queueTableRows(ssoDomain, [])
149+
dbChainMockFns.returning.mockResolvedValueOnce([]) // lost the race
150+
queueTableRows(ssoDomain, [{ ...PENDING_ROW, status: 'verified' }])
151+
await POST(createMockRequest('POST'), routeContext)
152+
expect(dbChainMockFns.set).not.toHaveBeenCalledWith({ domainVerified: true })
153+
})
154+
116155
it('409s (not 500) when a concurrent cross-org verification wins the unique index', async () => {
117156
queueAdminWithPendingRow()
118157
queueTableRows(ssoDomain, []) // verified-elsewhere check → none at read time

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

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
22
import { db } from '@sim/db'
3-
import { member, ssoDomain } from '@sim/db/schema'
3+
import { member, ssoDomain, ssoProvider } from '@sim/db/schema'
44
import { createLogger } from '@sim/logger'
55
import { isOrgAdminRole } from '@sim/platform-authz/workspace'
66
import { getPostgresErrorCode } from '@sim/utils/errors'
7-
import { and, eq } from 'drizzle-orm'
7+
import { and, eq, sql } from 'drizzle-orm'
88
import { type NextRequest, NextResponse } from 'next/server'
99
import { verifyOrganizationDomainContract } from '@/lib/api/contracts/organization'
1010
import { parseRequest } from '@/lib/api/server'
@@ -103,17 +103,39 @@ export const POST = withRouteHandler(
103103
// that as a 409 rather than an unhandled 500.
104104
let updated: (typeof row)[]
105105
try {
106-
updated = await db
107-
.update(ssoDomain)
108-
.set({ status: 'verified', verifiedAt: new Date(), updatedAt: new Date() })
109-
.where(
110-
and(
111-
eq(ssoDomain.id, domainId),
112-
eq(ssoDomain.verificationToken, row.verificationToken),
113-
eq(ssoDomain.status, 'pending')
106+
updated = await db.transaction(async (tx) => {
107+
const flipped = await tx
108+
.update(ssoDomain)
109+
.set({ status: 'verified', verifiedAt: new Date(), updatedAt: new Date() })
110+
.where(
111+
and(
112+
eq(ssoDomain.id, domainId),
113+
eq(ssoDomain.verificationToken, row.verificationToken),
114+
eq(ssoDomain.status, 'pending')
115+
)
114116
)
115-
)
116-
.returning()
117+
.returning()
118+
119+
// Restore trust on any provider this proof covers, mirroring the revocation
120+
// performed when a verified domain is deleted. Without this, a delete followed
121+
// by a re-verification leaves the provider untrusted — and because that flag
122+
// gates sign-in, not just linking, the org would sit in a silent SSO outage
123+
// until someone re-saved the SSO config. Wildcard-tolerant, matching the
124+
// revoking comparison exactly so the two stay symmetric.
125+
if (flipped.length > 0) {
126+
await tx
127+
.update(ssoProvider)
128+
.set({ domainVerified: true })
129+
.where(
130+
and(
131+
eq(ssoProvider.organizationId, organizationId),
132+
sql`lower(regexp_replace(btrim(${ssoProvider.domain}), '^\\*\\.', '')) = ${flipped[0].domain}`
133+
)
134+
)
135+
}
136+
137+
return flipped
138+
})
117139
} catch (error) {
118140
if (getPostgresErrorCode(error) === '23505') {
119141
return NextResponse.json(

0 commit comments

Comments
 (0)