Skip to content
Merged
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
10 changes: 8 additions & 2 deletions apps/docs/content/docs/platform/enterprise/sso.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ An organization can run several identity providers at once: Okta for `eng.acme.c

### 4. Copy the callback URL

Copy **Callback URL** for OIDC or **ACS URL (Reply URL)** for SAML. This is the endpoint that receives your identity provider's authentication response. Register it in your IdP before saving. If you set a SAML **Callback URL override** under Advanced options, the copyable ACS URL uses that override.
Copy **Callback URL** for OIDC or **ACS URL (Reply URL)** for SAML. This is the endpoint that receives your identity provider's authentication response. Register it in your IdP before saving. On Sim Cloud, `<your-sim-domain>` is `www.sim.ai`; self-hosted deployments use their own domain. If you set a SAML **Callback URL override** under Advanced options, the copyable ACS URL uses that override.

**OIDC providers** (Okta, Microsoft Entra ID, Google Workspace, Auth0):
```
Expand Down Expand Up @@ -140,7 +140,11 @@ The first time someone signs in through the new provider, Sim links it to their
```
4. Under **Assignments**, grant access to the relevant users or groups
5. Copy the **Client ID** and **Client Secret** from the app's **General** tab
6. Copy your Okta organization domain from the account menu in the Admin Console, e.g. `dev-1234567.okta.com`. The Admin Console's `-admin` hostname is a different URL. See [Find your Okta domain](https://developer.okta.com/docs/guides/find-your-domain/main/).
6. To open Sim from the Okta dashboard, set **Login initiated by** to **Either Okta or App**, show the app icon to users, choose **Redirect to app to initiate login (OIDC Compliant)**, and set **Initiate login URI** to the provider's **Initiate login URL** from Sim:
```
https://<your-sim-domain>/sso/launch/okta
```
7. Copy your Okta organization domain from the account menu in the Admin Console, e.g. `dev-1234567.okta.com`. The Admin Console's `-admin` hostname is a different URL. See [Find your Okta domain](https://developer.okta.com/docs/guides/find-your-domain/main/).

**In Sim:**

Expand Down Expand Up @@ -305,6 +309,8 @@ Once SSO is configured, users with your domain (`company.com`) can sign in throu
5. If **First sign-in** is **Automatic**, Sim adds them to the organization as a Member, growing a Team seat count or validating available fixed-seat capacity
6. They land in an accessible workspace, or see a clear no-access state until an admin grants workspace access

People can also open Sim straight from an OIDC identity provider's app dashboard, such as the Okta tile. Open **Sign-in**, select the provider, and copy its **Initiate login URL** from **Identity provider**. Set it as the app's initiate login URI in your identity provider. Sim starts sign-in through that provider without asking for an email, and only when the provider's domain is verified and the request comes from its own issuer. People who are already signed in go straight to Sim.

With **Automatic** provisioning, no invitation is required for organization membership. The join follows the organization's seat policy and does not infer a role from IdP claims: every newly provisioned user starts as a Member. Team subscriptions grow their billed seat count with membership; fixed-seat plans reject the join when capacity is full. With **Invite only**, SSO proves identity but does not create new membership or workspace access; new access must be granted separately, while existing organization membership and workspace access remain available.

<Callout type="warn">
Expand Down
136 changes: 136 additions & 0 deletions apps/sim/app/(auth)/sso/launch/[providerId]/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/**
* @vitest-environment node
*/
import { createMockRequest, setEnvFlags } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { mockGetSession, mockSignInSSO, mockIsAllowed, mockEnforceIpRateLimit } = vi.hoisted(() => ({
mockGetSession: vi.fn(),
mockSignInSSO: vi.fn(),
mockIsAllowed: vi.fn(),
mockEnforceIpRateLimit: vi.fn(),
}))

vi.mock('@/lib/auth', () => ({
getSession: mockGetSession,
auth: { api: { signInSSO: mockSignInSSO } },
}))
vi.mock('@/lib/auth/sso/idp-initiated-login', () => ({ isIdpInitiatedLoginAllowed: mockIsAllowed }))
vi.mock('@/lib/core/rate-limiter', () => ({ enforceIpRateLimit: mockEnforceIpRateLimit }))

import { GET } from '@/app/(auth)/sso/launch/[providerId]/route'

const context = { params: Promise.resolve({ providerId: 'acme-okta' }) }
const ISSUER = 'https://acme.okta.test'
const SIGN_IN_LINK = 'https://test.sim.ai/sso?provider=acme-okta'

function open(search = `?iss=${encodeURIComponent(ISSUER)}`) {
return GET(
createMockRequest('GET', undefined, {}, `https://test.sim.ai/sso/launch/acme-okta${search}`),
context
)
}

