Skip to content

Commit de0f118

Browse files
authored
feat(sso): let organizations require single sign-on (#7881)
* 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. * fix(sso): close policy gaps found in review - Let an admin turn the requirement off after the organization loses its SSO entitlement, so a restored entitlement cannot silently resume it - Fail closed when the requirement cannot be read: a failed lookup no longer admits a password sign-in - Invalidate the policy cache when a provider is deleted or a domain's verification changes, on the server and in the query client - Report whether the stored requirement is actually enforced, and say so in the settings copy * refactor(sso): move the sign-in requirement behind an application use case - Replace the hand-rolled route guards with organization operations, the pattern the SSO domain already uses for its primary-provider change - Drop the multi-membership scan: users belong to at most one organization, the same assumption the expiry clamp makes - Match the provider check to what sign-in resolution actually requires, so a capable provider is never read as incapable - Invalidate the policy cache when a provider is registered or edited - Show a policy refusal as a form-level message rather than marking the password field wrong * fix(sso): close the fail-open paths an independent audit found - Ask the entitlement read to throw rather than answer "not entitled" on an outage, so a blip cannot drop the requirement and cache that - Keep the membership read's old resilience for paths the requirement allows anyway, and fail closed only on the ones it governs - Say why a verification code was refused instead of calling it invalid - Report the requirement from fresh reads, so the settings surface cannot disagree with what sign-in enforces - Document that social sign-in is refused too, and how an owner without a password gets back in
1 parent 360d653 commit de0f118

33 files changed

Lines changed: 28326 additions & 23 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 — setting one through **Forgot password** if they only ever signed in through the identity provider — 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, an email code, or a social sign-in such as Google or GitHub 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, email-code, and social sign-in for members. Organization owners keep password sign-in as a way back in if the identity provider breaks, and can set a password through Forgot password if they never had one."
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: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,8 @@ export default function LoginPage({
101101
const [password, setPassword] = useState('')
102102
const [passwordErrors, setPasswordErrors] = useState<string[]>([])
103103
const [showValidationError, setShowValidationError] = useState(false)
104+
/** A refusal that is about the account or its organization, not the credentials typed in. */
105+
const [policyError, setPolicyError] = useState<string | null>(null)
104106
const callbackUrlParam = searchParams?.get('callbackUrl')
105107
const isValidCallbackUrl = callbackUrlParam ? validateCallbackUrl(callbackUrlParam) : false
106108
const invalidCallbackRef = useRef(false)
@@ -157,6 +159,7 @@ export default function LoginPage({
157159
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
158160
e.preventDefault()
159161
setIsLoading(true)
162+
setPolicyError(null)
160163

161164
const redirectToVerify = (emailToVerify: string) => {
162165
if (typeof window !== 'undefined') {
@@ -202,6 +205,20 @@ export default function LoginPage({
202205
return
203206
}
204207

208+
/**
209+
* A policy refusal explains itself — an organization requiring single sign-on, or a
210+
* suspended account. It belongs in the form-level slot: the password is not what is
211+
* wrong, so marking that field would send the person to reset a password that is fine.
212+
*/
213+
if (ctx.error.status === 403 && ctx.error.message) {
214+
errorHandled = true
215+
setResetSuccessMessage(null)
216+
setPasswordErrors([])
217+
setShowValidationError(false)
218+
setPolicyError(ctx.error.message)
219+
return
220+
}
221+
205222
errorHandled = true
206223
const errorMessage: string[] = ['Invalid email or password']
207224

@@ -409,6 +426,12 @@ export default function LoginPage({
409426
</AuthField>
410427
</div>
411428

429+
{policyError && (
430+
<AuthFormMessage type='error'>
431+
<p>{policyError}</p>
432+
</AuthFormMessage>
433+
)}
434+
412435
{resetSuccessMessage && (
413436
<AuthFormMessage type='success'>
414437
<p>{resetSuccessMessage}</p>

‎apps/sim/app/(auth)/verify/use-verification.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { createLogger } from '@sim/logger'
55
import { normalizeEmail } from '@sim/utils/string'
66
import { useSearchParams } from 'next/navigation'
77
import { client, useSession } from '@/lib/auth/auth-client'
8+
import { SSO_REQUIRED_ERROR_CODE } from '@/lib/auth/constants'
89
import { validateCallbackUrl } from '@/lib/core/security/input-validation'
910
import { DEFAULT_POST_AUTH_ROUTE, POST_AUTH_REDIRECT_STORAGE_KEY } from '@/app/(auth)/auth-redirect'
1011

@@ -122,7 +123,14 @@ export function useVerification({
122123
}, 1000)
123124
} else {
124125
logger.info('Setting invalid OTP state - API error response')
125-
const message = 'Invalid verification code. Please check and try again.'
126+
/**
127+
* A refusal by policy — an organization requiring single sign-on — is not a bad code, and
128+
* telling the person to re-check their code sends them round a loop they cannot exit.
129+
*/
130+
const message =
131+
response?.error?.code === SSO_REQUIRED_ERROR_CODE && response.error.message
132+
? response.error.message
133+
: 'Invalid verification code. Please check and try again.'
126134
setStatus('error')
127135
setErrorMessage(message)
128136
logger.info('Error state after API error:', { errorMessage: message })

‎apps/sim/app/api/auth/sso/providers/[providerId]/route.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
setPrimarySsoProvider,
1717
setPrimarySsoProviderOperation,
1818
} from '@/lib/auth/sso/application/set-primary-provider'
19+
import { invalidateSsoPolicyCache } from '@/lib/auth/sso-policy'
1920
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
2021
import { isOrganizationAdminOrOwner } from '@/lib/workspaces/permissions/utils'
2122

@@ -105,6 +106,9 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Rou
105106
return NextResponse.json({ error: 'Provider not found' }, { status: 404 })
106107
}
107108

109+
/** The organization may have just lost the provider its sign-in requirement depends on. */
110+
if (organizationId) invalidateSsoPolicyCache(organizationId)
111+
108112
logger.info('Deleted SSO provider', {
109113
providerId,
110114
organizationId,

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { type NextRequest, NextResponse } from 'next/server'
88
import { ssoRegistrationContract } from '@/lib/api/contracts/auth'
99
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
1010
import { auth, getSession } from '@/lib/auth'
11+
import { invalidateSsoPolicyCache } from '@/lib/auth/sso-policy'
1112
import { hasSSOAccess } from '@/lib/billing'
1213
import { isSsoEnabled } from '@/lib/core/config/env-flags'
1314
import { runWithOutboundOrganization } from '@/lib/core/network/context.server'
@@ -756,6 +757,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
756757
return domainNotVerifiedResponse()
757758
}
758759

760+
/** The edit may have changed whether this provider can satisfy the sign-in requirement. */
761+
invalidateSsoPolicyCache(orgId)
762+
759763
logger.info('SSO provider updated successfully', { providerId, providerType, domain })
760764
return NextResponse.json({
761765
success: true,
@@ -801,6 +805,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
801805
return domainNotVerifiedResponse()
802806
}
803807

808+
/** A new provider can make an organization able to require single sign-on again. */
809+
invalidateSsoPolicyCache(orgId)
810+
804811
logger.info('SSO provider registered successfully', {
805812
providerId,
806813
providerType,

‎apps/sim/app/api/organizations/[id]/domains/[domainId]/route.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { type NextRequest, NextResponse } from 'next/server'
99
import { removeOrganizationDomainContract } from '@/lib/api/contracts/organization'
1010
import { parseRequest } from '@/lib/api/server'
1111
import { getSession } from '@/lib/auth'
12+
import { invalidateSsoPolicyCache } from '@/lib/auth/sso-policy'
1213
import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription'
1314
import { isBillingEnabled } from '@/lib/core/config/env-flags'
1415
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
@@ -91,6 +92,9 @@ export const DELETE = withRouteHandler(
9192
return NextResponse.json({ error: 'Domain not found' }, { status: 404 })
9293
}
9394

95+
/** Providers on the removed domain no longer satisfy the sign-in requirement. */
96+
invalidateSsoPolicyCache(organizationId)
97+
9498
logger.info('Domain removed', { organizationId, domain: removed.domain })
9599
recordAudit({
96100
workspaceId: null,

‎apps/sim/app/api/organizations/[id]/domains/[domainId]/verify/route.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { verifyOrganizationDomainContract } from '@/lib/api/contracts/organizati
1111
import { parseRequest } from '@/lib/api/server'
1212
import { getSession } from '@/lib/auth'
1313
import { checkDomainTxtRecord, toDomainResponse } from '@/lib/auth/sso/domain-verification'
14+
import { invalidateSsoPolicyCache } from '@/lib/auth/sso-policy'
1415
import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription'
1516
import { isBillingEnabled } from '@/lib/core/config/env-flags'
1617
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
@@ -188,6 +189,9 @@ export const POST = withRouteHandler(
188189
)
189190
}
190191

192+
/** A newly verified domain can make the organization able to require single sign-on. */
193+
invalidateSsoPolicyCache(organizationId)
194+
191195
logger.info('Domain verified', { organizationId, domain: row.domain })
192196
recordAudit({
193197
workspaceId: null,
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import {
2+
getOrganizationSsoPolicyContract,
3+
updateOrganizationSsoPolicyContract,
4+
} from '@/lib/api/contracts/organization'
5+
import {
6+
defineInternalJsonRoute,
7+
internalOrchestrationErrorPolicy,
8+
internalRateLimits,
9+
internalSessionAuth,
10+
} from '@/lib/api/server/routes'
11+
import {
12+
readSsoRequirement,
13+
readSsoRequirementOperation,
14+
type SsoRequirement,
15+
setSsoRequirement,
16+
setSsoRequirementOperation,
17+
} from '@/lib/auth/sso/application/sso-requirement'
18+
19+
const present = (requirement: SsoRequirement) => ({ success: true as const, data: requirement })
20+
21+
/** Whether members must sign in through the organization's identity provider. */
22+
export const GET = defineInternalJsonRoute({
23+
contract: getOrganizationSsoPolicyContract,
24+
auth: internalSessionAuth,
25+
operation: readSsoRequirementOperation,
26+
rateLimit: internalRateLimits.none({ reason: 'Settings read behind organization membership' }),
27+
errorPolicy: internalOrchestrationErrorPolicy,
28+
mapInput: ({ params }) => ({ organizationId: params.id }),
29+
useCase: readSsoRequirement,
30+
present,
31+
})
32+
33+
/** Turns the requirement on or off. Never ends a session that already exists. */
34+
export const PUT = defineInternalJsonRoute({
35+
contract: updateOrganizationSsoPolicyContract,
36+
auth: internalSessionAuth,
37+
operation: setSsoRequirementOperation,
38+
rateLimit: internalRateLimits.user({ bucketName: 'sso-set-requirement' }),
39+
errorPolicy: internalOrchestrationErrorPolicy,
40+
mapInput: ({ params, body }) => ({ organizationId: params.id, requireSso: body.requireSso }),
41+
useCase: setSsoRequirement,
42+
present,
43+
})

‎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/constants'
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)