Skip to content

Commit 386d7ae

Browse files
committed
fix(sso): recover gracefully from an unreadable saved SAML key
An update that keeps the stored key returned 500 when the key could not be decrypted; it now returns the same kind of 400 the OIDC path does, asking for the key again. Replace gained the Keep saved action its client-secret counterpart has, so opening the field is no longer a one-way door, and the form recognizes a key left flat by the retired registration script, matching the fallback the server already had.
1 parent 277abfe commit 386d7ae

4 files changed

Lines changed: 110 additions & 20 deletions

File tree

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -604,6 +604,36 @@ describe('POST /api/auth/sso/register', () => {
604604
expect(samlConfig.spMetadata.encPrivateKey).toBe(SP_KEY)
605605
})
606606

607+
it('asks for the key again when the stored one cannot be decrypted', async () => {
608+
mockDecryptSecret.mockRejectedValue(new Error('auth tag mismatch'))
609+
queueMembers([{ organizationId: 'org1', role: 'owner' }])
610+
queueTableRows(schemaMock.ssoProvider, [])
611+
queueTableRows(schemaMock.ssoProvider, [])
612+
queueTableRows(schemaMock.ssoProvider, [
613+
{
614+
samlConfig: JSON.stringify({
615+
spMetadata: { encPrivateKey: `sim.sso.v1:${'a'.repeat(32)}:dead:${'b'.repeat(32)}` },
616+
}),
617+
},
618+
])
619+
620+
const res = await POST(
621+
request(
622+
samlBody({
623+
encryptAssertions: true,
624+
spEncryptionCert: SP_CERT,
625+
spDecryptionKey: '[REDACTED]',
626+
})
627+
)
628+
)
629+
630+
/** A key the app can no longer read is an operator action, not a server fault. */
631+
expect(res.status).toBe(400)
632+
await expect(res.json()).resolves.toMatchObject({
633+
error: expect.stringContaining('Re-enter it'),
634+
})
635+
})
636+
607637
it('refuses the marker when no key is stored', async () => {
608638
queueMembers([{ organizationId: 'org1', role: 'owner' }])
609639
queueTableRows(schemaMock.ssoProvider, [])

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

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -615,9 +615,23 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
615615
.from(ssoProvider)
616616
.where(ownerClause)
617617
.limit(1)
618-
const storedKey = existing?.samlConfig
619-
? readStoredDecryptionKey(await decryptProviderConfig(existing.samlConfig, 'samlConfig'))
620-
: null
618+
let storedKey: string | null = null
619+
if (existing?.samlConfig) {
620+
try {
621+
storedKey = readStoredDecryptionKey(
622+
await decryptProviderConfig(existing.samlConfig, 'samlConfig')
623+
)
624+
} catch {
625+
/** A key the app can no longer read is re-entered, not a 500. */
626+
return NextResponse.json(
627+
{
628+
error:
629+
'Cannot update: failed to read the saved service provider private key. Re-enter it.',
630+
},
631+
{ status: 400 }
632+
)
633+
}
634+
}
621635
if (!storedKey) {
622636
return NextResponse.json(
623637
{

‎apps/sim/ee/sso/components/sso-provider-settings.tsx‎

Lines changed: 33 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,9 @@ function hasStoredSpDecryptionKey(samlConfig: string | null | undefined): boolea
170170
const config: unknown = JSON.parse(samlConfig)
171171
if (!isRecordLike(config)) return false
172172
const spMetadata = config.spMetadata
173-
return isRecordLike(spMetadata) && typeof spMetadata.encPrivateKey === 'string'
173+
if (isRecordLike(spMetadata) && typeof spMetadata.encPrivateKey === 'string') return true
174+
/** Rows from the retired registration script kept the key flat; the server reuses it too. */
175+
return typeof config.decryptionPvk === 'string'
174176
} catch {
175177
return false
176178
}
@@ -1191,22 +1193,36 @@ export function SsoProviderSettings({
11911193
</Chip>
11921194
</div>
11931195
) : (
1194-
<ChipTextarea
1195-
id='sso-sp-decryption-key'
1196-
placeholder={
1197-
'-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----'
1198-
}
1199-
value={formData.spDecryptionKey}
1200-
autoComplete='off'
1201-
autoCapitalize='none'
1202-
spellCheck={false}
1203-
onChange={(e) =>
1204-
handleInputChange('spDecryptionKey', e.target.value)
1205-
}
1206-
className='min-h-20'
1207-
error={showErrors && errors.spDecryptionKey?.length > 0}
1208-
rows={3}
1209-
/>
1196+
<div className='flex flex-col gap-2'>
1197+
<ChipTextarea
1198+
id='sso-sp-decryption-key'
1199+
placeholder={
1200+
'-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----'
1201+
}
1202+
value={formData.spDecryptionKey}
1203+
autoComplete='off'
1204+
autoCapitalize='none'
1205+
spellCheck={false}
1206+
onChange={(e) =>
1207+
handleInputChange('spDecryptionKey', e.target.value)
1208+
}
1209+
className='min-h-20'
1210+
error={showErrors && errors.spDecryptionKey?.length > 0}
1211+
rows={3}
1212+
/>
1213+
{/** The pair to Replace, as on the client secret: put the saved key back. */}
1214+
{hasStoredDecryptionKey && (
1215+
<Chip
1216+
className='w-fit'
1217+
onClick={() => {
1218+
setIsReplacingDecryptionKey(false)
1219+
handleInputChange('spDecryptionKey', '')
1220+
}}
1221+
>
1222+
Keep saved
1223+
</Chip>
1224+
)}
1225+
</div>
12101226
)}
12111227
</SettingRow>
12121228
</>

‎apps/sim/ee/sso/components/sso-settings.test.tsx‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -743,6 +743,36 @@ describe('SAML encrypted assertions', () => {
743743
expect(container.querySelector('#sso-sp-decryption-key')).not.toBeNull()
744744
})
745745

746+
it('offers to keep the saved key after choosing Replace', () => {
747+
editSaml(
748+
JSON.stringify({
749+
entryPoint: 'https://idp.test/sso',
750+
cert: 'IDP',
751+
spMetadata: { isAssertionEncrypted: true, encPrivateKey: '[REDACTED]' },
752+
})
753+
)
754+
755+
act(() => findButton('Replace')?.click())
756+
757+
expect(container.querySelector('#sso-sp-decryption-key')).not.toBeNull()
758+
expect(findButton('Keep saved')).toBeDefined()
759+
})
760+
761+
it('recognizes a key stored by the retired registration script', () => {
762+
editSaml(
763+
JSON.stringify({
764+
entryPoint: 'https://idp.test/sso',
765+
cert: 'IDP',
766+
decryptionPvk: '[REDACTED]',
767+
spMetadata: { isAssertionEncrypted: true },
768+
})
769+
)
770+
771+
/** Masked rather than demanding a fresh paste, because the server reuses that key. */
772+
const keyField = container.querySelector<HTMLInputElement>('#sso-sp-decryption-key')
773+
expect(keyField?.value).toMatch(/^•+$/)
774+
})
775+
746776
it('shows the saved certificate and masks the stored private key', () => {
747777
const cert = '-----BEGIN CERTIFICATE-----\nQUJD\n-----END CERTIFICATE-----'
748778
editSaml(

0 commit comments

Comments
 (0)