/** Better Auth answers with the authorization URL and the signed `state` cookie for it. */
function authorizationResponse() {
return new Response(JSON.stringify({ url: 'https://acme.okta.test/oauth2/v1/authorize?x=1' }), {
status: 200,
headers: { 'content-type': 'application/json', 'set-cookie': 'sso_state=abc; Path=/' },
})
}

describe('GET /sso/launch/[providerId]', () => {
beforeEach(() => {
vi.clearAllMocks()
setEnvFlags({ isSsoEnabled: true })
mockGetSession.mockResolvedValue(null)
mockIsAllowed.mockResolvedValue(true)
mockEnforceIpRateLimit.mockResolvedValue(null)
mockSignInSSO.mockResolvedValue(authorizationResponse())
})

it("redirects to the identity provider and carries Better Auth's state cookie", async () => {
const response = await open()

expect(response.status).toBe(307)
expect(response.headers.get('location')).toBe('https://acme.okta.test/oauth2/v1/authorize?x=1')
expect(response.headers.get('set-cookie')).toContain('sso_state=abc')
expect(mockIsAllowed).toHaveBeenCalledWith('acme-okta', ISSUER)
const [{ body }] = mockSignInSSO.mock.calls[0]
expect(body.providerId).toBe('acme-okta')
expect(body).not.toHaveProperty('email')
/** The plugin appends `?error=…`, which must not corrupt the provider on the way back. */
const retry = new URL(`${body.errorCallbackURL}?error=invalid_provider`)
expect(retry.pathname).toBe('/sso')
expect(retry.searchParams.get('provider')).toBe('acme-okta')
})

it('sends someone already signed in to the app without signing in again', async () => {
mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })

const response = await open()

expect(response.headers.get('location')).toBe('https://test.sim.ai/home')
expect(mockIsAllowed).not.toHaveBeenCalled()
expect(mockSignInSSO).not.toHaveBeenCalled()
})

it('keeps sending a signed-in visitor to the app when the address is rate limited', async () => {
mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
mockEnforceIpRateLimit.mockResolvedValue(new Response(null, { status: 429 }))

const response = await open()

expect(response.headers.get('location')).toBe('https://test.sim.ai/home')
expect(mockEnforceIpRateLimit).not.toHaveBeenCalled()
})

it.each([
['no issuer', '', () => undefined],
[
'an issuer the provider does not use',
`?iss=${encodeURIComponent('https://other.test')}`,
() => mockIsAllowed.mockResolvedValue(false),
],
])("sends a visitor with %s to the provider's sign-in link", async (_label, search, arrange) => {
arrange()

const response = await open(search)

expect(response.headers.get('location')).toBe(SIGN_IN_LINK)
expect(mockSignInSSO).not.toHaveBeenCalled()
})

it.each([
['refuses', () => mockSignInSSO.mockResolvedValue(new Response('{}', { status: 400 }))],
['throws', () => mockSignInSSO.mockRejectedValue(new Error('network'))],
])("reports the failure on the provider's sign-in link when sign-in %s", async (_l, arrange) => {
arrange()

const response = await open()

const failure = new URL(response.headers.get('location') ?? '')
expect(failure.pathname).toBe('/sso')
expect(failure.searchParams.get('error')).toBe('sso_failed')
expect(failure.searchParams.get('provider')).toBe('acme-okta')
})

it('sends a rate-limited visitor to the sign-in link before any lookup', async () => {
mockEnforceIpRateLimit.mockResolvedValue(new Response(null, { status: 429 }))

const response = await open()

expect(response.headers.get('location')).toBe(SIGN_IN_LINK)
expect(mockIsAllowed).not.toHaveBeenCalled()
expect(mockSignInSSO).not.toHaveBeenCalled()
})

it('leaves SSO off when the deployment has not enabled it', async () => {
setEnvFlags({ isSsoEnabled: false })

const response = await open()

expect(response.headers.get('location')).toBe('https://test.sim.ai/login')
expect(mockEnforceIpRateLimit).not.toHaveBeenCalled()
})
})
89 changes: 89 additions & 0 deletions apps/sim/app/(auth)/sso/launch/[providerId]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { auth, getSession } from '@/lib/auth'
import { isIdpInitiatedLoginAllowed } from '@/lib/auth/sso/idp-initiated-login'
import { isSsoEnabled } from '@/lib/core/config/env-flags'
import { enforceIpRateLimit } from '@/lib/core/rate-limiter'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { DEFAULT_POST_AUTH_ROUTE } from '@/app/(auth)/auth-redirect'

