Skip to content

Commit d4405cb

Browse files
committed
fix(sso): regenerate the primary provider migration after staging, and harden provider switching
1 parent fe2f144 commit d4405cb

25 files changed

Lines changed: 27393 additions & 197 deletions

apps/docs/content/docs/platform/enterprise/sso.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ Click **Save**. To test, sign out and use the **Sign in with SSO** button on the
101101

102102
## Editing and advanced configuration
103103

104-
For a saved connection, open **Sign-in**, select the provider, and select **Edit**. The Provider ID remains fixed. **Delete** removes that sign-in path only: accounts and memberships it admitted stay. If the domain has another provider, it becomes primary; otherwise people at the domain sign in another way until a provider serves it again. A saved OIDC client secret appears as a mask with a suffix when available; **Replace** lets you enter a new secret, and **Keep saved** cancels that replacement. Select **Update** to save the provider, or **Discard** to abandon changes.
104+
For a saved connection, open **Sign-in**, select the provider, and select **Edit**. The Provider ID remains fixed. **Delete** removes that sign-in path only: accounts and memberships it admitted stay. If you delete the primary provider and the domain has another verified provider, that one becomes primary; otherwise people at the domain sign in another way until a provider serves it again. A saved OIDC client secret appears as a mask with a suffix when available; **Replace** lets you enter a new secret, and **Keep saved** cancels that replacement. Select **Update** to save the provider, or **Discard** to abandon changes.
105105

106106
**Advanced options** contains OIDC scopes and optional authorization, token, and JWKS endpoint overrides. For SAML, it contains Audience, Callback URL override, signed-assertion requirements, NameID format, and optional IdP metadata XML. **Attribute mapping** lets either protocol override the email, name, and stable user-ID claim names. Leave a mapping blank to use the protocol default.
107107

apps/sim/app/api/auth/sso/providers/[providerId]/route.test.ts

Lines changed: 34 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,23 @@ import {
1010
schemaMock,
1111
} from '@sim/testing'
1212
import { beforeEach, describe, expect, it, vi } from 'vitest'
13+
import { OrchestrationError } from '@/lib/core/orchestration/types'
1314

14-
const { mockGetSession } = vi.hoisted(() => ({
15+
const { mockGetSession, mockSetPrimary } = vi.hoisted(() => ({
1516
mockGetSession: vi.fn(),
17+
mockSetPrimary: vi.fn(),
1618
}))
1719

1820
vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock }))
1921
vi.mock('@/lib/auth', () => ({ getSession: mockGetSession }))
22+
/** Authorization and the primary switch are the use case's; its own tests and the PostgreSQL suite cover them. */
23+
vi.mock('@/lib/auth/sso/application/set-primary-provider', () => ({
24+
setPrimarySsoProviderOperation: { id: 'organization.sso.set_primary_provider' },
25+
setPrimarySsoProvider: {
26+
operation: { id: 'organization.sso.set_primary_provider' },
27+
execute: mockSetPrimary,
28+
},
29+
}))
2030

2131
import { DELETE, PATCH } from '@/app/api/auth/sso/providers/[providerId]/route'
2232

