diff --git a/apps/docs/content/docs/platform/enterprise/sso.mdx b/apps/docs/content/docs/platform/enterprise/sso.mdx index 76fc3dbb1d1..5ed4d7a7fc1 100644 --- a/apps/docs/content/docs/platform/enterprise/sso.mdx +++ b/apps/docs/content/docs/platform/enterprise/sso.mdx @@ -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, `` 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): ``` @@ -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:///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:** @@ -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. diff --git a/apps/sim/app/(auth)/sso/launch/[providerId]/route.test.ts b/apps/sim/app/(auth)/sso/launch/[providerId]/route.test.ts new file mode 100644 index 00000000000..a17ffeae63b --- /dev/null +++ b/apps/sim/app/(auth)/sso/launch/[providerId]/route.test.ts @@ -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() + }) +}) diff --git a/apps/sim/app/(auth)/sso/launch/[providerId]/route.ts b/apps/sim/app/(auth)/sso/launch/[providerId]/route.ts new file mode 100644 index 00000000000..86afda73121 --- /dev/null +++ b/apps/sim/app/(auth)/sso/launch/[providerId]/route.ts @@ -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) + + const issuer = request.nextUrl.searchParams.get('iss') + if (!issuer || !(await isIdpInitiatedLoginAllowed(providerId, issuer))) { + return NextResponse.redirect(signInLink) + } + + /** + * 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 +}) diff --git a/apps/sim/ee/sso/components/sso-provider-settings.tsx b/apps/sim/ee/sso/components/sso-provider-settings.tsx index e01275de70b..2bfd5af853e 100644 --- a/apps/sim/ee/sso/components/sso-provider-settings.tsx +++ b/apps/sim/ee/sso/components/sso-provider-settings.tsx @@ -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')) || @@ -593,11 +595,24 @@ export function SsoProviderSettings({ )} + {isOidcProvider && ( + + +

+ Configure this in your identity provider to open Sim from its app dashboard +

+
+ )} + {onMakePrimary && (

diff --git a/apps/sim/ee/sso/components/sso-settings.test.tsx b/apps/sim/ee/sso/components/sso-settings.test.tsx index f9f3fae3009..bbd66655693 100644 --- a/apps/sim/ee/sso/components/sso-settings.test.tsx +++ b/apps/sim/ee/sso/components/sso-settings.test.tsx @@ -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 = {}) { + function renderMigration( + searchParams = '', + okta: Record = {}, + entra: Record = {} + ) { mockUseSSOProviders.mockReturnValue({ data: { providers: [ @@ -804,6 +808,7 @@ describe('SSO primary provider', () => { providerId: 'acme-entra', domainVerified: true, isPrimary: true, + ...entra, }, { ...provider('org-a'), @@ -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('#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') diff --git a/apps/sim/lib/auth/sso/idp-initiated-login.test.ts b/apps/sim/lib/auth/sso/idp-initiated-login.test.ts new file mode 100644 index 00000000000..2ebcbeb41d6 --- /dev/null +++ b/apps/sim/lib/auth/sso/idp-initiated-login.test.ts @@ -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 + ) + }) +}) diff --git a/apps/sim/lib/auth/sso/idp-initiated-login.ts b/apps/sim/lib/auth/sso/idp-initiated-login.ts new file mode 100644 index 00000000000..67957c7dcc2 --- /dev/null +++ b/apps/sim/lib/auth/sso/idp-initiated-login.ts @@ -0,0 +1,51 @@ +import { db, ssoProvider } from '@sim/db' +import { and, eq, isNull } from 'drizzle-orm' + +/** Issuers compare without trailing slashes, which identity providers add or drop freely. */ +function normalizeIssuer(issuer: string): string { + return issuer.trim().replace(/\/+$/, '') +} + +/** The issuer's origin, so an Okta custom authorization server matches its organization URL. */ +function issuerOrigin(issuer: string): string | null { + try { + return new URL(issuer).origin + } catch { + return null + } +} + +/** + * Whether an identity provider's app dashboard may start sign-in through this provider + * (OpenID Connect third-party initiated login). + * + * The dashboard opens the provider's initiate login URL with its own issuer in `iss`. It is + * honored only for a domain-verified OIDC provider configured with that issuer, or one on the + * same host — Okta sends the organization URL even for a provider registered against a custom + * authorization server under it. The gate is defense in depth: a crafted link can then only + * reach an identity provider this deployment already registered, never an attacker's own, and + * Better Auth re-checks the provider before it issues the authorization request. + */ +export async function isIdpInitiatedLoginAllowed( + providerId: string, + issuer: string +): Promise { + const [provider] = await db + .select({ issuer: ssoProvider.issuer }) + .from(ssoProvider) + .where( + and( + eq(ssoProvider.providerId, providerId), + eq(ssoProvider.domainVerified, true), + isNull(ssoProvider.samlConfig) + ) + ) + .limit(1) + if (!provider) return false + + const configured = normalizeIssuer(provider.issuer) + const opened = normalizeIssuer(issuer) + if (configured === opened) return true + const configuredOrigin = issuerOrigin(configured) + return configuredOrigin !== null && configuredOrigin === issuerOrigin(opened) +}