Skip to content

Commit 0850719

Browse files
committed
fix(sso): encrypt SSO provider secrets at rest
The OIDC client secret, and the SAML signing and decryption keys, were stored as plain JSON in sso_provider while every sibling credential is encrypted with ENCRYPTION_KEY, so a copy of the database exposed them without the key. They are now encrypted field-by-field at the Better Auth adapter, which is the only seam the SSO plugin's reads and writes both pass through; the surrounding config stays readable JSON. Values written before this keep working and are encrypted the next time the provider is saved. The providers API also redacts the SAML key material it used to return in full.
1 parent c45e8b5 commit 0850719

19 files changed

Lines changed: 917 additions & 32 deletions

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ Click **Save**. To test, sign out and use the **Sign in with SSO** button on the
101101

102102
## Editing and advanced configuration
103103

104-
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. Select **Update** to save the provider, or **Discard** to abandon changes.
104+
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

106106
**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.
107107

‎apps/docs/content/docs/platform/self-hosting/architecture.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ Three places once the deployment is configured for production. Everything else i
7373
</Callout>
7474

7575
<Callout type="error">
76-
`ENCRYPTION_KEY` is not recoverable and not derivable. It encrypts workspace and personal environment variables, stored provider API keys, MCP OAuth credentials, and deployment/chat secrets at rest — a database restore paired with a *different* key yields a working app in which none of that can be decrypted. Back it up separately from the database, and never rotate it casually.
76+
`ENCRYPTION_KEY` is not recoverable and not derivable. It encrypts workspace and personal environment variables, stored provider API keys, MCP OAuth credentials, deployment/chat secrets, and SSO provider secrets at rest — a database restore paired with a *different* key yields a working app in which none of that can be decrypted. Back it up separately from the database, and never rotate it casually.
7777
</Callout>
7878

7979
Redis is a cache and message bus. Losing it drops in-flight live updates; it does not lose committed data.

‎apps/docs/content/docs/platform/self-hosting/docker.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ EOF
4848
</Callout>
4949

5050
<Callout type="error">
51-
Save `ENCRYPTION_KEY` and `API_ENCRYPTION_KEY` somewhere outside this server. `ENCRYPTION_KEY` encrypts workspace and personal environment variables, stored provider API keys, MCP OAuth credentials, and deployment/chat secrets; `API_ENCRYPTION_KEY` encrypts user-generated Sim API keys. Neither can be regenerated — a database restore paired with a different key leaves the data it protected permanently unreadable.
51+
Save `ENCRYPTION_KEY` and `API_ENCRYPTION_KEY` somewhere outside this server. `ENCRYPTION_KEY` encrypts workspace and personal environment variables, stored provider API keys, MCP OAuth credentials, deployment/chat secrets, and SSO provider secrets; `API_ENCRYPTION_KEY` encrypts user-generated Sim API keys. Neither can be regenerated — a database restore paired with a different key leaves the data it protected permanently unreadable.
5252
</Callout>
5353

