Skip to content

Commit c73789c

Browse files
committed
feat(sso): support encrypted SAML assertions and retire the registration scripts
Encrypted assertions are now configured in the settings UI and the registration API: the service provider certificate is published in the SP metadata Sim serves, and the matching private key is stored encrypted and handed to the SAML library for decryption. Request signing is deliberately absent — Better Auth's service provider neither signs requests nor publishes a signing certificate, so a key field for it would do nothing. The operator registration scripts are removed along with the env vars and docs that drove them; the UI covers registration, and the scripts were a second write path with their own copy of the crypto. The field they wrote for assertion decryption was never read by the SAML library, so it never worked. Also: run the new adapter PostgreSQL suite in CI, reject a stored OIDC config whose client secret is missing rather than saving a provider without one, and cover the nested service-provider keys in both encryption at rest and API redaction.
1 parent bdb8785 commit c73789c

15 files changed

Lines changed: 663 additions & 1161 deletions

File tree

‎.github/workflows/test-build.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ jobs:
116116
ee/scim/lib/managed-membership.postgres.test.ts
117117
lib/auth/sso/application/admit-sso-user.postgres.test.ts
118118
lib/auth/sso/primary-provider.postgres.test.ts
119+
lib/auth/sso-provider-secret-adapter.postgres.test.ts
119120
120121
- name: Verify billing and organization activity in PostgreSQL
121122
working-directory: apps/sim

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

Lines changed: 17 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,22 @@ Click **Save**. To test, sign out and use the **Sign in with SSO** button on the
103103

104104
For a saved connection, open **Sign-in**, select the provider, and select **Edit**. The Provider ID remains fixed. **Delete** removes that sign-in path only: accounts and memberships it admitted stay. If you delete the primary provider and the domain has another verified provider, that one becomes primary; otherwise people at the domain sign in another way until a provider serves it again. A saved OIDC client secret appears as a mask with a suffix when available; **Replace** lets you enter a new secret, and **Keep saved** cancels that replacement. Provider secrets — the OIDC client secret, and SAML signing and decryption keys — are encrypted with `ENCRYPTION_KEY` before they are stored, so a copy of the database alone does not expose them. Select **Update** to save the provider, or **Discard** to abandon changes.
105105

106-
**Advanced options** contains OIDC scopes and optional authorization, token, and JWKS endpoint overrides. For SAML, it contains Audience, Callback URL override, signed-assertion requirements, NameID format, and optional IdP metadata XML. **Attribute mapping** lets either protocol override the email, name, and stable user-ID claim names. Leave a mapping blank to use the protocol default.
106+
**Advanced options** contains OIDC scopes and optional authorization, token, and JWKS endpoint overrides. For SAML, it contains Audience, Callback URL override, signed-assertion requirements, encrypted assertions, NameID format, and optional IdP metadata XML. **Attribute mapping** lets either protocol override the email, name, and stable user-ID claim names. Leave a mapping blank to use the protocol default.
107+
108+
### Encrypted assertions
109+
110+
Turn on **Encrypt SAML assertions** when your identity provider encrypts the assertion, which some organizations require for assertions carrying personal data. It takes a key pair you generate:
111+
112+
- **Service provider certificate** — the public half. Sim publishes it in its service provider metadata, and you upload it to the identity provider as the encryption certificate.
113+
- **Service provider private key** — the half Sim decrypts with. It is encrypted with `ENCRYPTION_KEY` before it is stored, and the form shows only a mask afterwards; **Replace** takes a new key.
114+
115+
Generate a pair with `openssl req -x509 -newkey rsa:2048 -keyout sp-key.pem -out sp-cert.pem -days 3650 -nodes`. Turning the setting off clears the stored key.
116+
117+
<Callout type="info">
118+
Signing the authentication request Sim sends is not supported. Identity providers that
119+
can require signed requests — Entra ID's **Require verification certificates**, for
120+
example — must leave that off for Sim's application, which is their default.
121+
</Callout>
107122

