Skip to content

Commit cfc8f27

Browse files
committed
feat(sso): show the saved client secret as a masked fact with an explicit Replace action
1 parent c9574d5 commit cfc8f27

2 files changed

Lines changed: 121 additions & 43 deletions

File tree

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,21 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1111

1212
const logger = createLogger('SSOProvidersRoute')
1313

14+
/** Secrets shorter than this reveal too large a fraction of themselves in 4 characters. */
15+
const MIN_LENGTH_FOR_HINT = 16
16+
17+
/**
18+
* Last four characters of a stored client secret, so an admin can tell *which*
19+
* secret is saved rather than only that one exists. Four characters of a
20+
* high-entropy secret is not a meaningful disclosure to an owner or admin, who
21+
* can rotate it anyway — but short secrets are left unhinted, where the same four
22+
* characters would be a large share of the value.
23+
*/
24+
function buildClientSecretHint(clientSecret: unknown): string | null {
25+
if (typeof clientSecret !== 'string' || clientSecret.length < MIN_LENGTH_FOR_HINT) return null
26+
return clientSecret.slice(-4)
27+
}
28+
1429
export const GET = withRouteHandler(async (request: NextRequest) => {
1530
try {
1631
const session = await getSession()
@@ -69,7 +84,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
6984
if (oidcConfig) {
7085
try {
7186
const parsed = JSON.parse(oidcConfig)
87+
const hint = buildClientSecretHint(parsed.clientSecret)
7288
parsed.clientSecret = REDACTED_MARKER
89+
if (hint) parsed.clientSecretHint = hint
7390
oidcConfig = JSON.stringify(parsed)
7491
} catch {
7592
oidcConfig = null

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

Lines changed: 104 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { useState } from 'react'
44
import {
55
Button,
6+
Chip,
67
ChipCombobox,
78
ChipCopyInput,
89
ChipInput,
@@ -70,6 +71,17 @@ const SAML_NAMEID_FORMATS = [
7071

7172
const PROVIDER_ID_SUGGESTIONS = SSO_TRUSTED_PROVIDERS.map((id) => ({ label: id, value: id }))
7273

74+
/** Reads the display-only hint the API attaches beside the redacted client secret. */
75+
function readClientSecretHint(oidcConfig?: string): string | null {
76+
if (!oidcConfig) return null
77+
try {
78+
const hint = JSON.parse(oidcConfig).clientSecretHint
79+
return typeof hint === 'string' ? hint : null
80+
} catch {
81+
return null
82+
}
83+
}
84+
7385
const DEFAULT_FORM_DATA = {
7486
providerType: 'oidc' as 'oidc' | 'saml',
7587
providerId: '',
@@ -145,12 +157,18 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) {
145157
const [errors, setErrors] = useState<Record<string, string[]>>(DEFAULT_ERRORS)
146158
const [showErrors, setShowErrors] = useState(false)
147159

160+
const [isReplacingClientSecret, setIsReplacingClientSecret] = useState(false)
161+
148162
/**
149163
* Editing an OIDC provider always means a secret is stored — the contract
150164
* requires one to register, and the API returns only its sentinel, never the
151165
* value. Leaving the field blank therefore means "keep it", not "clear it".
152166
*/
153167
const hasStoredClientSecret = isEditing && existingProvider?.providerType === 'oidc'
168+
/** Last four characters of the saved secret, when the API judged it safe to hint. */
169+
const storedClientSecretHint = hasStoredClientSecret
170+
? readClientSecretHint(existingProvider?.oidcConfig)
171+
: null
154172

155173
const hasChanges = (Object.keys(formData) as (keyof typeof formData)[]).some(
156174
(k) => formData[k] !== originalFormData[k]
@@ -263,6 +281,7 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) {
263281
setErrors(DEFAULT_ERRORS)
264282
setShowErrors(false)
265283
setShowAdvanced(false)
284+
setIsReplacingClientSecret(false)
266285
}
267286

268287
const handleSubmit = async (e?: React.FormEvent) => {
@@ -339,6 +358,7 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) {
339358
setShowErrors(false)
340359
setIsEditing(false)
341360
setShowAdvanced(false)
361+
setIsReplacingClientSecret(false)
342362
} catch (err) {
343363
const message = getErrorMessage(err, 'Unknown error occurred')
344364
toast.error(message)
@@ -447,6 +467,7 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) {
447467
setIsEditing(true)
448468
setShowErrors(false)
449469
setShowAdvanced(false)
470+
setIsReplacingClientSecret(false)
450471
setShowMapping(Boolean(snapshot.mapId || snapshot.mapEmail || snapshot.mapName))
451472
} catch (err) {
452473
logger.error('Failed to parse provider config', { error: err })
@@ -684,57 +705,97 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) {
684705
<SettingRow
685706
label='Client Secret'
686707
description={
687-
hasStoredClientSecret
688-
? 'Your saved secret is never shown again. Leave this blank to keep it, or type a new one to replace it.'
689-
: undefined
708+
isReplacingClientSecret ? 'Replaces the saved secret when you save.' : undefined
690709
}
691710
error={
692711
showErrors && errors.clientSecret.length > 0
693712
? errors.clientSecret.join(' ')
694713
: undefined
695714
}
696715
>
697-
<ChipInput
698-
id='sso-client-secret'
699-
type='text'
700-
placeholder={hasStoredClientSecret ? '••••••••••••' : 'Enter Client Secret'}
701-
value={formData.clientSecret}
702-
name='sso_client_key'
703-
autoComplete='off'
704-
autoCapitalize='none'
705-
spellCheck={false}
706-
readOnly
707-
onFocus={(e) => {
708-
e.target.removeAttribute('readOnly')
709-
setShowClientSecret(true)
710-
}}
711-
onBlurCapture={() => setShowClientSecret(false)}
712-
onChange={(e) => handleInputChange('clientSecret', e.target.value)}
713-
inputClassName={!showClientSecret ? '[-webkit-text-security:disc]' : undefined}
714-
error={showErrors && errors.clientSecret.length > 0}
715-
endAdornment={
716-
// Only offer the reveal once there is something to reveal. The
717-
// stored secret is never sent to the browser, so on an untouched
718-
// edit the toggle would be a control that visibly does nothing.
719-
formData.clientSecret ? (
720-
<Button
721-
type='button'
722-
variant='ghost'
723-
onClick={() => setShowClientSecret((s) => !s)}
724-
className='size-6 p-0 text-[var(--text-muted)] hover:text-[var(--text-primary)]'
725-
aria-label={
726-
showClientSecret ? 'Hide client secret' : 'Show client secret'
727-
}
716+
{hasStoredClientSecret && !isReplacingClientSecret ? (
717+
// A saved secret is a fact, not an editable value — the browser
718+
// never receives it. Showing it as a static row with an explicit
719+
// Replace action removes the "is blank going to clear it?" question
720+
// an empty input invites, and stops a stray keystroke from arming
721+
// a replacement.
722+
<div className='flex items-center gap-2'>
723+
<ChipInput
724+
id='sso-client-secret'
725+
readOnly
726+
value={
727+
storedClientSecretHint
728+
? `••••••••••••${storedClientSecretHint}`
729+
: '••••••••••••'
730+
}
731+
inputClassName='cursor-default font-mono'
732+
className='min-w-0 flex-1'
733+
aria-label={
734+
storedClientSecretHint
735+
? `Saved client secret ending ${storedClientSecretHint}`
736+
: 'Saved client secret'
737+
}
738+
/>
739+
<Chip onClick={() => setIsReplacingClientSecret(true)}>Replace</Chip>
740+
</div>
741+
) : (
742+
<div className='flex items-center gap-2'>
743+
<ChipInput
744+
id='sso-client-secret'
745+
type='text'
746+
placeholder='Enter Client Secret'
747+
className='min-w-0 flex-1'
748+
value={formData.clientSecret}
749+
name='sso_client_key'
750+
autoComplete='off'
751+
autoCapitalize='none'
752+
spellCheck={false}
753+
readOnly
754+
onFocus={(e) => {
755+
e.target.removeAttribute('readOnly')
756+
setShowClientSecret(true)
757+
}}
758+
onBlurCapture={() => setShowClientSecret(false)}
759+
onChange={(e) => handleInputChange('clientSecret', e.target.value)}
760+
inputClassName={
761+
!showClientSecret ? '[-webkit-text-security:disc]' : undefined
762+
}
763+
error={showErrors && errors.clientSecret.length > 0}
764+
endAdornment={
765+
// Only offer the reveal once there is something to reveal. The
766+
// stored secret is never sent to the browser, so on an untouched
767+
// edit the toggle would be a control that visibly does nothing.
768+
formData.clientSecret ? (
769+
<Button
770+
type='button'
771+
variant='ghost'
772+
onClick={() => setShowClientSecret((s) => !s)}
773+
className='size-6 p-0 text-[var(--text-muted)] hover:text-[var(--text-primary)]'
774+
aria-label={
775+
showClientSecret ? 'Hide client secret' : 'Show client secret'
776+
}
777+
>
778+
{showClientSecret ? (
779+
<EyeOff className='size-[14px]' />
780+
) : (
781+
<Eye className='size-[14px]' />
782+
)}
783+
</Button>
784+
) : undefined
785+
}
786+
/>
787+
{hasStoredClientSecret && (
788+
<Chip
789+
onClick={() => {
790+
setIsReplacingClientSecret(false)
791+
handleInputChange('clientSecret', '')
792+
}}
728793
>
729-
{showClientSecret ? (
730-
<EyeOff className='size-[14px]' />
731-
) : (
732-
<Eye className='size-[14px]' />
733-
)}
734-
</Button>
735-
) : undefined
736-
}
737-
/>
794+
Cancel
795+
</Chip>
796+
)}
797+
</div>
798+
)}
738799
</SettingRow>
739800

740801
<div className='flex flex-col gap-2'>

0 commit comments

Comments
 (0)