@@ -136,59 +146,45 @@ describe('PATCH /api/auth/sso/providers/[providerId]', () => {
136146

137147
beforeEach(() => {
138148
vi.clearAllMocks()
139-
resetDbChainMock()
140-
mockGetSession.mockResolvedValue({ user: { id: 'u1' } })
141-
dbChainMockFns.returning.mockResolvedValue([{ id: 'domain-1' }])
149+
mockGetSession.mockResolvedValue({ user: { id: 'u1' }, session: { id: 's1' } })
150+
mockSetPrimary.mockResolvedValue({
151+
providerId: 'acme-okta',
152+
organizationId: 'org1',
153+
domain: 'acme.com',
154+
})
142155
})
143156

144-
function queueOrgProvider(role: string) {
145-
queueTableRows(schemaMock.ssoProvider, [
146-
{ id: 'row-1', organizationId: 'org1', userId: 'u-other', domain: 'acme.com' },
147-
])
148-
queueTableRows(schemaMock.member, [{ role }])
149-
}
150-
151-
it('requires a session', async () => {
157+
it('requires a session before the use case runs', async () => {
152158
mockGetSession.mockResolvedValue(null)
153159
expect((await patch()).status).toBe(401)
154-
expect(dbChainMockFns.update).not.toHaveBeenCalled()
160+
expect(mockSetPrimary).not.toHaveBeenCalled()
155161
})
156162

157163
it('only accepts making a provider primary', async () => {
158164
expect((await patch({ isPrimary: false })).status).toBe(400)
159-
expect(dbChainMockFns.update).not.toHaveBeenCalled()
160-
})
161-
162-
it("refuses a member who is not the organization's owner or admin", async () => {
163-
queueOrgProvider('member')
164-
expect((await patch()).status).toBe(403)
165-
expect(dbChainMockFns.update).not.toHaveBeenCalled()
165+
expect(mockSetPrimary).not.toHaveBeenCalled()
166166
})
167167

168-
it('names the provider on its verified domain', async () => {
169-
queueOrgProvider('admin')
168+
it('passes the routed provider to the use case and presents its result', async () => {
170169
const res = await patch()
171170
expect(res.status).toBe(200)
172171
await expect(res.json()).resolves.toEqual({ success: true, providerId: 'acme-okta' })
173-
expect(dbChainMockFns.update).toHaveBeenCalledWith(schemaMock.ssoDomain)
174-
expect(dbChainMockFns.set).toHaveBeenCalledWith(
175-
expect.objectContaining({ primaryProviderId: 'acme-okta' })
172+
expect(mockSetPrimary).toHaveBeenCalledWith(
173+
expect.objectContaining({
174+
principal: expect.objectContaining({ kind: 'session', userId: 'u1' }),
175+
input: { providerId: 'acme-okta' },
176+
})
176177
)
177178
})
178179

179-
it('refuses a provider whose domain is not verified', async () => {
180-
queueOrgProvider('owner')
181-
dbChainMockFns.returning.mockResolvedValue([])
180+
it.each([
181+
['conflict', 409, 'Verify acme.com before making this provider primary.'],
182+
['forbidden', 403, 'Organization administrator access is required'],
183+
['not_found', 404, 'Provider not found'],
184+
] as const)('projects a %s refusal with its message', async (code, status, message) => {
185+
mockSetPrimary.mockRejectedValue(new OrchestrationError(code, message))
182186
const res = await patch()
183-
expect(res.status).toBe(409)
184-
await expect(res.json()).resolves.toMatchObject({ error: expect.stringContaining('acme.com') })
185-
})
186-
187-
it('refuses a personal provider, which has no primary', async () => {
188-
queueTableRows(schemaMock.ssoProvider, [
189-
{ id: 'row-1', organizationId: null, userId: 'u1', domain: 'acme.com' },
190-
])
191-
expect((await patch()).status).toBe(400)
192-
expect(dbChainMockFns.update).not.toHaveBeenCalled()
187+
expect(res.status).toBe(status)
188+
await expect(res.json()).resolves.toEqual({ error: message })
193189
})
194190
})

apps/sim/app/api/auth/sso/providers/[providerId]/route.ts

Lines changed: 28 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,21 @@
1-
import { db, ssoDomain, ssoProvider } from '@sim/db'
2-
import { forgetPrimaryProviders, verifiedDomainOfProvider } from '@sim/db/sso-primary-provider'
1+
import { db, ssoProvider } from '@sim/db'
2+
import { forgetPrimaryProvider } from '@sim/db/sso-primary-provider'
33
import { createLogger } from '@sim/logger'
4-
import { and, eq, exists, isNull, sql } from 'drizzle-orm'
4+
import { and, eq, isNull } from 'drizzle-orm'
55
import { type NextRequest, NextResponse } from 'next/server'
66
import { deleteSsoProviderContract, setPrimarySsoProviderContract } from '@/lib/api/contracts/auth'
77
import { parseRequest } from '@/lib/api/server'
8+
import {
9+
defineInternalJsonRoute,
10+
internalOrchestrationErrorPolicy,
11+
internalRateLimits,
12+
internalSessionAuth,
13+
} from '@/lib/api/server/routes'
814
import { getSession } from '@/lib/auth'
15+
import {
16+
setPrimarySsoProvider,
17+
setPrimarySsoProviderOperation,
18+
} from '@/lib/auth/sso/application/set-primary-provider'
919
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1020
import { isOrganizationAdminOrOwner } from '@/lib/workspaces/permissions/utils'
1121

@@ -15,10 +25,10 @@ type RouteContext = { params: Promise<{ providerId: string }> }
1525

1626
/**
1727
* Loads a provider the caller may manage: an organization provider for its
18-
* owners and admins, a personal provider for its creator. Sim owns these
19-
* mutations rather than exposing the SSO plugin's own, which
20-
* `/api/auth/[...all]` blocks by design: the plugin gates only on the row's
21-
* creator, while an organization's providers belong to the organization.
28+
* owners and admins, a personal provider for its creator. Sim owns deleting
29+
* rather than exposing the SSO plugin's own delete, which `/api/auth/[...all]`
30+
* blocks by design: the plugin gates only on the row's creator, while an
31+
* organization's providers belong to the organization.
2232
*/
2333
async function loadManagedProvider(userId: string, providerId: string) {
2434
const [provider] = await db
@@ -41,69 +51,16 @@ async function loadManagedProvider(userId: string, providerId: string) {
4151
return provider
4252
}
4353

44-
/**
45-
* Makes this provider the one its domain signs in through. The previous primary
46-
* stays configured and reachable by test link, so the switch can be reversed
47-
* the same way.
48-
*/
49-
export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteContext) => {
50-
const session = await getSession()
51-
if (!session?.user?.id) {
52-
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
53-
}
54-
55-
const parsed = await parseRequest(setPrimarySsoProviderContract, request, context)
56-
if (!parsed.success) return parsed.response
57-
const { providerId } = parsed.data.params
58-
59-
const provider = await loadManagedProvider(session.user.id, providerId)
60-
if (provider instanceof NextResponse) return provider
61-
if (!provider.organizationId) {
62-
return NextResponse.json(
63-
{ error: 'Only organization identity providers have a primary provider' },
64-
{ status: 400 }
65-
)
66-
}
67-
68-
/**
69-
* Names the provider on the one domain record sign-in joins it to, so a
70-
* provider this succeeds for is exactly the one sign-in then uses.
71-
*/
72-
const named = await db
73-
.update(ssoDomain)
74-
.set({ primaryProviderId: providerId, updatedAt: new Date() })
75-
.where(
76-
and(
77-
eq(ssoDomain.organizationId, provider.organizationId),
78-
exists(
79-
db
80-
.select({ found: sql`1` })
81-
.from(ssoProvider)
82-
.where(
83-
and(
84-
eq(ssoProvider.id, provider.id),
85-
eq(ssoProvider.domainVerified, true),
86-
verifiedDomainOfProvider
87-
)
88-
)
89-
)
90-
)
91-
)
92-
.returning({ id: ssoDomain.id })
93-
if (named.length === 0) {
94-
return NextResponse.json(
95-
{ error: `Verify ${provider.domain} before making this provider primary.` },
96-
{ status: 409 }
97-
)
98-
}
99-
100-
logger.info('Set primary SSO provider', {
101-
providerId,
102-
organizationId: provider.organizationId,
103-
domain: provider.domain,
104-
userId: session.user.id,
105-
})
106-
return NextResponse.json({ success: true, providerId })
54+
/** Makes this provider the one its domain signs in through. */
55+
export const PATCH = defineInternalJsonRoute({
56+
contract: setPrimarySsoProviderContract,
57+
auth: internalSessionAuth,
58+
operation: setPrimarySsoProviderOperation,
59+
rateLimit: internalRateLimits.user({ bucketName: 'sso-set-primary-provider' }),
60+
errorPolicy: internalOrchestrationErrorPolicy,
61+
mapInput: ({ params }) => ({ providerId: params.providerId }),
62+
useCase: setPrimarySsoProvider,
63+
present: ({ providerId }) => ({ success: true as const, providerId }),
10764
})
10865

10966
/**
@@ -140,7 +97,7 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Rou
14097
.where(and(eq(ssoProvider.id, provider.id), ownerClause))
14198
.returning({ id: ssoProvider.id })
14299
if (deleted.length > 0 && organizationId) {
143-
await forgetPrimaryProviders(tx, organizationId, [providerId])
100+
await forgetPrimaryProvider(tx, organizationId, providerId)
144101
}
145102
return deleted
146103
})

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -514,12 +514,25 @@ describe('POST /api/auth/sso/register', () => {
514514

515515
it("does not report the caller's own org-less provider as another tenant's claim", async () => {
516516
queueMembers([{ organizationId: 'org1', role: 'owner' }])
517-
queueProviders([{ domain: 'acme.com', userId: 'u1', organizationId: null }])
517+
queueProviders([
518+
{ domain: 'acme.com', userId: 'u1', organizationId: null, providerId: 'acme-oidc' },
519+
])
518520
const res = await POST(request(OIDC_BODY))
519521
expect(res.status).toBe(200)
520522
expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1)
521523
})
522524

525+
it("refuses a second provider on a domain the caller's own org-less provider signs in", async () => {
526+
queueMembers([{ organizationId: 'org1', role: 'owner' }])
527+
queueProviders([
528+
{ domain: 'acme.com', userId: 'u1', organizationId: null, providerId: 'acme-personal' },
529+
])
530+
const res = await POST(request(OIDC_BODY))
531+
expect(res.status).toBe(409)
532+
await expect(res.json()).resolves.toMatchObject({ code: 'SSO_DOMAIN_ALREADY_ROUTED' })
533+
expect(mockRegisterSSOProvider).not.toHaveBeenCalled()
534+
})
535+
523536
it("still blocks an org admin from claiming another user's user-scoped domain", async () => {
524537
queueMembers([{ organizationId: 'org1', role: 'owner' }])
525538
queueProviders([{ domain: 'acme.com', userId: 'someone-else', organizationId: null }])

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

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { db, member, ssoDomain, ssoProvider } from '@sim/db'
2-
import { nameIncumbentSignInProvider, ssoProviderDomainKey } from '@sim/db/sso-primary-provider'
2+
import { keepDomainSignInProvider, ssoProviderDomainKey } from '@sim/db/sso-primary-provider'
33
import { createLogger } from '@sim/logger'
44
import { getErrorMessage } from '@sim/utils/errors'
55
import { normalizeSSODomain } from '@sim/utils/sso-domain'
@@ -191,16 +191,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
191191
)
192192

193193
/**
194-
* Refuses the domain when another tenant has claimed it. The caller's own
195-
* organization may add a provider to a domain it already signs in through:
196-
* the new provider waits, reachable by test link, until an admin makes it
197-
* the domain's primary.
194+
* Refuses the domain when another tenant has claimed it, or when the caller's
195+
* own personal provider signs it in. The caller's organization may add a
196+
* provider to a domain it already signs in through: the new provider waits,
197+
* reachable by test link, until an admin makes it the domain's primary.
198198
*/
199199
const findDomainRefusal = async (): Promise<NextResponse | null> => {
200200
const claims = await db
201201
.select({
202202
userId: ssoProvider.userId,
203203
organizationId: ssoProvider.organizationId,
204+
providerId: ssoProvider.providerId,
204205
})
205206
.from(ssoProvider)
206207
.where(sql`${ssoProviderDomainKey} = ${domain}`)
@@ -218,6 +219,21 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
218219
{ status: 409 }
219220
)
220221
}
222+
const personal = claims.find(
223+
(provider) =>
224+
!provider.organizationId &&
225+
typeof provider.providerId === 'string' &&
226+
provider.providerId !== providerId
227+
)
228+
if (personal) {
229+
return NextResponse.json(
230+
{
231+
error: `${domain} already signs in through the provider "${personal.providerId}". Edit that provider, or give this one a different verified domain.`,
232+
code: 'SSO_DOMAIN_ALREADY_ROUTED',
233+
},
234+
{ status: 409 }
235+
)
236+
}
221237
return null
222238
}
223239

@@ -625,6 +641,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
625641
id: ssoProvider.id,
626642
issuer: ssoProvider.issuer,
627643
domain: ssoProvider.domain,
644+
domainVerified: ssoProvider.domainVerified,
628645
oidcConfig: ssoProvider.oidcConfig,
629646
samlConfig: ssoProvider.samlConfig,
630647
jitProvisioningEnabled: ssoProvider.jitProvisioningEnabled,
@@ -643,14 +660,15 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
643660
* first the SELECT finds nothing.
644661
*
645662
* A provider joining a domain another provider already signs in does not
646-
* take over by sorting first: when the domain names no primary, the provider
647-
* signing it in until now is named, in the same transaction. The lock is
648-
* `FOR UPDATE` so two providers joining at once name it one after the other.
663+
* take over by sorting first: unless the domain's named primary still signs
664+
* it in, the provider signing it in until now is named, in the same
665+
* transaction. The lock is `FOR UPDATE` so two providers joining at once
666+
* settle it one after the other.
649667
*/
650668
const grantProviderDomainTrust = (joinsDomain: boolean): Promise<boolean> =>
651669
db.transaction(async (tx) => {
652670
const [proof] = await tx
653-
.select({ id: ssoDomain.id, primaryProviderId: ssoDomain.primaryProviderId })
671+
.select({ id: ssoDomain.id })
654672
.from(ssoDomain)
655673
.where(verifiedDomainClause)
656674
.limit(1)
@@ -664,8 +682,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
664682
.returning({ id: ssoProvider.id })
665683
if (granted.length === 0) return false
666684

667-
if (joinsDomain && proof.primaryProviderId === null) {
668-
await nameIncumbentSignInProvider(tx, {
685+
if (joinsDomain) {
686+
await keepDomainSignInProvider(tx, {
669687
domainRecordId: proof.id,
670688
organizationId: orgId,
671689
domain,
@@ -703,8 +721,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
703721

704722
let domainTrustGranted: boolean
705723
try {
724+
/** An owned provider joins the domain when it moves to it or is not yet trusted on it. */
706725
domainTrustGranted = await grantProviderDomainTrust(
707-
normalizeSSODomain(existingOwnedProvider.domain) !== domain
726+
!existingOwnedProvider.domainVerified ||
727+
normalizeSSODomain(existingOwnedProvider.domain) !== domain
708728
)
709729
} catch (error) {
710730
try {

apps/sim/ee/sso/components/sso-form.test.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,8 @@ describe('SSOForm sign-in errors', () => {
183183
expect(mockSsoSignIn).toHaveBeenCalledWith(
184184
expect.objectContaining({ email: 'user@example.com', providerId: 'example-okta' })
185185
)
186+
const [signIn] = mockSsoSignIn.mock.calls[0]
187+
expect(signIn.errorCallbackURL).not.toContain('provider=')
186188
})
187189

188190
it('signs in through the provider a test link names, and returns to that link on failure', async () => {
@@ -199,6 +201,9 @@ describe('SSOForm sign-in errors', () => {
199201
const [signIn] = mockSsoSignIn.mock.calls[0]
200202
expect(signIn.providerId).toBe('example-okta')
201203
expect(signIn.errorCallbackURL).toContain('provider=example-okta')
204+
/** The SSO plugin appends `?error=…` to the URL, so the test link must survive that suffix. */
205+
const retry = new URL(`${signIn.errorCallbackURL}?error=invalid_provider`, 'https://sim.test')
206+
expect(retry.searchParams.get('provider')).toBe('example-okta')
202207
})
203208

204209
it('explains a test link that does not match the email domain', async () => {

0 commit comments

Comments
 (0)