Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ on:
push:
branches: [main, staging, dev]
pull_request:
branches: [main, staging, dev]
branches: [main, staging, dev, e2e/settings-playwright]
# Docs content and markdown don't affect the app build or images; push
# runs stay unfiltered because they feed the deploy pipeline.
paths-ignore:
Expand Down
102 changes: 101 additions & 1 deletion .github/workflows/test-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -258,4 +258,104 @@ jobs:
AWS_REGION: 'us-west-2'
ENCRYPTION_KEY: '7cf672e460e430c1fba707575c2b0e2ad5a99dddf9b7b7e3b5646e630861db1c' # dummy key for CI only
TURBO_CACHE_DIR: .turbo
run: bunx turbo run build --filter=sim
run: bunx turbo run build --filter=sim

settings-e2e:
name: Settings E2E (informational)
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 45
continue-on-error: true

services:
postgres:
image: pgvector/pgvector:pg17
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 5s
--health-timeout 5s
--health-retries 10

steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6

- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.3.13

- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 22

- name: Mount Bun cache (Sticky Disk)
uses: useblacksmith/stickydisk@4c034ba57b706cf0e3b4b0ce098c2a3b1071580c # v1
with:
key: ${{ github.repository }}-bun-cache-${{ github.event_name }}${{ github.event.pull_request.head.repo.fork && '-fork' || '' }}
path: ~/.bun/install/cache

- name: Mount node_modules (Sticky Disk)
uses: useblacksmith/stickydisk@4c034ba57b706cf0e3b4b0ce098c2a3b1071580c # v1
with:
key: ${{ github.repository }}-node-modules-${{ github.event_name }}${{ github.event.pull_request.head.repo.fork && '-fork' || '' }}
path: ./node_modules

- name: Mount Playwright browsers (Sticky Disk)
uses: useblacksmith/stickydisk@4c034ba57b706cf0e3b4b0ce098c2a3b1071580c # v1
with:
key: ${{ github.repository }}-playwright-browsers-${{ github.event_name }}${{ github.event.pull_request.head.repo.fork && '-fork' || '' }}
path: ~/.cache/ms-playwright

- name: Install dependencies
run: bun install --frozen-lockfile

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

- name: Install Chromium
working-directory: apps/sim
run: bun run test:e2e:install-browsers -- --with-deps

- name: Run settings E2E foundation
timeout-minutes: 40
working-directory: apps/sim
env:
CI: 'true'
E2E_PG_ADMIN_URL: 'postgresql://postgres:postgres@127.0.0.1:5432/postgres'
run: bun run test:e2e