108123
SCIM settings save immediately in the **Provisioning** tab. Its **Disable just-in-time provisioning** rule overrides Automatic first-sign-in membership while the connection is active and entitled. Existing members can still sign in. See [directory provisioning](/platform/enterprise/scim#provisioning-and-sso-together).
109124

@@ -417,48 +432,4 @@ SSO_TRUSTED_PROVIDER_IDS=custom-oidc,partner-saml
417432
depend on your IdP asserting `email_verified`.
418433
</Callout>
419434

420-
You can register providers through the **Settings UI** (same as cloud) or by running the registration script directly against your database.
421-
422-
### Script-based registration
423-
424-
Use this when you need to register an SSO provider without going through the UI — for example, during initial deployment or CI/CD automation.
425-
426-
```bash
427-
# OIDC example (Okta)
428-
SSO_ENABLED=true \
429-
NEXT_PUBLIC_APP_URL=https://your-instance.com \
430-
SSO_PROVIDER_TYPE=oidc \
431-
SSO_PROVIDER_ID=okta \
432-
SSO_ISSUER=https://dev-1234567.okta.com \
433-
SSO_DOMAIN=company.com \
434-
SSO_USER_EMAIL=admin@company.com \
435-
SSO_OIDC_CLIENT_ID=your-client-id \
436-
SSO_OIDC_CLIENT_SECRET=your-client-secret \
437-
bun run packages/db/scripts/register-sso-provider.ts
438-
```
439-
440-
```bash
441-
# SAML example (ADFS)
442-
SSO_ENABLED=true \
443-
NEXT_PUBLIC_APP_URL=https://your-instance.com \
444-
SSO_PROVIDER_TYPE=saml \
445-
SSO_PROVIDER_ID=adfs \
446-
SSO_ISSUER=https://adfs.company.com/adfs/services/trust \
447-
SSO_SAML_AUDIENCE=https://your-instance.com \
448-
SSO_DOMAIN=company.com \
449-
SSO_USER_EMAIL=admin@company.com \
450-
SSO_SAML_ENTRY_POINT=https://adfs.company.com/adfs/ls \
451-
SSO_SAML_CERT="-----BEGIN CERTIFICATE-----
452-
...
453-
-----END CERTIFICATE-----" \
454-
bun run packages/db/scripts/register-sso-provider.ts
455-
```
456-
457-
The script outputs the callback URL to configure in your IdP once it completes.
458-
459-
To remove a provider:
460-
461-
```bash
462-
SSO_USER_EMAIL=admin@company.com \
463-
bun run packages/db/scripts/deregister-sso-provider.ts
464-
```
435+
Register providers through the **Settings UI**, the same flow as cloud. Verify the email domain first: a provider is only saved against a domain the organization has verified, which is what authorizes it to sign people in.

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

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,11 @@ import { listSsoProvidersContract } from '@/lib/api/contracts/auth'
1111
import { parseRequest } from '@/lib/api/server'
1212
import { getSession } from '@/lib/auth'
1313
import { markSignInProviders } from '@/lib/auth/sso/primary-provider'
14-
import { decryptProviderConfig } from '@/lib/auth/sso-provider-secrets'
14+
import {
15+
decryptProviderConfig,
16+
resolveHolder,
17+
SECRET_FIELDS,
18+
} from '@/lib/auth/sso-provider-secrets'
1519
import { REDACTED_MARKER } from '@/lib/core/security/redaction'
1620
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1721

@@ -58,17 +62,23 @@ async function redactOidcConfig(oidcConfig: string | null): Promise<string | nul
5862
}
5963

