Skip to content

Commit 7031d3f

Browse files
Bill Leoutsakoscursoragent
authored andcommitted
test(e2e): cover enterprise integrations
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 2d283c3 commit 7031d3f

67 files changed

Lines changed: 23565 additions & 1460 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: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,3 +137,45 @@ describe('auth catch-all route organization mutations', () => {
137137
expect(json).toEqual({ data: { ok: true } })
138138
})
139139
})
140+
141+
describe('auth catch-all route SSO mutations', () => {
142+
beforeEach(() => {
143+
vi.clearAllMocks()
144+
})
145+
146+
it.each([
147+
'register',
148+
'update-provider',
149+
'delete-provider',
150+
'request-domain-verification',
151+
'verify-domain',
152+
])('blocks the raw Better Auth /sso/%s endpoint', async (path) => {
153+
const req = createMockRequest(
154+
'POST',
155+
undefined,
156+
{},
157+
`http://localhost:3000/api/auth/sso/${path}`
158+
)
159+
const res = await POST(req as any)
160+
161+
expect(res.status).toBe(404)
162+
expect(handlerMocks.betterAuthPOST).not.toHaveBeenCalled()
163+
})
164+
165+
it('continues to delegate non-mutation SSO endpoints', async () => {
166+
const { NextResponse } = await import('next/server')
167+
handlerMocks.betterAuthPOST.mockResolvedValueOnce(
168+
new NextResponse(null, { status: 200 }) as any
169+
)
170+
const req = createMockRequest(
171+
'POST',
172+
undefined,
173+
{},
174+
'http://localhost:3000/api/auth/sign-in/sso'
175+
)
176+
177+
const res = await POST(req as any)
178+
expect(res.status).toBe(200)
179+
expect(handlerMocks.betterAuthPOST).toHaveBeenCalledTimes(1)
180+
})
181+
})

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@ export const dynamic = 'force-dynamic'
99

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

1320
function getAuthPath(request: NextRequest): string {
1421
const pathname = request.nextUrl?.pathname ?? new URL(request.url).pathname
@@ -40,5 +47,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
4047
)
4148
}
4249

