Skip to content

Commit 1a631d9

Browse files
BillLeoutsakosvl346Bill Leoutsakoscursoragent
authored
test(e2e): cover enterprise integrations (#5868)
* test(e2e): cover enterprise integrations Co-authored-by: Cursor <cursoragent@cursor.com> * fix(sso): preserve uniqueness during migration replay Co-authored-by: Cursor <cursoragent@cursor.com> * fix settings e2e and sso route contracts Co-authored-by: Cursor <cursoragent@cursor.com> * serialize sso provider mutations Co-authored-by: Cursor <cursoragent@cursor.com> * close sso account link races Co-authored-by: Cursor <cursoragent@cursor.com> * normalize sso overlap comparisons Co-authored-by: Cursor <cursoragent@cursor.com> * serialize sso domain verification Co-authored-by: Cursor <cursoragent@cursor.com> * allow sso cleanup after plan loss Co-authored-by: Cursor <cursoragent@cursor.com> * refresh sso provider inside mutation lock Co-authored-by: Cursor <cursoragent@cursor.com> * harden sso update and delete concurrency Co-authored-by: Cursor <cursoragent@cursor.com> * fix(sso): avoid long-held callback mutation locks Co-authored-by: Cursor <cursoragent@cursor.com> * fix(sso): release mutation lock during DNS verification Co-authored-by: Cursor <cursoragent@cursor.com> * fix(sso): normalize audited domain overlaps Co-authored-by: Cursor <cursoragent@cursor.com> * fix(e2e): override auth endpoint rate limits Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Bill Leoutsakos <billleoutsakos@Bills-MacBook-Pro.local> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 2d283c3 commit 1a631d9

77 files changed

Lines changed: 24494 additions & 1471 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/test-build.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -316,8 +316,8 @@ jobs:
316316
- name: Install dependencies
317317
run: bun install --frozen-lockfile
318318

319-
- name: Configure E2E hostname
320-
run: echo "127.0.0.1 e2e.sim.ai" | sudo tee -a /etc/hosts
319+
- name: Configure E2E hostnames
320+
run: echo "127.0.0.1 e2e.sim.ai mcp.e2e.sim.ai" | sudo tee -a /etc/hosts
321321

322322
- name: Install Chromium
323323
working-directory: apps/sim

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

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,7 @@ SSO provisioning creates internal organization members. External workspace membe
252252
},
253253
{
254254
question: "A user already has an account with the same email — what happens when they sign in with SSO?",
255-
answer: "Sim links the SSO identity to the existing account automatically, as long as your identity provider reports the email as verified (email_verified) or the provider is trusted. Most OIDC providers (Okta, Google Workspace, Auth0) assert email_verified, so linking just works. If sign-in fails with 'account not linked' — common with SAML providers that omit the claim — add the provider's ID to SSO_TRUSTED_PROVIDER_IDS on self-hosted and restart."
255+
answer: "Before verified-domain enforcement is activated, Sim links the SSO identity when your identity provider reports a verified email or the provider is operator-trusted. After enforcement is activated, the provider's verified organization domain is the account-linking trust boundary."
256256
},
257257
{
258258
question: "Who can configure SSO on Sim Cloud?",
@@ -281,11 +281,16 @@ Self-hosted deployments use environment variables instead of the billing/plan ch
281281
SSO_ENABLED=true
282282
NEXT_PUBLIC_SSO_ENABLED=true
283283

284+
# Enable only after the SSO schema migration, provider audit, and approved
285+
# domain-verification backfill described below.
286+
SSO_DOMAIN_VERIFICATION_ENABLED=false
287+
284288
# Required if you want users auto-added to your organization on first SSO sign-in
285289
ORGANIZATIONS_ENABLED=true
286290
NEXT_PUBLIC_ORGANIZATIONS_ENABLED=true
287291

288-
# Optional: comma-separated SSO provider IDs to trust for automatic account linking
292+
# Optional while domain verification is disabled: comma-separated SSO provider IDs
293+
# to trust for automatic account linking
289294
# (links an SSO sign-in to an existing account with the same email). Needed when your
290295
# IdP does not assert email_verified — typically SAML providers, or OIDC providers that
291296
# omit the claim. Set it to the Provider ID you registered, then restart.
@@ -295,12 +300,27 @@ SSO_TRUSTED_PROVIDER_IDS=custom-oidc,partner-saml
295300
```
296301

297302
<Callout type="info">
298-
When someone signs in with SSO and an account with the same email already exists
299-
(for example, they previously signed up with email/password), Sim links the SSO
300-
identity to that account automatically as long as your IdP reports the email as
301-
verified, or the provider is trusted. If you hit an `account not linked` error,
302-
either confirm your IdP sends `email_verified`, or add the provider's ID to
303-
`SSO_TRUSTED_PROVIDER_IDS` and restart.
303+
While `SSO_DOMAIN_VERIFICATION_ENABLED` is false, existing linking behavior uses
304+
the IdP's `email_verified` claim or `SSO_TRUSTED_PROVIDER_IDS`. Once the flag is
305+
true, Sim requires the SSO provider's organization domain to be verified and no
306+
longer trusts an IdP-controlled email-verification claim by itself.
307+
</Callout>
308+
309+
<Callout type="warning">
310+
For upgrades, leave domain verification disabled until the database migration
311+
has landed and
312+
`SSO_AUDIT_APPROVED_PROVIDER_IDS=reviewed-provider-ids bun run --cwd packages/db db:audit-sso-providers`
313+
passes. The approval list records the providers whose existing account links
314+
and active sessions operators chose to retain; omit a provider until its
315+
links are migrated or removed and its sessions are revoked. The audit reports
316+
linked-user and active-session counts and requires one owner organization and
317+
one registrable domain per provider.
318+
Remediate legacy user-scoped, public-suffix, duplicate, or overlapping rows;
319+
explicitly backfill `domain_verified` only for domains your operators have
320+
verified; quiesce SSO writes and rerun the audit immediately before enabling
321+
the flag. Internal pseudo-domains are not eligible for verified-domain
322+
enforcement. Existing internal-domain providers can remain unchanged while
323+
the flag is off, but must move to a registrable domain before activation.
304324
</Callout>
305325

306326
You can register providers through the **Settings UI** (same as cloud) or by running the registration script directly against your database.
@@ -318,6 +338,7 @@ SSO_PROVIDER_ID=okta \
318338
SSO_ISSUER=https://dev-1234567.okta.com/oauth2/default \
319339
SSO_DOMAIN=company.com \
320340
SSO_USER_EMAIL=admin@company.com \
341+
SSO_ORGANIZATION_ID=your-organization-id \
321342
SSO_OIDC_CLIENT_ID=your-client-id \
322343
SSO_OIDC_CLIENT_SECRET=your-client-secret \
323344
bun run packages/db/scripts/register-sso-provider.ts
@@ -332,6 +353,7 @@ SSO_PROVIDER_ID=adfs \
332353
SSO_ISSUER=https://your-instance.com \
333354
SSO_DOMAIN=company.com \
334355
SSO_USER_EMAIL=admin@company.com \
356+
SSO_ORGANIZATION_ID=your-organization-id \
335357
SSO_SAML_ENTRY_POINT=https://adfs.company.com/adfs/ls \
336358
SSO_SAML_CERT="-----BEGIN CERTIFICATE-----
337359
...
@@ -345,5 +367,7 @@ To remove a provider:
345367

346368
```bash
347369
SSO_USER_EMAIL=admin@company.com \
370+
SSO_ORGANIZATION_ID=your-organization-id \
371+
SSO_PROVIDER_ID=adfs \
348372
bun run packages/db/scripts/deregister-sso-provider.ts
349373
```

apps/sim/app/api/auth/[...all]/route.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ const handlerMocks = vi.hoisted(() => ({
1313
session: { id: 'anon-session' },
1414
})),
1515
isAuthDisabled: false,
16+
withSSOCallbackIntent: vi.fn((_providerId: string, callback: () => Promise<unknown>) =>
17+
callback()
18+
),
19+
}))
20+
21+
vi.mock('@/lib/auth/sso/provider-operation-intent', () => ({
22+
withSSOCallbackIntent: handlerMocks.withSSOCallbackIntent,
1623
}))
1724

1825
vi.mock('better-auth/next-js', () => ({
@@ -137,3 +144,82 @@ describe('auth catch-all route organization mutations', () => {
137144
expect(json).toEqual({ data: { ok: true } })
138145
})
139146
})
147+
148+
describe('auth catch-all route SSO mutations', () => {
149+
beforeEach(() => {
150+
vi.clearAllMocks()
151+
})
152+
153+
it.each([
154+
'register',
155+
'update-provider',
156+
'delete-provider',
157+
'request-domain-verification',
158+
'verify-domain',
159+
])('blocks the raw Better Auth /sso/%s endpoint', async (path) => {
160+
const req = createMockRequest(
161+
'POST',
162+
undefined,
163+
{},
164+
`http://localhost:3000/api/auth/sso/${path}`
165+
)
166+
const res = await POST(req as any)
167+
168+
expect(res.status).toBe(404)
169+
expect(handlerMocks.betterAuthPOST).not.toHaveBeenCalled()
170+
})
171+
172+
it('continues to delegate non-mutation SSO endpoints', async () => {
173+
const { NextResponse } = await import('next/server')
174+
handlerMocks.betterAuthPOST.mockResolvedValueOnce(
175+
new NextResponse(null, { status: 200 }) as any
176+
)
177+
const req = createMockRequest(
178+
'POST',
179+
undefined,
180+
{},
181+
'http://localhost:3000/api/auth/sign-in/sso'
182+
)
183+
184+
const res = await POST(req as any)
185+
expect(res.status).toBe(200)
186+
expect(handlerMocks.betterAuthPOST).toHaveBeenCalledTimes(1)
187+
expect(handlerMocks.withSSOCallbackIntent).not.toHaveBeenCalled()
188+
})
189+
190+
it('registers an intent around OIDC GET callbacks', async () => {
191+
const { NextResponse } = await import('next/server')
192+
handlerMocks.betterAuthGET.mockResolvedValueOnce(new NextResponse(null, { status: 302 }) as any)
193+
const req = createMockRequest(
194+
'GET',
195+
undefined,
196+
{},
197+
'http://localhost:3000/api/auth/sso/callback/acme'
198+
)
199+
200+
const res = await GET(req as any)
201+
202+
expect(res.status).toBe(302)
203+
expect(handlerMocks.withSSOCallbackIntent).toHaveBeenCalledWith('acme', expect.any(Function))
204+
expect(handlerMocks.betterAuthGET).toHaveBeenCalledTimes(1)
205+
})
206+
207+
it('registers an intent around SAML POST callbacks', async () => {
208+
const { NextResponse } = await import('next/server')
209+
handlerMocks.betterAuthPOST.mockResolvedValueOnce(
210+
new NextResponse(null, { status: 302 }) as any
211+
)
212+
const req = createMockRequest(
213+
'POST',
214+
undefined,
215+
{},
216+
'http://localhost:3000/api/auth/sso/saml2/callback/acme'
217+
)
218+
219+
const res = await POST(req as any)
220+
221+
expect(res.status).toBe(302)
222+
expect(handlerMocks.withSSOCallbackIntent).toHaveBeenCalledWith('acme', expect.any(Function))
223+
expect(handlerMocks.betterAuthPOST).toHaveBeenCalledTimes(1)
224+
})
225+
})

