Skip to content

Commit 016dbba

Browse files
committed
feat(sso): let admins map IdP claims, and trim setup comments
Identity providers disagree on which claim carries each value — Entra can send the address as `upn` rather than `email` — and the mapping was hardcoded, so a mismatch had no fix in the UI at all. Adds an Attribute mapping section for both protocols, defaulting to each protocol's standard claim names shown as placeholders, so the common case still needs no input. Editing an existing provider now loads its stored mapping and only treats a value as an override when it differs from the default, so a saved custom mapping is never silently rewritten.
1 parent 4dea2d4 commit 016dbba

2 files changed

Lines changed: 118 additions & 32 deletions

File tree

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

Lines changed: 13 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -656,16 +656,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
656656

657657
/**
658658
* Grants domain trust with the ownership test folded into the UPDATE's WHERE
659-
* clause, so Postgres evaluates both in one statement and the write simply
660-
* matches nothing once the proof is gone. That removes the window a separate
661-
* read-then-write leaves open.
662-
*
663-
* Together with the domain-delete route — which clears this flag in the same
664-
* transaction that removes the proof — the provider cannot end up trusted
665-
* without current ownership in either commit order: if this write lands first
666-
* the delete clears it, and if the delete lands first this write no-ops.
667-
* Org-less (personal) SSO is not domain-gated by Sim, so it grants
668-
* unconditionally as it always has.
659+
* clause, so the write matches nothing once the proof is gone and reports that
660+
* as `false`. Paired with the domain-delete route clearing this flag in the
661+
* same transaction that removes the proof, the provider cannot end up trusted
662+
* without current ownership in either commit order. Org-less (personal) SSO is
663+
* not domain-gated by Sim, so it grants unconditionally as it always has.
669664
*/
670665
const grantProviderDomainTrust = async (): Promise<boolean> => {
671666
if (!orgId) {
@@ -708,13 +703,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
708703
headers,
709704
})
710705

