Skip to content

Commit 4e35422

Browse files
committed
fix(sso): keep the settings page reachable when a secret cannot be decrypted
Listing providers now reports an undecryptable config as a provider with no config rather than failing the request. The failure is already logged where it happens, and the form stays reachable — it still offers Replace, which is the only way back now that the operator scripts are gone. A 500 there would have taken the page down with no recovery path. Also reworks the client-secret reuse branch to validate after reading rather than throwing into its own catch, and covers SAML body validation now that its contract branch carries a refinement.
1 parent 386d7ae commit 4e35422

4 files changed

Lines changed: 58 additions & 20 deletions

File tree

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

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,14 +94,20 @@ describe('GET /api/auth/sso/providers', () => {
9494
expect(mockDecryptSecret).not.toHaveBeenCalled()
9595
})
9696

97-
it('fails the request when a stored secret cannot be decrypted', async () => {
97+
it('still lists a provider whose secret cannot be decrypted', async () => {
9898
mockDecryptSecret.mockRejectedValue(new Error('auth tag mismatch'))
9999
queueTableRows(schemaMock.ssoProvider, [providerRow])
100100

101101
const res = await GET(createMockRequest('GET'))
102102

103-
/** Reporting a key problem as a provider with no config would hide it behind a 200. */
104-
expect(res.status).toBe(500)
103+
/**
104+
* The settings form stays reachable, which is where an admin replaces the
105+
* secret; the decryption failure is logged rather than 500ing the page.
106+
*/
107+
expect(res.status).toBe(200)
108+
const { providers } = await res.json()
109+
expect(providers).toHaveLength(1)
110+
expect(providers[0]).toMatchObject({ providerId: 'acme-okta', oidcConfig: null })
105111
})
106112

107113
it('redacts SAML key material and keeps the certificate', async () => {

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

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -44,14 +44,16 @@ function buildClientSecretHint(clientSecret: unknown): string | null {
4444
*/
4545
async function redactOidcConfig(oidcConfig: string | null): Promise<string | null> {
4646
if (!oidcConfig) return oidcConfig
47-
/**
48-
* Outside the catch: a config that will not decrypt is a key problem, and
49-
* reporting it as a provider with no config would hide it behind a healthy
50-
* 200. Unreadable JSON stays tolerated below, as it was before.
51-
*/
52-
const decrypted = await decryptProviderConfig(oidcConfig, 'oidcConfig')
5347
try {
54-
const parsed = JSON.parse(decrypted as string)
48+
/**
49+
* A secret that will not decrypt — a lost or rotated `ENCRYPTION_KEY` —
50+
* reports as a provider with no config rather than failing the request.
51+
* `decryptProviderConfig` has already logged the cause, and listing the
52+
* provider is what keeps the settings form reachable: it still offers
53+
* Replace, which is how an admin restores a working secret. A 500 here
54+
* would take the whole page down and leave no way back.
55+
*/
56+
const parsed = JSON.parse((await decryptProviderConfig(oidcConfig, 'oidcConfig')) as string)
5557
const hint = buildClientSecretHint(parsed.clientSecret)
5658
parsed.clientSecret = REDACTED_MARKER
5759
if (hint) parsed.clientSecretHint = hint

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,34 @@ describe('POST /api/auth/sso/register', () => {
381381
expect(mockUpdateSSOProvider).not.toHaveBeenCalled()
382382
})
383383

384+
/** The SAML branch carries a superRefine now; these prove the union still narrows cleanly. */
385+
it.each([
386+
['an empty certificate', { cert: '' }, /Certificate is required for SAML/],
387+
['a malformed entry point', { entryPoint: 'not-a-url' }, /[Ee]ntry point/],
388+
/** A missing field reports the type error; the point is that it narrows to SAML at all. */
389+
['a missing certificate', { cert: undefined }, /expected string/],
390+
])('rejects a SAML body with %s', async (_label, overrides, expected) => {
391+
queueMembers([{ organizationId: 'org1', role: 'owner' }])
392+
queueProviders([])
393+
394+
const res = await POST(
395+
request({
396+
providerType: 'saml',
397+
providerId: 'acme-saml',
398+
issuer: 'https://idp.acme.com',
399+
domain: 'acme.com',
400+
orgId: 'org1',
401+
entryPoint: 'https://idp.acme.com/sso',
402+
cert: 'IDP-CERT',
403+
...overrides,
404+
})
405+
)
406+
407+
expect(res.status).toBe(400)
408+
await expect(res.json()).resolves.toMatchObject({ error: expect.stringMatching(expected) })
409+
expect(mockRegisterSSOProvider).not.toHaveBeenCalled()
410+
})
411+
384412
describe('SAML encrypted assertions', () => {
385413
/**
386414
* Real key material, because the route parses both halves and checks they

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

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -384,26 +384,28 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
384384
{ status: 400 }
385385
)
386386
}
387+
let storedSecret: unknown
387388
try {
388389
const stored = await decryptProviderConfig(existing.oidcConfig, 'oidcConfig')
389-
const storedSecret = JSON.parse(stored as string).clientSecret
390-
/**
391-
* A stored config without a usable secret cannot be reused: letting it
392-
* through would save the provider with no client secret at all, and the
393-
* failure would only appear at the next sign-in.
394-
*/
395-
if (typeof storedSecret !== 'string' || storedSecret === '') {
396-
throw new Error('stored OIDC config has no client secret')
397-
}
398-
clientSecret = storedSecret
390+
storedSecret = JSON.parse(stored as string).clientSecret
399391
} catch {
392+
storedSecret = null
393+
}
394+
395+
/**
396+
* Unreadable, or readable but holding no secret: either way there is
397+
* nothing to carry forward, and saving without one would surface only at
398+
* the next sign-in.
399+
*/
400+
if (typeof storedSecret !== 'string' || storedSecret === '') {
400401
return NextResponse.json(
401402
{
402403
error: 'Cannot update: failed to read existing secret. Re-enter your client secret.',
403404
},
404405
{ status: 400 }
405406
)
406407
}
408+
clientSecret = storedSecret
407409
}
408410

409411
const oidcConfig: any = {

0 commit comments

Comments
 (0)