Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions apps/docs/content/docs/platform/enterprise/sso.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -319,8 +319,18 @@ With **Automatic** provisioning, no invitation is required for organization memb

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.

## Require single sign-on

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.

- 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**.
- 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.
- 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.
- Desktop app handoff from an already signed-in browser keeps working, because that session derives from one the requirement already admitted.
- 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.

<Callout type="info">
Password-based login remains available. Forcing all organization members to use SSO exclusively is not yet supported.
Turning the requirement off restores password and email sign-in immediately for everyone.
</Callout>

---
Expand Down Expand Up @@ -352,7 +362,7 @@ SSO provisioning creates internal organization members but does not grant worksp
},
{
question: "Can I still use email/password login after enabling SSO?",
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."
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."
},
{
question: "A user already has an account with the same email — what happens when they sign in with SSO?",
Expand Down
23 changes: 23 additions & 0 deletions apps/sim/app/(auth)/login/login-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ export default function LoginPage({
const [password, setPassword] = useState('')
const [passwordErrors, setPasswordErrors] = useState<string[]>([])
const [showValidationError, setShowValidationError] = useState(false)
/** A refusal that is about the account or its organization, not the credentials typed in. */
const [policyError, setPolicyError] = useState<string | null>(null)
const callbackUrlParam = searchParams?.get('callbackUrl')
const isValidCallbackUrl = callbackUrlParam ? validateCallbackUrl(callbackUrlParam) : false
const invalidCallbackRef = useRef(false)
Expand Down Expand Up @@ -157,6 +159,7 @@ export default function LoginPage({
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
setIsLoading(true)
setPolicyError(null)

const redirectToVerify = (emailToVerify: string) => {
if (typeof window !== 'undefined') {
Expand Down Expand Up @@ -202,6 +205,20 @@ export default function LoginPage({
return
}

/**
* A policy refusal explains itself — an organization requiring single sign-on, or a
* suspended account. It belongs in the form-level slot: the password is not what is
* wrong, so marking that field would send the person to reset a password that is fine.
*/
if (ctx.error.status === 403 && ctx.error.message) {
errorHandled = true
setResetSuccessMessage(null)
setPasswordErrors([])
setShowValidationError(false)
setPolicyError(ctx.error.message)
return
}

errorHandled = true
const errorMessage: string[] = ['Invalid email or password']

Expand Down Expand Up @@ -409,6 +426,12 @@ export default function LoginPage({
</AuthField>
</div>

{policyError && (
<AuthFormMessage type='error'>
<p>{policyError}</p>
</AuthFormMessage>
)}

{resetSuccessMessage && (
<AuthFormMessage type='success'>
<p>{resetSuccessMessage}</p>
Expand Down
10 changes: 9 additions & 1 deletion apps/sim/app/(auth)/verify/use-verification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { createLogger } from '@sim/logger'
import { normalizeEmail } from '@sim/utils/string'
import { useSearchParams } from 'next/navigation'
import { client, useSession } from '@/lib/auth/auth-client'
import { SSO_REQUIRED_ERROR_CODE } from '@/lib/auth/constants'
import { validateCallbackUrl } from '@/lib/core/security/input-validation'
import { DEFAULT_POST_AUTH_ROUTE, POST_AUTH_REDIRECT_STORAGE_KEY } from '@/app/(auth)/auth-redirect'

Expand Down Expand Up @@ -122,7 +123,14 @@ export function useVerification({
}, 1000)
} else {
logger.info('Setting invalid OTP state - API error response')
const message = 'Invalid verification code. Please check and try again.'
/**
* A refusal by policy — an organization requiring single sign-on — is not a bad code, and
* telling the person to re-check their code sends them round a loop they cannot exit.
*/
const message =
response?.error?.code === SSO_REQUIRED_ERROR_CODE && response.error.message
? response.error.message
: 'Invalid verification code. Please check and try again.'
setStatus('error')
setErrorMessage(message)
logger.info('Error state after API error:', { errorMessage: message })
Expand Down
4 changes: 4 additions & 0 deletions apps/sim/app/api/auth/sso/providers/[providerId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
setPrimarySsoProvider,
setPrimarySsoProviderOperation,
} from '@/lib/auth/sso/application/set-primary-provider'
import { invalidateSsoPolicyCache } from '@/lib/auth/sso-policy'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { isOrganizationAdminOrOwner } from '@/lib/workspaces/permissions/utils'

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

/** The organization may have just lost the provider its sign-in requirement depends on. */
if (organizationId) invalidateSsoPolicyCache(organizationId)

logger.info('Deleted SSO provider', {
providerId,
organizationId,
Expand Down
7 changes: 7 additions & 0 deletions apps/sim/app/api/auth/sso/register/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { ssoRegistrationContract } from '@/lib/api/contracts/auth'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { auth, getSession } from '@/lib/auth'
import { invalidateSsoPolicyCache } from '@/lib/auth/sso-policy'
import { hasSSOAccess } from '@/lib/billing'
import { isSsoEnabled } from '@/lib/core/config/env-flags'
import { runWithOutboundOrganization } from '@/lib/core/network/context.server'
Expand Down Expand Up @@ -756,6 +757,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
return domainNotVerifiedResponse()
}

/** The edit may have changed whether this provider can satisfy the sign-in requirement. */
invalidateSsoPolicyCache(orgId)

logger.info('SSO provider updated successfully', { providerId, providerType, domain })
return NextResponse.json({
success: true,
Expand Down Expand Up @@ -801,6 +805,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
return domainNotVerifiedResponse()
}

/** A new provider can make an organization able to require single sign-on again. */
invalidateSsoPolicyCache(orgId)

logger.info('SSO provider registered successfully', {
providerId,
providerType,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { removeOrganizationDomainContract } from '@/lib/api/contracts/organization'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { invalidateSsoPolicyCache } from '@/lib/auth/sso-policy'
import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription'
import { isBillingEnabled } from '@/lib/core/config/env-flags'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
Expand Down Expand Up @@ -91,6 +92,9 @@ export const DELETE = withRouteHandler(
return NextResponse.json({ error: 'Domain not found' }, { status: 404 })
}

/** Providers on the removed domain no longer satisfy the sign-in requirement. */
invalidateSsoPolicyCache(organizationId)

logger.info('Domain removed', { organizationId, domain: removed.domain })
recordAudit({
workspaceId: null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { verifyOrganizationDomainContract } from '@/lib/api/contracts/organizati
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { checkDomainTxtRecord, toDomainResponse } from '@/lib/auth/sso/domain-verification'
import { invalidateSsoPolicyCache } from '@/lib/auth/sso-policy'
import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription'
import { isBillingEnabled } from '@/lib/core/config/env-flags'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
Expand Down Expand Up @@ -188,6 +189,9 @@ export const POST = withRouteHandler(
)
}

/** A newly verified domain can make the organization able to require single sign-on. */
invalidateSsoPolicyCache(organizationId)

logger.info('Domain verified', { organizationId, domain: row.domain })
recordAudit({
workspaceId: null,
Expand Down
43 changes: 43 additions & 0 deletions apps/sim/app/api/organizations/[id]/sso-policy/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import {
getOrganizationSsoPolicyContract,
updateOrganizationSsoPolicyContract,
} from '@/lib/api/contracts/organization'
import {
defineInternalJsonRoute,
internalOrchestrationErrorPolicy,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import {
readSsoRequirement,
readSsoRequirementOperation,
type SsoRequirement,
setSsoRequirement,
setSsoRequirementOperation,
} from '@/lib/auth/sso/application/sso-requirement'

const present = (requirement: SsoRequirement) => ({ success: true as const, data: requirement })

/** Whether members must sign in through the organization's identity provider. */
export const GET = defineInternalJsonRoute({
contract: getOrganizationSsoPolicyContract,
auth: internalSessionAuth,
operation: readSsoRequirementOperation,
rateLimit: internalRateLimits.none({ reason: 'Settings read behind organization membership' }),
errorPolicy: internalOrchestrationErrorPolicy,
mapInput: ({ params }) => ({ organizationId: params.id }),
useCase: readSsoRequirement,
present,
})

/** Turns the requirement on or off. Never ends a session that already exists. */
export const PUT = defineInternalJsonRoute({
contract: updateOrganizationSsoPolicyContract,
auth: internalSessionAuth,
operation: setSsoRequirementOperation,
rateLimit: internalRateLimits.user({ bucketName: 'sso-set-requirement' }),
errorPolicy: internalOrchestrationErrorPolicy,
mapInput: ({ params, body }) => ({ organizationId: params.id, requireSso: body.requireSso }),
useCase: setSsoRequirement,
present,
})
6 changes: 6 additions & 0 deletions apps/sim/app/oauth-error/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Metadata } from 'next'
import { SSO_REQUIRED_ERROR_CODE, SSO_REQUIRED_MESSAGE } from '@/lib/auth/constants'
import { DesktopHandoffShell } from '@/app/desktop/components/desktop-handoff-shell'

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