Skip to content

Commit 5bca037

Browse files
authored
feat(sso): open Sim from an identity provider's app dashboard (#7865)
* feat(sso): open Sim from an identity provider's app dashboard * improvement(sso): skip re-authentication when signed in and limit dashboard launch to OIDC providers * improvement(sso): redirect straight to the identity provider from the launch URL * fix(sso): keep a signed-in visitor going to the app when the launch address is rate limited * fix(sso): report a failed launch sign-in on the provider's sign-in link * chore(sso): normalize the caught error when a launch sign-in fails
1 parent aeec08a commit 5bca037

7 files changed

Lines changed: 372 additions & 4 deletions

File tree

apps/docs/content/docs/platform/enterprise/sso.mdx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ An organization can run several identity providers at once: Okta for `eng.acme.c
8181

8282
### 4. Copy the callback URL
8383

84-
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.
84+
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.
8585

8686
**OIDC providers** (Okta, Microsoft Entra ID, Google Workspace, Auth0):
8787
```
@@ -140,7 +140,11 @@ The first time someone signs in through the new provider, Sim links it to their
140140
```
141141
4. Under **Assignments**, grant access to the relevant users or groups
142142
5. Copy the **Client ID** and **Client Secret** from the app's **General** tab
143-
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/).
143+
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:
144+
```
145+
https://<your-sim-domain>/sso/launch/okta
146+
```
147+
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/).
144148

145149
**In Sim:**
146150

@@ -305,6 +309,8 @@ Once SSO is configured, users with your domain (`company.com`) can sign in throu
305309
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
306310
6. They land in an accessible workspace, or see a clear no-access state until an admin grants workspace access
307311

312+
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.
313+
308314
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.
309315

310316
<Callout type="warn">
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { createMockRequest, setEnvFlags } from '@sim/testing'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const { mockGetSession, mockSignInSSO, mockIsAllowed, mockEnforceIpRateLimit } = vi.hoisted(() => ({
8+
mockGetSession: vi.fn(),
9+
mockSignInSSO: vi.fn(),
10+
mockIsAllowed: vi.fn(),
11+
mockEnforceIpRateLimit: vi.fn(),
12+
}))
13+
14+
vi.mock('@/lib/auth', () => ({
15+
getSession: mockGetSession,
16+
auth: { api: { signInSSO: mockSignInSSO } },
17+
}))
18+
vi.mock('@/lib/auth/sso/idp-initiated-login', () => ({ isIdpInitiatedLoginAllowed: mockIsAllowed }))
19+
vi.mock('@/lib/core/rate-limiter', () => ({ enforceIpRateLimit: mockEnforceIpRateLimit }))
20+
21+
import { GET } from '@/app/(auth)/sso/launch/[providerId]/route'
22+
23+
const context = { params: Promise.resolve({ providerId: 'acme-okta' }) }
24+
const ISSUER = 'https://acme.okta.test'
25+
const SIGN_IN_LINK = 'https://test.sim.ai/sso?provider=acme-okta'
26+
27+
function open(search = `?iss=${encodeURIComponent(ISSUER)}`) {
28+
return GET(
29+
createMockRequest('GET', undefined, {}, `https://test.sim.ai/sso/launch/acme-okta${search}`),
30+
context
31+
)
32+
}
33+
34+
/** Better Auth answers with the authorization URL and the signed `state` cookie for it. */
35+
function authorizationResponse() {
36+
return new Response(JSON.stringify({ url: 'https://acme.okta.test/oauth2/v1/authorize?x=1' }), {
37+
status: 200,
38+
headers: { 'content-type': 'application/json', 'set-cookie': 'sso_state=abc; Path=/' },
39+
})
40+
}
41+
42+
describe('GET /sso/launch/[providerId]', () => {
43+
beforeEach(() => {
44+
vi.clearAllMocks()
45+
setEnvFlags({ isSsoEnabled: true })
46+
mockGetSession.mockResolvedValue(null)
47+
mockIsAllowed.mockResolvedValue(true)
48+
mockEnforceIpRateLimit.mockResolvedValue(null)
49+
mockSignInSSO.mockResolvedValue(authorizationResponse())
50+
})
51+
52+
it("redirects to the identity provider and carries Better Auth's state cookie", async () => {
53+
const response = await open()
54+
55+
expect(response.status).toBe(307)
56+
expect(response.headers.get('location')).toBe('https://acme.okta.test/oauth2/v1/authorize?x=1')
57+
expect(response.headers.get('set-cookie')).toContain('sso_state=abc')
58+
expect(mockIsAllowed).toHaveBeenCalledWith('acme-okta', ISSUER)
59+
const [{ body }] = mockSignInSSO.mock.calls[0]
60+
expect(body.providerId).toBe('acme-okta')
61+
expect(body).not.toHaveProperty('email')
62+
/** The plugin appends `?error=…`, which must not corrupt the provider on the way back. */
63+
const retry = new URL(`${body.errorCallbackURL}?error=invalid_provider`)
64+
expect(retry.pathname).toBe('/sso')
65+
expect(retry.searchParams.get('provider')).toBe('acme-okta')
66+
})
67+
68+
it('sends someone already signed in to the app without signing in again', async () => {
69+
mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
70+
71+
const response = await open()
72+
73+
expect(response.headers.get('location')).toBe('https://test.sim.ai/home')
74+
expect(mockIsAllowed).not.toHaveBeenCalled()
75+
expect(mockSignInSSO).not.toHaveBeenCalled()
76+
})
77+
78+
it('keeps sending a signed-in visitor to the app when the address is rate limited', async () => {
79+
mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
80+
mockEnforceIpRateLimit.mockResolvedValue(new Response(null, { status: 429 }))
81+
82+
const response = await open()
83+
84+
expect(response.headers.get('location')).toBe('https://test.sim.ai/home')
85+
expect(mockEnforceIpRateLimit).not.toHaveBeenCalled()
86+
})
87+
88+
it.each([
89+
['no issuer', '', () => undefined],
90+
[
91+
'an issuer the provider does not use',
92+
`?iss=${encodeURIComponent('https://other.test')}`,
93+
() => mockIsAllowed.mockResolvedValue(false),
94+
],
95+
])("sends a visitor with %s to the provider's sign-in link", async (_label, search, arrange) => {
96+
arrange()
97+
98+
const response = await open(search)
99+
100+
expect(response.headers.get('location')).toBe(SIGN_IN_LINK)
101+
expect(mockSignInSSO).not.toHaveBeenCalled()
102+
})
103+
104+
it.each([
105+
['refuses', () => mockSignInSSO.mockResolvedValue(new Response('{}', { status: 400 }))],
106+
['throws', () => mockSignInSSO.mockRejectedValue(new Error('network'))],
107+
])("reports the failure on the provider's sign-in link when sign-in %s", async (_l, arrange) => {
108+
arrange()
109+
110+
const response = await open()
111+
112+
const failure = new URL(response.headers.get('location') ?? '')
113+
expect(failure.pathname).toBe('/sso')
114+
expect(failure.searchParams.get('error')).toBe('sso_failed')
115+
expect(failure.searchParams.get('provider')).toBe('acme-okta')
116+
})
117+
118+
it('sends a rate-limited visitor to the sign-in link before any lookup', async () => {
119+
mockEnforceIpRateLimit.mockResolvedValue(new Response(null, { status: 429 }))
120+
121+
const response = await open()
122+
123+
expect(response.headers.get('location')).toBe(SIGN_IN_LINK)
124+
expect(mockIsAllowed).not.toHaveBeenCalled()
125+
expect(mockSignInSSO).not.toHaveBeenCalled()
126+
})
127+
128+
it('leaves SSO off when the deployment has not enabled it', async () => {
129+
setEnvFlags({ isSsoEnabled: false })
130+
131+
const response = await open()
132+
133+
expect(response.headers.get('location')).toBe('https://test.sim.ai/login')
134+
expect(mockEnforceIpRateLimit).not.toHaveBeenCalled()
135+
})
136+
})
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { createLogger } from '@sim/logger'
2+
import { toError } from '@sim/utils/errors'
3+
import { type NextRequest, NextResponse } from 'next/server'
4+
import { auth, getSession } from '@/lib/auth'
5+
import { isIdpInitiatedLoginAllowed } from '@/lib/auth/sso/idp-initiated-login'
6+
import { isSsoEnabled } from '@/lib/core/config/env-flags'
7+
import { enforceIpRateLimit } from '@/lib/core/rate-limiter'
8+
import { getBaseUrl } from '@/lib/core/utils/urls'
9+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
10+
import { DEFAULT_POST_AUTH_ROUTE } from '@/app/(auth)/auth-redirect'
11+
12+
const logger = createLogger('SSOLaunchRoute')
13+
14+
type RouteContext = { params: Promise<{ providerId: string }> }
15+
16+
/**
17+
* The initiate login URL an identity provider's app dashboard opens (OpenID Connect third-party
18+
* initiated login). The dashboard adds its issuer as `iss`, so the URL carries no query of its own.
19+
*
20+
* Sign-in starts here rather than on the sign-in page: the visitor arrives to be sent onward, and a
21+
* redirect spares them a page load and a hydration wait first. Someone already signed in goes
22+
* straight to the app, so a link cannot replace their session. Anything else — an unknown issuer, a
23+
* provider this deployment does not serve, a refused sign-in — falls back to the provider's ordinary
24+
* sign-in link, which asks for an email.
25+
*/
26+
export const GET = withRouteHandler(async (request: NextRequest, context: RouteContext) => {
27+
const { providerId } = await context.params
28+
const signInLink = new URL(
29+
`/sso?provider=${encodeURIComponent(providerId)}`,
30+
getBaseUrl()
31+
).toString()
32+
if (!isSsoEnabled) return NextResponse.redirect(new URL('/login', getBaseUrl()).toString())
33+
34+
const session = await getSession()
35+
if (session?.user) {
36+
return NextResponse.redirect(new URL(DEFAULT_POST_AUTH_ROUTE, getBaseUrl()).toString())
37+
}
38+
39+
/** Admitted per address, after the session, so a busy shared address never strands a signed-in visitor. */
40+
const rateLimited = await enforceIpRateLimit('sso-launch', request, {
41+
maxTokens: 30,
42+
refillRate: 30,
43+
refillIntervalMs: 60_000,
44+
})
45+
if (rateLimited) return NextResponse.redirect(signInLink)
46+
47+
const issuer = request.nextUrl.searchParams.get('iss')
48+
if (!issuer || !(await isIdpInitiatedLoginAllowed(providerId, issuer))) {
49+
return NextResponse.redirect(signInLink)
50+
}
51+
52+
/**
53+
* A failed sign-in returns to the provider's sign-in link with the error. `callbackUrl` comes
54+
* last because the SSO plugin appends its own error with a raw `?`, which runs into whichever
55+
* parameter is last — there it is harmless, on `provider` it would corrupt the retry.
56+
*/
57+
const errorCallbackURL = new URL(
58+
`/sso?error=sso_failed&provider=${encodeURIComponent(providerId)}&callbackUrl=${encodeURIComponent(DEFAULT_POST_AUTH_ROUTE)}`,
59+
getBaseUrl()
60+
).toString()
61+
/** A sign-in that never starts is a failure, so it carries the error rather than a blank form. */
62+
let signIn: Response
63+
try {
64+
signIn = await auth.api.signInSSO({
65+
body: { providerId, callbackURL: DEFAULT_POST_AUTH_ROUTE, errorCallbackURL },
66+
headers: request.headers,
67+
asResponse: true,
68+
})
69+
} catch (error) {
70+
logger.error('SSO sign-in could not be started', { providerId, error: toError(error) })
71+
return NextResponse.redirect(errorCallbackURL)
72+
}
73+
const payload = (await signIn.json().catch(() => null)) as { url?: string } | null
74+
if (!signIn.ok || !payload?.url) {
75+
logger.error('SSO sign-in did not return an authorization URL', {
76+
providerId,
77+
status: signIn.status,
78+
})
79+
return NextResponse.redirect(errorCallbackURL)
80+
}
81+
82+
const response = NextResponse.redirect(payload.url)
83+
/** Better Auth's signed `state` cookie has to reach the browser before the identity provider does. */
84+
const signInHeaders = signIn.headers as Headers & { getSetCookie?: () => string[] }
85+
for (const cookie of signInHeaders.getSetCookie?.() ?? []) {
86+
response.headers.append('set-cookie', cookie)
87+
}
88+
return response
89+
})