6064
/**
61-
* Drops the SAML key material an admin never needs back. Unlike the OIDC secret
62-
* these carry no hint: they are the service provider's own signing and
63-
* decryption keys, they are only ever set by the operator registration script,
64-
* and the settings form does not read them.
65+
* Drops the SAML key material an admin never needs back: the service provider's
66+
* own signing and decryption keys. Unlike the OIDC secret these carry no hint —
67+
* the admin holds the key pair already, and the certificate half stays readable.
68+
* The same field list drives encryption at rest, so the two cannot drift.
6569
*/
6670
function redactSamlConfig(samlConfig: string | null): string | null {
6771
if (!samlConfig) return samlConfig
6872
try {
6973
const parsed = JSON.parse(samlConfig)
70-
for (const field of ['privateKey', 'decryptionPvk']) {
71-
if (typeof parsed[field] === 'string' && parsed[field] !== '') parsed[field] = REDACTED_MARKER
74+
if (!parsed || typeof parsed !== 'object') return null
75+
for (const path of SECRET_FIELDS.samlConfig) {
76+
const holder = resolveHolder(parsed, path)
77+
if (!holder) continue
78+
const field = path[path.length - 1]
79+
if (typeof holder[field] === 'string' && holder[field] !== '') {
80+
holder[field] = REDACTED_MARKER
81+
}
7282
}
7383
return JSON.stringify(parsed)
7484
} catch {

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

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,155 @@ describe('POST /api/auth/sso/register', () => {
345345
expect(mockDecryptSecret).not.toHaveBeenCalled()
346346
})
347347

348+
it.each([
349+
['no client secret', JSON.stringify({ clientId: 'client' })],
350+
['an empty client secret', JSON.stringify({ clientId: 'client', clientSecret: '' })],
351+
])('refuses to reuse a stored config with %s', async (_label, oidcConfig) => {
352+
queueMembers([{ organizationId: 'org1', role: 'owner' }])
353+
queueTableRows(schemaMock.ssoProvider, [])
354+
queueTableRows(schemaMock.ssoProvider, [])
355+
queueTableRows(schemaMock.ssoProvider, [{ oidcConfig }])
356+
357+
const res = await POST(request({ ...OIDC_BODY, clientSecret: '[REDACTED]' }))
358+
359+
expect(res.status).toBe(400)
360+
await expect(res.json()).resolves.toMatchObject({
361+
error: expect.stringContaining('Re-enter your client secret'),
362+
})
363+
expect(mockUpdateSSOProvider).not.toHaveBeenCalled()
364+
})
365+
366+
describe('SAML encrypted assertions', () => {
367+
const SP_CERT = `-----BEGIN CERTIFICATE-----\nQUJD\n-----END CERTIFICATE-----`
368+
const SP_KEY = `-----BEGIN PRIVATE KEY-----\nREVG\n-----END PRIVATE KEY-----`
369+
const samlBody = (overrides: Record<string, unknown> = {}) => ({
370+
providerType: 'saml' as const,
371+
providerId: 'acme-saml',
372+
issuer: 'https://idp.acme.com',
373+
domain: 'acme.com',
374+
orgId: 'org1',
375+
entryPoint: 'https://idp.acme.com/sso',
376+
cert: 'IDP-CERT',
377+
...overrides,
378+
})
379+
380+
it('publishes the certificate and keeps the private key for decryption', async () => {
381+
queueMembers([{ organizationId: 'org1', role: 'owner' }])
382+
queueProviders([])
383+
384+
const res = await POST(
385+
request(
386+
samlBody({ encryptAssertions: true, spEncryptionCert: SP_CERT, spDecryptionKey: SP_KEY })
387+
)
388+
)
389+
390+
expect(res.status).toBe(200)
391+
const { samlConfig } = mockRegisterSSOProvider.mock.calls[0][0].body
392+
expect(samlConfig.spMetadata).toMatchObject({
393+
isAssertionEncrypted: true,
394+
encPrivateKey: SP_KEY,
395+
encryptionCert: SP_CERT,
396+
})
397+
/** The certificate travels in the metadata document, stripped of its PEM armor. */
398+
expect(samlConfig.spMetadata.metadata).toContain('use="encryption"')
399+
expect(samlConfig.spMetadata.metadata).toContain('QUJD')
400+
expect(samlConfig.spMetadata.metadata).not.toContain('BEGIN CERTIFICATE')
401+
expect(samlConfig.spMetadata.metadata).not.toContain('REVG')
402+
})
403+
404+
it('leaves the metadata and key material alone when encryption is off', async () => {
405+
queueMembers([{ organizationId: 'org1', role: 'owner' }])
406+
queueProviders([])
407+
408+
const res = await POST(request(samlBody()))
409+
410+
expect(res.status).toBe(200)
411+
const { samlConfig } = mockRegisterSSOProvider.mock.calls[0][0].body
412+
expect(samlConfig.spMetadata).not.toHaveProperty('encPrivateKey')
413+
expect(samlConfig.spMetadata).not.toHaveProperty('isAssertionEncrypted')
414+
expect(samlConfig.spMetadata).not.toHaveProperty('encryptionCert')
415+
expect(samlConfig.spMetadata.metadata).not.toContain('use="encryption"')
416+
})
417+
418+
it.each([
419+
['no certificate', { spDecryptionKey: SP_KEY }],
420+
['no private key', { spEncryptionCert: SP_CERT }],
421+
])('refuses to enable encryption with %s', async (_label, overrides) => {
422+
queueMembers([{ organizationId: 'org1', role: 'owner' }])
423+
queueProviders([])
424+
425+
const res = await POST(request(samlBody({ encryptAssertions: true, ...overrides })))
426+
427+
expect(res.status).toBe(400)
428+
expect(mockRegisterSSOProvider).not.toHaveBeenCalled()
429+
})
430+
431+
it.each([
432+
[
433+
'a certificate that is not PEM',
434+
{ spEncryptionCert: 'not-a-cert', spDecryptionKey: SP_KEY },
435+
],
436+
['a private key that is not PEM', { spEncryptionCert: SP_CERT, spDecryptionKey: 'nope' }],
437+
])('refuses %s', async (_label, overrides) => {
438+
queueMembers([{ organizationId: 'org1', role: 'owner' }])
439+
queueProviders([])
440+
441+
const res = await POST(request(samlBody({ encryptAssertions: true, ...overrides })))
442+
443+
expect(res.status).toBe(400)
444+
await expect(res.json()).resolves.toMatchObject({ error: expect.stringContaining('PEM') })
445+
expect(mockRegisterSSOProvider).not.toHaveBeenCalled()
446+
})
447+
448+
it('keeps the stored private key when the update sends the marker', async () => {
449+
queueMembers([{ organizationId: 'org1', role: 'owner' }])
450+
queueTableRows(schemaMock.ssoProvider, [])
451+
queueTableRows(schemaMock.ssoProvider, [])
452+
queueTableRows(schemaMock.ssoProvider, [
453+
{ samlConfig: JSON.stringify({ spMetadata: { encPrivateKey: SP_KEY } }) },
454+
])
455+
queueTableRows(schemaMock.ssoProvider, [])
456+
queueTableRows(schemaMock.ssoProvider, [])
457+
queueTableRows(schemaMock.ssoProvider, [{ id: 'p1' }])
458+
459+
const res = await POST(
460+
request(
461+
samlBody({
462+
encryptAssertions: true,
463+
spEncryptionCert: SP_CERT,
464+
spDecryptionKey: '[REDACTED]',
465+
})
466+
)
467+
)
468+
469+
expect(res.status).toBe(200)
470+
const { samlConfig } = mockUpdateSSOProvider.mock.calls[0][0].body
471+
expect(samlConfig.spMetadata.encPrivateKey).toBe(SP_KEY)
472+
})
473+
474+
it('refuses the marker when no key is stored', async () => {
475+
queueMembers([{ organizationId: 'org1', role: 'owner' }])
476+
queueTableRows(schemaMock.ssoProvider, [])
477+
queueTableRows(schemaMock.ssoProvider, [])
478+
queueTableRows(schemaMock.ssoProvider, [{ samlConfig: JSON.stringify({ spMetadata: {} }) }])
479+
480+
const res = await POST(
481+
request(
482+
samlBody({
483+
encryptAssertions: true,
484+
spEncryptionCert: SP_CERT,
485+
spDecryptionKey: '[REDACTED]',
486+
})
487+
)
488+
)
489+
490+
expect(res.status).toBe(400)
491+
await expect(res.json()).resolves.toMatchObject({
492+
error: expect.stringContaining('no stored service provider private key'),
493+
})
494+
})
495+
})
496+
348497
/** updateSSOProvider resets domainVerified to false whenever the domain changes. */
349498
it('re-marks the provider domain-verified after an update', async () => {
350499
queueMembers([{ organizationId: 'org1', role: 'owner' }])

0 commit comments

Comments
 (0)