Skip to content

Commit ad11808

Browse files
authored
fix(sso): require an organization when registering an SSO provider (#7801)
1 parent 2799994 commit ad11808

5 files changed

Lines changed: 99 additions & 152 deletions

File tree

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

Lines changed: 45 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ const OIDC_BODY = {
8888
providerId: 'acme-oidc',
8989
issuer: 'https://idp.acme.com',
9090
domain: 'acme.com',
91+
orgId: 'org1',
9192
clientId: 'client-id',
9293
clientSecret: 'client-secret',
9394
authorizationEndpoint: 'https://idp.acme.com/authorize',
@@ -137,21 +138,21 @@ describe('POST /api/auth/sso/register', () => {
137138

138139
it('rejects callers without an Enterprise plan', async () => {
139140
mockHasSSOAccess.mockResolvedValue(false)
140-
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
141+
const res = await POST(request(OIDC_BODY))
141142
expect(res.status).toBe(403)
142143
expect(mockRegisterSSOProvider).not.toHaveBeenCalled()
143144
})
144145

145146
it('rejects callers who are not an admin/owner of the target org', async () => {
146147
queueMembers([{ organizationId: 'org1', role: 'member' }])
147-
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
148+
const res = await POST(request(OIDC_BODY))
148149
expect(res.status).toBe(403)
149150
expect(mockRegisterSSOProvider).not.toHaveBeenCalled()
150151
})
151152

152153
it('rejects an invalid domain', async () => {
153154
queueMembers([{ organizationId: 'org1', role: 'owner' }])
154-
const res = await POST(request({ ...OIDC_BODY, domain: 'not-a-domain', orgId: 'org1' }))
155+
const res = await POST(request({ ...OIDC_BODY, domain: 'not-a-domain' }))
155156
expect(res.status).toBe(400)
156157
expect(mockRegisterSSOProvider).not.toHaveBeenCalled()
157158
})
@@ -160,7 +161,7 @@ describe('POST /api/auth/sso/register', () => {
160161
resetDbChainMock()
161162
queueMembers([{ organizationId: 'org1', role: 'owner' }])
162163
queueTableRows(schemaMock.ssoDomain, []) // no verified sso_domain row
163-
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
164+
const res = await POST(request(OIDC_BODY))
164165
const json = await res.json()
165166
expect(res.status).toBe(403)
166167
expect(json.code).toBe('SSO_DOMAIN_NOT_VERIFIED')
@@ -172,7 +173,7 @@ describe('POST /api/auth/sso/register', () => {
172173
queueMembers([{ organizationId: 'org1', role: 'owner' }])
173174
queueTableRows(schemaMock.ssoDomain, [{ id: 'v' }]) // entry gate: verified
174175
queueTableRows(schemaMock.ssoDomain, []) // re-check before write: revoked
175-
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
176+
const res = await POST(request(OIDC_BODY))
176177
const json = await res.json()
177178
expect(res.status).toBe(403)
178179
expect(json.code).toBe('SSO_DOMAIN_NOT_VERIFIED')
@@ -185,7 +186,7 @@ describe('POST /api/auth/sso/register', () => {
185186
queueTableRows(schemaMock.ssoDomain, [{ id: 'v' }]) // entry gate: verified
186187
queueTableRows(schemaMock.ssoDomain, [{ id: 'v' }]) // pre-write re-check: verified
187188
queueTableRows(schemaMock.ssoDomain, []) // locking read in the grant: proof gone
188-
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
189+
const res = await POST(request(OIDC_BODY))
189190
const json = await res.json()
190191
expect(res.status).toBe(403)
191192
expect(json.code).toBe('SSO_DOMAIN_NOT_VERIFIED')
@@ -221,7 +222,7 @@ describe('POST /api/auth/sso/register', () => {
221222
queueProviders([
222223
{ domain: 'acme.com', userId: 'u1', organizationId: 'org1', providerId: 'acme-saml' },
223224
])
224-
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
225+
const res = await POST(request(OIDC_BODY))
225226
const json = await res.json()
226227
expect(res.status).toBe(409)
227228
expect(json.code).toBe('SSO_DOMAIN_ALREADY_ROUTED')
@@ -238,7 +239,7 @@ describe('POST /api/auth/sso/register', () => {
238239
constraint_name: 'sso_provider_org_domain_unique',
239240
})
240241
)
241-
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
242+
const res = await POST(request(OIDC_BODY))
242243
const json = await res.json()
243244
expect(res.status).toBe(409)
244245
expect(json.code).toBe('SSO_DOMAIN_ALREADY_ROUTED')
@@ -248,14 +249,14 @@ describe('POST /api/auth/sso/register', () => {
248249
it('lets the organization add a provider for a different verified domain', async () => {
249250
queueMembers([{ organizationId: 'org1', role: 'owner' }])
250251
queueProviders([])
251-
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1', domain: 'eng.acme.com' }))
252+
const res = await POST(request({ ...OIDC_BODY, domain: 'eng.acme.com' }))
252253
expect(res.status).toBe(200)
253254
expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1)
254255
})
255256

256257
it('registers when the domain is unclaimed', async () => {
257258
queueMembers([{ organizationId: 'org1', role: 'owner' }])
258-
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
259+
const res = await POST(request(OIDC_BODY))
259260
expect(res.status).toBe(200)
260261
expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1)
261262
})
@@ -286,7 +287,7 @@ describe('POST /api/auth/sso/register', () => {
286287
it('does not treat the caller’s own provider as a providerId conflict', async () => {
287288
queueMembers([{ organizationId: 'org1', role: 'owner' }])
288289
queueProviders([], [{ domain: 'acme.com', userId: 'u1', organizationId: 'org1' }])
289-
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
290+
const res = await POST(request(OIDC_BODY))
290291
expect(res.status).toBe(200)
291292
})
292293

@@ -298,7 +299,7 @@ describe('POST /api/auth/sso/register', () => {
298299
*/
299300
it('marks the provider domain-verified after registering', async () => {
300301
queueMembers([{ organizationId: 'org1', role: 'owner' }])
301-
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
302+
const res = await POST(request(OIDC_BODY))
302303
expect(res.status).toBe(200)
303304
expect(dbChainMockFns.set).toHaveBeenCalledWith({
304305
domainVerified: true,
@@ -311,7 +312,7 @@ describe('POST /api/auth/sso/register', () => {
311312
queueMembers([{ organizationId: 'org1', role: 'owner' }])
312313
queueProviders([])
313314
queueTableRows(schemaMock.ssoProvider, [{ id: 'p1' }])
314-
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
315+
const res = await POST(request(OIDC_BODY))
315316
expect(res.status).toBe(200)
316317
expect(mockUpdateSSOProvider).toHaveBeenCalledTimes(1)
317318
expect(dbChainMockFns.set).toHaveBeenCalledWith({
@@ -346,7 +347,7 @@ describe('POST /api/auth/sso/register', () => {
346347
},
347348
]) // provider already owned → update path
348349

349-
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
350+
const res = await POST(request(OIDC_BODY))
350351
expect(res.status).toBe(403)
351352
expect(mockUpdateSSOProvider).toHaveBeenCalledTimes(1)
352353
// The conditional grant UPDATE is still issued — it simply matches no rows once
@@ -376,7 +377,7 @@ describe('POST /api/auth/sso/register', () => {
376377
])
377378
dbChainMockFns.returning.mockRejectedValueOnce(new Error('trust write failed'))
378379

379-
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1', jitProvisioningEnabled: false }))
380+
const res = await POST(request({ ...OIDC_BODY, jitProvisioningEnabled: false }))
380381

381382
expect(res.status).toBe(500)
382383
expect(mockUpdateSSOProvider).toHaveBeenCalledTimes(1)
@@ -397,41 +398,28 @@ describe('POST /api/auth/sso/register', () => {
397398
queueTableRows(schemaMock.ssoDomain, [{ id: 'verified-domain' }])
398399
queueTableRows(schemaMock.ssoDomain, [{ id: 'verified-domain' }])
399400
queueTableRows(schemaMock.ssoDomain, []) // locking read in the grant: proof gone
400-
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
401+
const res = await POST(request(OIDC_BODY))
401402
expect(res.status).toBe(403)
402403
expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1) // it was created…
403404
expect(dbChainMockFns.delete).toHaveBeenCalled() // …then rolled back
404405
})
405406

406407
/**
407-
* A personal provider has no verified domain behind it. On the hosted
408-
* multi-tenant deployment that must grant no linking authority, or anyone able
409-
* to register one could claim a domain they do not own and have their own IdP
410-
* auto-link to existing accounts on it.
408+
* An org-less provider has no `sso_domain` proof behind its domain, and domain
409+
* trust is what auto-links an SSO sign-in into an existing same-email account,
410+
* so the route refuses one before any write.
411411
*/
412-
it('does not grant domain trust to a personal provider when hosted', async () => {
413-
setEnvFlags({ isSsoEnabled: true, isHosted: true })
414-
const res = await POST(request(OIDC_BODY))
415-
expect(res.status).toBe(200)
416-
expect(dbChainMockFns.set).toHaveBeenCalledWith({
417-
domainVerified: false,
418-
jitProvisioningEnabled: true,
419-
})
420-
})
421-
422-
it('grants domain trust to a personal provider when self-hosted', async () => {
423-
setEnvFlags({ isSsoEnabled: true, isHosted: false })
424-
const res = await POST(request(OIDC_BODY))
425-
expect(res.status).toBe(200)
426-
expect(dbChainMockFns.set).toHaveBeenCalledWith({
427-
domainVerified: true,
428-
jitProvisioningEnabled: true,
429-
})
412+
it('refuses a provider without an organization', async () => {
413+
const { orgId: _orgId, ...orgLessBody } = OIDC_BODY
414+
const res = await POST(request(orgLessBody))
415+
expect(res.status).toBe(400)
416+
expect((await res.json()).error).toContain('Organization ID is required')
417+
expect(mockRegisterSSOProvider).not.toHaveBeenCalled()
430418
})
431419

432420
it('persists invite-only provisioning without changing Better Auth provider config', async () => {
433421
queueMembers([{ organizationId: 'org1', role: 'owner' }])
434-
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1', jitProvisioningEnabled: false }))
422+
const res = await POST(request({ ...OIDC_BODY, jitProvisioningEnabled: false }))
435423
expect(res.status).toBe(200)
436424
expect(dbChainMockFns.set).toHaveBeenCalledWith({
437425
domainVerified: true,
@@ -516,9 +504,7 @@ describe('POST /api/auth/sso/register', () => {
516504

517505
it('nests the attribute mapping inside oidcConfig (Better Auth reads it there)', async () => {
518506
queueMembers([{ organizationId: 'org1', role: 'owner' }])
519-
await POST(
520-
request({ ...OIDC_BODY, orgId: 'org1', mapping: { id: 'oid', email: 'upn', name: 'name' } })
521-
)
507+
await POST(request({ ...OIDC_BODY, mapping: { id: 'oid', email: 'upn', name: 'name' } }))
522508
expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1)
523509
const sent = mockRegisterSSOProvider.mock.calls[0][0].body
524510
expect(sent.mapping).toBeUndefined() // not passed at the top level (silently ignored there)
@@ -529,7 +515,7 @@ describe('POST /api/auth/sso/register', () => {
529515
queueMembers([{ organizationId: 'org1', role: 'owner' }])
530516
queueProviders([]) // no providerId or domain conflicts on either pass
531517
queueTableRows(schemaMock.ssoProvider, [{ id: 'p1' }]) // provider already owned → edit
532-
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
518+
const res = await POST(request(OIDC_BODY))
533519
expect(res.status).toBe(200)
534520
const body = await res.json()
535521
expect(body.message).toContain('updated')
@@ -540,30 +526,30 @@ describe('POST /api/auth/sso/register', () => {
540526
it('allows the owning tenant to update its own provider for the same domain', async () => {
541527
queueMembers([{ organizationId: 'org1', role: 'owner' }])
542528
queueProviders([{ domain: 'acme.com', userId: 'u1', organizationId: 'org1' }])
543-
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
529+
const res = await POST(request(OIDC_BODY))
544530
expect(res.status).toBe(200)
545531
expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1)
546532
})
547533

548-
it('lets an org admin adopt their own user-scoped provider for the same domain', async () => {
534+
it("does not report the caller's own org-less provider as another tenant's claim", async () => {
549535
queueMembers([{ organizationId: 'org1', role: 'owner' }])
550536
queueProviders([{ domain: 'acme.com', userId: 'u1', organizationId: null }])
551-
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
537+
const res = await POST(request(OIDC_BODY))
552538
expect(res.status).toBe(200)
553539
expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1)
554540
})
555541

556542
it("still blocks an org admin from claiming another user's user-scoped domain", async () => {
557543
queueMembers([{ organizationId: 'org1', role: 'owner' }])
558544
queueProviders([{ domain: 'acme.com', userId: 'someone-else', organizationId: null }])
559-
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
545+
const res = await POST(request(OIDC_BODY))
560546
expect(res.status).toBe(409)
561547
expect(mockRegisterSSOProvider).not.toHaveBeenCalled()
562548
})
563549

564550
it('normalizes the domain before persisting it', async () => {
565551
queueMembers([{ organizationId: 'org1', role: 'owner' }])
566-
const res = await POST(request({ ...OIDC_BODY, domain: 'ACME.com', orgId: 'org1' }))
552+
const res = await POST(request({ ...OIDC_BODY, domain: 'ACME.com' }))
567553
expect(res.status).toBe(200)
568554
expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1)
569555
const config = mockRegisterSSOProvider.mock.calls[0][0].body
@@ -572,15 +558,15 @@ describe('POST /api/auth/sso/register', () => {
572558

573559
it('passes skipDiscovery since Sim already resolved and validated the OIDC endpoints', async () => {
574560
queueMembers([{ organizationId: 'org1', role: 'owner' }])
575-
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
561+
const res = await POST(request(OIDC_BODY))
576562
expect(res.status).toBe(200)
577563
const config = mockRegisterSSOProvider.mock.calls[0][0].body
578564
expect(config.oidcConfig.skipDiscovery).toBe(true)
579565
})
580566

581567
it('omits userInfoEndpoint when skipUserInfoEndpoint is requested, forcing ID token claims', async () => {
582568
queueMembers([{ organizationId: 'org1', role: 'owner' }])
583-
const res = await POST(request({ ...OIDC_BODY, skipUserInfoEndpoint: true, orgId: 'org1' }))
569+
const res = await POST(request({ ...OIDC_BODY, skipUserInfoEndpoint: true }))
584570
expect(res.status).toBe(200)
585571
const config = mockRegisterSSOProvider.mock.calls[0][0].body
586572
expect(config.oidcConfig.userInfoEndpoint).toBeUndefined()
@@ -594,7 +580,7 @@ describe('POST /api/auth/sso/register', () => {
594580
}
595581
return { isValid: true, resolvedIP: '1.2.3.4' }
596582
})
597-
const res = await POST(request({ ...OIDC_BODY, skipUserInfoEndpoint: true, orgId: 'org1' }))
583+
const res = await POST(request({ ...OIDC_BODY, skipUserInfoEndpoint: true }))
598584
expect(res.status).toBe(200)
599585
const config = mockRegisterSSOProvider.mock.calls[0][0].body
600586
expect(config.oidcConfig.userInfoEndpoint).toBeUndefined()
@@ -624,15 +610,15 @@ describe('POST /api/auth/sso/register', () => {
624610
jwksEndpoint: undefined,
625611
skipUserInfoEndpoint: true,
626612
}
627-
const res = await POST(request({ ...discoveredBody, orgId: 'org1' }))
613+
const res = await POST(request(discoveredBody))
628614
expect(res.status).toBe(200)
629615
const config = mockRegisterSSOProvider.mock.calls[0][0].body
630616
expect(config.oidcConfig.userInfoEndpoint).toBeUndefined()
631617
})
632618

633619
it('keeps userInfoEndpoint when skipUserInfoEndpoint is not requested', async () => {
634620
queueMembers([{ organizationId: 'org1', role: 'owner' }])
635-
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
621+
const res = await POST(request(OIDC_BODY))
636622
expect(res.status).toBe(200)
637623
const config = mockRegisterSSOProvider.mock.calls[0][0].body
638624
expect(config.oidcConfig.userInfoEndpoint).toBe('https://idp.acme.com/userinfo')
@@ -656,7 +642,7 @@ describe('POST /api/auth/sso/register', () => {
656642
tokenEndpoint: undefined,
657643
jwksEndpoint: undefined,
658644
}
659-
const res = await POST(request({ ...discoveredBody, orgId: 'org1' }))
645+
const res = await POST(request(discoveredBody))
660646
expect(res.status).toBe(200)
661647
const config = mockRegisterSSOProvider.mock.calls[0][0].body
662648
expect(config.oidcConfig.tokenEndpointAuthentication).toBe('client_secret_post')
@@ -670,7 +656,7 @@ describe('POST /api/auth/sso/register', () => {
670656
token_endpoint_auth_methods_supported: ['client_secret_post'],
671657
}),
672658
})
673-
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
659+
const res = await POST(request(OIDC_BODY))
674660
expect(res.status).toBe(200)
675661
const config = mockRegisterSSOProvider.mock.calls[0][0].body
676662
expect(config.oidcConfig.tokenEndpointAuthentication).toBe('client_secret_post')
@@ -680,7 +666,7 @@ describe('POST /api/auth/sso/register', () => {
680666
it('registers successfully when discovery is unreachable and all endpoints are explicit', async () => {
681667
queueMembers([{ organizationId: 'org1', role: 'owner' }])
682668
mockSecureFetchWithPinnedIP.mockRejectedValue(new Error('ECONNREFUSED'))
683-
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
669+
const res = await POST(request(OIDC_BODY))
684670
expect(res.status).toBe(200)
685671
const config = mockRegisterSSOProvider.mock.calls[0][0].body
686672
expect(config.oidcConfig.skipDiscovery).toBe(true)
@@ -696,7 +682,7 @@ describe('POST /api/auth/sso/register', () => {
696682
token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post'],
697683
}),
698684
})
699-
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
685+
const res = await POST(request(OIDC_BODY))
700686
expect(res.status).toBe(200)
701687
const config = mockRegisterSSOProvider.mock.calls[0][0].body
702688
expect(config.oidcConfig.tokenEndpointAuthentication).toBe('client_secret_post')
@@ -708,7 +694,7 @@ describe('POST /api/auth/sso/register', () => {
708694
ok: true,
709695
json: async () => ({}),
710696
})
711-
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
697+
const res = await POST(request(OIDC_BODY))
712698
expect(res.status).toBe(200)
713699
const config = mockRegisterSSOProvider.mock.calls[0][0].body
714700
expect(config.oidcConfig.tokenEndpointAuthentication).toBe('client_secret_post')
@@ -728,7 +714,7 @@ describe('POST /api/auth/sso/register', () => {
728714
tokenEndpoint: undefined,
729715
jwksEndpoint: undefined,
730716
}
731-
const res = await POST(request({ ...discoveredBody, orgId: 'org1' }))
717+
const res = await POST(request(discoveredBody))
732718
const json = await res.json()
733719
expect(res.status).toBe(400)
734720
expect(json.error).toContain('resolves to a private IP address')

0 commit comments

Comments
 (0)