From 46ce7f57bdcb73091cb9fd5c6ed092a9d3e4cb9f Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 15 Sep 2026 15:59:57 -0700 Subject: [PATCH 1/6] feat(sso): open Sim from an identity provider's app dashboard --- .../content/docs/platform/enterprise/sso.mdx | 10 ++- .../sso/launch/[providerId]/page.test.tsx | 60 +++++++++++++++++ .../(auth)/sso/launch/[providerId]/page.tsx | 37 ++++++++++ .../sim/ee/sso/components/sso-launch.test.tsx | 67 +++++++++++++++++++ apps/sim/ee/sso/components/sso-launch.tsx | 47 +++++++++++++ .../sso/components/sso-provider-settings.tsx | 17 ++++- .../ee/sso/components/sso-settings.test.tsx | 26 ++++++- .../lib/auth/sso/idp-initiated-login.test.ts | 40 +++++++++++ apps/sim/lib/auth/sso/idp-initiated-login.ts | 29 ++++++++ 9 files changed, 329 insertions(+), 4 deletions(-) create mode 100644 apps/sim/app/(auth)/sso/launch/[providerId]/page.test.tsx create mode 100644 apps/sim/app/(auth)/sso/launch/[providerId]/page.tsx create mode 100644 apps/sim/ee/sso/components/sso-launch.test.tsx create mode 100644 apps/sim/ee/sso/components/sso-launch.tsx create mode 100644 apps/sim/lib/auth/sso/idp-initiated-login.test.ts create mode 100644 apps/sim/lib/auth/sso/idp-initiated-login.ts diff --git a/apps/docs/content/docs/platform/enterprise/sso.mdx b/apps/docs/content/docs/platform/enterprise/sso.mdx index 76fc3dbb1d1..19f9266d939 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. Set the provider's **Initiate login URL**, shown on its page under **Sign-in**, 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 request comes from the provider's own issuer. + 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]/page.test.tsx b/apps/sim/app/(auth)/sso/launch/[providerId]/page.test.tsx new file mode 100644 index 00000000000..81d4402b8bd --- /dev/null +++ b/apps/sim/app/(auth)/sso/launch/[providerId]/page.test.tsx @@ -0,0 +1,60 @@ +/** + * @vitest-environment node + */ +import type { ReactElement } from 'react' +import { setEnvFlags } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockResolveLaunchProvider } = vi.hoisted(() => ({ + mockResolveLaunchProvider: vi.fn(), +})) + +vi.mock('@/lib/auth/sso/idp-initiated-login', () => ({ + resolveIdpInitiatedLoginProvider: mockResolveLaunchProvider, +})) +vi.mock('@/ee/sso/components/sso-launch', () => ({ SSOLaunch: () => null })) +vi.mock('next/navigation', () => ({ + redirect: (path: string) => { + throw new Error(`redirect:${path}`) + }, +})) + +import SSOLaunchPage from '@/app/(auth)/sso/launch/[providerId]/page' + +function open(providerId: string, search: Record) { + return SSOLaunchPage({ + params: Promise.resolve({ providerId }), + searchParams: Promise.resolve(search), + }) as Promise> +} + +describe('SSO launch page', () => { + beforeEach(() => { + vi.clearAllMocks() + setEnvFlags({ isSsoEnabled: true }) + }) + + it("starts sign-in when the provider's own identity provider opened it", async () => { + mockResolveLaunchProvider.mockResolvedValue('acme-okta') + const page = await open('acme-okta', { iss: 'https://acme.okta.test' }) + expect(page.props.providerId).toBe('acme-okta') + expect(mockResolveLaunchProvider).toHaveBeenCalledWith('acme-okta', 'https://acme.okta.test') + }) + + it("sends a visitor without the provider's issuer to its ordinary sign-in link", async () => { + mockResolveLaunchProvider.mockResolvedValue(null) + await expect(open('acme-okta', { iss: 'https://other.example.test' })).rejects.toThrow( + 'redirect:/sso?provider=acme-okta' + ) + await expect(open('acme okta', {})).rejects.toThrow('redirect:/sso?provider=acme%20okta') + expect(mockResolveLaunchProvider).toHaveBeenCalledTimes(1) + }) + + it('leaves SSO off when the deployment has not enabled it', async () => { + setEnvFlags({ isSsoEnabled: false }) + await expect(open('acme-okta', { iss: 'https://acme.okta.test' })).rejects.toThrow( + 'redirect:/login' + ) + expect(mockResolveLaunchProvider).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/(auth)/sso/launch/[providerId]/page.tsx b/apps/sim/app/(auth)/sso/launch/[providerId]/page.tsx new file mode 100644 index 00000000000..e8ef0bc1639 --- /dev/null +++ b/apps/sim/app/(auth)/sso/launch/[providerId]/page.tsx @@ -0,0 +1,37 @@ +import type { Metadata } from 'next' +import { redirect } from 'next/navigation' +import { resolveIdpInitiatedLoginProvider } from '@/lib/auth/sso/idp-initiated-login' +import { isSsoEnabled } from '@/lib/core/config/env-flags' +import { SSOLaunch } from '@/ee/sso/components/sso-launch' + +export const metadata: Metadata = { + title: 'Single Sign-On', +} + +export const dynamic = 'force-dynamic' + +interface SSOLaunchPageProps { + params: Promise<{ providerId: string }> + searchParams: Promise> +} + +/** + * 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. + * When the issuer is the provider's own, sign-in starts through it at once; otherwise the visitor + * gets the provider's ordinary sign-in link, which asks for an email. + */ +export default async function SSOLaunchPage({ params, searchParams }: SSOLaunchPageProps) { + if (!isSsoEnabled) { + redirect('/login') + } + + const [{ providerId }, { iss }] = await Promise.all([params, searchParams]) + const launchProviderId = + typeof iss === 'string' ? await resolveIdpInitiatedLoginProvider(providerId, iss) : null + if (!launchProviderId) { + redirect(`/sso?provider=${encodeURIComponent(providerId)}`) + } + + return +} diff --git a/apps/sim/ee/sso/components/sso-launch.test.tsx b/apps/sim/ee/sso/components/sso-launch.test.tsx new file mode 100644 index 00000000000..e3e37f4a204 --- /dev/null +++ b/apps/sim/ee/sso/components/sso-launch.test.tsx @@ -0,0 +1,67 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockSsoSignIn, mockReplace } = vi.hoisted(() => ({ + mockSsoSignIn: vi.fn(), + mockReplace: vi.fn(), +})) + +vi.mock('@/lib/auth/auth-client', () => ({ client: { signIn: { sso: mockSsoSignIn } } })) +vi.mock('next/navigation', () => ({ useRouter: () => ({ replace: mockReplace }) })) + +import { SSOLaunch } from '@/ee/sso/components/sso-launch' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +let host: HTMLDivElement +let root: Root + +beforeEach(() => { + host = document.createElement('div') + document.body.appendChild(host) + root = createRoot(host) +}) + +afterEach(() => { + act(() => root.unmount()) + host.remove() + vi.clearAllMocks() +}) + +async function launch() { + await act(async () => root.render()) +} + +describe('SSOLaunch', () => { + it('starts sign-in through the provider once, with no email', async () => { + mockSsoSignIn.mockResolvedValue({ data: { url: 'https://idp.example.test' }, error: null }) + await launch() + + expect(host).toHaveTextContent('Redirecting to your identity provider') + expect(mockSsoSignIn).toHaveBeenCalledTimes(1) + const [signIn] = mockSsoSignIn.mock.calls[0] + expect(signIn).not.toHaveProperty('email') + expect(signIn.providerId).toBe('acme-okta') + /** The SSO plugin appends `?error=…`, which must not corrupt the provider on the way back. */ + const back = new URL(`${signIn.errorCallbackURL}?error=invalid_provider`, 'https://sim.test') + expect(back.pathname).toBe('/sso') + expect(back.searchParams.get('provider')).toBe('acme-okta') + expect(mockReplace).not.toHaveBeenCalled() + }) + + it.each([ + ['refuses', () => mockSsoSignIn.mockResolvedValue({ data: null, error: { message: 'no' } })], + ['throws', () => mockSsoSignIn.mockRejectedValue(new Error('network'))], + ])("returns to the provider's sign-in link when sign-in %s", async (_label, arrange) => { + arrange() + await launch() + + expect(mockReplace).toHaveBeenCalledWith( + '/sso?error=sso_failed&provider=acme-okta&callbackUrl=%2Fhome' + ) + }) +}) diff --git a/apps/sim/ee/sso/components/sso-launch.tsx b/apps/sim/ee/sso/components/sso-launch.tsx new file mode 100644 index 00000000000..fb91fa2abd3 --- /dev/null +++ b/apps/sim/ee/sso/components/sso-launch.tsx @@ -0,0 +1,47 @@ +'use client' + +import { useEffect, useRef } from 'react' +import { createLogger } from '@sim/logger' +import { useRouter } from 'next/navigation' +import { client } from '@/lib/auth/auth-client' +import { DEFAULT_POST_AUTH_ROUTE } from '@/app/(auth)/auth-redirect' +import { AuthHeader } from '@/app/(auth)/components' + +const logger = createLogger('SSOLaunch') + +interface SSOLaunchProps { + /** A provider whose own identity provider opened its initiate login URL. */ + providerId: string +} + +/** + * Starts SSO through a provider as soon as its identity provider's app dashboard opens Sim, with no + * email to enter. A sign-in that cannot start, or that fails at the identity provider, returns to the + * provider's sign-in link with the error. `provider` precedes `callbackUrl` there because the SSO + * plugin appends its own error with a raw `?`, which runs into whichever parameter comes last. + */ +export function SSOLaunch({ providerId }: SSOLaunchProps) { + const router = useRouter() + const started = useRef(false) + + useEffect(() => { + if (started.current) return + started.current = true + const failureUrl = `/sso?error=sso_failed&provider=${encodeURIComponent(providerId)}&callbackUrl=${encodeURIComponent(DEFAULT_POST_AUTH_ROUTE)}` + void client.signIn + .sso({ providerId, callbackURL: DEFAULT_POST_AUTH_ROUTE, errorCallbackURL: failureUrl }) + .then((result) => { + if (result && !result.error) return + logger.error('SSO sign-in failed to start', { error: result?.error, providerId }) + router.replace(failureUrl) + }) + .catch((error) => { + logger.error('SSO sign-in failed to start', { error, providerId }) + router.replace(failureUrl) + }) + }, [providerId, router]) + + return ( + + ) +} diff --git a/apps/sim/ee/sso/components/sso-provider-settings.tsx b/apps/sim/ee/sso/components/sso-provider-settings.tsx index e01275de70b..5b7d7f7a604 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 && ( + + +