711-
// The verified sso_domain row can be deleted while updateSSOProvider is in
712-
// flight, in which case the conditional grant matches nothing. There is no
713-
// newly-created row to roll back here, so clear the flag instead:
706+
// No newly-created row to roll back here, so clear the flag instead:
714707
// `updateSSOProvider` only resets it when the domain changes, so a
715-
// same-domain edit would otherwise leave stale trust standing. Reporting the
716-
// failure keeps the response honest rather than saying "saved" while the
717-
// provider is left unable to sign anyone in.
708+
// same-domain edit would otherwise leave stale trust standing.
718709
if (!(await grantProviderDomainTrust())) {
719710
await setProviderDomainVerified(false)
720711
logger.warn('Revoked SSO domain trust: verification was removed mid-update', {
@@ -740,15 +731,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
740731
headers,
741732
})
742733

743-
// Granting trust re-tests ownership inside the same statement, so a failure
744-
// here means the verified sso_domain row was removed between the pre-write
745-
// check and Better Auth persisting the provider. That would leave a provider
746-
// on a domain the org no longer proves, so roll it back.
747-
// registerSSOProvider is create-only (it throws if the
748-
// providerId already exists), so a successful call always created a brand-new
749-
// row — we roll it back by its primary-key `id` (not the logical providerId,
750-
// which a concurrent delete+recreate could point at a different row). Personal
751-
// SSO is not gated, so grantProviderDomainTrust always succeeds there.
734+
// A refused grant means the verified sso_domain row was removed between the
735+
// pre-write check and Better Auth persisting the provider, leaving a provider
736+
// on a domain the org no longer proves — roll it back. registerSSOProvider is
737+
// create-only, so a successful call always created a brand-new row; we delete
738+
// by its primary-key `id`, not the logical providerId, which a concurrent
739+
// delete+recreate could point at a different row.
752740
if (!(await grantProviderDomainTrust())) {
753741
// registerSSOProvider spreads the created row's `id` at runtime, but the
754742
// typed return omits it — read it defensively and only delete when it's a

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

Lines changed: 105 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,18 @@ interface SSOProvider {
4848
providerType: 'oidc' | 'saml'
4949
}
5050

51+
/**
52+
* Claim/attribute names each protocol uses out of the box. Kept as the fallback
53+
* rather than seeded into form state so switching protocol needs no reset logic
54+
* and the inputs can show them as placeholders.
55+
*/
56+
const OIDC_DEFAULT_MAPPING = { id: 'sub', email: 'email', name: 'name', image: 'picture' } as const
57+
const SAML_DEFAULT_MAPPING = {
58+
id: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier',
59+
email: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress',
60+
name: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name',
61+
} as const
62+
5163
const DEFAULT_FORM_DATA = {
5264
providerType: 'oidc' as 'oidc' | 'saml',
5365
providerId: '',
@@ -62,6 +74,9 @@ const DEFAULT_FORM_DATA = {
6274
audience: '',
6375
wantAssertionsSigned: true,
6476
idpMetadata: '',
77+
mapId: '',
78+
mapEmail: '',
79+
mapName: '',
6580
}
6681

6782
const DEFAULT_ERRORS = {
@@ -109,6 +124,7 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) {
109124
const [showClientSecret, setShowClientSecret] = useState(false)
110125
const [isEditing, setIsEditing] = useState(false)
111126
const [showAdvanced, setShowAdvanced] = useState(false)
127+
const [showMapping, setShowMapping] = useState(false)
112128

113129
const [formData, setFormData] = useState(DEFAULT_FORM_DATA)
114130
const [originalFormData, setOriginalFormData] = useState(DEFAULT_FORM_DATA)
@@ -271,10 +287,10 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) {
271287
domain: formData.domain,
272288
orgId: organizationId,
273289
mapping: {
274-
id: 'sub',
275-
email: 'email',
276-
name: 'name',
277-
image: 'picture',
290+
id: formData.mapId.trim() || OIDC_DEFAULT_MAPPING.id,
291+
email: formData.mapEmail.trim() || OIDC_DEFAULT_MAPPING.email,
292+
name: formData.mapName.trim() || OIDC_DEFAULT_MAPPING.name,
293+
image: OIDC_DEFAULT_MAPPING.image,
278294
},
279295
clientId: formData.clientId,
280296
clientSecret: formData.clientSecret,
@@ -287,9 +303,9 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) {
287303
domain: formData.domain,
288304
orgId: organizationId,
289305
mapping: {
290-
id: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier',
291-
email: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress',
292-
name: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name',
306+
id: formData.mapId.trim() || SAML_DEFAULT_MAPPING.id,
307+
email: formData.mapEmail.trim() || SAML_DEFAULT_MAPPING.email,
308+
name: formData.mapName.trim() || SAML_DEFAULT_MAPPING.name,
293309
},
294310
entryPoint: formData.entryPoint,
295311
cert: formData.cert,
@@ -328,6 +344,7 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) {
328344
}
329345

330346
const isSaml = formData.providerType === 'saml'
347+
const mappingDefaults = isSaml ? SAML_DEFAULT_MAPPING : OIDC_DEFAULT_MAPPING
331348
const callbackUrl = `${getBaseUrl()}/api/auth/${isSaml ? 'sso/saml2/callback' : 'sso/callback'}/${formData.providerId || existingProvider?.providerId || 'provider-id'}`
332349

333350
const handleEdit = () => {
@@ -343,12 +360,17 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) {
343360
let audience = ''
344361
let wantAssertionsSigned = true
345362
let idpMetadata = ''
363+
// Blank means "use the protocol default", so only carry over a stored value
364+
// that actually differs — otherwise editing would rewrite a default as an
365+
// explicit override, and a stored custom mapping must never silently reset.
366+
let mapping: { id?: string; email?: string; name?: string } = {}
346367

347368
if (existingProvider.providerType === 'oidc' && existingProvider.oidcConfig) {
348369
const config = JSON.parse(existingProvider.oidcConfig)
349370
clientId = config.clientId || ''
350371
clientSecret = config.clientSecret || ''
351372
scopes = config.scopes?.join(',') || 'openid,profile,email'
373+
mapping = config.mapping ?? {}
352374
} else if (existingProvider.providerType === 'saml' && existingProvider.samlConfig) {
353375
const config = JSON.parse(existingProvider.samlConfig)
354376
entryPoint = config.entryPoint || ''
@@ -357,8 +379,14 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) {
357379
audience = config.audience || ''
358380
wantAssertionsSigned = config.wantAssertionsSigned ?? true
359381
idpMetadata = config.idpMetadata?.metadata || config.idpMetadata || ''
382+
mapping = config.mapping ?? {}
360383
}
361384

385+
const defaults =
386+
existingProvider.providerType === 'saml' ? SAML_DEFAULT_MAPPING : OIDC_DEFAULT_MAPPING
387+
const overrideOf = (value: string | undefined, fallback: string) =>
388+
value && value !== fallback ? value : ''
389+
362390
const snapshot = {
363391
providerType: existingProvider.providerType,
364392
providerId: existingProvider.providerId,
@@ -373,12 +401,16 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) {
373401
audience,
374402
wantAssertionsSigned,
375403
idpMetadata,
404+
mapId: overrideOf(mapping.id, defaults.id),
405+
mapEmail: overrideOf(mapping.email, defaults.email),
406+
mapName: overrideOf(mapping.name, defaults.name),
376407
}
377408
setFormData(snapshot)
378409
setOriginalFormData(snapshot)
379410
setIsEditing(true)
380411
setShowErrors(false)
381412
setShowAdvanced(false)
413+
setShowMapping(Boolean(snapshot.mapId || snapshot.mapEmail || snapshot.mapName))
382414
} catch (err) {
383415
logger.error('Failed to parse provider config', { error: err })
384416
toast.error('Failed to load provider configuration')
@@ -775,6 +807,72 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) {
775807
Configure this in your identity provider
776808
</p>
777809
</SettingRow>
810+
811+
{/* Identity providers vary in which claim carries each value — Entra,
812+
for instance, can send the address as `upn` rather than `email`.
813+
Leaving a field blank uses the protocol default shown as its
814+
placeholder, so the common case needs no input at all. */}
815+
<div className='flex flex-col gap-2'>
816+
<Button
817+
type='button'
818+
variant='ghost'
819+
onClick={() => setShowMapping((v) => !v)}
820+
className='w-fit gap-1.5 px-0 text-[var(--text-muted)] hover:bg-transparent hover:text-[var(--text-primary)]'
821+
>
822+
<ChevronDown
823+
className={cn('size-[14px] transition-transform', showMapping && 'rotate-180')}
824+
/>
825+
Attribute mapping
826+
</Button>
827+
828+
<Expandable expanded={showMapping}>
829+
<ExpandableContent>
830+
<div className='flex flex-col gap-4.5 pt-2'>
831+
<SettingRow label='Email attribute' optional>
832+
<ChipInput
833+
type='text'
834+
placeholder={mappingDefaults.email}
835+
value={formData.mapEmail}
836+
autoComplete='off'
837+
autoCapitalize='none'
838+
spellCheck={false}
839+
inputClassName='font-mono'
840+
onChange={(e) => handleInputChange('mapEmail', e.target.value)}
841+
/>
842+
</SettingRow>
843+
844+
<SettingRow label='Name attribute' optional>
845+
<ChipInput
846+
type='text'
847+
placeholder={mappingDefaults.name}
848+
value={formData.mapName}
849+
autoComplete='off'
850+
autoCapitalize='none'
851+
spellCheck={false}
852+
inputClassName='font-mono'
853+
onChange={(e) => handleInputChange('mapName', e.target.value)}
854+
/>
855+
</SettingRow>
856+
857+
<SettingRow label='User ID attribute' optional>
858+
<ChipInput
859+
type='text'
860+
placeholder={mappingDefaults.id}
861+
value={formData.mapId}
862+
autoComplete='off'
863+
autoCapitalize='none'
864+
spellCheck={false}
865+
inputClassName='font-mono'
866+
onChange={(e) => handleInputChange('mapId', e.target.value)}
867+
/>
868+
<p className='text-[var(--text-muted)] text-small'>
869+
Must be stable and unique per user — changing it later re-links accounts.
870+
</p>
871+
</SettingRow>
872+
</div>
873+
</ExpandableContent>
874+
</Expandable>
875+
</div>
778876
</div>
779877
</SettingsSection>
780878
</SettingsPanel>

0 commit comments

Comments
 (0)