Skip to content

Commit 857fa59

Browse files
committed
feat(sso): let organizations require single sign-on
Adds an organization setting that refuses password and email-code sign-in for members, enforced when a session is created so enabling it signs nobody out. Owners keep every sign-in method as a break-glass path, and the requirement stops enforcing if no identity provider on a verified domain is left to satisfy it.
1 parent 31ad74b commit 857fa59

24 files changed

Lines changed: 28170 additions & 15 deletions

File tree

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

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -319,8 +319,18 @@ With **Automatic** provisioning, no invitation is required for organization memb
319319

320320
SSO provisioning creates internal organization members but does not grant workspace access. To grant workspace access from your identity provider, use [directory provisioning](/platform/enterprise/scim) and map a pushed group to a workspace. External workspace members are different: they are invited to a specific workspace without joining your organization or consuming one of your seats. Existing invitations and external access take precedence over automatic provisioning so their intended role and workspace grants are preserved.
321321

322+
## Require single sign-on
323+
324+
By default members can sign in with a password, an email code, or your identity provider. To make single sign-on the only way in, open **Settings → Organization → Single sign-on → Sign-in** and set **Allowed sign-in methods** to **Single sign-on**. It becomes available once the organization has an identity provider on a verified domain.
325+
326+
- The requirement is checked when a session is created, so turning it on signs nobody out. Members keep working and meet the requirement at their next sign-in. To end current sessions too, use **Sign out all members** under **Settings → Organization → Security**.
327+
- Organization owners keep every sign-in method. If the identity provider breaks, an owner can still sign in with a password and turn the requirement back off.
328+
- If the last identity provider is deleted or its domain verification lapses, the requirement stops being enforced instead of locking the organization out, and you can switch back to any method at any time.
329+
- Desktop app handoff from an already signed-in browser keeps working, because that session derives from one the requirement already admitted.
330+
- A member who tries a password or email code sees a message telling them to sign in through their identity provider.
331+
322332
<Callout type="info">
323-
Password-based login remains available. Forcing all organization members to use SSO exclusively is not yet supported.
333+
Turning the requirement off restores password and email sign-in immediately for everyone.
324334
</Callout>
325335

