Skip to content

Commit 4dea2d4

Browse files
committed
fix(sso): report a refused domain-trust grant instead of returning success
The conditional grant could match zero rows if the verified domain was deleted between the pre-write check and the write. The route ignored that and returned 200, leaving a provider that cannot sign anyone in while telling the admin it saved. The grant now reports whether it matched, and that result is the single decision point on both paths: the create path rolls the provider back, the update path clears the flag, and both return SSO_DOMAIN_NOT_VERIFIED. This also drops the separate post-write ownership read, since the UPDATE re-tests ownership itself.
1 parent a0bd392 commit 4dea2d4

2 files changed

Lines changed: 45 additions & 42 deletions

File tree

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

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -115,14 +115,16 @@ describe('POST /api/auth/sso/register', () => {
115115
mockValidateUrlWithDNS.mockResolvedValue({ isValid: true, resolvedIP: '1.2.3.4' })
116116
mockSecureFetchWithPinnedIP.mockRejectedValue(new Error('discovery not mocked for this test'))
117117
mockRegisterSSOProvider.mockResolvedValue({ id: 'row-1', providerId: 'acme-oidc' })
118+
// The conditional trust UPDATE returns its row by default, i.e. the verified
119+
// domain still existed at write time. Refusal tests override with [].
120+
dbChainMockFns.returning.mockResolvedValue([{ id: 'granted' }])
118121
mockUpdateSSOProvider.mockResolvedValue({ providerId: 'acme-oidc' })
119122
// Default: the org has already verified the domain, so the ownership gate
120-
// passes and each test exercises the logic beyond it. The gate is checked
121-
// three times for a successful org-scoped registration (fail-fast entry +
122-
// authoritative re-check before the write + compensating re-check after the
123-
// write), so queue three rows. Gate-specific tests reset the queue to assert
124-
// the unverified paths.
125-
queueTableRows(schemaMock.ssoDomain, [{ id: 'verified-domain' }])
123+
// passes and each test exercises the logic beyond it. The gate is read twice
124+
// for a successful org-scoped registration (fail-fast entry + authoritative
125+
// re-check before the write); ownership at write time is re-tested inside the
126+
// trust UPDATE itself, not by a third read. Gate-specific tests reset the
127+
// queue to assert the unverified paths.
126128
queueTableRows(schemaMock.ssoDomain, [{ id: 'verified-domain' }])
127129
queueTableRows(schemaMock.ssoDomain, [{ id: 'verified-domain' }])
128130
})
@@ -182,7 +184,7 @@ describe('POST /api/auth/sso/register', () => {
182184
queueMembers([{ organizationId: 'org1', role: 'owner' }])
183185
queueTableRows(schemaMock.ssoDomain, [{ id: 'v' }]) // entry gate: verified
184186
queueTableRows(schemaMock.ssoDomain, [{ id: 'v' }]) // pre-write re-check: verified
185-
queueTableRows(schemaMock.ssoDomain, []) // post-write compensating check: revoked
187+
dbChainMockFns.returning.mockResolvedValue([]) // trust UPDATE matched nothing: revoked
186188
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
187189
const json = await res.json()
188190
expect(res.status).toBe(403)
@@ -282,33 +284,31 @@ describe('POST /api/auth/sso/register', () => {
282284
* domain the org no longer proves it owns.
283285
*/
284286
it('revokes domain trust when verification is removed during an update', async () => {
285-
resetDbChainMock()
286287
queueMembers([{ organizationId: 'org1', role: 'owner' }])
287-
// Verified for the entry gate and the pre-write re-check, revoked afterwards.
288-
queueTableRows(schemaMock.ssoDomain, [{ id: 'verified-domain' }])
289-
queueTableRows(schemaMock.ssoDomain, [{ id: 'verified-domain' }])
290-
queueTableRows(schemaMock.ssoDomain, [])
291288
queueProviders([])
292289
queueTableRows(schemaMock.ssoProvider, [{ id: 'p1' }]) // provider already owned → update path
290+
dbChainMockFns.returning.mockResolvedValue([]) // trust UPDATE matched nothing
293291

294292
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
295293
expect(res.status).toBe(403)
296294
expect(mockUpdateSSOProvider).toHaveBeenCalledTimes(1)
295+
// The conditional UPDATE is still issued — it simply matches no rows once the
296+
// proof is gone — so the signal is the explicit clear plus the 403, not the
297+
// absence of the grant statement.
297298
expect(dbChainMockFns.set).toHaveBeenCalledWith({ domainVerified: false })
298-
expect(dbChainMockFns.set).not.toHaveBeenCalledWith({ domainVerified: true })
299299
})
300300

301301
it('does not mark domain-verified when the registration is rolled back', async () => {
302302
queueMembers([{ organizationId: 'org1', role: 'owner' }])
303303
resetDbChainMock()
304304
queueMembers([{ organizationId: 'org1', role: 'owner' }])
305-
// Verified for both pre-write gates, then revoked for the post-write re-check.
306305
queueTableRows(schemaMock.ssoDomain, [{ id: 'verified-domain' }])
307306
queueTableRows(schemaMock.ssoDomain, [{ id: 'verified-domain' }])
308-
queueTableRows(schemaMock.ssoDomain, [])
307+
dbChainMockFns.returning.mockResolvedValue([]) // trust UPDATE matched nothing
309308
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
310309
expect(res.status).toBe(403)
311-
expect(dbChainMockFns.set).not.toHaveBeenCalledWith({ domainVerified: true })
310+
expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1) // it was created…
311+
expect(dbChainMockFns.delete).toHaveBeenCalled() // …then rolled back
312312
})
313313

314314
it('nests the attribute mapping inside oidcConfig (Better Auth reads it there)', async () => {

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

Lines changed: 29 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -643,14 +643,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
643643
.limit(1)
644644

645645
/**
646-
* Mirrors Sim's own domain-ownership proof onto Better Auth's `domainVerified`
647-
* flag, which is what lets an SSO sign-in auto-link to an existing same-email
648-
* account. Must run after every write, not just on create: `registerSSOProvider`
649-
* always persists `false`, and `updateSSOProvider` resets it to `false` whenever
650-
* the domain changes. Callers re-check ownership immediately before granting it,
651-
* so it can never mark an unproven domain as verified. Org-less (personal) SSO is
652-
* not domain-gated by Sim and keeps its pre-existing trust here, matching how it
653-
* behaved before the flag existed.
646+
* Unconditional write of Better Auth's `domainVerified` flag — the value Sim
647+
* mirrors from its own DNS proof, and what lets an SSO sign-in auto-link to an
648+
* existing same-email account. Only used to *withdraw* trust, and to grant it on
649+
* the org-less (personal) path that Sim never domain-gated. Granting on an
650+
* org-scoped provider goes through {@link grantProviderDomainTrust}, which
651+
* re-tests ownership in the same statement.
654652
*/
655653
const setProviderDomainVerified = async (verified: boolean) => {
656654
await db.update(ssoProvider).set({ domainVerified: verified }).where(ownerClause)
@@ -669,12 +667,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
669667
* Org-less (personal) SSO is not domain-gated by Sim, so it grants
670668
* unconditionally as it always has.
671669
*/
672-
const grantProviderDomainTrust = async () => {
670+
const grantProviderDomainTrust = async (): Promise<boolean> => {
673671
if (!orgId) {
674672
await setProviderDomainVerified(true)
675-
return
673+
return true
676674
}
677-
await db
675+
const granted = await db
678676
.update(ssoProvider)
679677
.set({ domainVerified: true })
680678
.where(
@@ -694,6 +692,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
694692
)
695693
)
696694
)
695+
.returning({ id: ssoProvider.id })
696+
return granted.length > 0
697697
}
698698

699699
if (existingOwnedProvider) {
@@ -709,10 +709,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
709709
})
710710

711711
// The verified sso_domain row can be deleted while updateSSOProvider is in
712-
// flight. There is no newly-created row to roll back here, so on failure
713-
// clear the flag: `updateSSOProvider` only resets it when the domain
714-
// changes, so a same-domain edit would otherwise leave stale trust standing.
715-
if (orgId && !(await isOrgDomainVerified())) {
712+
// flight, in which case the conditional grant matches nothing. There is no
713+
// newly-created row to roll back here, so clear the flag instead:
714+
// `updateSSOProvider` only resets it when the domain changes, so a
715+
// same-domain edit would otherwise leave stale trust standing. Reporting the
716+
// failure keeps the response honest rather than saying "saved" while the
717+
// provider is left unable to sign anyone in.
718+
if (!(await grantProviderDomainTrust())) {
716719
await setProviderDomainVerified(false)
717720
logger.warn('Revoked SSO domain trust: verification was removed mid-update', {
718721
domain,
@@ -723,7 +726,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
723726
return domainNotVerifiedResponse()
724727
}
725728

726-
await grantProviderDomainTrust()
727729
logger.info('SSO provider updated successfully', { providerId, providerType, domain })
728730
return NextResponse.json({
729731
success: true,
@@ -738,22 +740,25 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
738740
headers,
739741
})
740742

741-
// Grant trust in the same statement that re-tests ownership, closing the
742-
// window between Better Auth persisting the provider and this write. A failure
743-
// means the verified sso_domain row was removed in that window, so roll the
744-
// provider back. registerSSOProvider is create-only (it throws if the
743+
// Granting trust re-tests ownership inside the same statement, so a failure
744+
// here means the verified sso_domain row was removed between the pre-write
745+
// check and Better Auth persisting the provider. That would leave a provider
746+
// on a domain the org no longer proves, so roll it back.
747+
// registerSSOProvider is create-only (it throws if the
745748
// providerId already exists), so a successful call always created a brand-new
746749
// row — we roll it back by its primary-key `id` (not the logical providerId,
747750
// which a concurrent delete+recreate could point at a different row). Personal
748-
// SSO is not gated, so this only runs for org-scoped registration.
749-
if (orgId && !(await isOrgDomainVerified())) {
751+
// SSO is not gated, so grantProviderDomainTrust always succeeds there.
752+
if (!(await grantProviderDomainTrust())) {
750753
// registerSSOProvider spreads the created row's `id` at runtime, but the
751754
// typed return omits it — read it defensively and only delete when it's a
752755
// real id, so a future shape change can't turn the rollback into a silent
753-
// no-op that leaves a provider on an unverified domain.
756+
// no-op that leaves a provider on an unverified domain. `orgId` is checked
757+
// only to narrow it: the org-less path grants unconditionally, so a refused
758+
// grant always means an org-scoped registration.
754759
// double-cast-allowed: Better Auth's return type omits the runtime `id`
755760
const createdRowId = (registration as unknown as { id?: unknown }).id
756-
if (typeof createdRowId === 'string' && createdRowId.length > 0) {
761+
if (orgId && typeof createdRowId === 'string' && createdRowId.length > 0) {
757762
await db
758763
.delete(ssoProvider)
759764
.where(and(eq(ssoProvider.id, createdRowId), eq(ssoProvider.organizationId, orgId)))
@@ -774,8 +779,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
774779
return domainNotVerifiedResponse()
775780
}
776781

777-
await grantProviderDomainTrust()
778-
779782
logger.info('SSO provider registered successfully', {
780783
providerId,
781784
providerType,

0 commit comments

Comments
 (0)