apps/sim/ee/sso/components/sso-provider-settings.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -542,6 +542,8 @@ export function SsoProviderSettings({
542542
? [{ text: 'Delete', variant: 'destructive', onSelect: onDelete } satisfies SettingsAction]
543543
: []),
544544
]
545+
const isOidcProvider = (existingProvider.providerType ?? 'oidc') === 'oidc'
546+
const encodedProviderId = encodeURIComponent(existingProvider.providerId ?? '')
545547
const providerCallbackUrl =
546548
(existingProvider.providerType === 'saml' &&
547549
readProviderConfigString(existingProvider.samlConfig, 'callbackUrl')) ||
@@ -593,11 +595,24 @@ export function SsoProviderSettings({
593595
</SettingRow>
594596
)}
595597

598+
{isOidcProvider && (
599+
<SettingRow htmlFor='sso-initiate-login-url' label='Initiate login URL'>
600+
<ChipCopyInput
601+
id='sso-initiate-login-url'
602+
value={`${getBaseUrl()}/sso/launch/${encodedProviderId}`}
603+
copyLabel='Copy initiate login URL'
604+
/>
605+
<p className='text-[var(--text-muted)] text-caption'>
606+
Configure this in your identity provider to open Sim from its app dashboard
607+
</p>
608+
</SettingRow>
609+
)}
610+
596611
{onMakePrimary && (
597612
<SettingRow htmlFor='sso-test-link' label='Test sign-in link'>
598613
<ChipCopyInput
599614
id='sso-test-link'
600-
value={`${getBaseUrl()}/sso?provider=${encodeURIComponent(existingProvider.providerId ?? '')}`}
615+
value={`${getBaseUrl()}/sso?provider=${encodedProviderId}`}
601616
copyLabel='Copy test sign-in link'
602617
/>
603618
<p className='text-[var(--text-muted)] text-caption'>

apps/sim/ee/sso/components/sso-settings.test.tsx

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -794,7 +794,11 @@ describe('SSO provider list', () => {
794794

795795
describe('SSO primary provider', () => {
796796
/** An organization moving one domain's sign-in from one identity provider to another. */
797-
function renderMigration(searchParams = '', okta: Record<string, unknown> = {}) {
797+
function renderMigration(
798+
searchParams = '',
799+
okta: Record<string, unknown> = {},
800+
entra: Record<string, unknown> = {}
801+
) {
798802
mockUseSSOProviders.mockReturnValue({
799803
data: {
800804
providers: [
@@ -804,6 +808,7 @@ describe('SSO primary provider', () => {
804808
providerId: 'acme-entra',
805809
domainVerified: true,
806810
isPrimary: true,
811+
...entra,
807812
},
808813
{
809814
...provider('org-a'),
@@ -848,6 +853,25 @@ describe('SSO primary provider', () => {
848853
expect(container.querySelector('#sso-test-link')).toBeNull()
849854
})
850855

856+
it("shows an OIDC provider's initiate login URL for its identity provider's app dashboard", () => {
857+
renderMigration()
858+
openProvider('acme-entra')
859+
860+
expect(container).toHaveTextContent('Initiate login URL')
861+
const link = new URL(
862+
container.querySelector<HTMLInputElement>('#sso-initiate-login-url')?.value ?? ''
863+
)
864+
expect(link.pathname).toBe('/sso/launch/acme-entra')
865+
expect(link.search).toBe('')
866+
})
867+
868+
it('shows no initiate login URL on a SAML provider', () => {
869+
renderMigration('', {}, { providerType: 'saml' })
870+
openProvider('acme-entra')
871+
872+
expect(container.querySelector('#sso-initiate-login-url')).toBeNull()
873+
})
874+
851875
it('offers a test sign-in link and Make primary on a provider waiting beside the primary', () => {
852876
renderMigration()
853877
openProvider('acme-okta')
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { dbChainMock, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock }))
8+
9+
import { isIdpInitiatedLoginAllowed } from '@/lib/auth/sso/idp-initiated-login'
10+
11+
function queueProvider(issuer: string) {
12+
queueTableRows(schemaMock.ssoProvider, [{ issuer }])
13+
}
14+
15+
describe('isIdpInitiatedLoginAllowed', () => {
16+
beforeEach(() => {
17+
resetDbChainMock()
18+
})
19+
20+
it.each([
21+
['the issuer it is configured with', 'https://acme.okta.test', 'https://acme.okta.test'],
22+
['that issuer with a trailing slash', 'https://acme.okta.test', 'https://acme.okta.test/'],
23+
[
24+
'the organization URL of its custom authorization server',
25+
'https://acme.okta.test/oauth2/default',
26+
'https://acme.okta.test',
27+
],
28+
])('allows a provider opened by %s', async (_label, configured, opened) => {
29+
queueProvider(configured)
30+
await expect(isIdpInitiatedLoginAllowed('acme-okta', opened)).resolves.toBe(true)
31+
})
32+
33+
it.each([
34+
['another identity provider', 'https://attacker.example.test'],
35+
['a value that is not a URL', 'not-a-url'],
36+
])('refuses a link opened by %s', async (_label, opened) => {
37+
queueProvider('https://acme.okta.test')
38+
await expect(isIdpInitiatedLoginAllowed('acme-okta', opened)).resolves.toBe(false)
39+
})
40+
41+
it('refuses a provider that is unknown, unverified, or SAML', async () => {
42+
queueTableRows(schemaMock.ssoProvider, [])
43+
await expect(isIdpInitiatedLoginAllowed('acme-okta', 'https://acme.okta.test')).resolves.toBe(
44+
false
45+
)
46+
})
47+
})

0 commit comments

Comments
 (0)