const logger = createLogger('SSOLaunchRoute')

type RouteContext = { params: Promise<{ providerId: string }> }

/**
* The initiate login URL an identity provider's app dashboard opens (OpenID Connect third-party
* initiated login). The dashboard adds its issuer as `iss`, so the URL carries no query of its own.
*
* Sign-in starts here rather than on the sign-in page: the visitor arrives to be sent onward, and a
* redirect spares them a page load and a hydration wait first. Someone already signed in goes
* straight to the app, so a link cannot replace their session. Anything else — an unknown issuer, a
* provider this deployment does not serve, a refused sign-in — falls back to the provider's ordinary
* sign-in link, which asks for an email.
*/
export const GET = withRouteHandler(async (request: NextRequest, context: RouteContext) => {
const { providerId } = await context.params
const signInLink = new URL(
`/sso?provider=${encodeURIComponent(providerId)}`,
getBaseUrl()
).toString()
if (!isSsoEnabled) return NextResponse.redirect(new URL('/login', getBaseUrl()).toString())

const session = await getSession()
if (session?.user) {
return NextResponse.redirect(new URL(DEFAULT_POST_AUTH_ROUTE, getBaseUrl()).toString())
}

/** Admitted per address, after the session, so a busy shared address never strands a signed-in visitor. */
const rateLimited = await enforceIpRateLimit('sso-launch', request, {
maxTokens: 30,
refillRate: 30,
refillIntervalMs: 60_000,
})
if (rateLimited) return NextResponse.redirect(signInLink)
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.

const issuer = request.nextUrl.searchParams.get('iss')
if (!issuer || !(await isIdpInitiatedLoginAllowed(providerId, issuer))) {
return NextResponse.redirect(signInLink)
Comment thread
waleedlatif1 marked this conversation as resolved.
}

/**
* A failed sign-in returns to the provider's sign-in link with the error. `callbackUrl` comes
* last because the SSO plugin appends its own error with a raw `?`, which runs into whichever
* parameter is last — there it is harmless, on `provider` it would corrupt the retry.
*/
const errorCallbackURL = new URL(
`/sso?error=sso_failed&provider=${encodeURIComponent(providerId)}&callbackUrl=${encodeURIComponent(DEFAULT_POST_AUTH_ROUTE)}`,
getBaseUrl()
).toString()
/** A sign-in that never starts is a failure, so it carries the error rather than a blank form. */
let signIn: Response
try {
signIn = await auth.api.signInSSO({
body: { providerId, callbackURL: DEFAULT_POST_AUTH_ROUTE, errorCallbackURL },
headers: request.headers,
asResponse: true,
})
} catch (error) {
logger.error('SSO sign-in could not be started', { providerId, error: toError(error) })
return NextResponse.redirect(errorCallbackURL)
}
const payload = (await signIn.json().catch(() => null)) as { url?: string } | null
if (!signIn.ok || !payload?.url) {
logger.error('SSO sign-in did not return an authorization URL', {
providerId,
status: signIn.status,
})
return NextResponse.redirect(errorCallbackURL)
}

const response = NextResponse.redirect(payload.url)
/** Better Auth's signed `state` cookie has to reach the browser before the identity provider does. */
const signInHeaders = signIn.headers as Headers & { getSetCookie?: () => string[] }
for (const cookie of signInHeaders.getSetCookie?.() ?? []) {
response.headers.append('set-cookie', cookie)
}
return response
})
17 changes: 16 additions & 1 deletion apps/sim/ee/sso/components/sso-provider-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -542,6 +542,8 @@ export function SsoProviderSettings({
? [{ text: 'Delete', variant: 'destructive', onSelect: onDelete } satisfies SettingsAction]
: []),
]
const isOidcProvider = (existingProvider.providerType ?? 'oidc') === 'oidc'
const encodedProviderId = encodeURIComponent(existingProvider.providerId ?? '')
const providerCallbackUrl =
(existingProvider.providerType === 'saml' &&
readProviderConfigString(existingProvider.samlConfig, 'callbackUrl')) ||
Expand Down Expand Up @@ -593,11 +595,24 @@ export function SsoProviderSettings({
</SettingRow>
)}