5454
The compose file refuses to start if `BETTER_AUTH_SECRET`, `ENCRYPTION_KEY`, `INTERNAL_API_SECRET`, or `POSTGRES_PASSWORD` is missing, rather than booting with empty or well-known values. Postgres applies `POSTGRES_PASSWORD` only when it first creates the database volume — see [Postgres on Compose](/platform/self-hosting/security#postgres-on-compose) before changing it on an existing install. `CRON_SECRET` is treated more gently: without it the `cron` service prints what to set and exits, leaving the rest of the stack running — so upgrading from a compose file that predates the scheduler still works.

‎apps/docs/content/docs/platform/self-hosting/environment-variables.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { Callout } from 'fumadocs-ui/components/callout'
1919
`openssl rand -hex 32` prints 64 hex characters. `ENCRYPTION_KEY` and `API_ENCRYPTION_KEY` must be exactly that — a value of any other shape throws the first time Sim encrypts or decrypts, not at startup. The rest are secrets of no fixed shape and only need 32 characters or more. The Sim app never checks — it runs its env schema with validation skipped — but the realtime service validates `BETTER_AUTH_SECRET` and `INTERNAL_API_SECRET` at boot and refuses to start if either is shorter.
2020

2121
<Callout type="error">
22-
`ENCRYPTION_KEY` and `API_ENCRYPTION_KEY` cannot be rotated or recovered. Losing either makes the data it protects permanently unreadable — workspace and personal environment variables, stored provider API keys, MCP OAuth credentials, and deployment/chat secrets in the first case, user-generated Sim API keys in the second. Back them up separately from the database.
22+
`ENCRYPTION_KEY` and `API_ENCRYPTION_KEY` cannot be rotated or recovered. Losing either makes the data it protects permanently unreadable — workspace and personal environment variables, stored provider API keys, MCP OAuth credentials, deployment/chat secrets, and SSO provider secrets in the first case, user-generated Sim API keys in the second. Back them up separately from the database.
2323
</Callout>
2424

2525
## Strongly recommended

‎apps/docs/content/docs/platform/self-hosting/kubernetes.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ helm install sim oci://ghcr.io/simstudioai/charts/sim \
5151
```
5252

5353
<Callout type="warn">
54-
Save all six values somewhere durable before moving on. `ENCRYPTION_KEY` in particular cannot be regenerated — losing it makes workspace environment variables and stored provider keys permanently unreadable.
54+
Save all six values somewhere durable before moving on. `ENCRYPTION_KEY` in particular cannot be regenerated — losing it makes workspace environment variables, stored provider keys, and SSO provider secrets permanently unreadable.
5555

5656
`API_ENCRYPTION_KEY` is optional, and the failure mode is silent: leave it unset and Sim stores user-generated API keys **in plain text**, logging one warning and nothing else. Set it at install time — it must be a 64-character hex string, which is exactly what `openssl rand -hex 32` produces — and back it up like `ENCRYPTION_KEY`.
5757

@@ -102,7 +102,7 @@ Signing is Sigstore-only — there is no GPG `.prov` file, so `helm install --ve
102102

103103
## Cloud-Specific Values
104104

105-
These are cloud-tuned **alternatives** to the generic install above — pick one path, don't run both. The commands reuse the `$BETTER_AUTH_SECRET`, `$ENCRYPTION_KEY`, `$INTERNAL_API_SECRET`, `$API_ENCRYPTION_KEY`, `$CRON_SECRET`, and `$POSTGRES_PASSWORD` variables generated in [Installation](#installation) above, so run that block's `openssl` lines first in the same shell. They use `helm upgrade --install`, so they work whether or not a release exists yet. Two caveats when converting an existing generic install rather than starting fresh: (1) **reuse the original secret values** — recover them with `helm get values sim -n simstudio` if your shell no longer has them; supplying a newly generated `ENCRYPTION_KEY` makes every previously encrypted value (workspace environment variables, stored provider keys, MCP OAuth credentials) undecryptable. (2) The cloud values rename the bundled PostgreSQL database to `simstudio`, but Postgres only applies that setting on first initialization — add `--set postgresql.auth.database=sim` to keep your existing database. If you'd rather start clean, `helm uninstall sim -n simstudio`, delete its PVCs, and run the cloud command fresh.
105+
These are cloud-tuned **alternatives** to the generic install above — pick one path, don't run both. The commands reuse the `$BETTER_AUTH_SECRET`, `$ENCRYPTION_KEY`, `$INTERNAL_API_SECRET`, `$API_ENCRYPTION_KEY`, `$CRON_SECRET`, and `$POSTGRES_PASSWORD` variables generated in [Installation](#installation) above, so run that block's `openssl` lines first in the same shell. They use `helm upgrade --install`, so they work whether or not a release exists yet. Two caveats when converting an existing generic install rather than starting fresh: (1) **reuse the original secret values** — recover them with `helm get values sim -n simstudio` if your shell no longer has them; supplying a newly generated `ENCRYPTION_KEY` makes every previously encrypted value (workspace environment variables, stored provider keys, MCP OAuth credentials, SSO provider secrets) undecryptable. (2) The cloud values rename the bundled PostgreSQL database to `simstudio`, but Postgres only applies that setting on first initialization — add `--set postgresql.auth.database=sim` to keep your existing database. If you'd rather start clean, `helm uninstall sim -n simstudio`, delete its PVCs, and run the cloud command fresh.
106106

107107
```bash
108108
# The example values files are not part of the packaged chart, so fetch the one

‎apps/docs/content/docs/platform/self-hosting/security.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ Five secrets drive the security of a deployment. Generate each with `openssl ran
1313
| Secret | Protects | Rotatable |
1414
|---|---|---|
1515
| `BETTER_AUTH_SECRET` | Session tokens | Yes — invalidates all sessions |
16-
| `ENCRYPTION_KEY` | Workspace env vars, stored provider keys, MCP OAuth credentials, deployment/chat secrets | **No** — see below |
16+
| `ENCRYPTION_KEY` | Workspace env vars, stored provider keys, MCP OAuth credentials, deployment/chat secrets, SSO provider secrets | **No** — see below |
1717
| `API_ENCRYPTION_KEY` | Reversible stored copy of user-generated API keys | **No** — existing keys keep authenticating, but their stored copy can no longer be displayed |
1818
| `INTERNAL_API_SECRET` | Service-to-service calls | Yes — roll app and realtime together |
1919
| `CRON_SECRET` | Background job endpoints | Yes — roll app and cron together |

‎apps/docs/content/docs/platform/self-hosting/troubleshooting.mdx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,8 @@ A document that fails with `vector 0 has N unexpected dimensions` means `EMBEDDI
258258

259259
Integrations show as connected but fail, or provider keys error on decrypt. `ENCRYPTION_KEY` does not match the value in use when the backup was taken. There is no recovery — the original key must be restored.
260260

261+
SSO sign-in fails the same way, since provider secrets are encrypted with the same key. A provider whose secret cannot be decrypted refuses the sign-in rather than sending an unusable secret to the identity provider; re-enter the client secret in organization settings once the correct key is in place.
262+
261263
## Kubernetes: App Pods Never Become Ready
262264

263265
Check the migrations init container first — a failed migration deliberately blocks the rollout:

‎apps/docs/content/docs/platform/self-hosting/upgrades.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,7 @@ There is a short window where the app is unavailable while containers restart. C
233233

234234
### Verify
235235

236-
Run the [verification checklist](/platform/self-hosting/verify). At minimum: sign in, open a workflow, execute it, upload a file, and confirm the [background jobs](/platform/self-hosting/background-jobs) are still firing.
236+
Run the [verification checklist](/platform/self-hosting/verify). At minimum: sign in, open a workflow, execute it, upload a file, and confirm the [background jobs](/platform/self-hosting/background-jobs) are still firing. If the deployment uses [SSO](/platform/enterprise/sso), complete one SSO sign-in too — provider secrets are encrypted with `ENCRYPTION_KEY`, so a key that does not match the one they were saved under surfaces here.
237237

238238
</Step>
239239

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

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,19 +11,33 @@ import {
1111
} from '@sim/testing'
1212
import { beforeEach, describe, expect, it, vi } from 'vitest'
1313

14-
const { mockGetSession } = vi.hoisted(() => ({ mockGetSession: vi.fn() }))
14+
const { mockGetSession, mockDecryptSecret } = vi.hoisted(() => ({
15+
mockGetSession: vi.fn(),
16+
mockDecryptSecret: vi.fn(),
17+
}))
1518

1619
vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock }))
1720
vi.mock('@/lib/auth', () => ({ getSession: mockGetSession }))
21+
/** The shared env mock's ENCRYPTION_KEY is not 64 hex characters, so real crypto would throw. */
22+
vi.mock('@/lib/core/security/encryption', () => ({
23+
encryptSecret: vi.fn(),
24+
decryptSecret: mockDecryptSecret,
25+
}))
1826

1927
import { GET } from '@/app/api/auth/sso/providers/route'
2028

29+
const IV = 'a'.repeat(32)
30+
const TAG = 'b'.repeat(32)
31+
const sealed = (secret: string) => `${IV}:${Buffer.from(secret).toString('hex')}:${TAG}`
32+
33+
const CLIENT_SECRET = 'a-long-client-secret-wxyz'
34+
2135
const providerRow = {
2236
id: 'row-1',
2337
providerId: 'acme-okta',
2438
domain: 'acme.com',
2539
issuer: 'https://acme.okta.test',
26-
oidcConfig: JSON.stringify({ clientId: 'client', clientSecret: 'a-long-client-secret-wxyz' }),
40+
oidcConfig: JSON.stringify({ clientId: 'client', clientSecret: sealed(CLIENT_SECRET) }),
2741
samlConfig: null,
2842
userId: 'user-1',
2943
organizationId: 'org-1',
@@ -38,6 +52,9 @@ describe('GET /api/auth/sso/providers', () => {
3852
vi.clearAllMocks()
3953
resetDbChainMock()
4054
mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
55+
mockDecryptSecret.mockImplementation(async (value: string) => ({
56+
decrypted: Buffer.from(value.split(':')[1], 'hex').toString('utf8'),
57+
}))
4158
})
4259

4360
it('refuses a caller without a session before reading any provider', async () => {
@@ -56,10 +73,55 @@ describe('GET /api/auth/sso/providers', () => {
5673
expect(providers[0]).toMatchObject({ providerId: 'acme-okta', providerType: 'oidc' })
5774
expect(JSON.parse(providers[0].oidcConfig)).toMatchObject({ clientSecretHint: 'wxyz' })
5875
expect(providers[0].oidcConfig).not.toContain('a-long-client-secret')
76+
expect(providers[0].oidcConfig).not.toContain(sealed(CLIENT_SECRET))
5977
const condition = JSON.stringify(dbChainMockFns.where.mock.calls[0][0])
6078
expect(condition).toContain('user-1')
6179
})
6280

81+
it('hints a secret stored before encryption existed', async () => {
82+
queueTableRows(schemaMock.ssoProvider, [
83+
{
84+
...providerRow,
85+
oidcConfig: JSON.stringify({ clientId: 'client', clientSecret: CLIENT_SECRET }),
86+
},
87+
])
88+
89+
const res = await GET(createMockRequest('GET'))
90+
91+
const { providers } = await res.json()
92+
expect(JSON.parse(providers[0].oidcConfig)).toMatchObject({ clientSecretHint: 'wxyz' })
93+
expect(providers[0].oidcConfig).not.toContain(CLIENT_SECRET)
94+
expect(mockDecryptSecret).not.toHaveBeenCalled()
95+
})
96+
97+
it('redacts SAML key material and keeps the certificate', async () => {
98+
queueTableRows(schemaMock.ssoProvider, [
99+
{
100+
...providerRow,
101+
oidcConfig: null,
102+
samlConfig: JSON.stringify({
103+
cert: 'public-cert',
104+
entryPoint: 'https://acme.okta.test/sso',
105+
privateKey: sealed('sp-signing-key'),
106+
decryptionPvk: sealed('sp-decryption-key'),
107+
}),
108+
},
109+
])
110+
111+
const res = await GET(createMockRequest('GET'))
112+
113+
const { providers } = await res.json()
114+
const samlConfig = JSON.parse(providers[0].samlConfig)
115+
expect(samlConfig).toMatchObject({
116+
cert: 'public-cert',
117+
entryPoint: 'https://acme.okta.test/sso',
118+
privateKey: '[REDACTED]',
119+
decryptionPvk: '[REDACTED]',
120+
})
121+
expect(providers[0].samlConfig).not.toContain('sp-signing-key')
122+
expect(providers[0].providerType).toBe('saml')
123+
})
124+
63125
it('refuses an organization the caller does not administer', async () => {
64126
queueTableRows(schemaMock.member, [{ organizationId: 'org-1', role: 'member' }])
65127
const res = await GET(

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

Lines changed: 45 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ 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'
1415
import { REDACTED_MARKER } from '@/lib/core/security/redaction'
1516
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1617

@@ -31,6 +32,44 @@ function buildClientSecretHint(clientSecret: unknown): string | null {
3132
return clientSecret.slice(-4)
3233
}
3334

35+
/**
36+
* Replaces the stored client secret with the redaction marker, keeping a hint
37+
* built from the real secret. The stored value is decrypted first: a hint taken
38+
* from the envelope would be four characters of the auth tag, which says nothing
39+
* about the secret and changes every time the row is rewritten.
40+
*/
41+
async function redactOidcConfig(oidcConfig: string | null): Promise<string | null> {
42+
if (!oidcConfig) return oidcConfig
43+
try {
44+
const parsed = JSON.parse((await decryptProviderConfig(oidcConfig, 'oidcConfig')) as string)
45+
const hint = buildClientSecretHint(parsed.clientSecret)
46+
parsed.clientSecret = REDACTED_MARKER
47+
if (hint) parsed.clientSecretHint = hint
48+
return JSON.stringify(parsed)
49+
} catch {
50+
return null
51+
}
52+
}
53+
54+
/**
55+
* Drops the SAML key material an admin never needs back. Unlike the OIDC secret
56+
* these carry no hint: they are the service provider's own signing and
57+
* decryption keys, they are only ever set by the operator registration script,
58+
* and the settings form does not read them.
59+
*/
60+
function redactSamlConfig(samlConfig: string | null): string | null {
61+
if (!samlConfig) return samlConfig
62+
try {
63+
const parsed = JSON.parse(samlConfig)
64+
for (const field of ['privateKey', 'decryptionPvk']) {
65+
if (typeof parsed[field] === 'string' && parsed[field] !== '') parsed[field] = REDACTED_MARKER
66+
}
67+
return JSON.stringify(parsed)
68+
} catch {
69+
return null
70+
}
71+
}
72+
3473
/**
3574
* Lists the identity providers the caller administers: an organization's when an
3675
* owner or admin names it, otherwise the ones the caller registered.
@@ -90,25 +129,14 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
90129
.where(whereClause)
91130
.orderBy(asc(ssoProvider.providerId))
92131

93-
const providers = markSignInProviders(results).map((provider) => {
94-
let oidcConfig = provider.oidcConfig
95-
if (oidcConfig) {
96-
try {
97-
const parsed = JSON.parse(oidcConfig)
98-
const hint = buildClientSecretHint(parsed.clientSecret)
99-
parsed.clientSecret = REDACTED_MARKER
100-
if (hint) parsed.clientSecretHint = hint
101-
oidcConfig = JSON.stringify(parsed)
102-
} catch {
103-
oidcConfig = null
104-
}
105-
}
106-
return {
132+
const providers = await Promise.all(
133+
markSignInProviders(results).map(async (provider) => ({
107134
...provider,
108-
oidcConfig,
135+
oidcConfig: await redactOidcConfig(provider.oidcConfig),
136+
samlConfig: redactSamlConfig(provider.samlConfig),
109137
providerType: (provider.samlConfig ? 'saml' : 'oidc') as 'oidc' | 'saml',
110-
}
111-
})
138+
}))
139+
)
112140

113141
logger.info('Fetched SSO providers', { userId, providerCount: providers.length })
114142

0 commit comments

Comments
 (0)