diff --git a/apps/docs/content/docs/en/platform/enterprise/sso.mdx b/apps/docs/content/docs/en/platform/enterprise/sso.mdx
index ee3d53be3ec..dc4c23f2cf9 100644
--- a/apps/docs/content/docs/en/platform/enterprise/sso.mdx
+++ b/apps/docs/content/docs/en/platform/enterprise/sso.mdx
@@ -12,11 +12,21 @@ Single Sign-On lets your team sign in to Sim through your company's identity pro
---
+## Before you start
+
+
+ [Verify your email domain](/platform/enterprise/verified-domains) first. SSO cannot be saved until the domain shows as **Verified**, and DNS changes take time to propagate.
+
+
+Decide your **Provider ID** before configuring your identity provider — it becomes part of the callback URL you register there, so changing it later means redoing that step.
+
+---
+
## Setup
### 1. Open SSO settings
-Go to **Settings → Enterprise → Single Sign-On** in your workspace.
+Go to **Settings → Security → Single sign-on** in your organization settings.
### 2. Choose a protocol
@@ -33,7 +43,7 @@ Go to **Settings → Enterprise → Single Sign-On** in your workspace.
| Field | What to enter |
|-------|--------------|
-| **Provider ID** | A short slug identifying this connection, e.g. `okta` or `azure-ad`. Letters, numbers, and dashes only. |
+| **Provider ID** | A short slug identifying this connection. Letters, numbers, and dashes only. It must be **unique across every Sim organization**, so include something specific to you — `azure-ad-acme`, not `azure-ad`. If the ID is taken, Sim tells you and suggests a free one. |
| **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. |
@@ -118,19 +128,21 @@ The issuer URL uses Okta's default authorization server, which is pre-configured
**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:
+2. Under **Redirect URI**, select **Web** and enter your Sim callback URL, using the Provider ID you chose:
```
- https://sim.ai/api/auth/sso/callback/azure-ad
+ https://sim.ai/api/auth/sso/callback/azure-ad-acme
```
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**
+5. Go to **Token configuration → Add optional claim**, choose **ID**, and add **email**. Entra omits the email address for managed users without this claim, and sign-in then fails with a missing-user-info error
+6. If **Enterprise applications → Sim → Properties → Assignment required** is **Yes**, assign the users or groups who should sign in. Microsoft rejects unassigned users before they reach Sim
**In Sim:**
| Field | Value |
|-------|-------|
| Provider Type | OIDC |
-| Provider ID | `azure-ad` |
+| Provider ID | `azure-ad-acme` (must be globally unique) |
| Issuer URL | `https://login.microsoftonline.com/{tenant-id}/v2.0` |
| Domain | `company.com` |
| Client ID | Application (client) ID |
@@ -252,7 +264,7 @@ SSO provisioning creates internal organization members. External workspace membe
},
{
question: "A user already has an account with the same email — what happens when they sign in with SSO?",
- answer: "Sim links the SSO identity to the existing account automatically, as long as your identity provider reports the email as verified (email_verified) or the provider is trusted. Most OIDC providers (Okta, Google Workspace, Auth0) assert email_verified, so linking just works. If sign-in fails with 'account not linked' — common with SAML providers that omit the claim — add the provider's ID to SSO_TRUSTED_PROVIDER_IDS on self-hosted and restart."
+ answer: "Sim links the SSO identity to that account automatically. Linking is authorized by your verified domain: because you proved ownership of the domain before configuring SSO, Sim treats your identity provider as authoritative for email addresses on it. This works the same for OIDC and SAML, and does not depend on your IdP sending an email_verified claim — Microsoft Entra, for example, never sends one."
},
{
question: "Who can configure SSO on Sim Cloud?",
@@ -264,7 +276,7 @@ SSO provisioning creates internal organization members. External workspace membe
},
{
question: "How do I update or replace an existing SSO configuration?",
- answer: "Open Settings → Enterprise → Single Sign-On and click Edit. Update the fields and save. The existing provider configuration is replaced."
+ answer: "Open Settings → Security → Single sign-on and click Edit. Update the fields and save. The existing provider configuration is replaced."
}
]} />
@@ -285,22 +297,18 @@ NEXT_PUBLIC_SSO_ENABLED=true
ORGANIZATIONS_ENABLED=true
NEXT_PUBLIC_ORGANIZATIONS_ENABLED=true
-# Optional: comma-separated SSO provider IDs to trust for automatic account linking
-# (links an SSO sign-in to an existing account with the same email). Needed when your
-# IdP does not assert email_verified — typically SAML providers, or OIDC providers that
-# omit the claim. Set it to the Provider ID you registered, then restart.
-# (If you also keep SSO_PROVIDER_ID in the app's environment, that provider is trusted
-# without listing it here.)
+# Optional: comma-separated provider IDs to trust for automatic account linking.
+# This applies to non-SSO providers only — SSO linking is authorized by the
+# verified domain on the provider itself, not by this list.
SSO_TRUSTED_PROVIDER_IDS=custom-oidc,partner-saml
```
When someone signs in with SSO and an account with the same email already exists
(for example, they previously signed up with email/password), Sim links the SSO
- identity to that account automatically as long as your IdP reports the email as
- verified, or the provider is trusted. If you hit an `account not linked` error,
- either confirm your IdP sends `email_verified`, or add the provider's ID to
- `SSO_TRUSTED_PROVIDER_IDS` and restart.
+ identity to that account automatically. That linking is authorized by the verified
+ domain attached to the provider, so it works for both OIDC and SAML and does not
+ depend on your IdP asserting `email_verified`.
You can register providers through the **Settings UI** (same as cloud) or by running the registration script directly against your database.
diff --git a/apps/sim/app/api/auth/sso/register/route.test.ts b/apps/sim/app/api/auth/sso/register/route.test.ts
index 3a4e5c752b2..a430e85a1fa 100644
--- a/apps/sim/app/api/auth/sso/register/route.test.ts
+++ b/apps/sim/app/api/auth/sso/register/route.test.ts
@@ -39,12 +39,19 @@ function queueMembers(rows: Array>) {
}
/**
- * Queues existing SSO provider rows for BOTH domain-conflict lookups (the
- * pre-registration check and the post-registration re-check).
+ * Queues the sso_provider lookups a registration performs, in route order:
+ * providerId conflict then domain conflict, once before OIDC discovery and again
+ * immediately before the write. `providerIdRows` defaults to empty so
+ * domain-conflict tests are unaffected by the providerId check.
*/
-function queueProviders(rows: Array>) {
- queueTableRows(schemaMock.ssoProvider, rows)
- queueTableRows(schemaMock.ssoProvider, rows)
+function queueProviders(
+ domainRows: Array>,
+ providerIdRows: Array> = []
+) {
+ queueTableRows(schemaMock.ssoProvider, providerIdRows)
+ queueTableRows(schemaMock.ssoProvider, domainRows)
+ queueTableRows(schemaMock.ssoProvider, providerIdRows)
+ queueTableRows(schemaMock.ssoProvider, domainRows)
}
vi.mock('@/lib/auth', () => ({
@@ -109,12 +116,14 @@ describe('POST /api/auth/sso/register', () => {
mockSecureFetchWithPinnedIP.mockRejectedValue(new Error('discovery not mocked for this test'))
mockRegisterSSOProvider.mockResolvedValue({ id: 'row-1', providerId: 'acme-oidc' })
mockUpdateSSOProvider.mockResolvedValue({ providerId: 'acme-oidc' })
+ // The trust UPDATE reports the row it matched; by default the provider exists.
+ dbChainMockFns.returning.mockResolvedValue([{ id: 'provider-row' }])
// Default: the org has already verified the domain, so the ownership gate
- // passes and each test exercises the logic beyond it. The gate is checked
- // three times for a successful org-scoped registration (fail-fast entry +
- // authoritative re-check before the write + compensating re-check after the
- // write), so queue three rows. Gate-specific tests reset the queue to assert
- // the unverified paths.
+ // passes and each test exercises the logic beyond it. A successful org-scoped
+ // registration reads it three times: the fail-fast entry gate, the
+ // authoritative re-check before the write, and the locking read inside the
+ // trust transaction. Gate-specific tests reset the queue to assert the
+ // unverified paths.
queueTableRows(schemaMock.ssoDomain, [{ id: 'verified-domain' }])
queueTableRows(schemaMock.ssoDomain, [{ id: 'verified-domain' }])
queueTableRows(schemaMock.ssoDomain, [{ id: 'verified-domain' }])
@@ -175,7 +184,7 @@ describe('POST /api/auth/sso/register', () => {
queueMembers([{ organizationId: 'org1', role: 'owner' }])
queueTableRows(schemaMock.ssoDomain, [{ id: 'v' }]) // entry gate: verified
queueTableRows(schemaMock.ssoDomain, [{ id: 'v' }]) // pre-write re-check: verified
- queueTableRows(schemaMock.ssoDomain, []) // post-write compensating check: revoked
+ queueTableRows(schemaMock.ssoDomain, []) // locking read in the grant: proof gone
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
const json = await res.json()
expect(res.status).toBe(403)
@@ -214,6 +223,204 @@ describe('POST /api/auth/sso/register', () => {
expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1)
})
+ /**
+ * Better Auth scopes providerId uniqueness globally, not per tenant, and would
+ * otherwise reject this with an opaque 422 that reads like a bug. Sim catches
+ * it first and returns a 409 naming a free id.
+ */
+ it('rejects a providerId already taken by another organization', async () => {
+ queueMembers([{ organizationId: 'org-b', role: 'owner' }])
+ queueProviders([], [{ domain: 'other.com', userId: 'u-other', organizationId: 'org-other' }])
+ const res = await POST(request({ ...OIDC_BODY, orgId: 'org-b' }))
+ const json = await res.json()
+ expect(res.status).toBe(409)
+ expect(json.code).toBe('SSO_PROVIDER_ID_TAKEN')
+ expect(mockRegisterSSOProvider).not.toHaveBeenCalled()
+ })
+
+ it('suggests a free, domain-scoped providerId when the requested one is taken', async () => {
+ queueMembers([{ organizationId: 'org-b', role: 'owner' }])
+ queueProviders([], [{ domain: 'other.com', userId: 'u-other', organizationId: 'org-other' }])
+ const res = await POST(request({ ...OIDC_BODY, orgId: 'org-b' }))
+ const json = await res.json()
+ expect(json.error).toContain('acme-oidc-acme')
+ })
+
+ it('does not treat the caller’s own provider as a providerId conflict', async () => {
+ queueMembers([{ organizationId: 'org1', role: 'owner' }])
+ queueProviders([], [{ domain: 'acme.com', userId: 'u1', organizationId: 'org1' }])
+ const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
+ expect(res.status).toBe(200)
+ })
+
+ /**
+ * Better Auth's `isTrustedProvider` reads this flag, and it is the only thing
+ * that lets an SSO sign-in link to a pre-existing same-email account once the
+ * plugin stopped honouring `trustedProviders` for SSO. `registerSSOProvider`
+ * always persists `false`, so the route must set it after the write.
+ */
+ it('marks the provider domain-verified after registering', async () => {
+ queueMembers([{ organizationId: 'org1', role: 'owner' }])
+ const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
+ expect(res.status).toBe(200)
+ expect(dbChainMockFns.set).toHaveBeenCalledWith({ domainVerified: true })
+ })
+
+ /** updateSSOProvider resets domainVerified to false whenever the domain changes. */
+ it('re-marks the provider domain-verified after an update', async () => {
+ queueMembers([{ organizationId: 'org1', role: 'owner' }])
+ queueProviders([])
+ queueTableRows(schemaMock.ssoProvider, [{ id: 'p1' }])
+ const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
+ expect(res.status).toBe(200)
+ expect(mockUpdateSSOProvider).toHaveBeenCalledTimes(1)
+ expect(dbChainMockFns.set).toHaveBeenCalledWith({ domainVerified: true })
+ })
+
+ /**
+ * The create path rolls the provider back when verification is revoked during the
+ * write. The update path has no new row to delete, so it restores the pre-update
+ * config and clears the trust flag together. Clearing alone would leave the
+ * rejected config stored, and re-verifying the domain regrants trust
+ * automatically — silently activating a config the caller was told had failed.
+ */
+ it('reverts the config and revokes trust when verification is removed mid-update', async () => {
+ queueMembers([{ organizationId: 'org1', role: 'owner' }])
+ resetDbChainMock()
+ queueMembers([{ organizationId: 'org1', role: 'owner' }])
+ queueTableRows(schemaMock.ssoDomain, [{ id: 'v' }]) // entry gate
+ queueTableRows(schemaMock.ssoDomain, [{ id: 'v' }]) // pre-write re-check
+ queueTableRows(schemaMock.ssoDomain, []) // locking read in the grant: proof gone
+ queueProviders([])
+ queueTableRows(schemaMock.ssoProvider, [
+ {
+ id: 'p1',
+ issuer: 'https://old-issuer.example.com',
+ domain: 'acme.com',
+ oidcConfig: '{"stored":"oidc"}',
+ samlConfig: null,
+ },
+ ]) // provider already owned → update path
+
+ const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
+ expect(res.status).toBe(403)
+ expect(mockUpdateSSOProvider).toHaveBeenCalledTimes(1)
+ // The conditional grant UPDATE is still issued — it simply matches no rows once
+ // the proof is gone — so the signal is the restoring write plus the 403.
+ expect(dbChainMockFns.set).toHaveBeenCalledWith({
+ issuer: 'https://old-issuer.example.com',
+ domain: 'acme.com',
+ oidcConfig: '{"stored":"oidc"}',
+ samlConfig: null,
+ domainVerified: false,
+ })
+ })
+
+ it('does not mark domain-verified when the registration is rolled back', async () => {
+ queueMembers([{ organizationId: 'org1', role: 'owner' }])
+ resetDbChainMock()
+ queueMembers([{ organizationId: 'org1', role: 'owner' }])
+ queueTableRows(schemaMock.ssoDomain, [{ id: 'verified-domain' }])
+ queueTableRows(schemaMock.ssoDomain, [{ id: 'verified-domain' }])
+ queueTableRows(schemaMock.ssoDomain, []) // locking read in the grant: proof gone
+ const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
+ expect(res.status).toBe(403)
+ expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1) // it was created…
+ expect(dbChainMockFns.delete).toHaveBeenCalled() // …then rolled back
+ })
+
+ /**
+ * A personal provider has no verified domain behind it. On the hosted
+ * multi-tenant deployment that must grant no linking authority, or anyone able
+ * to register one could claim a domain they do not own and have their own IdP
+ * auto-link to existing accounts on it.
+ */
+ it('does not grant domain trust to a personal provider when hosted', async () => {
+ setEnvFlags({ isSsoEnabled: true, isHosted: true })
+ const res = await POST(request(OIDC_BODY))
+ expect(res.status).toBe(200)
+ expect(dbChainMockFns.set).toHaveBeenCalledWith({ domainVerified: false })
+ })
+
+ it('grants domain trust to a personal provider when self-hosted', async () => {
+ setEnvFlags({ isSsoEnabled: true, isHosted: false })
+ const res = await POST(request(OIDC_BODY))
+ expect(res.status).toBe(200)
+ expect(dbChainMockFns.set).toHaveBeenCalledWith({ domainVerified: true })
+ })
+
+ /**
+ * Better Auth merges SAML config with `??`, so dropping an empty identifierFormat
+ * would silently retain a previously stored NameID format while the admin had
+ * selected the provider default.
+ */
+ it('forwards an empty SAML identifierFormat so the provider default can be restored', async () => {
+ queueMembers([{ organizationId: 'org1', role: 'owner' }])
+ queueProviders([])
+ await POST(
+ request({
+ providerType: 'saml',
+ providerId: 'acme-saml',
+ issuer: 'https://idp.acme.com',
+ domain: 'acme.com',
+ orgId: 'org1',
+ entryPoint: 'https://idp.acme.com/sso',
+ cert: 'CERT',
+ identifierFormat: '',
+ })
+ )
+ expect(mockRegisterSSOProvider).toHaveBeenCalledTimes(1)
+ const sent = mockRegisterSSOProvider.mock.calls[0][0].body
+ expect(sent.samlConfig).toHaveProperty('identifierFormat', '')
+ })
+
+ /**
+ * Persisting generated IdP metadata made re-saving destructive: the form loaded
+ * it back, resent it, and it then won over the certificate — so rotating a SAML
+ * cert through the form silently did nothing.
+ */
+ it('writes empty IdP metadata when the admin supplied none, so a stored one clears', async () => {
+ queueMembers([{ organizationId: 'org1', role: 'owner' }])
+ queueProviders([])
+ await POST(
+ request({
+ providerType: 'saml',
+ providerId: 'acme-saml',
+ issuer: 'https://idp.acme.com',
+ domain: 'acme.com',
+ orgId: 'org1',
+ entryPoint: 'https://idp.acme.com/sso',
+ cert: 'ORIGINAL-CERT',
+ })
+ )
+ const sent = mockRegisterSSOProvider.mock.calls[0][0].body
+ // Written as empty rather than omitted: Better Auth merges with `??`, so an
+ // omitted key would retain a previously stored document on update.
+ expect(sent.samlConfig.idpMetadata).toEqual({ metadata: '' })
+ expect(sent.samlConfig.cert).toBe('ORIGINAL-CERT')
+ })
+
+ it('persists IdP metadata the admin did supply', async () => {
+ queueMembers([{ organizationId: 'org1', role: 'owner' }])
+ queueProviders([])
+ await POST(
+ request({
+ providerType: 'saml',
+ providerId: 'acme-saml',
+ issuer: 'https://idp.acme.com',
+ domain: 'acme.com',
+ orgId: 'org1',
+ entryPoint: 'https://idp.acme.com/sso',
+ cert: 'CERT',
+ idpMetadata: 'supplied',
+ })
+ )
+ const sent = mockRegisterSSOProvider.mock.calls[0][0].body
+ expect(sent.samlConfig.idpMetadata).toEqual({
+ metadata: 'supplied',
+ })
+ })
+
it('nests the attribute mapping inside oidcConfig (Better Auth reads it there)', async () => {
queueMembers([{ organizationId: 'org1', role: 'owner' }])
await POST(
@@ -227,8 +434,7 @@ describe('POST /api/auth/sso/register', () => {
it('routes an edit of an existing owned provider through updateSSOProvider', async () => {
queueMembers([{ organizationId: 'org1', role: 'owner' }])
- queueTableRows(schemaMock.ssoProvider, []) // findDomainConflict #1 → no conflict
- queueTableRows(schemaMock.ssoProvider, []) // findDomainConflict #2 → no conflict
+ queueProviders([]) // no providerId or domain conflicts on either pass
queueTableRows(schemaMock.ssoProvider, [{ id: 'p1' }]) // provider already owned → edit
const res = await POST(request({ ...OIDC_BODY, orgId: 'org1' }))
expect(res.status).toBe(200)
diff --git a/apps/sim/app/api/auth/sso/register/route.ts b/apps/sim/app/api/auth/sso/register/route.ts
index 8defefba9b8..c009e56b915 100644
--- a/apps/sim/app/api/auth/sso/register/route.ts
+++ b/apps/sim/app/api/auth/sso/register/route.ts
@@ -8,7 +8,7 @@ import { ssoRegistrationContract } from '@/lib/api/contracts/auth'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { auth, getSession } from '@/lib/auth'
import { hasSSOAccess } from '@/lib/billing'
-import { isSsoEnabled } from '@/lib/core/config/env-flags'
+import { isHosted, isSsoEnabled } from '@/lib/core/config/env-flags'
import {
secureFetchWithPinnedIP,
validateUrlWithDNS,
@@ -40,6 +40,15 @@ function selectTokenEndpointAuthMethod(
return 'client_secret_post'
}
+/**
+ * Proposes a free provider ID by suffixing the domain's first label
+ * (`azure-ad` + `acme.com` -> `azure-ad-acme`). Callers pass a domain already
+ * through `normalizeSSODomain`, whose shape guarantees a non-empty first label.
+ */
+function suggestProviderId(providerId: string, domain: string): string {
+ return `${providerId}-${domain.split('.')[0]}`
+}
+
type DiscoveryResult =
| { ok: true; discovery: Record }
| { ok: false; error: string }
@@ -123,12 +132,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
)
}
- // Security gate: configuring org SSO for a domain requires the org to have
- // proven ownership of it (DNS TXT verification). Without this, the old
- // first-come claim let any org wire another company's domain to their own
- // IdP — an account-takeover primitive. Existing domains were grandfathered
- // as verified by migration 0266, so live tenants are unaffected. Personal
- // (org-less) SSO is not gated.
+ /**
+ * Configuring org SSO for a domain requires DNS-proven ownership; without it
+ * a first-come claim lets any org wire another company's domain to their own
+ * IdP. Migration 0266 grandfathered existing domains. Org-less SSO is not gated.
+ */
const isOrgDomainVerified = async (): Promise => {
if (!orgId) return true
const [verified] = await db
@@ -154,9 +162,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
{ status: 403 }
)
- // Fail fast before the expensive OIDC discovery. Re-checked immediately
- // before the provider write below to close the TOCTOU window (the verified
- // row could be removed while discovery is in flight).
+ // Fail fast before OIDC discovery; re-checked before the write to close the
+ // window where the proof is removed while discovery is in flight.
if (!(await isOrgDomainVerified())) return domainNotVerifiedResponse()
const isOwnedByCaller = (provider: {
@@ -187,6 +194,40 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
{ status: 409 }
)
+ /**
+ * Better Auth treats `providerId` as globally unique, not per-tenant, and
+ * resolves providers by that column alone. Catching the cross-tenant
+ * collision here turns its opaque 422 into a 409 naming a free id.
+ */
+ const findProviderIdConflict = async () =>
+ (
+ await db
+ .select({
+ userId: ssoProvider.userId,
+ organizationId: ssoProvider.organizationId,
+ })
+ .from(ssoProvider)
+ .where(eq(ssoProvider.providerId, providerId))
+ ).find((provider) => !isOwnedByCaller(provider))
+
+ const providerIdConflictResponse = () =>
+ NextResponse.json(
+ {
+ error: `The provider ID "${providerId}" is already taken by another organization. Provider IDs are global, so pick a unique one — for example "${suggestProviderId(providerId, domain)}". It appears in the redirect URL you register with your identity provider, so choose it before configuring the IdP.`,
+ code: 'SSO_PROVIDER_ID_TAKEN',
+ },
+ { status: 409 }
+ )
+
+ if (await findProviderIdConflict()) {
+ logger.warn('Rejected SSO registration for providerId owned by another tenant', {
+ providerId,
+ orgId,
+ userId: session.user.id,
+ })
+ return providerIdConflictResponse()
+ }
+
if (await findDomainConflict()) {
logger.warn('Rejected SSO registration for domain owned by another tenant', {
domain,
@@ -465,28 +506,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
`
- const certBase64 = cert
- .replace(/-----BEGIN CERTIFICATE-----/g, '')
- .replace(/-----END CERTIFICATE-----/g, '')
- .replace(/\s/g, '')
-
- const computedIdpMetadataXml =
- idpMetadata ||
- `
-
-
-
-
-
- ${certBase64}
-
-
-
-
-
-
-`
-
const samlConfig: any = {
entryPoint,
cert,
@@ -494,16 +513,24 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
spMetadata: {
metadata: spMetadataXml,
},
- idpMetadata: {
- metadata: computedIdpMetadataXml,
- },
}
if (audience) samlConfig.audience = audience
if (wantAssertionsSigned !== undefined) samlConfig.wantAssertionsSigned = wantAssertionsSigned
if (signatureAlgorithm) samlConfig.signatureAlgorithm = signatureAlgorithm
if (digestAlgorithm) samlConfig.digestAlgorithm = digestAlgorithm
- if (identifierFormat) samlConfig.identifierFormat = identifierFormat
+
+ /**
+ * Always written, empty when unset: Better Auth merges SAML config with
+ * `??`, so an omitted key keeps whatever was stored and clearing either
+ * field would never take effect. Both are falsy-guarded downstream.
+ *
+ * Metadata must not be generated here — a document built from cert +
+ * entryPoint outranks the certificate on re-save, silently defeating
+ * SAML cert rotation.
+ */
+ samlConfig.idpMetadata = { metadata: idpMetadata ?? '' }
+ samlConfig.identifierFormat = identifierFormat ?? ''
// Better Auth reads the attribute mapping from samlConfig.mapping.
if (mapping) samlConfig.mapping = mapping
@@ -537,6 +564,15 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
),
})
+ if (await findProviderIdConflict()) {
+ logger.warn('Rejected SSO registration: providerId was claimed during registration', {
+ providerId,
+ orgId,
+ userId: session.user.id,
+ })
+ return providerIdConflictResponse()
+ }
+
if (await findDomainConflict()) {
logger.warn('Rejected SSO registration: domain was claimed during registration', {
domain,
@@ -578,12 +614,62 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
eq(ssoProvider.userId, session.user.id),
isNull(ssoProvider.organizationId)
)
+ // Config columns are captured, not just the id: an update whose trust grant is
+ // refused has to be undone, or the rejected config stays stored and goes live
+ // the moment the domain is verified again.
const [existingOwnedProvider] = await db
- .select({ id: ssoProvider.id })
+ .select({
+ id: ssoProvider.id,
+ issuer: ssoProvider.issuer,
+ domain: ssoProvider.domain,
+ oidcConfig: ssoProvider.oidcConfig,
+ samlConfig: ssoProvider.samlConfig,
+ })
.from(ssoProvider)
.where(ownerClause)
.limit(1)
+ /**
+ * Grants domain trust only while the proof is held under a row lock.
+ *
+ * A WHERE-clause EXISTS test is not enough: under READ COMMITTED the subquery
+ * sees the statement's original snapshot, so a delete committing while the
+ * UPDATE waits can still grant trust after ownership is gone. `FOR SHARE`
+ * orders the two — the delete blocks until this commits, and if it committed
+ * first the SELECT finds nothing.
+ *
+ * Org-less SSO is self-host-only (Sim's UI always registers org-scoped) and
+ * has no proof behind it, so it is trusted only when self-hosted.
+ */
+ const grantProviderDomainTrust = async (): Promise => {
+ if (!orgId) {
+ await db.update(ssoProvider).set({ domainVerified: !isHosted }).where(ownerClause)
+ return true
+ }
+ return db.transaction(async (tx) => {
+ const [proof] = await tx
+ .select({ id: ssoDomain.id })
+ .from(ssoDomain)
+ .where(
+ and(
+ eq(ssoDomain.organizationId, orgId),
+ eq(ssoDomain.domain, domain),
+ eq(ssoDomain.status, 'verified')
+ )
+ )
+ .limit(1)
+ .for('share')
+ if (!proof) return false
+
+ const granted = await tx
+ .update(ssoProvider)
+ .set({ domainVerified: true })
+ .where(ownerClause)
+ .returning({ id: ssoProvider.id })
+ return granted.length > 0
+ })
+ }
+
if (existingOwnedProvider) {
await auth.api.updateSSOProvider({
body: {
@@ -595,6 +681,30 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
},
headers,
})
+
+ // Restore the pre-update config and clear the flag together. Clearing alone
+ // is not enough: re-verifying the domain now regrants trust automatically,
+ // which would activate the very config this request reported as rejected.
+ if (!(await grantProviderDomainTrust())) {
+ await db
+ .update(ssoProvider)
+ .set({
+ issuer: existingOwnedProvider.issuer,
+ domain: existingOwnedProvider.domain,
+ oidcConfig: existingOwnedProvider.oidcConfig,
+ samlConfig: existingOwnedProvider.samlConfig,
+ domainVerified: false,
+ })
+ .where(eq(ssoProvider.id, existingOwnedProvider.id))
+ logger.warn('Reverted SSO update: domain verification was removed mid-write', {
+ domain,
+ orgId,
+ providerId,
+ userId: session.user.id,
+ })
+ return domainNotVerifiedResponse()
+ }
+
logger.info('SSO provider updated successfully', { providerId, providerType, domain })
return NextResponse.json({
success: true,
@@ -609,21 +719,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
headers,
})
- // Close the residual TOCTOU between the re-check above and Better Auth
- // persisting the provider: the verified sso_domain row could be removed in
- // that window. registerSSOProvider is create-only (it throws if the
- // providerId already exists), so a successful call always created a brand-new
- // row — we roll it back by its primary-key `id` (not the logical providerId,
- // which a concurrent delete+recreate could point at a different row). Personal
- // SSO is not gated, so this only runs for org-scoped registration.
- if (orgId && !(await isOrgDomainVerified())) {
+ // A refused grant means the proof vanished mid-write, leaving a provider on a
+ // domain the org no longer proves — roll it back. Deleted by primary key, not
+ // providerId, which a concurrent delete+recreate could point at another row.
+ if (!(await grantProviderDomainTrust())) {
// registerSSOProvider spreads the created row's `id` at runtime, but the
// typed return omits it — read it defensively and only delete when it's a
// real id, so a future shape change can't turn the rollback into a silent
- // no-op that leaves a provider on an unverified domain.
+ // no-op that leaves a provider on an unverified domain. `orgId` is checked
+ // only to narrow it: the org-less path grants unconditionally, so a refused
+ // grant always means an org-scoped registration.
// double-cast-allowed: Better Auth's return type omits the runtime `id`
const createdRowId = (registration as unknown as { id?: unknown }).id
- if (typeof createdRowId === 'string' && createdRowId.length > 0) {
+ if (orgId && typeof createdRowId === 'string' && createdRowId.length > 0) {
await db
.delete(ssoProvider)
.where(and(eq(ssoProvider.id, createdRowId), eq(ssoProvider.organizationId, orgId)))
diff --git a/apps/sim/app/api/organizations/[id]/domains/[domainId]/route.test.ts b/apps/sim/app/api/organizations/[id]/domains/[domainId]/route.test.ts
index 6dd6c29ada1..e151b818ef4 100644
--- a/apps/sim/app/api/organizations/[id]/domains/[domainId]/route.test.ts
+++ b/apps/sim/app/api/organizations/[id]/domains/[domainId]/route.test.ts
@@ -83,4 +83,41 @@ describe('remove org domain route', () => {
expect.objectContaining({ action: 'organization.domain.removed' })
)
})
+
+ /**
+ * `domainVerified` on a provider is what authorizes auto-linking an SSO sign-in
+ * to an existing same-email account. Removing the proof has to withdraw that
+ * trust in the same transaction, or the authorization outlives the ownership.
+ */
+ it('revokes SSO domain trust for providers on the removed domain', async () => {
+ queueTableRows(member, [{ role: 'owner' }])
+ dbChainMockFns.returning.mockResolvedValueOnce([{ domain: 'acme.com' }])
+ const res = await DELETE(createMockRequest('DELETE'), routeContext)
+ expect(res.status).toBe(200)
+ expect(dbChainMockFns.set).toHaveBeenCalledWith({ domainVerified: false })
+ })
+
+ /**
+ * Migration 0268 grandfathered providers by stripping a leading `*.`, so a
+ * provider can be stored as `*.acme.com` while its verified row holds
+ * `acme.com`. A naive equality match would leave that provider trusted after
+ * the proof was deleted.
+ */
+ it('matches the provider domain the way it was grandfathered (wildcard-tolerant)', async () => {
+ queueTableRows(member, [{ role: 'owner' }])
+ dbChainMockFns.returning.mockResolvedValueOnce([{ domain: 'acme.com' }])
+ await DELETE(createMockRequest('DELETE'), routeContext)
+ const revokeWhere = dbChainMockFns.where.mock.calls.find(([condition]) =>
+ JSON.stringify(condition ?? '').includes('regexp_replace')
+ )
+ expect(revokeWhere).toBeDefined()
+ })
+
+ it('does not revoke trust when no domain was removed', async () => {
+ queueTableRows(member, [{ role: 'owner' }])
+ dbChainMockFns.returning.mockResolvedValueOnce([]) // delete matched nothing
+ const res = await DELETE(createMockRequest('DELETE'), routeContext)
+ expect(res.status).toBe(404)
+ expect(dbChainMockFns.set).not.toHaveBeenCalled()
+ })
})
diff --git a/apps/sim/app/api/organizations/[id]/domains/[domainId]/route.ts b/apps/sim/app/api/organizations/[id]/domains/[domainId]/route.ts
index 9dd220c2274..a0779f7fd33 100644
--- a/apps/sim/app/api/organizations/[id]/domains/[domainId]/route.ts
+++ b/apps/sim/app/api/organizations/[id]/domains/[domainId]/route.ts
@@ -1,9 +1,9 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
-import { member, ssoDomain } from '@sim/db/schema'
+import { member, ssoDomain, ssoProvider } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { isOrgAdminRole } from '@sim/platform-authz/workspace'
-import { and, eq } from 'drizzle-orm'
+import { and, eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { removeOrganizationDomainContract } from '@/lib/api/contracts/organization'
import { parseRequest } from '@/lib/api/server'
@@ -18,8 +18,10 @@ const logger = createLogger('OrgDomainDeleteAPI')
* DELETE /api/organizations/[id]/domains/[domainId]
* Removes a claimed/verified domain. Requires owner/admin role. Removing a
* verified domain drops the ownership proof, so SSO can no longer be configured
- * for it until it is re-verified. It does not retroactively un-register an
- * already-configured SSO provider — that flows through the SSO provider itself.
+ * for it until it is re-verified, and any provider already on that domain loses
+ * its `domainVerified` trust in the same transaction. The provider itself is not
+ * un-registered — that flows through the SSO provider — but it can no longer
+ * auto-link sign-ins to existing accounts.
*/
export const DELETE = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string; domainId: string }> }) => {
@@ -59,10 +61,30 @@ export const DELETE = withRouteHandler(
)
}
- const [removed] = await db
- .delete(ssoDomain)
- .where(and(eq(ssoDomain.id, domainId), eq(ssoDomain.organizationId, organizationId)))
- .returning({ domain: ssoDomain.domain })
+ // Removing the proof withdraws the trust it granted, in the same transaction
+ // so a domain can never be gone while its provider still claims verification.
+ const removed = await db.transaction(async (tx) => {
+ const [deleted] = await tx
+ .delete(ssoDomain)
+ .where(and(eq(ssoDomain.id, domainId), eq(ssoDomain.organizationId, organizationId)))
+ .returning({ domain: ssoDomain.domain })
+
+ if (!deleted) return null
+
+ // Normalize as migration 0268 did when grandfathering these rows (lower,
+ // trimmed, leading `*.` stripped), so `*.acme.com` matches proof `acme.com`.
+ await tx
+ .update(ssoProvider)
+ .set({ domainVerified: false })
+ .where(
+ and(
+ eq(ssoProvider.organizationId, organizationId),
+ sql`lower(regexp_replace(btrim(${ssoProvider.domain}), '^\\*\\.', '')) = ${deleted.domain}`
+ )
+ )
+
+ return deleted
+ })
if (!removed) {
return NextResponse.json({ error: 'Domain not found' }, { status: 404 })
diff --git a/apps/sim/app/api/organizations/[id]/domains/[domainId]/verify/route.test.ts b/apps/sim/app/api/organizations/[id]/domains/[domainId]/verify/route.test.ts
index 11b14ddd877..7e8dda9dd06 100644
--- a/apps/sim/app/api/organizations/[id]/domains/[domainId]/verify/route.test.ts
+++ b/apps/sim/app/api/organizations/[id]/domains/[domainId]/verify/route.test.ts
@@ -113,6 +113,45 @@ describe('verify org domain route', () => {
expect(mockRecordAudit).not.toHaveBeenCalled()
})
+ /**
+ * Deleting a verified domain revokes `domainVerified` on the providers it covered.
+ * Re-verifying has to restore it, or the provider stays untrusted — and since that
+ * flag gates sign-in rather than only linking, the org would sit in a silent SSO
+ * outage until an admin happened to re-save the SSO config.
+ */
+ it('restores SSO domain trust for providers on the verified domain', async () => {
+ queueAdminWithPendingRow()
+ queueTableRows(ssoDomain, []) // verified-elsewhere check → none
+ dbChainMockFns.returning.mockResolvedValueOnce([{ ...PENDING_ROW, status: 'verified' }])
+ const res = await POST(createMockRequest('POST'), routeContext)
+ expect(res.status).toBe(200)
+ expect(dbChainMockFns.set).toHaveBeenCalledWith({ domainVerified: true })
+ })
+
+ /**
+ * Mirrors the revocation's wildcard-tolerant comparison: a provider grandfathered
+ * as `*.acme.com` must be re-trusted by a proof row holding `acme.com`.
+ */
+ it('matches the provider domain wildcard-tolerantly, as the revocation does', async () => {
+ queueAdminWithPendingRow()
+ queueTableRows(ssoDomain, [])
+ dbChainMockFns.returning.mockResolvedValueOnce([{ ...PENDING_ROW, status: 'verified' }])
+ await POST(createMockRequest('POST'), routeContext)
+ const grantWhere = dbChainMockFns.where.mock.calls.find(([condition]) =>
+ JSON.stringify(condition ?? '').includes('regexp_replace')
+ )
+ expect(grantWhere).toBeDefined()
+ })
+
+ it('does not grant trust when the conditional update matched no row', async () => {
+ queueAdminWithPendingRow()
+ queueTableRows(ssoDomain, [])
+ dbChainMockFns.returning.mockResolvedValueOnce([]) // lost the race
+ queueTableRows(ssoDomain, [{ ...PENDING_ROW, status: 'verified' }])
+ await POST(createMockRequest('POST'), routeContext)
+ expect(dbChainMockFns.set).not.toHaveBeenCalledWith({ domainVerified: true })
+ })
+
it('409s (not 500) when a concurrent cross-org verification wins the unique index', async () => {
queueAdminWithPendingRow()
queueTableRows(ssoDomain, []) // verified-elsewhere check → none at read time
diff --git a/apps/sim/app/api/organizations/[id]/domains/[domainId]/verify/route.ts b/apps/sim/app/api/organizations/[id]/domains/[domainId]/verify/route.ts
index 9f1c710a2cb..6142de1afb1 100644
--- a/apps/sim/app/api/organizations/[id]/domains/[domainId]/verify/route.ts
+++ b/apps/sim/app/api/organizations/[id]/domains/[domainId]/verify/route.ts
@@ -1,10 +1,10 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
-import { member, ssoDomain } from '@sim/db/schema'
+import { member, ssoDomain, ssoProvider } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { isOrgAdminRole } from '@sim/platform-authz/workspace'
import { getPostgresErrorCode } from '@sim/utils/errors'
-import { and, eq } from 'drizzle-orm'
+import { and, eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { verifyOrganizationDomainContract } from '@/lib/api/contracts/organization'
import { parseRequest } from '@/lib/api/server'
@@ -103,17 +103,37 @@ export const POST = withRouteHandler(
// that as a 409 rather than an unhandled 500.
let updated: (typeof row)[]
try {
- updated = await db
- .update(ssoDomain)
- .set({ status: 'verified', verifiedAt: new Date(), updatedAt: new Date() })
- .where(
- and(
- eq(ssoDomain.id, domainId),
- eq(ssoDomain.verificationToken, row.verificationToken),
- eq(ssoDomain.status, 'pending')
+ updated = await db.transaction(async (tx) => {
+ const flipped = await tx
+ .update(ssoDomain)
+ .set({ status: 'verified', verifiedAt: new Date(), updatedAt: new Date() })
+ .where(
+ and(
+ eq(ssoDomain.id, domainId),
+ eq(ssoDomain.verificationToken, row.verificationToken),
+ eq(ssoDomain.status, 'pending')
+ )
)
- )
- .returning()
+ .returning()
+
+ // Restore trust this proof covers, mirroring the revocation on delete.
+ // Without it a delete-then-reverify leaves the provider untrusted, and
+ // since that flag gates sign-in the org sits in a silent SSO outage. The
+ // comparison matches the revoking one exactly so the two stay symmetric.
+ if (flipped.length > 0) {
+ await tx
+ .update(ssoProvider)
+ .set({ domainVerified: true })
+ .where(
+ and(
+ eq(ssoProvider.organizationId, organizationId),
+ sql`lower(regexp_replace(btrim(${ssoProvider.domain}), '^\\*\\.', '')) = ${flipped[0].domain}`
+ )
+ )
+ }
+
+ return flipped
+ })
} catch (error) {
if (getPostgresErrorCode(error) === '23505') {
return NextResponse.json(
diff --git a/apps/sim/ee/sso/components/sso-settings.tsx b/apps/sim/ee/sso/components/sso-settings.tsx
index 6b31562a006..cf4f499c05e 100644
--- a/apps/sim/ee/sso/components/sso-settings.tsx
+++ b/apps/sim/ee/sso/components/sso-settings.tsx
@@ -48,6 +48,27 @@ interface SSOProvider {
providerType: 'oidc' | 'saml'
}
+/** Claim names each protocol uses out of the box; shown as input placeholders. */
+const OIDC_DEFAULT_MAPPING = { id: 'sub', email: 'email', name: 'name', image: 'picture' } as const
+const SAML_DEFAULT_MAPPING = {
+ id: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier',
+ email: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress',
+ name: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name',
+} as const
+
+const SAML_NAMEID_FORMATS = [
+ { label: 'Provider default', value: '' },
+ {
+ label: 'Email address',
+ value: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
+ },
+ { label: 'Persistent', value: 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent' },
+ { label: 'Transient', value: 'urn:oasis:names:tc:SAML:2.0:nameid-format:transient' },
+ { label: 'Unspecified', value: 'urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified' },
+] as const
+
+const PROVIDER_ID_SUGGESTIONS = SSO_TRUSTED_PROVIDERS.map((id) => ({ label: id, value: id }))
+
const DEFAULT_FORM_DATA = {
providerType: 'oidc' as 'oidc' | 'saml',
providerId: '',
@@ -62,6 +83,13 @@ const DEFAULT_FORM_DATA = {
audience: '',
wantAssertionsSigned: true,
idpMetadata: '',
+ mapId: '',
+ mapEmail: '',
+ mapName: '',
+ identifierFormat: '',
+ authorizationEndpoint: '',
+ tokenEndpoint: '',
+ jwksEndpoint: '',
}
const DEFAULT_ERRORS = {
@@ -109,6 +137,7 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) {
const [showClientSecret, setShowClientSecret] = useState(false)
const [isEditing, setIsEditing] = useState(false)
const [showAdvanced, setShowAdvanced] = useState(false)
+ const [showMapping, setShowMapping] = useState(false)
const [formData, setFormData] = useState(DEFAULT_FORM_DATA)
const [originalFormData, setOriginalFormData] = useState(DEFAULT_FORM_DATA)
@@ -226,30 +255,6 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) {
setShowAdvanced(false)
}
- const isFormValid = () => {
- const requiredFields = ['providerId', 'issuerUrl', 'domain']
- const hasRequiredFields = requiredFields.every((field) => {
- const value = formData[field as keyof typeof formData]
- return typeof value === 'string' && value.trim() !== ''
- })
-
- const providerType = formData.providerType || 'oidc'
-
- if (providerType === 'oidc') {
- return (
- hasRequiredFields &&
- formData.clientId.trim() !== '' &&
- formData.clientSecret.trim() !== '' &&
- formData.scopes.trim() !== ''
- )
- }
- if (providerType === 'saml') {
- return hasRequiredFields && formData.entryPoint.trim() !== '' && formData.cert.trim() !== ''
- }
-
- return false
- }
-
const handleSubmit = async (e?: React.FormEvent) => {
e?.preventDefault()
@@ -271,14 +276,23 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) {
domain: formData.domain,
orgId: organizationId,
mapping: {
- id: 'sub',
- email: 'email',
- name: 'name',
- image: 'picture',
+ id: formData.mapId.trim() || OIDC_DEFAULT_MAPPING.id,
+ email: formData.mapEmail.trim() || OIDC_DEFAULT_MAPPING.email,
+ name: formData.mapName.trim() || OIDC_DEFAULT_MAPPING.name,
+ image: OIDC_DEFAULT_MAPPING.image,
},
clientId: formData.clientId,
clientSecret: formData.clientSecret,
scopes: formData.scopes.split(',').map((s) => s.trim()),
+ ...(formData.authorizationEndpoint.trim()
+ ? { authorizationEndpoint: formData.authorizationEndpoint.trim() }
+ : {}),
+ ...(formData.tokenEndpoint.trim()
+ ? { tokenEndpoint: formData.tokenEndpoint.trim() }
+ : {}),
+ ...(formData.jwksEndpoint.trim()
+ ? { jwksEndpoint: formData.jwksEndpoint.trim() }
+ : {}),
}
: {
providerType: 'saml',
@@ -287,9 +301,9 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) {
domain: formData.domain,
orgId: organizationId,
mapping: {
- id: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier',
- email: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress',
- name: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name',
+ id: formData.mapId.trim() || SAML_DEFAULT_MAPPING.id,
+ email: formData.mapEmail.trim() || SAML_DEFAULT_MAPPING.email,
+ name: formData.mapName.trim() || SAML_DEFAULT_MAPPING.name,
},
entryPoint: formData.entryPoint,
cert: formData.cert,
@@ -297,6 +311,7 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) {
...(formData.callbackUrl ? { callbackUrl: formData.callbackUrl } : {}),
...(formData.audience ? { audience: formData.audience } : {}),
...(formData.idpMetadata ? { idpMetadata: formData.idpMetadata } : {}),
+ identifierFormat: formData.identifierFormat,
}
await configureSSOMutation.mutateAsync(requestBody)
@@ -318,8 +333,11 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) {
const handleInputChange = (field: keyof typeof formData, value: string | boolean) => {
const next = { ...formData, [field]: value }
-
+ // Claim names are protocol-specific, so an override must not survive a switch.
if (field === 'providerType') {
+ next.mapId = ''
+ next.mapEmail = ''
+ next.mapName = ''
setShowErrors(false)
}
@@ -328,6 +346,7 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) {
}
const isSaml = formData.providerType === 'saml'
+ const mappingDefaults = isSaml ? SAML_DEFAULT_MAPPING : OIDC_DEFAULT_MAPPING
const callbackUrl = `${getBaseUrl()}/api/auth/${isSaml ? 'sso/saml2/callback' : 'sso/callback'}/${formData.providerId || existingProvider?.providerId || 'provider-id'}`
const handleEdit = () => {
@@ -343,12 +362,23 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) {
let audience = ''
let wantAssertionsSigned = true
let idpMetadata = ''
+ // Blank means "use the protocol default", so only carry over a stored value
+ // that differs — otherwise editing rewrites a default as an explicit override.
+ let mapping: { id?: string; email?: string; name?: string } = {}
+ let identifierFormat = ''
+ let authorizationEndpoint = ''
+ let tokenEndpoint = ''
+ let jwksEndpoint = ''
if (existingProvider.providerType === 'oidc' && existingProvider.oidcConfig) {
const config = JSON.parse(existingProvider.oidcConfig)
clientId = config.clientId || ''
clientSecret = config.clientSecret || ''
scopes = config.scopes?.join(',') || 'openid,profile,email'
+ mapping = config.mapping ?? {}
+ authorizationEndpoint = config.authorizationEndpoint || ''
+ tokenEndpoint = config.tokenEndpoint || ''
+ jwksEndpoint = config.jwksEndpoint || ''
} else if (existingProvider.providerType === 'saml' && existingProvider.samlConfig) {
const config = JSON.parse(existingProvider.samlConfig)
entryPoint = config.entryPoint || ''
@@ -356,9 +386,22 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) {
callbackUrl = config.callbackUrl || ''
audience = config.audience || ''
wantAssertionsSigned = config.wantAssertionsSigned ?? true
- idpMetadata = config.idpMetadata?.metadata || config.idpMetadata || ''
+ // Two stored shapes: `{ metadata }` from the route, a bare string from older
+ // rows. Narrow on type, not truthiness — `{ metadata: '' }` is falsy at
+ // `.metadata` but truthy as an object, putting an object in a string field.
+ idpMetadata =
+ typeof config.idpMetadata === 'string'
+ ? config.idpMetadata
+ : (config.idpMetadata?.metadata ?? '')
+ mapping = config.mapping ?? {}
+ identifierFormat = config.identifierFormat || ''
}
+ const defaults =
+ existingProvider.providerType === 'saml' ? SAML_DEFAULT_MAPPING : OIDC_DEFAULT_MAPPING
+ const overrideOf = (value: string | undefined, fallback: string) =>
+ value && value !== fallback ? value : ''
+
const snapshot = {
providerType: existingProvider.providerType,
providerId: existingProvider.providerId,
@@ -373,12 +416,20 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) {
audience,
wantAssertionsSigned,
idpMetadata,
+ mapId: overrideOf(mapping.id, defaults.id),
+ mapEmail: overrideOf(mapping.email, defaults.email),
+ mapName: overrideOf(mapping.name, defaults.name),
+ identifierFormat,
+ authorizationEndpoint,
+ tokenEndpoint,
+ jwksEndpoint,
}
setFormData(snapshot)
setOriginalFormData(snapshot)
setIsEditing(true)
setShowErrors(false)
setShowAdvanced(false)
+ setShowMapping(Boolean(snapshot.mapId || snapshot.mapEmail || snapshot.mapName))
} catch (err) {
logger.error('Failed to parse provider config', { error: err })
toast.error('Failed to load provider configuration')
@@ -414,12 +465,22 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) {
-
+
Configure this in your identity provider
+
+ {existingProvider.providerType === 'saml' && (
+
+
+
+ )}
@@ -468,7 +529,8 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) {
...saveDiscardActions({
dirty: hasChanges,
saving: configureSSOMutation.isPending,
- saveDisabled: hasAnyErrors(errors) || !isFormValid(),
+ // Never disabled on validation errors: showErrors is only set by
+ // handleSubmit, so disabling Save left a greyed button and no message.
saveLabel: isEditing ? 'Update' : 'Save',
savingLabel: isEditing ? 'Updating...' : 'Saving...',
onSave: () => void handleSubmit(),
@@ -506,16 +568,30 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) {
showErrors && errors.providerId.length > 0 ? errors.providerId.join(' ') : undefined
}
>
- handleInputChange('providerId', value)}
- options={SSO_TRUSTED_PROVIDERS.map((id) => ({
- label: id,
- value: id,
- }))}
- placeholder='Select or enter a provider ID'
- editable
- />
+ {isEditing ? (
+ <>
+
+
+ Fixed once saved — it forms the redirect URL registered with your identity
+ provider.
+
+ >
+ ) : (
+ <>
+ handleInputChange('providerId', value)}
+ options={PROVIDER_ID_SUGGESTIONS}
+ placeholder='Select or enter a provider ID'
+ editable
+ />
+
+ Must be unique across all Sim organizations — include something specific to you,
+ like azure-ad-acme. It cannot be changed
+ later.
+