{isOidcProvider && (
<SettingRow htmlFor='sso-initiate-login-url' label='Initiate login URL'>
<ChipCopyInput
id='sso-initiate-login-url'
value={`${getBaseUrl()}/sso/launch/${encodedProviderId}`}
copyLabel='Copy initiate login URL'
/>
<p className='text-[var(--text-muted)] text-caption'>
Configure this in your identity provider to open Sim from its app dashboard
</p>
</SettingRow>
)}

{onMakePrimary && (
<SettingRow htmlFor='sso-test-link' label='Test sign-in link'>
<ChipCopyInput
id='sso-test-link'
value={`${getBaseUrl()}/sso?provider=${encodeURIComponent(existingProvider.providerId ?? '')}`}
value={`${getBaseUrl()}/sso?provider=${encodedProviderId}`}
copyLabel='Copy test sign-in link'
/>
<p className='text-[var(--text-muted)] text-caption'>
Expand Down
26 changes: 25 additions & 1 deletion apps/sim/ee/sso/components/sso-settings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -794,7 +794,11 @@ describe('SSO provider list', () => {

describe('SSO primary provider', () => {
/** An organization moving one domain's sign-in from one identity provider to another. */
function renderMigration(searchParams = '', okta: Record<string, unknown> = {}) {
function renderMigration(
searchParams = '',
okta: Record<string, unknown> = {},
entra: Record<string, unknown> = {}
) {
mockUseSSOProviders.mockReturnValue({
data: {
providers: [
Expand All @@ -804,6 +808,7 @@ describe('SSO primary provider', () => {
providerId: 'acme-entra',
domainVerified: true,
isPrimary: true,
...entra,
},
{
...provider('org-a'),
Expand Down Expand Up @@ -848,6 +853,25 @@ describe('SSO primary provider', () => {
expect(container.querySelector('#sso-test-link')).toBeNull()
})

it("shows an OIDC provider's initiate login URL for its identity provider's app dashboard", () => {
renderMigration()
openProvider('acme-entra')

expect(container).toHaveTextContent('Initiate login URL')
const link = new URL(
container.querySelector<HTMLInputElement>('#sso-initiate-login-url')?.value ?? ''
)
expect(link.pathname).toBe('/sso/launch/acme-entra')
expect(link.search).toBe('')
})

it('shows no initiate login URL on a SAML provider', () => {
renderMigration('', {}, { providerType: 'saml' })
openProvider('acme-entra')

expect(container.querySelector('#sso-initiate-login-url')).toBeNull()
})

it('offers a test sign-in link and Make primary on a provider waiting beside the primary', () => {
renderMigration()
openProvider('acme-okta')
Expand Down
47 changes: 47 additions & 0 deletions apps/sim/lib/auth/sso/idp-initiated-login.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* @vitest-environment node
*/
import { dbChainMock, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'

vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock }))

import { isIdpInitiatedLoginAllowed } from '@/lib/auth/sso/idp-initiated-login'

function queueProvider(issuer: string) {
queueTableRows(schemaMock.ssoProvider, [{ issuer }])
}

describe('isIdpInitiatedLoginAllowed', () => {
beforeEach(() => {
resetDbChainMock()
})

it.each([
['the issuer it is configured with', 'https://acme.okta.test', 'https://acme.okta.test'],
['that issuer with a trailing slash', 'https://acme.okta.test', 'https://acme.okta.test/'],
[
'the organization URL of its custom authorization server',
'https://acme.okta.test/oauth2/default',
'https://acme.okta.test',
],
])('allows a provider opened by %s', async (_label, configured, opened) => {
queueProvider(configured)
await expect(isIdpInitiatedLoginAllowed('acme-okta', opened)).resolves.toBe(true)
})

it.each([
['another identity provider', 'https://attacker.example.test'],
['a value that is not a URL', 'not-a-url'],
])('refuses a link opened by %s', async (_label, opened) => {
queueProvider('https://acme.okta.test')
await expect(isIdpInitiatedLoginAllowed('acme-okta', opened)).resolves.toBe(false)
})

it('refuses a provider that is unknown, unverified, or SAML', async () => {
queueTableRows(schemaMock.ssoProvider, [])
await expect(isIdpInitiatedLoginAllowed('acme-okta', 'https://acme.okta.test')).resolves.toBe(
false
)
})
})
Loading
Loading