Skip to content

Commit ca48eb5

Browse files
committed
fix(sso): revoke domain trust when verification is removed mid-update
The create path re-checks domain ownership after Better Auth persists the provider and rolls the row back if the verified sso_domain row disappeared in that window. The update path had no equivalent, so deleting the verified domain while updateSSOProvider was in flight still set domainVerified, restoring same-email account-linking trust for a domain the org no longer proves it owns. The update path has no newly-created row to roll back, so it clears the flag instead: that denies linking and blocks sign-in on the provider until the domain is verified again.
1 parent fcfda44 commit ca48eb5

2 files changed

Lines changed: 48 additions & 8 deletions

File tree

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,29 @@ describe('POST /api/auth/sso/register', () => {
275275
expect(dbChainMockFns.set).toHaveBeenCalledWith({ domainVerified: true })
276276
})
277277

278+
/**
279+
* The create path rolls the provider back when verification is revoked during the
280+
* write; the update path has no row to roll back, so it must instead strip the
281+
* trust flag. Leaving it set would re-authorize same-email account linking for a
282+
* domain the org no longer proves it owns.
283+
*/
284+
it('revokes domain trust when verification is removed during an update', async () => {
285+
resetDbChainMock()
286+
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, [])
291+
queueProviders([])
292+
queueTableRows(schemaMock.ssoProvider, [{ id: 'p1' }]) // provider already owned → update path
293+
294+
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
295+
expect(res.status).toBe(403)
296+
expect(mockUpdateSSOProvider).toHaveBeenCalledTimes(1)
297+
expect(dbChainMockFns.set).toHaveBeenCalledWith({ domainVerified: false })
298+
expect(dbChainMockFns.set).not.toHaveBeenCalledWith({ domainVerified: true })
299+
})
300+
278301
it('does not mark domain-verified when the registration is rolled back', async () => {
279302
queueMembers([{ organizationId: 'org1', role: 'owner' }])
280303
resetDbChainMock()

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

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -647,13 +647,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
647647
* flag, which is what lets an SSO sign-in auto-link to an existing same-email
648648
* account. Must run after every write, not just on create: `registerSSOProvider`
649649
* always persists `false`, and `updateSSOProvider` resets it to `false` whenever
650-
* the domain changes. Only ever called once the verification gate above has
651-
* passed for this exact domain, so it can never mark an unproven domain as
652-
* verified. Org-less (personal) SSO is not domain-gated by Sim and keeps its
653-
* pre-existing trust here, matching how it behaved before the flag existed.
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.
654654
*/
655-
const markProviderDomainVerified = async () => {
656-
await db.update(ssoProvider).set({ domainVerified: true }).where(ownerClause)
655+
const setProviderDomainVerified = async (verified: boolean) => {
656+
await db.update(ssoProvider).set({ domainVerified: verified }).where(ownerClause)
657657
}
658658

659659
if (existingOwnedProvider) {
@@ -667,7 +667,24 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
667667
},
668668
headers,
669669
})
670-
await markProviderDomainVerified()
670+
671+
// Compensating re-check, mirroring the create path below: the verified
672+
// sso_domain row can be deleted while updateSSOProvider is in flight.
673+
// Granting trust here would re-authorize same-email account linking for a
674+
// domain the org no longer proves it owns, so clear the flag instead —
675+
// that both denies linking and blocks sign-in until it is re-verified.
676+
if (orgId && !(await isOrgDomainVerified())) {
677+
await setProviderDomainVerified(false)
678+
logger.warn('Revoked SSO domain trust: verification was removed mid-update', {
679+
domain,
680+
orgId,
681+
providerId,
682+
userId: session.user.id,
683+
})
684+
return domainNotVerifiedResponse()
685+
}
686+
687+
await setProviderDomainVerified(true)
671688
logger.info('SSO provider updated successfully', { providerId, providerType, domain })
672689
return NextResponse.json({
673690
success: true,
@@ -717,7 +734,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
717734
return domainNotVerifiedResponse()
718735
}
719736

720-
await markProviderDomainVerified()
737+
await setProviderDomainVerified(true)
721738

722739
logger.info('SSO provider registered successfully', {
723740
providerId,

0 commit comments

Comments
 (0)