50+
if (BLOCKED_SSO_MUTATION_PATHS.has(path)) {
51+
return NextResponse.json(
52+
{ error: 'SSO mutations are handled by application API routes.' },
53+
{ status: 404 }
54+
)
55+
}
56+
4357
return betterAuthPOST(request)
4458
})
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { createLogger } from '@sim/logger'
2+
import { getErrorMessage } from '@sim/utils/errors'
3+
import type { NextRequest } from 'next/server'
4+
import { NextResponse } from 'next/server'
5+
import { requestSsoDomainVerificationContract } from '@/lib/api/contracts/auth'
6+
import { parseRequest } from '@/lib/api/server'
7+
import { auth, getSession } from '@/lib/auth'
8+
import {
9+
collectAuthHeaders,
10+
getDomainVerificationRecordName,
11+
getDomainVerificationRecordValue,
12+
getManagedSSOProvider,
13+
ssoManagementErrorResponse,
14+
} from '@/lib/auth/sso/management'
15+
import { env, isTruthy } from '@/lib/core/config/env'
16+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
17+
18+
const logger = createLogger('SSODomainVerificationRequestRoute')
19+
20+
type RouteContext = { params: Promise<{ id: string }> }
21+
22+
export const POST = withRouteHandler(async (request: NextRequest, context: RouteContext) => {
23+
try {
24+
if (!env.SSO_ENABLED) {
25+
return NextResponse.json({ error: 'SSO is not enabled' }, { status: 400 })
26+
}
27+
if (!isTruthy(env.SSO_DOMAIN_VERIFICATION_ENABLED)) {
28+
return NextResponse.json({ error: 'SSO domain verification is not enabled' }, { status: 404 })
29+
}
30+
const session = await getSession()
31+
if (!session?.user?.id) {
32+
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
33+
}
34+
35+
const parsed = await parseRequest(requestSsoDomainVerificationContract, request, context)
36+
if (!parsed.success) return parsed.response
37+
38+
const provider = await getManagedSSOProvider(parsed.data.params.id, session.user.id, {
39+
requireCreator: true,
40+
})
41+
const result = await auth.api.requestDomainVerification({
42+
body: { providerId: provider.providerId },
43+
headers: collectAuthHeaders(request),
44+
})
45+
46+
return NextResponse.json(
47+
{
48+
recordName: getDomainVerificationRecordName(provider.providerId, provider.domain),
49+
recordValue: getDomainVerificationRecordValue(
50+
provider.providerId,
51+
result.domainVerificationToken
52+
),
53+
},
54+
{ status: 201 }
55+
)
56+
} catch (error) {
57+
const managedResponse = ssoManagementErrorResponse(error)
58+
if (managedResponse) return managedResponse
59+
logger.error('Failed to request SSO domain verification', {
60+
error: getErrorMessage(error, 'Unknown error'),
61+
})
62+
return NextResponse.json(
63+
{ error: 'Failed to request SSO domain verification' },
64+
{ status: 500 }
65+
)
66+
}
67+
})
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { createEnvMock, createMockRequest } from '@sim/testing'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const {
8+
dbState,
9+
memberTable,
10+
mockGetSession,
11+
mockIsOrganizationOnEnterprisePlan,
12+
mockRequestDomainVerification,
13+
mockVerifyDomain,
14+
ssoProviderTable,
15+
} = vi.hoisted(() => ({
16+
dbState: {
17+
members: [] as Array<{ role: string }>,
18+
providers: [] as Array<Record<string, unknown>>,
19+
},
20+
memberTable: {
21+
userId: 'member.userId',
22+
organizationId: 'member.organizationId',
23+
role: 'member.role',
24+
},
25+
mockGetSession: vi.fn(),
26+
mockIsOrganizationOnEnterprisePlan: vi.fn(),
27+
mockRequestDomainVerification: vi.fn(),
28+
mockVerifyDomain: vi.fn(),
29+
ssoProviderTable: {
30+
id: 'sso.id',
31+
issuer: 'sso.issuer',
32+
domain: 'sso.domain',
33+
domainVerified: 'sso.domainVerified',
34+
oidcConfig: 'sso.oidcConfig',
35+
samlConfig: 'sso.samlConfig',
36+
userId: 'sso.userId',
37+
providerId: 'sso.providerId',
38+
organizationId: 'sso.organizationId',
39+
},
40+
}))
41+
42+
function makeBuilder(rows: unknown[]): Promise<unknown[]> & {
43+
where: () => ReturnType<typeof makeBuilder>
44+
limit: () => Promise<unknown[]>
45+
} {
46+
const builder = Promise.resolve(rows) as Promise<unknown[]> & {
47+
where: () => ReturnType<typeof makeBuilder>
48+
limit: () => Promise<unknown[]>
49+
}
50+
builder.where = () => makeBuilder(rows)
51+
builder.limit = () => Promise.resolve(rows)
52+
return builder
53+
}
54+
55+
vi.mock('@sim/db', () => ({
56+
db: {
57+
select: () => ({
58+
from: (table: unknown) =>
59+
makeBuilder(table === memberTable ? dbState.members : dbState.providers),
60+
}),
61+
},
62+
member: memberTable,
63+
ssoProvider: ssoProviderTable,
64+
}))
65+
66+
vi.mock('@/lib/auth', () => ({
67+
getSession: mockGetSession,
68+
auth: {
69+
api: {
70+
requestDomainVerification: mockRequestDomainVerification,
71+
verifyDomain: mockVerifyDomain,
72+
},
73+
},
74+
}))
75+
76+
vi.mock('@/lib/billing', () => ({
77+
isOrganizationOnEnterprisePlan: mockIsOrganizationOnEnterprisePlan,
78+
}))
79+
80+
vi.mock('@/lib/core/config/env', () =>
81+
createEnvMock({ SSO_ENABLED: 'true', SSO_DOMAIN_VERIFICATION_ENABLED: 'true' })
82+
)
83+
84+
import { POST as requestVerification } from '@/app/api/auth/sso/providers/[id]/domain-verification/request/route'
85+
import { POST as verifyDomain } from '@/app/api/auth/sso/providers/[id]/domain-verification/verify/route'
86+
87+
const PROVIDER = {
88+
id: 'row-1',
89+
issuer: 'https://idp.example.com',
90+
domain: 'acme.com',
91+
domainVerified: false,
92+
oidcConfig: null,
93+
samlConfig: '{}',
94+
userId: 'creator-1',
95+
providerId: 'acme-saml',
96+
organizationId: 'org-1',
97+
}
98+
const context = { params: Promise.resolve({ id: 'row-1' }) }
99+
100+
describe('SSO domain verification façades', () => {
101+
beforeEach(() => {
102+
vi.clearAllMocks()
103+
dbState.members = [{ role: 'admin' }]
104+
dbState.providers = [PROVIDER]
105+
mockGetSession.mockResolvedValue({ user: { id: 'creator-1' } })
106+
mockIsOrganizationOnEnterprisePlan.mockResolvedValue(true)
107+
mockRequestDomainVerification.mockResolvedValue({ domainVerificationToken: 'secret-token' })
108+
mockVerifyDomain.mockResolvedValue(undefined)
109+
})
110+
111+
it('preserves Better Auth creator identity authorization', async () => {
112+
mockGetSession.mockResolvedValue({ user: { id: 'different-admin' } })
113+
const response = await requestVerification(createMockRequest('POST'), context)
114+
expect(response.status).toBe(403)
115+
expect(mockRequestDomainVerification).not.toHaveBeenCalled()
116+
})
117+
118+
it('returns DNS instructions only to the creator', async () => {
119+
const response = await requestVerification(
120+
createMockRequest('POST', undefined, { cookie: 'session=one' }),
121+
context
122+
)
123+
expect(response.status).toBe(201)
124+
await expect(response.json()).resolves.toEqual({
125+
recordName: '_better-auth-token-acme-saml.acme.com',
126+
recordValue: '_better-auth-token-acme-saml=secret-token',
127+
})
128+
expect(mockRequestDomainVerification).toHaveBeenCalledWith(
129+
expect.objectContaining({ headers: expect.objectContaining({ cookie: 'session=one' }) })
130+
)
131+
})
132+
133+
it('delegates DNS verification to Better Auth', async () => {
134+
const response = await verifyDomain(createMockRequest('POST'), context)
135+
expect(response.status).toBe(200)
136+
expect(mockVerifyDomain).toHaveBeenCalledWith(
137+
expect.objectContaining({ body: { providerId: 'acme-saml' } })
138+
)
139+
})
140+
})

0 commit comments

Comments
 (0)