Skip to content

Commit 569c6a6

Browse files
committed
improvement(sso): verified multi-provider flow and cleaner provider settings
- Registration guard uses the same domain expression as sign-in resolution and domain verification - Self-host registration script stores the normalized provider domain - Sign-in reports "no provider" only on 404; other failures keep the generic message - Settings: canonical back slot and title on provider detail and form, Edit then Delete, no duplicate Open in the row menu, awaited list refetch before navigating, replace-history on close, unsaved-changes guard on back - Docs: state that an organization can use more than one identity provider
1 parent b81c797 commit 569c6a6

10 files changed

Lines changed: 647 additions & 594 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
@@ -8,7 +8,7 @@ import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
88
import { FAQ } from '@/components/ui/faq'
99
import { Image } from '@/components/ui/image'
1010

11-
Single Sign-On lets your team sign in to Sim through your company's identity provider instead of managing separate passwords. Sim supports both OIDC and SAML 2.0.
11+
Single Sign-On lets your team sign in to Sim through your company's identity provider instead of managing separate passwords. Sim supports both OIDC and SAML 2.0, and an organization can use more than one identity provider at a time, one per verified domain.
1212

1313
---
1414

apps/sim/app/api/auth/sso/register/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
193193
providerId: ssoProvider.providerId,
194194
})
195195
.from(ssoProvider)
196-
.where(sql`lower(${ssoProvider.domain}) = ${domain}`)
196+
.where(sql`lower(regexp_replace(btrim(${ssoProvider.domain}), '^\\*\\.', '')) = ${domain}`)
197197
if (claims.some((provider) => !isOwnedByCaller(provider))) {
198198
logger.warn('Rejected SSO registration for domain owned by another tenant', {
199199
domain,

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

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ vi.mock('@/lib/core/config/env', () => ({
6464
isFalsy: (value: unknown) => value === undefined || value === 'false',
6565
}))
6666

67+
import { ApiClientError } from '@/lib/api/client/errors'
6768
import SSOForm from '@/ee/sso/components/sso-form'
6869

6970
function renderFirstFrame(search: string, registrationDisabled = false): string {
@@ -185,7 +186,9 @@ describe('SSOForm sign-in errors', () => {
185186
})
186187

187188
it('explains when no provider serves the domain, without starting a sign-in', async () => {
188-
mockRequestJson.mockRejectedValue(new Error('No identity provider is configured'))
189+
mockRequestJson.mockRejectedValue(
190+
new ApiClientError({ status: 404, message: 'No identity provider is configured', body: {} })
191+
)
189192
renderInteractive('email=user%40nowhere.test')
190193

191194
await submitForm()
@@ -196,6 +199,19 @@ describe('SSOForm sign-in errors', () => {
196199
expect(submitButton?.disabled).toBe(false)
197200
})
198201

202+
it('keeps the generic message when resolution fails for another reason', async () => {
203+
mockRequestJson.mockRejectedValue(
204+
new ApiClientError({ status: 429, message: 'Too many requests', body: {} })
205+
)
206+
renderInteractive('email=user%40example.com')
207+
208+
await submitForm()
209+
210+
expect(container).toHaveTextContent('Unable to start SSO. Check your email and try again.')
211+
expect(container).not.toHaveTextContent('No SSO provider is configured')
212+
expect(mockSsoSignIn).not.toHaveBeenCalled()
213+
})
214+
199215
it('shows a generic retryable error when Better Auth resolves with a 404', async () => {
200216
mockSsoSignIn.mockResolvedValue({
201217
data: null,

apps/sim/ee/sso/components/sso-form.tsx

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Button, cn, Input, Label } from '@sim/emcn'
55
import { createLogger } from '@sim/logger'
66
import Link from 'next/link'
77
import { useSearchParams } from 'next/navigation'
8+
import { isApiClientError } from '@/lib/api/client/errors'
89
import { requestJson } from '@/lib/api/client/request'
910
import { resolveSsoProviderContract } from '@/lib/api/contracts/auth'
1011
import { client } from '@/lib/auth/auth-client'
@@ -17,7 +18,7 @@ import { AuthFormMessage, AuthSubmitButton } from '@/app/(auth)/components'
1718
const logger = createLogger('SSOForm')
1819
const SSO_SIGN_IN_ERROR = 'Unable to start SSO. Check your email and try again.'
1920
const SSO_NO_PROVIDER_ERROR =
20-
'No SSO provider is configured for this email domain. Ask your administrator, or sign in another way.'
21+
'No SSO provider is configured for this email domain. Ask your administrator.'
2122
const SSO_ERROR_MESSAGES = {
2223
account_not_found: 'No account found. Please contact your administrator to set up SSO access.',
2324
sso_failed: 'SSO authentication failed. Please try again.',
@@ -139,19 +140,14 @@ function SSOFormContent({
139140
try {
140141
const safeCallbackUrl = callbackUrl
141142

142-
/**
143-
* The provider is named explicitly. Letting the SSO plugin choose by
144-
* domain is unordered and blind to domain verification, so an
145-
* organization with several providers would be routed arbitrarily.
146-
*/
147-
const resolved = await requestJson(resolveSsoProviderContract, {
148-
body: { email: emailValue },
149-
}).catch((error: unknown) => {
150-
logger.warn('No SSO provider resolved for address', { error })
151-
return null
152-
})
153-
if (!resolved) {
154-
setFormError(SSO_NO_PROVIDER_ERROR)
143+
/** Named explicitly; see `resolveSsoProviderContract` for why the domain lookup is not trusted. */
144+
let resolved: { providerId: string }
145+
try {
146+
resolved = await requestJson(resolveSsoProviderContract, { body: { email: emailValue } })
147+
} catch (error) {
148+
const noProvider = isApiClientError(error) && error.status === 404
149+
if (!noProvider) logger.error('SSO provider resolution failed', { error })
150+
setFormError(noProvider ? SSO_NO_PROVIDER_ERROR : SSO_SIGN_IN_ERROR)
155151
return
156152
}
157153

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/componen
1212
interface SsoProviderListProps {
1313
providers: SsoProviderView[]
1414
active: boolean
15+
docsLink: string
1516
onAdd: () => void
1617
onOpen: (providerId: string) => void
1718
onDelete: (providerId: string) => void
@@ -29,11 +30,13 @@ export function SsoProviderList({
2930
onAdd,
3031
onOpen,
3132
onDelete,
33+
docsLink,
3234
}: SsoProviderListProps) {
3335
return (
3436
<>
3537
{active && (
3638
<SettingsPanel
39+
docsLink={docsLink}
3740
actions={[{ text: 'Add identity provider', variant: 'primary', onSelect: onAdd }]}
3841
/>
3942
)}
@@ -54,7 +57,6 @@ export function SsoProviderList({
5457
<RowActionsMenu
5558
label={`${providerId} actions`}
5659
actions={[
57-
{ label: 'Open', onSelect: () => onOpen(providerId) },
5860
{ label: 'Delete', onSelect: () => onDelete(providerId), destructive: true },
5961
]}
6062
/>

0 commit comments

Comments
 (0)