Skip to content

Commit 6aee268

Browse files
committed
improvement(sso): use the chip switch for the SAML toggles and reuse a legacy key
Both SAML toggles now use ChipSwitch, the canonical control, instead of the legacy Switch primitive; the encrypted-assertions toggle is new here and the signed-assertions one beside it moves with it so the card stays consistent. An update that keeps the stored decryption key also falls back to the flat decryptionPvk that rows from the retired registration script carry, so an admin enabling encryption on such a provider is not asked to re-paste a key they already hold. The pair is validated against the submitted certificate either way.
1 parent ce2496f commit 6aee268

4 files changed

Lines changed: 70 additions & 18 deletions

File tree

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -577,6 +577,33 @@ describe('POST /api/auth/sso/register', () => {
577577
expect(samlConfig.spMetadata.encPrivateKey).toBe(SP_KEY)
578578
})
579579

580+
it('reuses a key left behind by the retired registration script', async () => {
581+
queueMembers([{ organizationId: 'org1', role: 'owner' }])
582+
queueTableRows(schemaMock.ssoProvider, [])
583+
queueTableRows(schemaMock.ssoProvider, [])
584+
/** Rows the script wrote kept the key flat, where the SAML library never read it. */
585+
queueTableRows(schemaMock.ssoProvider, [
586+
{ samlConfig: JSON.stringify({ decryptionPvk: SP_KEY }) },
587+
])
588+
queueTableRows(schemaMock.ssoProvider, [])
589+
queueTableRows(schemaMock.ssoProvider, [])
590+
queueTableRows(schemaMock.ssoProvider, [{ id: 'p1' }])
591+
592+
const res = await POST(
593+
request(
594+
samlBody({
595+
encryptAssertions: true,
596+
spEncryptionCert: SP_CERT,
597+
spDecryptionKey: '[REDACTED]',
598+
})
599+
)
600+
)
601+
602+
expect(res.status).toBe(200)
603+
const { samlConfig } = mockUpdateSSOProvider.mock.calls[0][0].body
604+
expect(samlConfig.spMetadata.encPrivateKey).toBe(SP_KEY)
605+
})
606+
580607
it('refuses the marker when no key is stored', async () => {
581608
queueMembers([{ organizationId: 'org1', role: 'owner' }])
582609
queueTableRows(schemaMock.ssoProvider, [])

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

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -134,13 +134,24 @@ function checkKeyPair(cert: string | undefined, privateKey: string | undefined):
134134
return { certificate }
135135
}
136136

137-
/** The stored decryption key of a SAML config, when it holds one. */
137+
/**
138+
* The stored decryption key of a SAML config, when it holds one.
139+
*
140+
* `spMetadata.encPrivateKey` is where the SAML library reads it. The flat
141+
* `decryptionPvk` is only ever found on rows written by the retired operator
142+
* script, where it sat unread; an admin turning encryption on for such a
143+
* provider already holds that key, so it is offered rather than demanded again.
144+
* Either way the pair is validated against the submitted certificate before it
145+
* is saved.
146+
*/
138147
function readStoredDecryptionKey(samlConfig: string | null | undefined): string | null {
139148
if (!samlConfig) return null
140149
try {
141150
const parsed = JSON.parse(samlConfig)
142-
const stored = parsed?.spMetadata?.encPrivateKey
143-
return typeof stored === 'string' && stored !== '' ? stored : null
151+
for (const stored of [parsed?.spMetadata?.encPrivateKey, parsed?.decryptionPvk]) {
152+
if (typeof stored === 'string' && stored !== '') return stored
153+
}
154+
return null
144155
} catch {
145156
return null
146157
}

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

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import {
1313
Expandable,
1414
ExpandableContent,
1515
Label,
16-
Switch,
1716
toast,
1817
} from '@sim/emcn'
1918
import { ArrowLeft, ChevronDown, Eye, EyeOff } from '@sim/emcn/icons'
@@ -61,6 +60,12 @@ const CLIENT_SECRET_FIELD_ID = 'sso-client-secret'
6160
/** Fixed width, so the mask never leaks how long the stored secret is. */
6261
const CLIENT_SECRET_MASK = '••••••••••••'
6362

63+
/** On/off options for the SAML toggles, the chip equivalent of a boolean switch. */
64+
const TOGGLE_OPTIONS = [
65+
{ value: 'on', label: 'On' },
66+
{ value: 'off', label: 'Off' },
67+
] as const
68+
6469
interface ClientSecretFieldProps {
6570
/** A secret is already saved, so the field opens as a masked fact rather than an input. */
6671
hasStoredSecret: boolean
@@ -1109,22 +1114,24 @@ export function SsoProviderSettings({
11091114
<Label htmlFor='sso-signed-assertions'>
11101115
Require signed SAML assertions
11111116
</Label>
1112-
<Switch
1113-
id='sso-signed-assertions'
1114-
checked={formData.wantAssertionsSigned}
1115-
onCheckedChange={(checked) =>
1116-
handleInputChange('wantAssertionsSigned', checked)
1117+
<ChipSwitch
1118+
aria-label='Require signed SAML assertions'
1119+
options={TOGGLE_OPTIONS}
1120+
value={formData.wantAssertionsSigned ? 'on' : 'off'}
1121+
onChange={(value) =>
1122+
handleInputChange('wantAssertionsSigned', value === 'on')
11171123
}
11181124
/>
11191125
</div>
11201126

11211127
<div className='flex items-center justify-between gap-4'>
11221128
<Label htmlFor='sso-encrypt-assertions'>Encrypt SAML assertions</Label>
1123-
<Switch
1124-
id='sso-encrypt-assertions'
1125-
checked={formData.encryptAssertions}
1126-
onCheckedChange={(checked) =>
1127-
handleInputChange('encryptAssertions', checked)
1129+
<ChipSwitch
1130+
aria-label='Encrypt SAML assertions'
1131+
options={TOGGLE_OPTIONS}
1132+
value={formData.encryptAssertions ? 'on' : 'off'}
1133+
onChange={(value) =>
1134+
handleInputChange('encryptAssertions', value === 'on')
11281135
}
11291136
/>
11301137
</div>

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

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -99,12 +99,14 @@ vi.mock('@sim/emcn', () => ({
9999
options,
100100
value,
101101
onChange,
102+
'aria-label': ariaLabel,
102103
}: {
103104
options: Array<{ label: string; value: string }>
104105
value: string
105106
onChange: (value: string) => void
107+
'aria-label'?: string
106108
}) => (
107-
<div>
109+
<div role='group' aria-label={ariaLabel}>
108110
{options.map((option) => (
109111
<button
110112
key={option.value}
@@ -720,8 +722,13 @@ describe('SAML encrypted assertions', () => {
720722
})
721723
}
722724

723-
function toggle() {
724-
return container.querySelector<HTMLButtonElement>('#sso-encrypt-assertions')
725+
/** The chip switch renders one button per option; 'On' enables encryption. */
726+
function turnEncryptionOn() {
727+
const group = container.querySelector('[aria-label="Encrypt SAML assertions"]')
728+
const on = Array.from(group?.querySelectorAll('button') ?? []).find(
729+
(button) => button.textContent === 'On'
730+
)
731+
act(() => on?.click())
725732
}
726733

727734
it('hides the key pair until encryption is turned on', () => {
@@ -730,7 +737,7 @@ describe('SAML encrypted assertions', () => {
730737
expect(container.querySelector('#sso-sp-encryption-cert')).toBeNull()
731738
expect(container.querySelector('#sso-sp-decryption-key')).toBeNull()
732739

733-
act(() => toggle()?.click())
740+
turnEncryptionOn()
734741

735742
expect(container.querySelector('#sso-sp-encryption-cert')).not.toBeNull()
736743
expect(container.querySelector('#sso-sp-decryption-key')).not.toBeNull()

0 commit comments

Comments
 (0)