+ Set 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..300535443af --- /dev/null +++ b/apps/sim/lib/auth/sso/idp-initiated-login.test.ts @@ -0,0 +1,40 @@ +/** + * @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 { resolveIdpInitiatedLoginProvider } from '@/lib/auth/sso/idp-initiated-login' + +describe('resolveIdpInitiatedLoginProvider', () => { + beforeEach(() => { + resetDbChainMock() + }) + + it('names a verified provider whose issuer opened the link', async () => { + queueTableRows(schemaMock.ssoProvider, [ + { providerId: 'acme-okta', issuer: 'https://acme.okta.test' }, + ]) + await expect( + resolveIdpInitiatedLoginProvider('acme-okta', 'https://acme.okta.test/') + ).resolves.toBe('acme-okta') + }) + + it('refuses a link opened by a different issuer', async () => { + queueTableRows(schemaMock.ssoProvider, [ + { providerId: 'acme-okta', issuer: 'https://acme.okta.test' }, + ]) + await expect( + resolveIdpInitiatedLoginProvider('acme-okta', 'https://attacker.example.test') + ).resolves.toBeNull() + }) + + it('refuses an unknown or unverified provider', async () => { + queueTableRows(schemaMock.ssoProvider, []) + await expect( + resolveIdpInitiatedLoginProvider('acme-okta', 'https://acme.okta.test') + ).resolves.toBeNull() + }) +}) 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..1bf153d0136 --- /dev/null +++ b/apps/sim/lib/auth/sso/idp-initiated-login.ts @@ -0,0 +1,29 @@ +import { db, ssoProvider } from '@sim/db' +import { and, eq } from 'drizzle-orm' + +/** Issuers compare without trailing slashes, which identity providers add or drop freely. */ +function normalizeIssuer(issuer: string): string { + return issuer.trim().replace(/\/+$/, '') +} + +/** + * Names the provider a login started from an identity provider's app dashboard + * signs in through (OpenID Connect third-party initiated login). + * + * The identity provider opens the provider's sign-in link with its own issuer in + * `iss`. The link is honored only for a domain-verified provider whose configured + * issuer is that one, so a crafted link cannot start sign-in against another + * identity provider. `null` leaves the sign-in page as it is. + */ +export async function resolveIdpInitiatedLoginProvider( + providerId: string, + issuer: string +): Promise { + const [provider] = await db + .select({ providerId: ssoProvider.providerId, issuer: ssoProvider.issuer }) + .from(ssoProvider) + .where(and(eq(ssoProvider.providerId, providerId), eq(ssoProvider.domainVerified, true))) + .limit(1) + if (!provider || normalizeIssuer(provider.issuer) !== normalizeIssuer(issuer)) return null + return provider.providerId +} From 16f31771513491867c6e5f71c3cbe82842a759b3 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 15 Sep 2026 16:04:43 -0700 Subject: [PATCH 2/6] improvement(sso): skip re-authentication when signed in and limit dashboard launch to OIDC providers --- .../content/docs/platform/enterprise/sso.mdx | 2 +- .../sso/launch/[providerId]/page.test.tsx | 14 +++++++++++++- .../(auth)/sso/launch/[providerId]/page.tsx | 13 ++++++++++--- .../sso/components/sso-provider-settings.tsx | 2 +- apps/sim/lib/auth/sso/idp-initiated-login.ts | 18 ++++++++++++------ 5 files changed, 37 insertions(+), 12 deletions(-) diff --git a/apps/docs/content/docs/platform/enterprise/sso.mdx b/apps/docs/content/docs/platform/enterprise/sso.mdx index 19f9266d939..b80665e389b 100644 --- a/apps/docs/content/docs/platform/enterprise/sso.mdx +++ b/apps/docs/content/docs/platform/enterprise/sso.mdx @@ -309,7 +309,7 @@ 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. Set the provider's **Initiate login URL**, shown on its page under **Sign-in**, 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 request comes from the provider's own issuer. +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. The dashboard sends your organization's issuer, so a provider registered with a custom authorization server issuer falls back to the email sign-in page. 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]/page.test.tsx b/apps/sim/app/(auth)/sso/launch/[providerId]/page.test.tsx index 81d4402b8bd..0c26c916053 100644 --- a/apps/sim/app/(auth)/sso/launch/[providerId]/page.test.tsx +++ b/apps/sim/app/(auth)/sso/launch/[providerId]/page.test.tsx @@ -5,10 +5,13 @@ import type { ReactElement } from 'react' import { setEnvFlags } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockResolveLaunchProvider } = vi.hoisted(() => ({ +const { mockResolveLaunchProvider, mockGetSession } = vi.hoisted(() => ({ mockResolveLaunchProvider: vi.fn(), + mockGetSession: vi.fn(), })) +vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) + vi.mock('@/lib/auth/sso/idp-initiated-login', () => ({ resolveIdpInitiatedLoginProvider: mockResolveLaunchProvider, })) @@ -32,6 +35,15 @@ describe('SSO launch page', () => { beforeEach(() => { vi.clearAllMocks() setEnvFlags({ isSsoEnabled: true }) + mockGetSession.mockResolvedValue(null) + }) + + it('sends someone already signed in to the app without signing in again', async () => { + mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + await expect(open('acme-okta', { iss: 'https://acme.okta.test' })).rejects.toThrow( + 'redirect:/home' + ) + expect(mockResolveLaunchProvider).not.toHaveBeenCalled() }) it("starts sign-in when the provider's own identity provider opened it", async () => { diff --git a/apps/sim/app/(auth)/sso/launch/[providerId]/page.tsx b/apps/sim/app/(auth)/sso/launch/[providerId]/page.tsx index e8ef0bc1639..134bbc79f92 100644 --- a/apps/sim/app/(auth)/sso/launch/[providerId]/page.tsx +++ b/apps/sim/app/(auth)/sso/launch/[providerId]/page.tsx @@ -1,7 +1,9 @@ import type { Metadata } from 'next' import { redirect } from 'next/navigation' +import { getSession } from '@/lib/auth' import { resolveIdpInitiatedLoginProvider } from '@/lib/auth/sso/idp-initiated-login' import { isSsoEnabled } from '@/lib/core/config/env-flags' +import { DEFAULT_POST_AUTH_ROUTE } from '@/app/(auth)/auth-redirect' import { SSOLaunch } from '@/ee/sso/components/sso-launch' export const metadata: Metadata = { @@ -18,15 +20,20 @@ interface SSOLaunchPageProps { /** * 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. - * When the issuer is the provider's own, sign-in starts through it at once; otherwise the visitor - * gets the provider's ordinary sign-in link, which asks for an email. + * Someone already signed in goes straight to the app, so a link cannot replace their session. When + * the issuer is the provider's own, sign-in starts through it at once; otherwise the visitor gets the + * provider's ordinary sign-in link, which asks for an email. */ export default async function SSOLaunchPage({ params, searchParams }: SSOLaunchPageProps) { if (!isSsoEnabled) { redirect('/login') } - const [{ providerId }, { iss }] = await Promise.all([params, searchParams]) + const [session, { providerId }, { iss }] = await Promise.all([getSession(), params, searchParams]) + if (session?.user) { + redirect(DEFAULT_POST_AUTH_ROUTE) + } + const launchProviderId = typeof iss === 'string' ? await resolveIdpInitiatedLoginProvider(providerId, iss) : null if (!launchProviderId) { diff --git a/apps/sim/ee/sso/components/sso-provider-settings.tsx b/apps/sim/ee/sso/components/sso-provider-settings.tsx index 5b7d7f7a604..2bfd5af853e 100644 --- a/apps/sim/ee/sso/components/sso-provider-settings.tsx +++ b/apps/sim/ee/sso/components/sso-provider-settings.tsx @@ -603,7 +603,7 @@ export function SsoProviderSettings({ copyLabel='Copy initiate login URL' />

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

)} diff --git a/apps/sim/lib/auth/sso/idp-initiated-login.ts b/apps/sim/lib/auth/sso/idp-initiated-login.ts index 1bf153d0136..f6543239a29 100644 --- a/apps/sim/lib/auth/sso/idp-initiated-login.ts +++ b/apps/sim/lib/auth/sso/idp-initiated-login.ts @@ -1,5 +1,5 @@ import { db, ssoProvider } from '@sim/db' -import { and, eq } from 'drizzle-orm' +import { and, eq, isNull } from 'drizzle-orm' /** Issuers compare without trailing slashes, which identity providers add or drop freely. */ function normalizeIssuer(issuer: string): string { @@ -10,10 +10,10 @@ function normalizeIssuer(issuer: string): string { * Names the provider a login started from an identity provider's app dashboard * signs in through (OpenID Connect third-party initiated login). * - * The identity provider opens the provider's sign-in link with its own issuer in - * `iss`. The link is honored only for a domain-verified provider whose configured - * issuer is that one, so a crafted link cannot start sign-in against another - * identity provider. `null` leaves the sign-in page as it is. + * The identity provider opens the provider's initiate login URL with its own issuer + * in `iss`. The URL is honored only for a domain-verified OIDC provider whose + * configured issuer is that one, so a crafted link cannot start sign-in against + * another identity provider. `null` falls back to the ordinary sign-in link. */ export async function resolveIdpInitiatedLoginProvider( providerId: string, @@ -22,7 +22,13 @@ export async function resolveIdpInitiatedLoginProvider( const [provider] = await db .select({ providerId: ssoProvider.providerId, issuer: ssoProvider.issuer }) .from(ssoProvider) - .where(and(eq(ssoProvider.providerId, providerId), eq(ssoProvider.domainVerified, true))) + .where( + and( + eq(ssoProvider.providerId, providerId), + eq(ssoProvider.domainVerified, true), + isNull(ssoProvider.samlConfig) + ) + ) .limit(1) if (!provider || normalizeIssuer(provider.issuer) !== normalizeIssuer(issuer)) return null return provider.providerId From ab848f687a3c49eefe20e323f01c3dd2747ea37a Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 15 Sep 2026 16:18:33 -0700 Subject: [PATCH 3/6] improvement(sso): redirect straight to the identity provider from the launch URL --- .../content/docs/platform/enterprise/sso.mdx | 2 +- .../sso/launch/[providerId]/page.test.tsx | 72 ----------- .../(auth)/sso/launch/[providerId]/page.tsx | 44 ------- .../sso/launch/[providerId]/route.test.ts | 120 ++++++++++++++++++ .../(auth)/sso/launch/[providerId]/route.ts | 80 ++++++++++++ .../sim/ee/sso/components/sso-launch.test.tsx | 67 ---------- apps/sim/ee/sso/components/sso-launch.tsx | 47 ------- .../lib/auth/sso/idp-initiated-login.test.ts | 47 ++++--- apps/sim/lib/auth/sso/idp-initiated-login.ts | 38 ++++-- 9 files changed, 255 insertions(+), 262 deletions(-) delete mode 100644 apps/sim/app/(auth)/sso/launch/[providerId]/page.test.tsx delete mode 100644 apps/sim/app/(auth)/sso/launch/[providerId]/page.tsx create mode 100644 apps/sim/app/(auth)/sso/launch/[providerId]/route.test.ts create mode 100644 apps/sim/app/(auth)/sso/launch/[providerId]/route.ts delete mode 100644 apps/sim/ee/sso/components/sso-launch.test.tsx delete mode 100644 apps/sim/ee/sso/components/sso-launch.tsx diff --git a/apps/docs/content/docs/platform/enterprise/sso.mdx b/apps/docs/content/docs/platform/enterprise/sso.mdx index b80665e389b..5ed4d7a7fc1 100644 --- a/apps/docs/content/docs/platform/enterprise/sso.mdx +++ b/apps/docs/content/docs/platform/enterprise/sso.mdx @@ -309,7 +309,7 @@ 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. The dashboard sends your organization's issuer, so a provider registered with a custom authorization server issuer falls back to the email sign-in page. +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]/page.test.tsx b/apps/sim/app/(auth)/sso/launch/[providerId]/page.test.tsx deleted file mode 100644 index 0c26c916053..00000000000 --- a/apps/sim/app/(auth)/sso/launch/[providerId]/page.test.tsx +++ /dev/null @@ -1,72 +0,0 @@ -/** - * @vitest-environment node - */ -import type { ReactElement } from 'react' -import { setEnvFlags } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockResolveLaunchProvider, mockGetSession } = vi.hoisted(() => ({ - mockResolveLaunchProvider: vi.fn(), - mockGetSession: vi.fn(), -})) - -vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) - -vi.mock('@/lib/auth/sso/idp-initiated-login', () => ({ - resolveIdpInitiatedLoginProvider: mockResolveLaunchProvider, -})) -vi.mock('@/ee/sso/components/sso-launch', () => ({ SSOLaunch: () => null })) -vi.mock('next/navigation', () => ({ - redirect: (path: string) => { - throw new Error(`redirect:${path}`) - }, -})) - -import SSOLaunchPage from '@/app/(auth)/sso/launch/[providerId]/page' - -function open(providerId: string, search: Record) { - return SSOLaunchPage({ - params: Promise.resolve({ providerId }), - searchParams: Promise.resolve(search), - }) as Promise> -} - -describe('SSO launch page', () => { - beforeEach(() => { - vi.clearAllMocks() - setEnvFlags({ isSsoEnabled: true }) - mockGetSession.mockResolvedValue(null) - }) - - it('sends someone already signed in to the app without signing in again', async () => { - mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) - await expect(open('acme-okta', { iss: 'https://acme.okta.test' })).rejects.toThrow( - 'redirect:/home' - ) - expect(mockResolveLaunchProvider).not.toHaveBeenCalled() - }) - - it("starts sign-in when the provider's own identity provider opened it", async () => { - mockResolveLaunchProvider.mockResolvedValue('acme-okta') - const page = await open('acme-okta', { iss: 'https://acme.okta.test' }) - expect(page.props.providerId).toBe('acme-okta') - expect(mockResolveLaunchProvider).toHaveBeenCalledWith('acme-okta', 'https://acme.okta.test') - }) - - it("sends a visitor without the provider's issuer to its ordinary sign-in link", async () => { - mockResolveLaunchProvider.mockResolvedValue(null) - await expect(open('acme-okta', { iss: 'https://other.example.test' })).rejects.toThrow( - 'redirect:/sso?provider=acme-okta' - ) - await expect(open('acme okta', {})).rejects.toThrow('redirect:/sso?provider=acme%20okta') - expect(mockResolveLaunchProvider).toHaveBeenCalledTimes(1) - }) - - it('leaves SSO off when the deployment has not enabled it', async () => { - setEnvFlags({ isSsoEnabled: false }) - await expect(open('acme-okta', { iss: 'https://acme.okta.test' })).rejects.toThrow( - 'redirect:/login' - ) - expect(mockResolveLaunchProvider).not.toHaveBeenCalled() - }) -}) diff --git a/apps/sim/app/(auth)/sso/launch/[providerId]/page.tsx b/apps/sim/app/(auth)/sso/launch/[providerId]/page.tsx deleted file mode 100644 index 134bbc79f92..00000000000 --- a/apps/sim/app/(auth)/sso/launch/[providerId]/page.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import type { Metadata } from 'next' -import { redirect } from 'next/navigation' -import { getSession } from '@/lib/auth' -import { resolveIdpInitiatedLoginProvider } from '@/lib/auth/sso/idp-initiated-login' -import { isSsoEnabled } from '@/lib/core/config/env-flags' -import { DEFAULT_POST_AUTH_ROUTE } from '@/app/(auth)/auth-redirect' -import { SSOLaunch } from '@/ee/sso/components/sso-launch' - -export const metadata: Metadata = { - title: 'Single Sign-On', -} - -export const dynamic = 'force-dynamic' - -interface SSOLaunchPageProps { - params: Promise<{ providerId: string }> - searchParams: Promise> -} - -/** - * 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. - * Someone already signed in goes straight to the app, so a link cannot replace their session. When - * the issuer is the provider's own, sign-in starts through it at once; otherwise the visitor gets the - * provider's ordinary sign-in link, which asks for an email. - */ -export default async function SSOLaunchPage({ params, searchParams }: SSOLaunchPageProps) { - if (!isSsoEnabled) { - redirect('/login') - } - - const [session, { providerId }, { iss }] = await Promise.all([getSession(), params, searchParams]) - if (session?.user) { - redirect(DEFAULT_POST_AUTH_ROUTE) - } - - const launchProviderId = - typeof iss === 'string' ? await resolveIdpInitiatedLoginProvider(providerId, iss) : null - if (!launchProviderId) { - redirect(`/sso?provider=${encodeURIComponent(providerId)}`) - } - - return -} 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..2af45123a36 --- /dev/null +++ b/apps/sim/app/(auth)/sso/launch/[providerId]/route.test.ts @@ -0,0 +1,120 @@ +/** + * @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.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("falls back to the provider's sign-in link when sign-in cannot start", async () => { + mockSignInSSO.mockResolvedValue(new Response('{}', { status: 400 })) + + const response = await open() + + expect(response.headers.get('location')).toBe(SIGN_IN_LINK) + }) + + 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(mockGetSession).not.toHaveBeenCalled() + expect(mockIsAllowed).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..04b8333d966 --- /dev/null +++ b/apps/sim/app/(auth)/sso/launch/[providerId]/route.ts @@ -0,0 +1,80 @@ +import { createLogger } from '@sim/logger' +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 rateLimited = await enforceIpRateLimit('sso-launch', request, { + maxTokens: 30, + refillRate: 30, + refillIntervalMs: 60_000, + }) + if (rateLimited) return NextResponse.redirect(signInLink) + + const session = await getSession() + if (session?.user) { + return NextResponse.redirect(new URL(DEFAULT_POST_AUTH_ROUTE, getBaseUrl()).toString()) + } + + 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() + const signIn = await auth.api.signInSSO({ + body: { providerId, callbackURL: DEFAULT_POST_AUTH_ROUTE, errorCallbackURL }, + headers: request.headers, + asResponse: true, + }) + 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(signInLink) + } + + 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-launch.test.tsx b/apps/sim/ee/sso/components/sso-launch.test.tsx deleted file mode 100644 index e3e37f4a204..00000000000 --- a/apps/sim/ee/sso/components/sso-launch.test.tsx +++ /dev/null @@ -1,67 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import { act } from 'react' -import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockSsoSignIn, mockReplace } = vi.hoisted(() => ({ - mockSsoSignIn: vi.fn(), - mockReplace: vi.fn(), -})) - -vi.mock('@/lib/auth/auth-client', () => ({ client: { signIn: { sso: mockSsoSignIn } } })) -vi.mock('next/navigation', () => ({ useRouter: () => ({ replace: mockReplace }) })) - -import { SSOLaunch } from '@/ee/sso/components/sso-launch' - -;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true - -let host: HTMLDivElement -let root: Root - -beforeEach(() => { - host = document.createElement('div') - document.body.appendChild(host) - root = createRoot(host) -}) - -afterEach(() => { - act(() => root.unmount()) - host.remove() - vi.clearAllMocks() -}) - -async function launch() { - await act(async () => root.render()) -} - -describe('SSOLaunch', () => { - it('starts sign-in through the provider once, with no email', async () => { - mockSsoSignIn.mockResolvedValue({ data: { url: 'https://idp.example.test' }, error: null }) - await launch() - - expect(host).toHaveTextContent('Redirecting to your identity provider') - expect(mockSsoSignIn).toHaveBeenCalledTimes(1) - const [signIn] = mockSsoSignIn.mock.calls[0] - expect(signIn).not.toHaveProperty('email') - expect(signIn.providerId).toBe('acme-okta') - /** The SSO plugin appends `?error=…`, which must not corrupt the provider on the way back. */ - const back = new URL(`${signIn.errorCallbackURL}?error=invalid_provider`, 'https://sim.test') - expect(back.pathname).toBe('/sso') - expect(back.searchParams.get('provider')).toBe('acme-okta') - expect(mockReplace).not.toHaveBeenCalled() - }) - - it.each([ - ['refuses', () => mockSsoSignIn.mockResolvedValue({ data: null, error: { message: 'no' } })], - ['throws', () => mockSsoSignIn.mockRejectedValue(new Error('network'))], - ])("returns to the provider's sign-in link when sign-in %s", async (_label, arrange) => { - arrange() - await launch() - - expect(mockReplace).toHaveBeenCalledWith( - '/sso?error=sso_failed&provider=acme-okta&callbackUrl=%2Fhome' - ) - }) -}) diff --git a/apps/sim/ee/sso/components/sso-launch.tsx b/apps/sim/ee/sso/components/sso-launch.tsx deleted file mode 100644 index fb91fa2abd3..00000000000 --- a/apps/sim/ee/sso/components/sso-launch.tsx +++ /dev/null @@ -1,47 +0,0 @@ -'use client' - -import { useEffect, useRef } from 'react' -import { createLogger } from '@sim/logger' -import { useRouter } from 'next/navigation' -import { client } from '@/lib/auth/auth-client' -import { DEFAULT_POST_AUTH_ROUTE } from '@/app/(auth)/auth-redirect' -import { AuthHeader } from '@/app/(auth)/components' - -const logger = createLogger('SSOLaunch') - -interface SSOLaunchProps { - /** A provider whose own identity provider opened its initiate login URL. */ - providerId: string -} - -/** - * Starts SSO through a provider as soon as its identity provider's app dashboard opens Sim, with no - * email to enter. A sign-in that cannot start, or that fails at the identity provider, returns to the - * provider's sign-in link with the error. `provider` precedes `callbackUrl` there because the SSO - * plugin appends its own error with a raw `?`, which runs into whichever parameter comes last. - */ -export function SSOLaunch({ providerId }: SSOLaunchProps) { - const router = useRouter() - const started = useRef(false) - - useEffect(() => { - if (started.current) return - started.current = true - const failureUrl = `/sso?error=sso_failed&provider=${encodeURIComponent(providerId)}&callbackUrl=${encodeURIComponent(DEFAULT_POST_AUTH_ROUTE)}` - void client.signIn - .sso({ providerId, callbackURL: DEFAULT_POST_AUTH_ROUTE, errorCallbackURL: failureUrl }) - .then((result) => { - if (result && !result.error) return - logger.error('SSO sign-in failed to start', { error: result?.error, providerId }) - router.replace(failureUrl) - }) - .catch((error) => { - logger.error('SSO sign-in failed to start', { error, providerId }) - router.replace(failureUrl) - }) - }, [providerId, router]) - - return ( - - ) -} diff --git a/apps/sim/lib/auth/sso/idp-initiated-login.test.ts b/apps/sim/lib/auth/sso/idp-initiated-login.test.ts index 300535443af..2ebcbeb41d6 100644 --- a/apps/sim/lib/auth/sso/idp-initiated-login.test.ts +++ b/apps/sim/lib/auth/sso/idp-initiated-login.test.ts @@ -6,35 +6,42 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) -import { resolveIdpInitiatedLoginProvider } from '@/lib/auth/sso/idp-initiated-login' +import { isIdpInitiatedLoginAllowed } from '@/lib/auth/sso/idp-initiated-login' -describe('resolveIdpInitiatedLoginProvider', () => { +function queueProvider(issuer: string) { + queueTableRows(schemaMock.ssoProvider, [{ issuer }]) +} + +describe('isIdpInitiatedLoginAllowed', () => { beforeEach(() => { resetDbChainMock() }) - it('names a verified provider whose issuer opened the link', async () => { - queueTableRows(schemaMock.ssoProvider, [ - { providerId: 'acme-okta', issuer: 'https://acme.okta.test' }, - ]) - await expect( - resolveIdpInitiatedLoginProvider('acme-okta', 'https://acme.okta.test/') - ).resolves.toBe('acme-okta') + 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('refuses a link opened by a different issuer', async () => { - queueTableRows(schemaMock.ssoProvider, [ - { providerId: 'acme-okta', issuer: 'https://acme.okta.test' }, - ]) - await expect( - resolveIdpInitiatedLoginProvider('acme-okta', 'https://attacker.example.test') - ).resolves.toBeNull() + 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 an unknown or unverified provider', async () => { + it('refuses a provider that is unknown, unverified, or SAML', async () => { queueTableRows(schemaMock.ssoProvider, []) - await expect( - resolveIdpInitiatedLoginProvider('acme-okta', 'https://acme.okta.test') - ).resolves.toBeNull() + 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 index f6543239a29..67957c7dcc2 100644 --- a/apps/sim/lib/auth/sso/idp-initiated-login.ts +++ b/apps/sim/lib/auth/sso/idp-initiated-login.ts @@ -6,21 +6,32 @@ 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 + } +} + /** - * Names the provider a login started from an identity provider's app dashboard - * signs in through (OpenID Connect third-party initiated login). + * Whether an identity provider's app dashboard may start sign-in through this provider + * (OpenID Connect third-party initiated login). * - * The identity provider opens the provider's initiate login URL with its own issuer - * in `iss`. The URL is honored only for a domain-verified OIDC provider whose - * configured issuer is that one, so a crafted link cannot start sign-in against - * another identity provider. `null` falls back to the ordinary sign-in link. + * 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 resolveIdpInitiatedLoginProvider( +export async function isIdpInitiatedLoginAllowed( providerId: string, issuer: string -): Promise { +): Promise { const [provider] = await db - .select({ providerId: ssoProvider.providerId, issuer: ssoProvider.issuer }) + .select({ issuer: ssoProvider.issuer }) .from(ssoProvider) .where( and( @@ -30,6 +41,11 @@ export async function resolveIdpInitiatedLoginProvider( ) ) .limit(1) - if (!provider || normalizeIssuer(provider.issuer) !== normalizeIssuer(issuer)) return null - return provider.providerId + 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) } From 2cb6d3c452ed79218b032387339a70931a121b92 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 15 Sep 2026 16:24:23 -0700 Subject: [PATCH 4/6] fix(sso): keep a signed-in visitor going to the app when the launch address is rate limited --- .../app/(auth)/sso/launch/[providerId]/route.test.ts | 12 +++++++++++- apps/sim/app/(auth)/sso/launch/[providerId]/route.ts | 11 ++++++----- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/apps/sim/app/(auth)/sso/launch/[providerId]/route.test.ts b/apps/sim/app/(auth)/sso/launch/[providerId]/route.test.ts index 2af45123a36..08df6032872 100644 --- a/apps/sim/app/(auth)/sso/launch/[providerId]/route.test.ts +++ b/apps/sim/app/(auth)/sso/launch/[providerId]/route.test.ts @@ -75,6 +75,16 @@ describe('GET /sso/launch/[providerId]', () => { 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], [ @@ -105,8 +115,8 @@ describe('GET /sso/launch/[providerId]', () => { const response = await open() expect(response.headers.get('location')).toBe(SIGN_IN_LINK) - expect(mockGetSession).not.toHaveBeenCalled() expect(mockIsAllowed).not.toHaveBeenCalled() + expect(mockSignInSSO).not.toHaveBeenCalled() }) it('leaves SSO off when the deployment has not enabled it', async () => { diff --git a/apps/sim/app/(auth)/sso/launch/[providerId]/route.ts b/apps/sim/app/(auth)/sso/launch/[providerId]/route.ts index 04b8333d966..9fe018de5fd 100644 --- a/apps/sim/app/(auth)/sso/launch/[providerId]/route.ts +++ b/apps/sim/app/(auth)/sso/launch/[providerId]/route.ts @@ -30,6 +30,12 @@ export const GET = withRouteHandler(async (request: NextRequest, context: RouteC ).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, @@ -37,11 +43,6 @@ export const GET = withRouteHandler(async (request: NextRequest, context: RouteC }) if (rateLimited) return NextResponse.redirect(signInLink) - const session = await getSession() - if (session?.user) { - return NextResponse.redirect(new URL(DEFAULT_POST_AUTH_ROUTE, getBaseUrl()).toString()) - } - const issuer = request.nextUrl.searchParams.get('iss') if (!issuer || !(await isIdpInitiatedLoginAllowed(providerId, issuer))) { return NextResponse.redirect(signInLink) From 73dec086edcea9294ea71317f4dbc0438b6b6a45 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 15 Sep 2026 16:30:02 -0700 Subject: [PATCH 5/6] fix(sso): report a failed launch sign-in on the provider's sign-in link --- .../sso/launch/[providerId]/route.test.ts | 12 +++++++++--- .../(auth)/sso/launch/[providerId]/route.ts | 19 +++++++++++++------ 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/apps/sim/app/(auth)/sso/launch/[providerId]/route.test.ts b/apps/sim/app/(auth)/sso/launch/[providerId]/route.test.ts index 08df6032872..a17ffeae63b 100644 --- a/apps/sim/app/(auth)/sso/launch/[providerId]/route.test.ts +++ b/apps/sim/app/(auth)/sso/launch/[providerId]/route.test.ts @@ -101,12 +101,18 @@ describe('GET /sso/launch/[providerId]', () => { expect(mockSignInSSO).not.toHaveBeenCalled() }) - it("falls back to the provider's sign-in link when sign-in cannot start", async () => { - mockSignInSSO.mockResolvedValue(new Response('{}', { status: 400 })) + 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() - expect(response.headers.get('location')).toBe(SIGN_IN_LINK) + 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 () => { diff --git a/apps/sim/app/(auth)/sso/launch/[providerId]/route.ts b/apps/sim/app/(auth)/sso/launch/[providerId]/route.ts index 9fe018de5fd..9b343ca03e1 100644 --- a/apps/sim/app/(auth)/sso/launch/[providerId]/route.ts +++ b/apps/sim/app/(auth)/sso/launch/[providerId]/route.ts @@ -57,18 +57,25 @@ export const GET = withRouteHandler(async (request: NextRequest, context: RouteC `/sso?error=sso_failed&provider=${encodeURIComponent(providerId)}&callbackUrl=${encodeURIComponent(DEFAULT_POST_AUTH_ROUTE)}`, getBaseUrl() ).toString() - const signIn = await auth.api.signInSSO({ - body: { providerId, callbackURL: DEFAULT_POST_AUTH_ROUTE, errorCallbackURL }, - headers: request.headers, - asResponse: true, - }) + /** 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 }) + 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(signInLink) + return NextResponse.redirect(errorCallbackURL) } const response = NextResponse.redirect(payload.url) From be4db0be35e11a79989ed4c80cfa49d3b72956ec Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 15 Sep 2026 16:37:58 -0700 Subject: [PATCH 6/6] chore(sso): normalize the caught error when a launch sign-in fails --- apps/sim/app/(auth)/sso/launch/[providerId]/route.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/(auth)/sso/launch/[providerId]/route.ts b/apps/sim/app/(auth)/sso/launch/[providerId]/route.ts index 9b343ca03e1..86afda73121 100644 --- a/apps/sim/app/(auth)/sso/launch/[providerId]/route.ts +++ b/apps/sim/app/(auth)/sso/launch/[providerId]/route.ts @@ -1,4 +1,5 @@ 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' @@ -66,7 +67,7 @@ export const GET = withRouteHandler(async (request: NextRequest, context: RouteC asResponse: true, }) } catch (error) { - logger.error('SSO sign-in could not be started', { providerId, 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