diff --git a/apps/docs/content/docs/en/enterprise/index.mdx b/apps/docs/content/docs/en/enterprise/index.mdx index 0cd2aa9dbae..4484e719051 100644 --- a/apps/docs/content/docs/en/enterprise/index.mdx +++ b/apps/docs/content/docs/en/enterprise/index.mdx @@ -34,26 +34,9 @@ Define permission groups to control what features and integrations team members ## Single Sign-On (SSO) -Enterprise authentication with SAML 2.0 and OIDC support for centralized identity management. +Enterprise authentication with SAML 2.0 and OIDC support. Works with Okta, Azure AD (Entra ID), Google Workspace, ADFS, and any standard OIDC or SAML 2.0 provider. -### Supported Providers - -- Okta -- Azure AD / Entra ID -- Google Workspace -- OneLogin -- Any SAML 2.0 or OIDC provider - -### Setup - -1. Navigate to **Settings** → **SSO** in your workspace -2. Choose your identity provider -3. Configure the connection using your IdP's metadata -4. Enable SSO for your organization - - - Once SSO is enabled, team members authenticate through your identity provider instead of email/password. - +See the [SSO setup guide](/docs/enterprise/sso) for step-by-step instructions and provider-specific configuration. --- diff --git a/apps/docs/content/docs/en/enterprise/meta.json b/apps/docs/content/docs/en/enterprise/meta.json new file mode 100644 index 00000000000..86316a8d2f3 --- /dev/null +++ b/apps/docs/content/docs/en/enterprise/meta.json @@ -0,0 +1,5 @@ +{ + "title": "Enterprise", + "pages": ["index", "sso"], + "defaultOpen": false +} diff --git a/apps/docs/content/docs/en/enterprise/sso.mdx b/apps/docs/content/docs/en/enterprise/sso.mdx new file mode 100644 index 00000000000..174adb0a3ff --- /dev/null +++ b/apps/docs/content/docs/en/enterprise/sso.mdx @@ -0,0 +1,345 @@ +--- +title: Single Sign-On (SSO) +description: Configure SAML 2.0 or OIDC-based single sign-on for your organization +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { Tab, Tabs } from 'fumadocs-ui/components/tabs' +import { FAQ } from '@/components/ui/faq' + +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. + +--- + +## Requirements + +**Sim Cloud:** Enterprise plan. You must be a workspace owner or admin. + +**Self-hosted:** Set `SSO_ENABLED=true` and `NEXT_PUBLIC_SSO_ENABLED=true` in your environment. No plan requirement. + +--- + +## Setup + +### 1. Open SSO settings + +Go to **Settings → Enterprise → Single Sign-On** in your workspace. + +### 2. Choose a protocol + +| Protocol | Use when | +|----------|----------| +| **OIDC** | Your IdP supports OpenID Connect — Okta, Microsoft Entra ID, Auth0, Google Workspace | +| **SAML 2.0** | Your IdP is SAML-only — ADFS, Shibboleth, or older enterprise IdPs | + +### 3. Fill in the form + +**Fields required for both protocols:** + +| Field | What to enter | +|-------|--------------| +| **Provider ID** | A short slug identifying this connection, e.g. `okta` or `azure-ad`. Letters, numbers, and dashes only. | +| **Issuer URL** | The identity provider's issuer URL. Must be HTTPS. | +| **Domain** | Your organization's email domain, e.g. `company.com`. Users with this domain will be routed through SSO at sign-in. | + +**OIDC additional fields:** + +| Field | What to enter | +|-------|--------------| +| **Client ID** | The application client ID from your IdP. | +| **Client Secret** | The client secret from your IdP. | +| **Scopes** | Comma-separated OIDC scopes. Default: `openid,profile,email`. | + + + For OIDC, Sim automatically fetches endpoints (`authorization_endpoint`, `token_endpoint`, `userinfo_endpoint`, `jwks_uri`) from your issuer's `/.well-known/openid-configuration` discovery document. You only need to provide the issuer URL. + + +**SAML additional fields:** + +| Field | What to enter | +|-------|--------------| +| **Entry Point URL** | The IdP's SSO service URL where Sim sends authentication requests. | +| **Identity Provider Certificate** | The Base-64 encoded X.509 certificate from your IdP for verifying assertions. | + +### 4. Copy the Callback URL + +The **Callback URL** shown in the form is the endpoint your identity provider must redirect users back to after authentication. Copy it and register it in your IdP before saving. + +**OIDC providers** (Okta, Microsoft Entra ID, Google Workspace, Auth0): +``` +https://simstudio.ai/api/auth/sso/callback/{provider-id} +``` + +**SAML providers** (ADFS, Shibboleth): +``` +https://simstudio.ai/api/auth/sso/saml2/callback/{provider-id} +``` + +For self-hosted, replace `simstudio.ai` with your instance hostname. + +### 5. Save and test + +Click **Save**. To test, sign out and use the **Sign in with SSO** button on the login page. Enter an email address at your configured domain — Sim will redirect you to your identity provider. + +--- + +## Provider Guides + + + + + +### Okta (OIDC) + +**In Okta** ([official docs](https://help.okta.com/en-us/content/topics/apps/apps_app_integration_wizard_oidc.htm)): + +1. Go to **Applications → Create App Integration** +2. Select **OIDC - OpenID Connect**, then **Web Application** +3. Set the **Sign-in redirect URI** to your Sim callback URL: + ``` + https://simstudio.ai/api/auth/sso/callback/okta + ``` +4. Under **Assignments**, grant access to the relevant users or groups +5. Copy the **Client ID** and **Client Secret** from the app's **General** tab +6. Your Okta domain is the hostname of your admin console, e.g. `dev-1234567.okta.com` + +**In Sim:** + +| Field | Value | +|-------|-------| +| Provider Type | OIDC | +| Provider ID | `okta` | +| Issuer URL | `https://dev-1234567.okta.com/oauth2/default` | +| Domain | `company.com` | +| Client ID | From Okta app | +| Client Secret | From Okta app | + + + The issuer URL uses Okta's default authorization server (`/oauth2/default`), which is pre-configured on every Okta org. If you created a custom authorization server, replace `default` with your server name. + + + + + + +### Microsoft Entra ID (OIDC) + +**In Azure** ([official docs](https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-register-app)): + +1. Go to **Microsoft Entra ID → App registrations → New registration** +2. Under **Redirect URI**, select **Web** and enter your Sim callback URL: + ``` + https://simstudio.ai/api/auth/sso/callback/azure-ad + ``` +3. After registration, go to **Certificates & secrets → New client secret** and copy the value immediately — it won't be shown again +4. Go to **Overview** and copy the **Application (client) ID** and **Directory (tenant) ID** + +**In Sim:** + +| Field | Value | +|-------|-------| +| Provider Type | OIDC | +| Provider ID | `azure-ad` | +| Issuer URL | `https://login.microsoftonline.com/{tenant-id}/v2.0` | +| Domain | `company.com` | +| Client ID | Application (client) ID | +| Client Secret | Secret value | + + + Replace `{tenant-id}` with your Directory (tenant) ID from the app's Overview page. Sim auto-discovers token and JWKS endpoints from the issuer. + + + + + + +### Google Workspace (OIDC) + +**In Google Cloud Console** ([official docs](https://developers.google.com/identity/openid-connect/openid-connect)): + +1. Go to **APIs & Services → Credentials → Create Credentials → OAuth 2.0 Client ID** +2. Set the application type to **Web application** +3. Add your Sim callback URL to **Authorized redirect URIs**: + ``` + https://simstudio.ai/api/auth/sso/callback/google-workspace + ``` +4. Copy the **Client ID** and **Client Secret** + +**In Sim:** + +| Field | Value | +|-------|-------| +| Provider Type | OIDC | +| Provider ID | `google-workspace` | +| Issuer URL | `https://accounts.google.com` | +| Domain | `company.com` | +| Client ID | From Google Cloud Console | +| Client Secret | From Google Cloud Console | + + + To restrict sign-in to your Google Workspace domain, configure the OAuth consent screen and ensure your app is set to **Internal** (Workspace users only) under **User type**. Setting the app to Internal limits access to users within your Google Workspace organization. + + + + + + +### ADFS (SAML 2.0) + +**In ADFS** ([official docs](https://learn.microsoft.com/en-us/windows-server/identity/ad-fs/operations/create-a-relying-party-trust)): + +1. Open **AD FS Management → Relying Party Trusts → Add Relying Party Trust** +2. Choose **Claims aware**, then **Enter data about the relying party manually** +3. Set the **Relying party identifier** (Entity ID) to your Sim base URL: + ``` + https://simstudio.ai + ``` + For self-hosted, use your instance's base URL (e.g. `https://sim.company.com`) +4. Add an endpoint: **SAML Assertion Consumer Service** (HTTP POST) with the URL: + ``` + https://simstudio.ai/api/auth/sso/saml2/callback/adfs + ``` + For self-hosted: `https://sim.company.com/api/auth/sso/saml2/callback/adfs` +5. Export the **Token-signing certificate** from **Certificates**: right-click → **View Certificate → Details → Copy to File**, choose **Base-64 encoded X.509 (.CER)**. The `.cer` file is PEM-encoded — rename it to `.pem` before pasting its contents into Sim. +6. Note the **ADFS Federation Service endpoint URL** (e.g. `https://adfs.company.com/adfs/ls`) + +**In Sim:** + +| Field | Value | +|-------|-------| +| Provider Type | SAML | +| Provider ID | `adfs` | +| Issuer URL | `https://simstudio.ai` | +| Domain | `company.com` | +| Entry Point URL | `https://adfs.company.com/adfs/ls` | +| Certificate | Contents of the `.pem` file | + + + For ADFS, the **Issuer URL** field is the SP entity ID — the identifier ADFS uses to identify Sim as a relying party. It must match the **Relying party identifier** you registered in ADFS. + + + + + + +--- + +## How sign-in works after setup + +Once SSO is configured, users with your domain (`company.com`) can sign in through your identity provider: + +1. User goes to `simstudio.ai` and clicks **Sign in with SSO** +2. They enter their work email (e.g. `alice@company.com`) +3. Sim redirects them to your identity provider +4. After authenticating, they are returned to Sim and added to your organization automatically +5. They land in the workspace + +Users who sign in via SSO for the first time are automatically provisioned and added to your organization — no manual invite required. + + + Password-based login remains available. Forcing all organization members to use SSO exclusively is not yet supported. + + + + **Self-hosted:** Automatic organization provisioning requires `ORGANIZATIONS_ENABLED=true` in addition to `SSO_ENABLED=true`. Without it, SSO authentication still works — users get a valid session — but they are not automatically added to an organization. + + +--- + + + +--- + +## Self-hosted setup + +Self-hosted deployments use environment variables instead of the billing/plan check. + +### Environment variables + +```bash +# Required +SSO_ENABLED=true +NEXT_PUBLIC_SSO_ENABLED=true + +# Required if you want users auto-added to your organization on first SSO sign-in +ORGANIZATIONS_ENABLED=true +NEXT_PUBLIC_ORGANIZATIONS_ENABLED=true +``` + +You can register providers through the **Settings UI** (same as cloud) or by running the registration script directly against your database. + +### Script-based registration + +Use this when you need to register an SSO provider without going through the UI — for example, during initial deployment or CI/CD automation. + +```bash +# OIDC example (Okta) +SSO_ENABLED=true \ +NEXT_PUBLIC_APP_URL=https://your-instance.com \ +SSO_PROVIDER_TYPE=oidc \ +SSO_PROVIDER_ID=okta \ +SSO_ISSUER=https://dev-1234567.okta.com/oauth2/default \ +SSO_DOMAIN=company.com \ +SSO_USER_EMAIL=admin@company.com \ +SSO_OIDC_CLIENT_ID=your-client-id \ +SSO_OIDC_CLIENT_SECRET=your-client-secret \ +bun run packages/db/scripts/register-sso-provider.ts +``` + +```bash +# SAML example (ADFS) +SSO_ENABLED=true \ +NEXT_PUBLIC_APP_URL=https://your-instance.com \ +SSO_PROVIDER_TYPE=saml \ +SSO_PROVIDER_ID=adfs \ +SSO_ISSUER=https://your-instance.com \ +SSO_DOMAIN=company.com \ +SSO_USER_EMAIL=admin@company.com \ +SSO_SAML_ENTRY_POINT=https://adfs.company.com/adfs/ls \ +SSO_SAML_CERT="-----BEGIN CERTIFICATE----- +... +-----END CERTIFICATE-----" \ +bun run packages/db/scripts/register-sso-provider.ts +``` + +The script outputs the callback URL to configure in your IdP once it completes. + +To remove a provider: + +```bash +SSO_USER_EMAIL=admin@company.com \ +bun run packages/db/scripts/deregister-sso-provider.ts +``` diff --git a/apps/docs/content/docs/en/meta.json b/apps/docs/content/docs/en/meta.json index 24eb6eba869..b0446caab8f 100644 --- a/apps/docs/content/docs/en/meta.json +++ b/apps/docs/content/docs/en/meta.json @@ -25,7 +25,7 @@ "execution", "permissions", "self-hosting", - "./enterprise/index", + "enterprise", "./keyboard-shortcuts/index" ], "defaultOpen": false diff --git a/apps/docs/public/static/enterprise/sso-form.png b/apps/docs/public/static/enterprise/sso-form.png new file mode 100644 index 00000000000..78c54bd9f7e Binary files /dev/null and b/apps/docs/public/static/enterprise/sso-form.png differ diff --git a/apps/sim/app/api/auth/sso/providers/route.ts b/apps/sim/app/api/auth/sso/providers/route.ts index d4bcfa35db2..fbaaf18398f 100644 --- a/apps/sim/app/api/auth/sso/providers/route.ts +++ b/apps/sim/app/api/auth/sso/providers/route.ts @@ -1,17 +1,42 @@ -import { db, ssoProvider } from '@sim/db' +import { db, member, ssoProvider } from '@sim/db' import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' -import { NextResponse } from 'next/server' +import { and, eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' +import { REDACTED_MARKER } from '@/lib/core/security/redaction' const logger = createLogger('SSOProvidersRoute') -export async function GET() { +export async function GET(request: NextRequest) { try { const session = await getSession() + const { searchParams } = new URL(request.url) + const organizationId = searchParams.get('organizationId') let providers if (session?.user?.id) { + const userId = session.user.id + + let verifiedOrganizationId: string | null = null + if (organizationId) { + const [membership] = await db + .select({ organizationId: member.organizationId, role: member.role }) + .from(member) + .where(and(eq(member.userId, userId), eq(member.organizationId, organizationId))) + .limit(1) + if (!membership) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + if (membership.role !== 'owner' && membership.role !== 'admin') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + verifiedOrganizationId = membership.organizationId + } + + const whereClause = verifiedOrganizationId + ? eq(ssoProvider.organizationId, verifiedOrganizationId) + : eq(ssoProvider.userId, userId) + const results = await db .select({ id: ssoProvider.id, @@ -24,19 +49,25 @@ export async function GET() { organizationId: ssoProvider.organizationId, }) .from(ssoProvider) - .where(eq(ssoProvider.userId, session.user.id)) + .where(whereClause) - providers = results.map((provider) => ({ - ...provider, - providerType: - provider.oidcConfig && provider.samlConfig - ? 'oidc' - : provider.oidcConfig - ? 'oidc' - : provider.samlConfig - ? 'saml' - : ('oidc' as 'oidc' | 'saml'), - })) + providers = results.map((provider) => { + let oidcConfig = provider.oidcConfig + if (oidcConfig) { + try { + const parsed = JSON.parse(oidcConfig) + parsed.clientSecret = REDACTED_MARKER + oidcConfig = JSON.stringify(parsed) + } catch { + oidcConfig = null + } + } + return { + ...provider, + oidcConfig, + providerType: (provider.samlConfig ? 'saml' : 'oidc') as 'oidc' | 'saml', + } + }) } else { const results = await db .select({ diff --git a/apps/sim/app/api/auth/sso/register/route.ts b/apps/sim/app/api/auth/sso/register/route.ts index 809a921655c..63d4034f306 100644 --- a/apps/sim/app/api/auth/sso/register/route.ts +++ b/apps/sim/app/api/auth/sso/register/route.ts @@ -1,4 +1,6 @@ +import { db, member, ssoProvider } from '@sim/db' import { createLogger } from '@sim/logger' +import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { auth, getSession } from '@/lib/auth' @@ -9,6 +11,7 @@ import { validateUrlWithDNS, } from '@/lib/core/security/input-validation.server' import { REDACTED_MARKER } from '@/lib/core/security/redaction' +import { getBaseUrl } from '@/lib/core/utils/urls' const logger = createLogger('SSORegisterRoute') @@ -32,6 +35,7 @@ const ssoRegistrationSchema = z.discriminatedUnion('providerType', [ providerId: z.string().min(1, 'Provider ID is required'), issuer: z.string().url('Issuer must be a valid URL'), domain: z.string().min(1, 'Domain is required'), + orgId: z.string().optional(), mapping: mappingSchema, clientId: z.string().min(1, 'Client ID is required for OIDC'), clientSecret: z.string().min(1, 'Client Secret is required for OIDC'), @@ -57,6 +61,7 @@ const ssoRegistrationSchema = z.discriminatedUnion('providerType', [ providerId: z.string().min(1, 'Provider ID is required'), issuer: z.string().url('Issuer must be a valid URL'), domain: z.string().min(1, 'Domain is required'), + orgId: z.string().optional(), mapping: mappingSchema, entryPoint: z.string().url('Entry point must be a valid URL for SAML'), cert: z.string().min(1, 'Certificate is required for SAML'), @@ -107,7 +112,21 @@ export async function POST(request: NextRequest) { } const body = parseResult.data - const { providerId, issuer, domain, providerType, mapping } = body + const { providerId, issuer, domain, providerType, mapping, orgId } = body + + if (orgId) { + const [membership] = await db + .select({ organizationId: member.organizationId, role: member.role }) + .from(member) + .where(and(eq(member.userId, session.user.id), eq(member.organizationId, orgId))) + .limit(1) + if (!membership) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + if (membership.role !== 'owner' && membership.role !== 'admin') { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + } const headers: Record = {} request.headers.forEach((value, key) => { @@ -119,12 +138,13 @@ export async function POST(request: NextRequest) { issuer, domain, mapping, + ...(orgId ? { organizationId: orgId } : {}), } if (providerType === 'oidc') { const { clientId, - clientSecret, + clientSecret: rawClientSecret, scopes, pkce, authorizationEndpoint, @@ -133,6 +153,34 @@ export async function POST(request: NextRequest) { jwksEndpoint, } = body + let clientSecret = rawClientSecret + if (rawClientSecret === REDACTED_MARKER) { + const ownerClause = orgId + ? and(eq(ssoProvider.providerId, providerId), eq(ssoProvider.organizationId, orgId)) + : and(eq(ssoProvider.providerId, providerId), eq(ssoProvider.userId, session.user.id)) + const [existing] = await db + .select({ oidcConfig: ssoProvider.oidcConfig }) + .from(ssoProvider) + .where(ownerClause) + .limit(1) + if (!existing?.oidcConfig) { + return NextResponse.json( + { error: 'Cannot update: existing provider not found. Re-enter your client secret.' }, + { status: 400 } + ) + } + try { + clientSecret = JSON.parse(existing.oidcConfig).clientSecret + } catch { + return NextResponse.json( + { + error: 'Cannot update: failed to read existing secret. Re-enter your client secret.', + }, + { status: 400 } + ) + } + } + const oidcConfig: any = { clientId, clientSecret, @@ -324,7 +372,7 @@ export async function POST(request: NextRequest) { } = body const computedCallbackUrl = - callbackUrl || `${issuer.replace('/metadata', '')}/callback/${providerId}` + callbackUrl || `${getBaseUrl()}/api/auth/sso/saml2/callback/${providerId}` const escapeXml = (str: string) => str.replace(/[<>&"']/g, (c) => { @@ -345,12 +393,34 @@ export async function POST(request: NextRequest) { }) const spMetadataXml = ` - + ` + const certBase64 = cert + .replace(/-----BEGIN CERTIFICATE-----/g, '') + .replace(/-----END CERTIFICATE-----/g, '') + .replace(/\s/g, '') + + const computedIdpMetadataXml = + idpMetadata || + ` + + + + + + ${certBase64} + + + + + + +` + const samlConfig: any = { entryPoint, cert, @@ -358,7 +428,9 @@ export async function POST(request: NextRequest) { spMetadata: { metadata: spMetadataXml, }, - mapping, + idpMetadata: { + metadata: computedIdpMetadataXml, + }, } if (audience) samlConfig.audience = audience @@ -366,14 +438,8 @@ export async function POST(request: NextRequest) { if (signatureAlgorithm) samlConfig.signatureAlgorithm = signatureAlgorithm if (digestAlgorithm) samlConfig.digestAlgorithm = digestAlgorithm if (identifierFormat) samlConfig.identifierFormat = identifierFormat - if (idpMetadata) { - samlConfig.idpMetadata = { - metadata: idpMetadata, - } - } providerConfig.samlConfig = samlConfig - providerConfig.mapping = undefined } logger.info('Calling Better Auth registerSSOProvider with config:', { @@ -432,7 +498,6 @@ export async function POST(request: NextRequest) { { error: 'Failed to register SSO provider', details: error instanceof Error ? error.message : 'Unknown error', - fullError: JSON.stringify(error), }, { status: 500 } ) diff --git a/apps/sim/ee/sso/components/sso-settings.tsx b/apps/sim/ee/sso/components/sso-settings.tsx index 7c1eec013ea..d0aaf762973 100644 --- a/apps/sim/ee/sso/components/sso-settings.tsx +++ b/apps/sim/ee/sso/components/sso-settings.tsx @@ -3,7 +3,18 @@ import { useState } from 'react' import { createLogger } from '@sim/logger' import { Check, ChevronDown, Clipboard, Eye, EyeOff } from 'lucide-react' -import { Button, Combobox, Input, Skeleton, Switch, Textarea } from '@/components/emcn' +import { + Button, + Combobox, + Expandable, + ExpandableContent, + FormField, + Input, + Skeleton, + Switch, + Textarea, + toast, +} from '@/components/emcn' import { useSession } from '@/lib/auth/auth-client' import { getSubscriptionAccessState } from '@/lib/billing/client/utils' import { isBillingEnabled } from '@/lib/core/config/feature-flags' @@ -43,7 +54,6 @@ const DEFAULT_FORM_DATA = { audience: '', wantAssertionsSigned: true, idpMetadata: '', - showAdvanced: false, } const DEFAULT_ERRORS = { @@ -64,9 +74,13 @@ export function SSO() { const { data: session } = useSession() const { data: orgsData } = useOrganizations() const { data: subscriptionData } = useSubscriptionData() - const { data: providersData, isLoading: isLoadingProviders } = useSSOProviders() const activeOrganization = orgsData?.activeOrganization + + const { data: providersData, isLoading: isLoadingProviders } = useSSOProviders({ + organizationId: activeOrganization?.id, + }) + const providers = providersData?.providers || [] const existingProvider = providers[0] as SSOProvider | undefined @@ -84,19 +98,20 @@ export function SSO() { const configureSSOMutation = useConfigureSSO() - const [error, setError] = useState(null) const [showClientSecret, setShowClientSecret] = useState(false) const [copied, setCopied] = useState(false) const [isEditing, setIsEditing] = useState(false) + const [showAdvanced, setShowAdvanced] = useState(false) const [formData, setFormData] = useState(DEFAULT_FORM_DATA) + const [originalFormData, setOriginalFormData] = useState(DEFAULT_FORM_DATA) const [errors, setErrors] = useState>(DEFAULT_ERRORS) const [showErrors, setShowErrors] = useState(false) if (isBillingEnabled) { if (!activeOrganization) { return ( -
+
You must be part of an organization to configure Single Sign-On.
) @@ -104,7 +119,7 @@ export function SSO() { if (!hasEnterprisePlan) { return ( -
+
Single Sign-On is available on Enterprise plans only.
) @@ -112,15 +127,27 @@ export function SSO() { if (!canManageSSO) { return ( -
+
Only organization owners and admins can configure Single Sign-On settings.
) } } else { - if (!isLoadingProviders && isSSOProviderOwner === false && providers.length > 0) { + if (activeOrganization && !canManageSSO) { return ( -
+
+ Only organization owners and admins can configure Single Sign-On settings. +
+ ) + } + if ( + !activeOrganization && + !isLoadingProviders && + isSSOProviderOwner === false && + providers.length > 0 + ) { + return ( +
Only the user who configured SSO can manage these settings.
) @@ -153,7 +180,7 @@ export function SSO() { if (!value || !value.trim()) return ['Domain is required.'] if (/^https?:\/\//i.test(value.trim())) out.push('Do not include protocol (https://).') if (!/^[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/.test(value.trim())) - out.push('Enter a valid domain like your-domain.identityprovider.com') + out.push('Enter a valid domain like company.com') return out } @@ -201,6 +228,11 @@ export function SSO() { const hasAnyErrors = (errs: Record) => Object.values(errs).some((l) => l.length > 0) + const hasChanges = () => + (Object.keys(formData) as (keyof typeof formData)[]).some( + (k) => formData[k] !== originalFormData[k] + ) + const isFormValid = () => { const requiredFields = ['providerId', 'issuerUrl', 'domain'] const hasRequiredFields = requiredFields.every((field) => { @@ -227,7 +259,6 @@ export function SSO() { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() - setError(null) setShowErrors(true) const validation = validateAll(formData) @@ -274,27 +305,22 @@ export function SSO() { await configureSSOMutation.mutateAsync(requestBody) logger.info('SSO provider configured', { providerId: formData.providerId }) + toast.success(isEditing ? 'SSO provider updated' : 'SSO provider configured') setFormData(DEFAULT_FORM_DATA) setErrors(DEFAULT_ERRORS) setShowErrors(false) setIsEditing(false) - setError(null) + setShowAdvanced(false) } catch (err) { const message = err instanceof Error ? err.message : 'Unknown error occurred' - setError(message) + toast.error(message) logger.error('Failed to configure SSO provider', { error: err }) } } - const handleInputChange = (field: keyof typeof formData, value: string) => { + const handleInputChange = (field: keyof typeof formData, value: string | boolean) => { setFormData((prev) => { - let processedValue: any = value - - if (field === 'wantAssertionsSigned' || field === 'showAdvanced') { - processedValue = value === 'true' - } - - const next = { ...prev, [field]: processedValue } + const next = { ...prev, [field]: value } if (field === 'providerType') { setShowErrors(false) @@ -306,7 +332,8 @@ export function SSO() { }) } - const callbackUrl = `${getBaseUrl()}/api/auth/sso/callback/${formData.providerId || existingProvider?.providerId || 'provider-id'}` + const isSaml = formData.providerType === 'saml' + const callbackUrl = `${getBaseUrl()}/api/auth/${isSaml ? 'sso/saml2/callback' : 'sso/callback'}/${formData.providerId || existingProvider?.providerId || 'provider-id'}` const copyToClipboard = async (url: string) => { try { @@ -342,10 +369,10 @@ export function SSO() { callbackUrl = config.callbackUrl || '' audience = config.audience || '' wantAssertionsSigned = config.wantAssertionsSigned ?? true - idpMetadata = config.idpMetadata || '' + idpMetadata = config.idpMetadata?.metadata || config.idpMetadata || '' } - setFormData({ + const snapshot = { providerType: existingProvider.providerType, providerId: existingProvider.providerId, issuerUrl: existingProvider.issuer, @@ -359,14 +386,15 @@ export function SSO() { audience, wantAssertionsSigned, idpMetadata, - showAdvanced: false, - }) + } + setFormData(snapshot) + setOriginalFormData(snapshot) setIsEditing(true) - setError(null) setShowErrors(false) + setShowAdvanced(false) } catch (err) { logger.error('Failed to parse provider config', { error: err }) - setError('Failed to load provider configuration') + toast.error('Failed to load provider configuration') } } @@ -375,69 +403,56 @@ export function SSO() { } if (existingProvider && !isEditing) { - const providerCallbackUrl = `${getBaseUrl()}/api/auth/sso/callback/${existingProvider.providerId}` + const providerCallbackUrl = `${getBaseUrl()}/api/auth/${existingProvider.providerType === 'saml' ? 'sso/saml2/callback' : 'sso/callback'}/${existingProvider.providerId}` return (
- {/* Scrollable Content */}
- {/* Provider Info */} -
- Provider ID -

{existingProvider.providerId}

-
+ +

{existingProvider.providerId}

+
-
- - Provider Type - -

+ +

{existingProvider.providerType.toUpperCase()}

-
+ -
- Domain -

{existingProvider.domain}

-
+ +

{existingProvider.domain}

+
-
- Issuer URL -

+ +

{existingProvider.issuer}

-
+ - {/* Callback URL */} -
-
- - Callback URL - + +
+
- -

+

Configure this in your identity provider

-
+
- {/* Footer */}
- {showErrors && errors.clientSecret.length > 0 && ( -

- {errors.clientSecret.join(' ')} -

- )} -
+ -
- Scopes + {/* Scopes */} + 0 ? errors.scopes.join(' ') : undefined} + > handleInputChange('scopes', e.target.value)} className={cn( 'h-9', - showErrors && - errors.scopes.length > 0 && - 'border-[var(--text-error)] focus:border-[var(--text-error)]' + showErrors && errors.scopes.length > 0 && 'border-[var(--text-error)]' )} /> - {showErrors && errors.scopes.length > 0 && ( -

- {errors.scopes.join(' ')} -

- )} -

+

Comma-separated list of OIDC scopes to request

-
+ ) : ( <> -
- - Entry Point URL - + {/* Entry Point URL */} + 0 + ? errors.entryPoint.join(' ') + : undefined + } + > handleInputChange('entryPoint', e.target.value)} className={cn( 'h-9', - showErrors && - errors.entryPoint.length > 0 && - 'border-[var(--text-error)] focus:border-[var(--text-error)]' + showErrors && errors.entryPoint.length > 0 && 'border-[var(--text-error)]' )} /> - {showErrors && errors.entryPoint.length > 0 && ( -

- {errors.entryPoint.join(' ')} -

- )} -
+ -
- - Identity Provider Certificate - + {/* Identity Provider Certificate */} + 0 ? errors.cert.join(' ') : undefined} + >