apps/sim/app/api/auth/[...all]/route.ts

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,21 @@ import { toNextJsHandler } from 'better-auth/next-js'
22
import { type NextRequest, NextResponse } from 'next/server'
33
import { auth } from '@/lib/auth'
44
import { createAnonymousSession, ensureAnonymousUserExists } from '@/lib/auth/anonymous'
5+
import { withSSOCallbackIntent } from '@/lib/auth/sso/provider-operation-intent'
56
import { isAuthDisabled } from '@/lib/core/config/env-flags'
67
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
78

89
export const dynamic = 'force-dynamic'
910

1011
const { GET: betterAuthGET, POST: betterAuthPOST } = toNextJsHandler(auth.handler)
1112
const SAFE_ORGANIZATION_POST_PATHS = new Set(['organization/check-slug', 'organization/set-active'])
13+
const BLOCKED_SSO_MUTATION_PATHS = new Set([
14+
'sso/register',
15+
'sso/update-provider',
16+
'sso/delete-provider',
17+
'sso/request-domain-verification',
18+
'sso/verify-domain',
19+
])
1220

1321
function getAuthPath(request: NextRequest): string {
1422
const pathname = request.nextUrl?.pathname ?? new URL(request.url).pathname
@@ -19,6 +27,23 @@ function isBlockedOrganizationMutationPath(path: string): boolean {
1927
return path.startsWith('organization/') && !SAFE_ORGANIZATION_POST_PATHS.has(path)
2028
}
2129

30+
function getSSOCallbackProviderId(path: string): string | null {
31+
const prefix = path.startsWith('sso/callback/')
32+
? 'sso/callback/'
33+
: path.startsWith('sso/saml2/callback/')
34+
? 'sso/saml2/callback/'
35+
: null
36+
if (!prefix) return null
37+
38+
const encodedProviderId = path.slice(prefix.length).split('/')[0]
39+
if (!encodedProviderId) return null
40+
try {
41+
return decodeURIComponent(encodedProviderId)
42+
} catch {
43+
return null
44+
}
45+
}
46+
2247
export const GET = withRouteHandler(async (request: NextRequest) => {
2348
const path = getAuthPath(request)
2449

@@ -27,7 +52,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
2752
return NextResponse.json(createAnonymousSession())
2853
}
2954

30-
return betterAuthGET(request)
55+
const callbackProviderId = getSSOCallbackProviderId(path)
56+
return callbackProviderId
57+
? withSSOCallbackIntent(callbackProviderId, () => betterAuthGET(request))
58+
: betterAuthGET(request)
3159
})
3260

