Skip to content

Commit ab848f6

Browse files
committed
improvement(sso): redirect straight to the identity provider from the launch URL
1 parent 16f3177 commit ab848f6

9 files changed

Lines changed: 255 additions & 262 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,7 @@ Once SSO is configured, users with your domain (`company.com`) can sign in throu
309309
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
310310
6. They land in an accessible workspace, or see a clear no-access state until an admin grants workspace access
311311

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. 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.
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.
313313

314314
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.
315315

apps/sim/app/(auth)/sso/launch/[providerId]/page.test.tsx

Lines changed: 0 additions & 72 deletions
This file was deleted.

apps/sim/app/(auth)/sso/launch/[providerId]/page.tsx

Lines changed: 0 additions & 44 deletions
This file was deleted.
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
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.each([
79+
['no issuer', '', () => undefined],
80+
[
81+
'an issuer the provider does not use',
82+
`?iss=${encodeURIComponent('https://other.test')}`,
83+
() => mockIsAllowed.mockResolvedValue(false),
84+
],
85+
])("sends a visitor with %s to the provider's sign-in link", async (_label, search, arrange) => {
86+
arrange()
87+
88+
const response = await open(search)
89+
90+
expect(response.headers.get('location')).toBe(SIGN_IN_LINK)
91+
expect(mockSignInSSO).not.toHaveBeenCalled()
92+
})
93+
94+
it("falls back to the provider's sign-in link when sign-in cannot start", async () => {
95+
mockSignInSSO.mockResolvedValue(new Response('{}', { status: 400 }))
96+
97+
const response = await open()
98+
99+
expect(response.headers.get('location')).toBe(SIGN_IN_LINK)
100+
})
101+
102+
it('sends a rate-limited visitor to the sign-in link before any lookup', async () => {
103+
mockEnforceIpRateLimit.mockResolvedValue(new Response(null, { status: 429 }))
104+
105+
const response = await open()
106+
107+
expect(response.headers.get('location')).toBe(SIGN_IN_LINK)
108+
expect(mockGetSession).not.toHaveBeenCalled()
109+
expect(mockIsAllowed).not.toHaveBeenCalled()
110+
})
111+
112+
it('leaves SSO off when the deployment has not enabled it', async () => {
113+
setEnvFlags({ isSsoEnabled: false })
114+
115+
const response = await open()
116+
117+
expect(response.headers.get('location')).toBe('https://test.sim.ai/login')
118+
expect(mockEnforceIpRateLimit).not.toHaveBeenCalled()
119+
})
120+
})
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { createLogger } from '@sim/logger'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { auth, getSession } from '@/lib/auth'
4+
import { isIdpInitiatedLoginAllowed } from '@/lib/auth/sso/idp-initiated-login'
5+
import { isSsoEnabled } from '@/lib/core/config/env-flags'
6+
import { enforceIpRateLimit } from '@/lib/core/rate-limiter'
7+
import { getBaseUrl } from '@/lib/core/utils/urls'
8+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
9+
import { DEFAULT_POST_AUTH_ROUTE } from '@/app/(auth)/auth-redirect'
10+
11+
const logger = createLogger('SSOLaunchRoute')
12+
13+
type RouteContext = { params: Promise<{ providerId: string }> }
14+
15+
/**
16+
* The initiate login URL an identity provider's app dashboard opens (OpenID Connect third-party
17+
* initiated login). The dashboard adds its issuer as `iss`, so the URL carries no query of its own.
18+
*
19+
* Sign-in starts here rather than on the sign-in page: the visitor arrives to be sent onward, and a
20+
* redirect spares them a page load and a hydration wait first. Someone already signed in goes
21+
* straight to the app, so a link cannot replace their session. Anything else — an unknown issuer, a
22+
* provider this deployment does not serve, a refused sign-in — falls back to the provider's ordinary
23+
* sign-in link, which asks for an email.
24+
*/
25+
export const GET = withRouteHandler(async (request: NextRequest, context: RouteContext) => {
26+
const { providerId } = await context.params
27+
const signInLink = new URL(
28+
`/sso?provider=${encodeURIComponent(providerId)}`,
29+
getBaseUrl()
30+
).toString()
31+
if (!isSsoEnabled) return NextResponse.redirect(new URL('/login', getBaseUrl()).toString())
32+
33+
const rateLimited = await enforceIpRateLimit('sso-launch', request, {
34+
maxTokens: 30,
35+
refillRate: 30,
36+
refillIntervalMs: 60_000,
37+
})
38+
if (rateLimited) return NextResponse.redirect(signInLink)
39+
40+
const session = await getSession()
41+
if (session?.user) {
42+
return NextResponse.redirect(new URL(DEFAULT_POST_AUTH_ROUTE, getBaseUrl()).toString())
43+
}
44+
45+
const issuer = request.nextUrl.searchParams.get('iss')
46+
if (!issuer || !(await isIdpInitiatedLoginAllowed(providerId, issuer))) {
47+
return NextResponse.redirect(signInLink)
48+
}
49+
50+
/**
51+
* A failed sign-in returns to the provider's sign-in link with the error. `callbackUrl` comes
52+
* last because the SSO plugin appends its own error with a raw `?`, which runs into whichever
53+
* parameter is last — there it is harmless, on `provider` it would corrupt the retry.
54+
*/
55+
const errorCallbackURL = new URL(
56+
`/sso?error=sso_failed&provider=${encodeURIComponent(providerId)}&callbackUrl=${encodeURIComponent(DEFAULT_POST_AUTH_ROUTE)}`,
57+
getBaseUrl()
58+
).toString()
59+
const signIn = await auth.api.signInSSO({
60+
body: { providerId, callbackURL: DEFAULT_POST_AUTH_ROUTE, errorCallbackURL },
61+
headers: request.headers,
62+
asResponse: true,
63+
})
64+
const payload = (await signIn.json().catch(() => null)) as { url?: string } | null
65+
if (!signIn.ok || !payload?.url) {
66+
logger.error('SSO sign-in did not return an authorization URL', {
67+
providerId,
68+
status: signIn.status,
69+
})
70+
return NextResponse.redirect(signInLink)
71+
}
72+
73+
const response = NextResponse.redirect(payload.url)
74+
/** Better Auth's signed `state` cookie has to reach the browser before the identity provider does. */
75+
const signInHeaders = signIn.headers as Headers & { getSetCookie?: () => string[] }
76+
for (const cookie of signInHeaders.getSetCookie?.() ?? []) {
77+
response.headers.append('set-cookie', cookie)
78+
}
79+
return response
80+
})

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

Lines changed: 0 additions & 67 deletions
This file was deleted.

0 commit comments

Comments
 (0)