- name: Check E2E diagnostics eligibility
id: e2e_diagnostics
if: failure()
shell: bash
run: |
shopt -s nullglob
markers=(apps/sim/e2e/.runs/*/markers/leak-scan-complete.json)
if (( ${#markers[@]} > 0 )); then
echo "safe=true" >> "$GITHUB_OUTPUT"
else
echo "safe=false" >> "$GITHUB_OUTPUT"
fi

- name: Upload E2E diagnostics
if: failure() && steps.e2e_diagnostics.outputs.safe == 'true'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: settings-e2e-${{ github.run_id }}
path: |
apps/sim/playwright-report/
apps/sim/test-results/
apps/sim/e2e/.runs/
!apps/sim/e2e/.runs/**/auth/**
!apps/sim/e2e/.runs/**/private/**
!apps/sim/e2e/.runs/**/homes/**
if-no-files-found: ignore
include-hidden-files: true
retention-days: 7
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ package-lock.json
# testing
/coverage
/apps/**/coverage
/apps/**/playwright-report/
/apps/**/test-results/
/apps/**/e2e/.runs/
/apps/**/e2e/.auth/
/apps/**/e2e/.cache/

# next.js
/.next/
Expand All @@ -31,6 +36,7 @@ package-lock.json
**/dist/
**/standalone/
sim-standalone.tar.gz
/.artifacts/

# redis
dump.rdb
Expand Down
40 changes: 32 additions & 8 deletions apps/docs/content/docs/en/platform/enterprise/sso.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,7 @@ SSO provisioning creates internal organization members. External workspace membe
},
{
question: "A user already has an account with the same email — what happens when they sign in with SSO?",
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."
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."
},
{
question: "Who can configure SSO on Sim Cloud?",
Expand Down Expand Up @@ -281,11 +281,16 @@ Self-hosted deployments use environment variables instead of the billing/plan ch
SSO_ENABLED=true
NEXT_PUBLIC_SSO_ENABLED=true

# Enable only after the SSO schema migration, provider audit, and approved
# domain-verification backfill described below.
SSO_DOMAIN_VERIFICATION_ENABLED=false

# Required if you want users auto-added to your organization on first SSO sign-in
ORGANIZATIONS_ENABLED=true
NEXT_PUBLIC_ORGANIZATIONS_ENABLED=true

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

<Callout type="info">
When someone signs in with SSO and an account with the same email already exists
(for example, they previously signed up with email/password), Sim links the SSO
identity to that account automatically as long as your IdP reports the email as
verified, or the provider is trusted. If you hit an `account not linked` error,
either confirm your IdP sends `email_verified`, or add the provider's ID to
`SSO_TRUSTED_PROVIDER_IDS` and restart.
While `SSO_DOMAIN_VERIFICATION_ENABLED` is false, existing linking behavior uses
the IdP's `email_verified` claim or `SSO_TRUSTED_PROVIDER_IDS`. Once the flag is
true, Sim requires the SSO provider's organization domain to be verified and no
longer trusts an IdP-controlled email-verification claim by itself.
</Callout>

<Callout type="warning">
For upgrades, leave domain verification disabled until the database migration
has landed and
`SSO_AUDIT_APPROVED_PROVIDER_IDS=reviewed-provider-ids bun run --cwd packages/db db:audit-sso-providers`
passes. The approval list records the providers whose existing account links
and active sessions operators chose to retain; omit a provider until its
links are migrated or removed and its sessions are revoked. The audit reports
linked-user and active-session counts and requires one owner organization and
one registrable domain per provider.
Remediate legacy user-scoped, public-suffix, duplicate, or overlapping rows;
explicitly backfill `domain_verified` only for domains your operators have
verified; quiesce SSO writes and rerun the audit immediately before enabling
the flag. Internal pseudo-domains are not eligible for verified-domain
enforcement. Existing internal-domain providers can remain unchanged while
the flag is off, but must move to a registrable domain before activation.
</Callout>

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

```bash
SSO_USER_EMAIL=admin@company.com \
SSO_ORGANIZATION_ID=your-organization-id \
SSO_PROVIDER_ID=adfs \
bun run packages/db/scripts/deregister-sso-provider.ts
```
1 change: 1 addition & 0 deletions apps/realtime/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const EnvSchema = z.object({
INTERNAL_API_SECRET: z.string().min(32),
NEXT_PUBLIC_APP_URL: z.string().url(),
ALLOWED_ORIGINS: z.string().optional(),
REALTIME_HOST: z.string().min(1).default('0.0.0.0'),
PORT: z.coerce.number().int().positive().default(3002),
SIM_DB_ROLE: z.enum(['web', 'trigger', 'realtime']).optional(),
DISABLE_AUTH: z
Expand Down
7 changes: 4 additions & 3 deletions apps/realtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ async function createRoomManager(io: SocketIOServer): Promise<IRoomManager> {
async function main() {
const httpServer = createServer()
const PORT = env.PORT
const HOST = env.REALTIME_HOST

logger.info('Starting Socket.IO server...', {
port: PORT,
Expand Down Expand Up @@ -96,9 +97,9 @@ async function main() {

await assertSchemaCompatibility()

httpServer.listen(PORT, '0.0.0.0', () => {
logger.info(`Socket.IO server running on port ${PORT}`)
logger.info(`Health check available at: http://localhost:${PORT}/health`)
httpServer.listen(PORT, HOST, () => {
logger.info(`Socket.IO server running on ${HOST}:${PORT}`)
logger.info(`Health check available at: http://${HOST}:${PORT}/health`)
})

const shutdown = async () => {
Expand Down
1 change: 1 addition & 0 deletions apps/realtime/src/routes/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export function createHttpHandler(roomManager: IRoomManager, logger: Logger) {
status: 'ok',
timestamp: new Date().toISOString(),
connections,
...(process.env.E2E_RUN_ID ? { runId: process.env.E2E_RUN_ID } : {}),
})
)
} catch (error) {
Expand Down
3 changes: 2 additions & 1 deletion apps/sim/app/(landing)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { XPageViewTracker } from '@/app/(landing)/x-page-view-tracker'
const HUBSPOT_SCRIPT_SRC = 'https://js-na2.hs-scripts.com/246720681.js' as const

const X_PIXEL_ID = 'q5xbl' as const
const isMarketingAnalyticsEnabled = isHosted && !process.env.E2E_PROFILE

/** X (Twitter) conversion tracking base code — loads uwt.js and fires the initial PageView. */
const X_PIXEL_BASE_CODE = `!function(e,t,n,s,u,a){e.twq||(s=e.twq=function(){s.exe?s.exe.apply(s,arguments):s.queue.push(arguments);
Expand Down Expand Up @@ -43,7 +44,7 @@ export default function LandingLayout({ children }: { children: ReactNode }) {
<LandingShell>
{children}
{/* HubSpot + X pixel tracking — hosted only */}
{isHosted && (
{isMarketingAnalyticsEnabled && (
<>
<Script id='hs-script-loader' src={HUBSPOT_SCRIPT_SRC} strategy='afterInteractive' />
<Script id='x-pixel-base' strategy='afterInteractive'>
Expand Down
86 changes: 86 additions & 0 deletions apps/sim/app/api/auth/[...all]/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ const handlerMocks = vi.hoisted(() => ({
session: { id: 'anon-session' },
})),
isAuthDisabled: false,
withSSOCallbackIntent: vi.fn((_providerId: string, callback: () => Promise<unknown>) =>
callback()
),
}))

vi.mock('@/lib/auth/sso/provider-operation-intent', () => ({
withSSOCallbackIntent: handlerMocks.withSSOCallbackIntent,
}))

vi.mock('better-auth/next-js', () => ({
Expand Down Expand Up @@ -137,3 +144,82 @@ describe('auth catch-all route organization mutations', () => {
expect(json).toEqual({ data: { ok: true } })
})
})

describe('auth catch-all route SSO mutations', () => {
beforeEach(() => {
vi.clearAllMocks()
})

it.each([
'register',
'update-provider',
'delete-provider',
'request-domain-verification',
'verify-domain',
])('blocks the raw Better Auth /sso/%s endpoint', async (path) => {
const req = createMockRequest(
'POST',
undefined,
{},
`http://localhost:3000/api/auth/sso/${path}`
)
const res = await POST(req as any)

expect(res.status).toBe(404)
expect(handlerMocks.betterAuthPOST).not.toHaveBeenCalled()
})

it('continues to delegate non-mutation SSO endpoints', async () => {
const { NextResponse } = await import('next/server')
handlerMocks.betterAuthPOST.mockResolvedValueOnce(
new NextResponse(null, { status: 200 }) as any
)
const req = createMockRequest(
'POST',
undefined,
{},
'http://localhost:3000/api/auth/sign-in/sso'
)

const res = await POST(req as any)
expect(res.status).toBe(200)
expect(handlerMocks.betterAuthPOST).toHaveBeenCalledTimes(1)
expect(handlerMocks.withSSOCallbackIntent).not.toHaveBeenCalled()
})

it('registers an intent around OIDC GET callbacks', async () => {
const { NextResponse } = await import('next/server')
handlerMocks.betterAuthGET.mockResolvedValueOnce(new NextResponse(null, { status: 302 }) as any)
const req = createMockRequest(
'GET',
undefined,
{},
'http://localhost:3000/api/auth/sso/callback/acme'
)

const res = await GET(req as any)

expect(res.status).toBe(302)
expect(handlerMocks.withSSOCallbackIntent).toHaveBeenCalledWith('acme', expect.any(Function))
expect(handlerMocks.betterAuthGET).toHaveBeenCalledTimes(1)
})

it('registers an intent around SAML POST callbacks', async () => {
const { NextResponse } = await import('next/server')
handlerMocks.betterAuthPOST.mockResolvedValueOnce(
new NextResponse(null, { status: 302 }) as any
)
const req = createMockRequest(
'POST',
undefined,
{},
'http://localhost:3000/api/auth/sso/saml2/callback/acme'
)

const res = await POST(req as any)

expect(res.status).toBe(302)
expect(handlerMocks.withSSOCallbackIntent).toHaveBeenCalledWith('acme', expect.any(Function))
expect(handlerMocks.betterAuthPOST).toHaveBeenCalledTimes(1)
})
})
Loading