3361
export const POST = withRouteHandler(async (request: NextRequest) => {
@@ -40,5 +68,15 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
4068
)
4169
}
4270

43-
return betterAuthPOST(request)
71+
if (BLOCKED_SSO_MUTATION_PATHS.has(path)) {
72+
return NextResponse.json(
73+
{ error: 'SSO mutations are handled by application API routes.' },
74+
{ status: 404 }
75+
)
76+
}
77+
78+
const callbackProviderId = getSSOCallbackProviderId(path)
79+
return callbackProviderId
80+
? withSSOCallbackIntent(callbackProviderId, () => betterAuthPOST(request))
81+
: betterAuthPOST(request)
4482
})
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { withSSOProviderMutationLock } from '@sim/db'
2+
import { createLogger } from '@sim/logger'
3+
import { getErrorMessage } from '@sim/utils/errors'
4+
import type { NextRequest } from 'next/server'
5+
import { NextResponse } from 'next/server'
6+
import { requestSsoDomainVerificationContract } from '@/lib/api/contracts/auth'
7+
import { parseRequest } from '@/lib/api/server'
8+
import { auth, getSession } from '@/lib/auth'
9+
import {
10+
collectAuthHeaders,
11+
getDomainVerificationRecordName,
12+
getDomainVerificationRecordValue,
13+
getManagedSSOProvider,
14+
ssoManagementErrorResponse,
15+
} from '@/lib/auth/sso/management'
16+
import { env, isTruthy } from '@/lib/core/config/env'
17+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
18+
19+
const logger = createLogger('SSODomainVerificationRequestRoute')
20+
21+
type RouteContext = { params: Promise<{ id: string }> }
22+
23+
export const POST = withRouteHandler(async (request: NextRequest, context: RouteContext) => {
24+
try {
25+
if (!env.SSO_ENABLED) {
26+
return NextResponse.json({ error: 'SSO is not enabled' }, { status: 400 })
27+
}
28+
if (!isTruthy(env.SSO_DOMAIN_VERIFICATION_ENABLED)) {
29+
return NextResponse.json({ error: 'SSO domain verification is not enabled' }, { status: 404 })
30+
}
31+
const session = await getSession()
32+
if (!session?.user?.id) {
33+
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
34+
}
35+
36+
const parsed = await parseRequest(requestSsoDomainVerificationContract, request, context)
37+
if (!parsed.success) return parsed.response
38+
39+
const { provider, result } = await withSSOProviderMutationLock(async () => {
40+
const provider = await getManagedSSOProvider(parsed.data.params.id, session.user.id, {
41+
requireCreator: true,
42+
})
43+
const result = await auth.api.requestDomainVerification({
44+
body: { providerId: provider.providerId },
45+
headers: collectAuthHeaders(request),
46+
})
47+
return { provider, result }
48+
})
49+
50+
return NextResponse.json(
51+
{
52+
recordName: getDomainVerificationRecordName(provider.providerId, provider.domain),
53+
recordValue: getDomainVerificationRecordValue(
54+
provider.providerId,
55+
result.domainVerificationToken
56+
),
57+
},
58+
{ status: 201 }
59+
)
60+
} catch (error) {
61+
const managedResponse = ssoManagementErrorResponse(error)
62+
if (managedResponse) return managedResponse
63+
logger.error('Failed to request SSO domain verification', {
64+
error: getErrorMessage(error, 'Unknown error'),
65+
})
66+
return NextResponse.json(
67+
{ error: 'Failed to request SSO domain verification' },
68+
{ status: 500 }
69+
)
70+
}
71+
})

0 commit comments

Comments
 (0)