326336
---
@@ -352,7 +362,7 @@ SSO provisioning creates internal organization members but does not grant worksp
352362
},
353363
{
354364
question: "Can I still use email/password login after enabling SSO?",
355-
answer: "Yes. Enabling SSO does not disable password-based login. Users can still sign in with their email and password if they have one. Forced SSO (requiring all users on the domain to use SSO) is not yet supported."
365+
answer: "Yes, unless you require single sign-on. Enabling SSO does not disable password login on its own; set Allowed sign-in methods to Single sign-on to refuse password and email-code sign-in for members. Organization owners keep password sign-in as a way back in if the identity provider breaks."
356366
},
357367
{
358368
question: "A user already has an account with the same email — what happens when they sign in with SSO?",

‎apps/sim/app/(auth)/login/login-form.tsx‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,19 @@ export default function LoginPage({
202202
return
203203
}
204204

205+
/**
206+
* A policy refusal explains itself — an organization requiring single sign-on, or a
207+
* suspended account. Leading with "Invalid email or password" would send the person
208+
* to reset a password that is fine.
209+
*/
210+
if (ctx.error.status === 403 && ctx.error.message) {
211+
errorHandled = true
212+
setResetSuccessMessage(null)
213+
setPasswordErrors([ctx.error.message])
214+
setShowValidationError(true)
215+
return
216+
}
217+
205218
errorHandled = true
206219
const errorMessage: string[] = ['Invalid email or password']
207220

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { member, organization, ssoProvider } from '@sim/db/schema'
5+
import {
6+
authMockFns,
7+
createMockRequest,
8+
dbChainMockFns,
9+
queueTableRows,
10+
resetDbChainMock,
11+
resetEnvFlagsMock,
12+
setEnvFlags,
13+
} from '@sim/testing'
14+
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
15+
16+
const { mockIsEnterprise, mockRecordAudit, mockInvalidate } = vi.hoisted(() => ({
17+
mockIsEnterprise: vi.fn(),
18+
mockRecordAudit: vi.fn(),
19+
mockInvalidate: vi.fn(),
20+
}))
21+
22+
vi.mock('@/lib/auth/sso-policy', () => ({
23+
invalidateSsoPolicyCache: mockInvalidate,
24+
}))
25+
26+
/**
27+
* These tests run with billing enabled, where `isOrganizationFeatureEntitled`
28+
* delegates straight to the plan check.
29+
*/
30+
vi.mock('@/lib/billing/core/subscription', () => ({
31+
isOrganizationOnEnterprisePlan: mockIsEnterprise,
32+
isOrganizationFeatureEntitled: mockIsEnterprise,
33+
}))
34+
35+
vi.mock('@sim/audit', () => ({
36+
recordAudit: mockRecordAudit,
37+
AuditAction: { ORGANIZATION_SSO_POLICY_UPDATED: 'organization.sso_policy.updated' },
38+
AuditResourceType: { ORGANIZATION: 'organization' },
39+
}))
40+
41+
import { GET, PUT } from '@/app/api/organizations/[id]/sso-policy/route'
42+
43+
const mockGetSession = authMockFns.mockGetSession
44+
45+
const ORG_ID = 'org-1'
46+
const routeContext = { params: Promise.resolve({ id: ORG_ID }) }
47+
48+
beforeAll(() => {
49+
setEnvFlags({ isBillingEnabled: true })
50+
})
51+
52+
afterAll(resetEnvFlagsMock)
53+
54+
describe('sso policy route', () => {
55+
beforeEach(() => {
56+
vi.clearAllMocks()
57+
resetDbChainMock()
58+
mockGetSession.mockResolvedValue({
59+
user: { id: 'user-1', name: 'Admin', email: 'admin@acme.dev' },
60+
session: { token: 'tok-1' },
61+
})
62+
mockIsEnterprise.mockResolvedValue(true)
63+
})
64+
65+
describe('GET', () => {
66+
it('returns 401 when unauthenticated', async () => {
67+
mockGetSession.mockResolvedValue(null)
68+
const response = await GET(createMockRequest('GET'), routeContext)
69+
expect(response.status).toBe(401)
70+
})
71+
72+
it('returns 403 for non-members', async () => {
73+
queueTableRows(member, [])
74+
const response = await GET(createMockRequest('GET'), routeContext)
75+
expect(response.status).toBe(403)
76+
})
77+
78+
it('reports the requirement and whether a provider can satisfy it', async () => {
79+
queueTableRows(member, [{ id: 'member-1' }])
80+
queueTableRows(organization, [{ requireSso: true }])
81+
queueTableRows(ssoProvider, [{ id: 'provider-1' }])
82+
83+
const response = await GET(createMockRequest('GET'), routeContext)
84+
expect(response.status).toBe(200)
85+
await expect(response.json()).resolves.toMatchObject({
86+
data: { requireSso: true, hasVerifiedProvider: true },
87+
})
88+
})
89+
})
90+
91+
describe('PUT', () => {
92+
it('rejects non-admin members', async () => {
93+
queueTableRows(member, [{ role: 'member' }])
94+
const response = await PUT(createMockRequest('PUT', { requireSso: true }), routeContext)
95+
expect(response.status).toBe(403)
96+
})
97+
98+
it('rejects organizations without the entitlement', async () => {
99+
queueTableRows(member, [{ role: 'owner' }])
100+
mockIsEnterprise.mockResolvedValue(false)
101+
const response = await PUT(createMockRequest('PUT', { requireSso: true }), routeContext)
102+
expect(response.status).toBe(403)
103+
})
104+
105+
it('refuses to require SSO with no verified provider', async () => {
106+
queueTableRows(member, [{ role: 'owner' }])
107+
queueTableRows(organization, [{ name: 'Acme' }])
108+
queueTableRows(ssoProvider, [])
109+
110+
const response = await PUT(createMockRequest('PUT', { requireSso: true }), routeContext)
111+
expect(response.status).toBe(400)
112+
expect(dbChainMockFns.update).not.toHaveBeenCalled()
113+
})
114+
115+
it('turns the requirement on, invalidates the cache, and records audit', async () => {
116+
queueTableRows(member, [{ role: 'admin' }])
117+
queueTableRows(organization, [{ name: 'Acme' }])
118+
queueTableRows(ssoProvider, [{ id: 'provider-1' }])
119+
dbChainMockFns.returning.mockResolvedValueOnce([{ id: ORG_ID }])
120+
121+
const response = await PUT(createMockRequest('PUT', { requireSso: true }), routeContext)
122+
expect(response.status).toBe(200)
123+
await expect(response.json()).resolves.toMatchObject({ data: { requireSso: true } })
124+
expect(dbChainMockFns.set).toHaveBeenCalledWith(expect.objectContaining({ requireSso: true }))
125+
expect(mockInvalidate).toHaveBeenCalledWith(ORG_ID)
126+
expect(mockRecordAudit).toHaveBeenCalledWith(
127+
expect.objectContaining({ action: 'organization.sso_policy.updated' })
128+
)
129+
})
130+
131+
it('turning the requirement off needs no provider', async () => {
132+
queueTableRows(member, [{ role: 'owner' }])
133+
queueTableRows(organization, [{ name: 'Acme' }])
134+
queueTableRows(ssoProvider, [])
135+
dbChainMockFns.returning.mockResolvedValueOnce([{ id: ORG_ID }])
136+
137+
const response = await PUT(createMockRequest('PUT', { requireSso: false }), routeContext)
138+
expect(response.status).toBe(200)
139+
await expect(response.json()).resolves.toMatchObject({ data: { requireSso: false } })
140+
})
141+
})
142+
})
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
2+
import { db } from '@sim/db'
3+
import { member, organization } from '@sim/db/schema'
4+
import { createLogger } from '@sim/logger'
5+
import { isOrgAdminRole } from '@sim/platform-authz/workspace'
6+
import { and, eq } from 'drizzle-orm'
7+
import { type NextRequest, NextResponse } from 'next/server'
8+
import { updateOrganizationSsoPolicyContract } from '@/lib/api/contracts/organization'
9+
import { parseRequest, validationErrorResponse } from '@/lib/api/server'
10+
import { getSession } from '@/lib/auth'
11+
import { hasSignInCapableSsoProvider } from '@/lib/auth/sso/verified-provider'
12+
import { invalidateSsoPolicyCache } from '@/lib/auth/sso-policy'
13+
import { isOrganizationFeatureEntitled } from '@/lib/billing/core/subscription'
14+
import { isBillingEnabled, isSsoEnabled } from '@/lib/core/config/env-flags'
15+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
16+
17+
const logger = createLogger('SsoPolicyAPI')
18+
19+
/**
20+
* GET /api/organizations/[id]/sso-policy
21+
* Returns whether members must sign in through the organization's identity
22+
* provider. Readable by any member.
23+
*/
24+
export const GET = withRouteHandler(
25+
async (_request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
26+
const session = await getSession()
27+
if (!session?.user?.id) {
28+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
29+
}
30+
31+
const { id: organizationId } = await params
32+
33+
const [memberEntry] = await db
34+
.select({ id: member.id })
35+
.from(member)
36+
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
37+
.limit(1)
38+
39+
if (!memberEntry) {
40+
return NextResponse.json(
41+
{ error: 'Forbidden - Not a member of this organization' },
42+
{ status: 403 }
43+
)
44+
}
45+
46+
const [org] = await db
47+
.select({ requireSso: organization.requireSso })
48+
.from(organization)
49+
.where(eq(organization.id, organizationId))
50+
.limit(1)
51+
52+
if (!org) {
53+
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
54+
}
55+
56+
return NextResponse.json({
57+
success: true,
58+
data: {
59+
requireSso: org.requireSso,
60+
hasVerifiedProvider: await hasSignInCapableSsoProvider(organizationId),
61+
},
62+
})
63+
}
64+
)
65+
66+
/**
67+
* PUT /api/organizations/[id]/sso-policy
68+
* Turns the single sign-on requirement on or off. The policy is read when a
69+
* session is created, so a change never ends a session that already exists —
70+
* signing everyone out stays the separate revoke action. Requires enterprise
71+
* entitlement and an owner/admin role.
72+
*/
73+
export const PUT = withRouteHandler(
74+
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
75+
const session = await getSession()
76+
if (!session?.user?.id) {
77+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
78+
}
79+
80+
const parsed = await parseRequest(updateOrganizationSsoPolicyContract, request, context, {
81+
validationErrorResponse: (err) => validationErrorResponse(err, 'Invalid request body'),
82+
})
83+
if (!parsed.success) return parsed.response
84+
85+
const { id: organizationId } = parsed.data.params
86+
const { requireSso } = parsed.data.body
87+
88+
const [memberEntry] = await db
89+
.select({ role: member.role })
90+
.from(member)
91+
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
92+
.limit(1)
93+
94+
if (!memberEntry) {
95+
return NextResponse.json(
96+
{ error: 'Forbidden - Not a member of this organization' },
97+
{ status: 403 }
98+
)
99+
}
100+
101+
if (!isOrgAdminRole(memberEntry.role)) {
102+
return NextResponse.json(
103+
{
104+
error:
105+
'Forbidden - Only organization owners and admins can change the single sign-on requirement',
106+
},
107+
{ status: 403 }
108+
)
109+
}
110+
111+
const entitled = await isOrganizationFeatureEntitled(organizationId, isSsoEnabled)
112+
if (!entitled) {
113+
return NextResponse.json(
114+
{
115+
error: isBillingEnabled
116+
? 'Single Sign-On is available on Enterprise plans only'
117+
: 'Single Sign-On is disabled. Set ENTERPRISE_ENABLED or SSO_ENABLED to enable it.',
118+
},
119+
{ status: 403 }
120+
)
121+
}
122+
123+
const [currentOrg] = await db
124+
.select({ name: organization.name })
125+
.from(organization)
126+
.where(eq(organization.id, organizationId))
127+
.limit(1)
128+
129+
if (!currentOrg) {
130+
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
131+
}
132+
133+
const verifiedProvider = await hasSignInCapableSsoProvider(organizationId)
134+
if (requireSso && !verifiedProvider) {
135+
return NextResponse.json(
136+
{
137+
error: 'Add an identity provider on a verified domain before requiring single sign-on',
138+
},
139+
{ status: 400 }
140+
)
141+
}
142+
143+
const [updated] = await db
144+
.update(organization)
145+
.set({ requireSso, updatedAt: new Date() })
146+
.where(eq(organization.id, organizationId))
147+
.returning({ id: organization.id })
148+
149+
if (!updated) {
150+
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
151+
}
152+
153+
invalidateSsoPolicyCache(organizationId)
154+
155+
logger.info('Updated organization single sign-on requirement', { organizationId, requireSso })
156+
157+
recordAudit({
158+
workspaceId: null,
159+
actorId: session.user.id,
160+
action: AuditAction.ORGANIZATION_SSO_POLICY_UPDATED,
161+
resourceType: AuditResourceType.ORGANIZATION,
162+
resourceId: organizationId,
163+
actorName: session.user.name ?? undefined,
164+
actorEmail: session.user.email ?? undefined,
165+
resourceName: currentOrg.name,
166+
description: requireSso ? 'Required single sign-on' : 'Stopped requiring single sign-on',
167+
metadata: { requireSso },
168+
request,
169+
})
170+
171+
return NextResponse.json({
172+
success: true,
173+
data: { requireSso, hasVerifiedProvider: verifiedProvider },
174+
})
175+
}
176+
)

‎apps/sim/app/oauth-error/page.tsx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { Metadata } from 'next'
2+
import { SSO_REQUIRED_ERROR_CODE, SSO_REQUIRED_MESSAGE } from '@/lib/auth/sso-policy'
23
import { DesktopHandoffShell } from '@/app/desktop/components/desktop-handoff-shell'
34

45
export const metadata: Metadata = {
@@ -40,6 +41,11 @@ const FRIENDLY: Record<string, string> = {
4041
*/
4142
account_not_linked:
4243
'An account already exists for this email address. Sign in using the method you originally signed up with.',
44+
/**
45+
* The person's organization requires single sign-on, so a social sign-in is
46+
* refused. Retrying the same provider can never succeed — name the way in.
47+
*/
48+
[SSO_REQUIRED_ERROR_CODE]: SSO_REQUIRED_MESSAGE,
4349
/** The provider returned no email claim, so there is nothing to sign in as. */
4450
email_not_found:
4551
'Your identity provider didn’t share an email address with us, so we couldn’t complete sign-in. Please contact your administrator.',

0 commit comments

Comments
 (0)