Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
fddec7a
feat(ui): SAML metadata URL submission in ConfigureSSO Configure step
iagodahlem May 12, 2026
8de47c2
fix(shared): add idpMetadataUrl to FieldId union
iagodahlem May 12, 2026
80a1fda
refactor(ui): hoist enterprise connection update to ConfigureSSO prov…
iagodahlem May 12, 2026
f2c5262
fix(ui): render Configure step description above input
iagodahlem May 12, 2026
98c07dd
refactor(ui): split Configure step into 4 inner Wizard sub-steps
iagodahlem May 12, 2026
8c4cd9c
fix(ui): make ConfigureSSO Configure sub-step sections fill the body
iagodahlem May 12, 2026
e381bed
fix(clerk-js): flatten SAML/OIDC body in toMeEnterpriseConnectionBody
iagodahlem May 12, 2026
3c50019
refactor(ui): add fill prop to ConfigureSSO Step.Section
iagodahlem May 12, 2026
6c77e41
feat(ui): build CreateAppSubStep content in ConfigureSSO Configure step
iagodahlem May 12, 2026
4069203
refactor(ui): tighten Configure step layout and form-wrap SP copy rows
iagodahlem May 12, 2026
6891ceb
feat(ui): build ConfigureAttributesSubStep content in ConfigureSSO
iagodahlem May 13, 2026
890a710
refactor(ui): adopt Text colorScheme=secondary and tighten attribute …
iagodahlem May 13, 2026
66994c2
refactor(ui): scope ConfigureSSO Configure locales by provider and ad…
iagodahlem May 13, 2026
d411528
refactor(ui): tweak ConfigureAttributes list spacing and bullet style
iagodahlem May 13, 2026
b82c1be
feat(ui): build AssignUsersSubStep and switch ConfigureSSO instructio…
iagodahlem May 13, 2026
cccfbb0
refactor(ui): tighten ConfigureAttributes badge and pairs list styling
iagodahlem May 13, 2026
31a7478
refactor(ui): call __internal_useUserEnterpriseConnections directly i…
iagodahlem May 13, 2026
6c5dc5e
refactor(ui): flatten Configure step instruction locales to single ke…
iagodahlem May 13, 2026
73c1213
refactor(ui): polish ConfigureSSO step header, spacing, and scrollbar
iagodahlem May 13, 2026
c1bcbdf
fix(ui): give Audience URI its own FieldId and fix the useFormControl…
iagodahlem May 14, 2026
3acc43b
fix(ui,shared,localizations): restore verifyEmailDomainStep keys lost…
iagodahlem May 14, 2026
1dc6f12
fix(shared): drop duplicate verifyEmailDomainStep type entry
iagodahlem May 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/configure-sso-configure-step-metadata-url.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@clerk/localizations': patch
'@clerk/shared': patch
'@clerk/ui': patch
---

Implement the Okta SAML metadata URL submission path in the Configure step of `<__experimental_ConfigureSSO />`. Adds a single text input for the IdP metadata URL; Continue posts `{ saml: { idpMetadataUrl } }` via `user.updateEnterpriseConnection` wrapped in `useReverification`, with `useCardState` driving the loading state and `handleError` routing backend errors inline to the field or to the card-level error surface. Locale keys added under `configureSSO.configureStep` in `en-US`. Manual entry, file upload, SP-side copy rows, and the Okta admin-console walkthrough ship in follow-up PRs.
5 changes: 5 additions & 0 deletions .changeset/fix-enterprise-connection-flat-body.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-js': patch
---

Fix `toMeEnterpriseConnectionBody` to produce the flat snake_case body shape the backend expects for `user.createEnterpriseConnection` and `user.updateEnterpriseConnection`. SAML and OIDC fields are now top-level prefixed (e.g., `saml_idp_metadata_url`) rather than nested under `saml` / `oidc` objects. Without this fix, IdP metadata submission in `<__experimental_ConfigureSSO />` silently fails on the backend.
62 changes: 50 additions & 12 deletions packages/clerk-js/src/core/resources/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@ import type {
VerifyTOTPParams,
Web3WalletResource,
} from '@clerk/shared/types';
import { deepCamelToSnake } from '@clerk/shared/underscore';

import { convertPageToOffsetSearchParams } from '../../utils/convertPageToOffsetSearchParams';
import { unixEpochToDate } from '../../utils/date';
Expand Down Expand Up @@ -559,25 +558,64 @@ export class User extends BaseResource implements UserResource {
* Serializes `CreateMeEnterpriseConnectionParams` / `UpdateMeEnterpriseConnectionParams`
* for the `/me/enterprise_connections` FAPI endpoints.
*
* Uses `deepCamelToSnake` but preserves `saml.attributeMapping` and `customAttributes` as-is. Their keys are
* The handler expects a flat form body where SAML and OIDC fields are
* prefixed (e.g. `saml_idp_metadata_url`, `oidc_client_id`) rather
* than nested under `saml`/`oidc` objects. `attribute_mapping` and
* `custom_attributes` stay as object values and are JSON-stringified
* by the form serializer downstream — their inner keys are
* user-supplied data and must not be camel→snake transformed.
*/
function toMeEnterpriseConnectionBody(
params: CreateMeEnterpriseConnectionParams | UpdateMeEnterpriseConnectionParams,
): Record<string, unknown> {
const originalAttributeMapping =
params.saml && typeof params.saml === 'object' ? params.saml.attributeMapping : undefined;
const originalCustomAttributes = 'customAttributes' in params ? params.customAttributes : undefined;

const body = deepCamelToSnake(params) as Record<string, any>;

if (originalAttributeMapping !== undefined && body.saml && typeof body.saml === 'object') {
body.saml.attribute_mapping = originalAttributeMapping;
const body: Record<string, unknown> = {};

// Top-level fields. `provider` is only on Create, the rest are shared
setIfDefined(body, 'provider', (params as CreateMeEnterpriseConnectionParams).provider);
setIfDefined(body, 'name', params.name);
setIfDefined(body, 'organization_id', params.organizationId);
setIfDefined(body, 'active', (params as UpdateMeEnterpriseConnectionParams).active);
setIfDefined(body, 'sync_user_attributes', (params as UpdateMeEnterpriseConnectionParams).syncUserAttributes);
setIfDefined(
body,
'disable_additional_identifications',
(params as UpdateMeEnterpriseConnectionParams).disableAdditionalIdentifications,
);
setIfDefined(body, 'custom_attributes', (params as UpdateMeEnterpriseConnectionParams).customAttributes);

if (params.saml) {
setIfDefined(body, 'saml_idp_entity_id', params.saml.idpEntityId);
setIfDefined(body, 'saml_idp_sso_url', params.saml.idpSsoUrl);
setIfDefined(body, 'saml_idp_certificate', params.saml.idpCertificate);
setIfDefined(body, 'saml_idp_metadata_url', params.saml.idpMetadataUrl);
setIfDefined(body, 'saml_idp_metadata', params.saml.idpMetadata);
setIfDefined(body, 'saml_attribute_mapping', params.saml.attributeMapping);
setIfDefined(body, 'saml_allow_subdomains', params.saml.allowSubdomains);
setIfDefined(body, 'saml_allow_idp_initiated', params.saml.allowIdpInitiated);
setIfDefined(body, 'saml_force_authn', params.saml.forceAuthn);
}

if (originalCustomAttributes !== undefined) {
body.custom_attributes = originalCustomAttributes;
if (params.oidc) {
setIfDefined(body, 'oidc_client_id', params.oidc.clientId);
setIfDefined(body, 'oidc_client_secret', params.oidc.clientSecret);
setIfDefined(body, 'oidc_discovery_url', params.oidc.discoveryUrl);
setIfDefined(body, 'oidc_auth_url', params.oidc.authUrl);
setIfDefined(body, 'oidc_token_url', params.oidc.tokenUrl);
setIfDefined(body, 'oidc_user_info_url', params.oidc.userInfoUrl);
setIfDefined(body, 'oidc_requires_pkce', params.oidc.requiresPkce);
}

return body;
}

/**
* Adds `value` under `key` only when the caller actually provided it.
* Mirrors the SDK's existing semantics: `undefined` means "don't send
* this field"; `null` is forwarded so users can explicitly clear a
* value via the form-encoded body
*/
function setIfDefined(target: Record<string, unknown>, key: string, value: unknown): void {
if (value !== undefined) {
target[key] = value;
}
}
22 changes: 9 additions & 13 deletions packages/clerk-js/src/core/resources/__tests__/User.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ describe('User', () => {
provider: 'saml_okta',
name: 'New SSO',
organization_id: 'org_1',
saml: { idp_entity_id: 'https://idp.example.com' },
saml_idp_entity_id: 'https://idp.example.com',
},
});

Expand Down Expand Up @@ -291,13 +291,11 @@ describe('User', () => {
body: {
provider: 'saml_okta',
name: 'New SSO',
saml: {
idp_entity_id: 'https://idp.example.com',
attribute_mapping: {
emailAddress: 'mail',
firstName: 'givenName',
'custom:role': 'role',
},
saml_idp_entity_id: 'https://idp.example.com',
saml_attribute_mapping: {
emailAddress: 'mail',
firstName: 'givenName',
'custom:role': 'role',
},
},
});
Expand Down Expand Up @@ -359,11 +357,9 @@ describe('User', () => {
CustomValue: 'y',
nestedCamelKey: { innerCamelKey: 'z' },
},
saml: {
attribute_mapping: {
emailAddress: 'mail',
firstName: 'givenName',
},
saml_attribute_mapping: {
emailAddress: 'mail',
firstName: 'givenName',
},
},
});
Expand Down
94 changes: 94 additions & 0 deletions packages/localizations/src/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,100 @@ export const enUS: LocalizationResource = {
subtitle: "Contact the application's administrator to get access through the existing connection.",
},
},
configureStep: {
spFields: {
acsUrl: {
label: 'Single sign-on URL',
},
spEntityId: {
label: 'Audience URI',
},
},
attributeMapping: {
title: 'We expect your SAML responses to have the following specific attributes:',
paragraph:
"These are the defaults and probably won't need you to change them. However, many SAML configuration errors are due to incorrect attribute mappings, so it's worth double-checking. Here's how:",
columns: {
attribute: 'Attribute',
claimName: 'Claim Name',
},
badges: {
required: 'Required',
optional: 'Optional',
},
rows: {
email: {
attribute: 'Email address',
claim: 'user.email',
},
firstName: {
attribute: 'First Name',
claim: 'user.firstName',
},
lastName: {
attribute: 'Last Name',
claim: 'user.lastName',
},
},
},
samlOkta: {
title: 'Configure Okta Workforce',
subtitle: 'Create a new enterprise application in your Okta Dashboard',
createApp: {
title: 'Create a new enterprise application in Okta',
step1: 'Sign in to Okta and go to Admin → Applications.',
step2: 'Click Create App Integration.',
step3: 'Select SAML 2.0.',
step4: 'Fill in the General Settings (App name is required).',
step5: 'Click Next to complete creating the application.',
},
serviceProvider: {
title: 'Configure service provider',
paragraph1:
'Once you have moved forward from the General Settings instructions, you will be presented with the Configure SAML page.',
paragraph2:
'To configure your service provider (Clerk), you must add these two fields to your Okta application:',
},
completeSamlIntegration: {
title: 'Complete SAML integration',
step1: 'Select This is an internal app that we have created from the options menu.',
step2: 'Complete the form with any comments and select "Finish".',
},
configureAttributes: {
step1: 'In the Okta dashboard, find the Attribute Statements section.',
step2: 'Select Add Expression for each attribute, and enter the following name and expression pairs:',
pairs: {
conjunction: ' and ',
email: {
name: 'mail',
expression: 'user.profile.mail',
},
firstName: {
name: 'firstName',
expression: 'user.profile.firstName',
},
lastName: {
name: 'lastName',
expression: 'user.profile.lastName',
},
},
},
assignUsers: {
title: 'Assign selected user or group in Okta',
paragraph: 'You need to assign users or groups to your enterprise app before they can use it to sign in.',
step1: 'In the Okta dashboard, select the Assignments tab.',
step2: 'Select the Assign dropdown. You can either select Assign to people or Assign to groups.',
step3: 'In the search field, enter the user or group of users that you want to assign to the application.',
step4: 'Select the Assign button next to the user or group that you want to assign.',
step5: 'Select the Done button to complete the assignment.',
},
metadataUrl: {
label: 'Metadata URL',
placeholder: 'Paste URL here...',
description: 'In your Okta SAML app, go to the Sign On tab and retrieve the metadata URL. Paste it below.',
},
},
},
},
createOrganization: {
formButtonSubmit: 'Create organization',
Expand Down
3 changes: 3 additions & 0 deletions packages/shared/src/types/elementIds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ export type FieldId =
| 'apiKeyExpirationDate'
| 'apiKeyRevokeConfirmation'
| 'apiKeySecret'
| 'idpMetadataUrl'
| 'acsUrl'
| 'spEntityId'
| 'web3WalletName';
export type ProfileSectionId =
| 'profile'
Expand Down
91 changes: 91 additions & 0 deletions packages/shared/src/types/localization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1338,6 +1338,97 @@ export type __internal_LocalizationResource = {
subtitle: LocalizationValue;
};
};
configureStep: {
spFields: {
acsUrl: {
label: LocalizationValue;
};
spEntityId: {
label: LocalizationValue;
};
};
attributeMapping: {
title: LocalizationValue;
paragraph: LocalizationValue;
columns: {
attribute: LocalizationValue;
claimName: LocalizationValue;
};
badges: {
required: LocalizationValue;
optional: LocalizationValue;
};
rows: {
email: {
attribute: LocalizationValue;
claim: LocalizationValue;
};
firstName: {
attribute: LocalizationValue;
claim: LocalizationValue;
};
lastName: {
attribute: LocalizationValue;
claim: LocalizationValue;
};
};
};
samlOkta: {
title: LocalizationValue;
subtitle: LocalizationValue;
createApp: {
title: LocalizationValue;
step1: LocalizationValue;
step2: LocalizationValue;
step3: LocalizationValue;
step4: LocalizationValue;
step5: LocalizationValue;
};
serviceProvider: {
title: LocalizationValue;
paragraph1: LocalizationValue;
paragraph2: LocalizationValue;
};
completeSamlIntegration: {
title: LocalizationValue;
step1: LocalizationValue;
step2: LocalizationValue;
};
configureAttributes: {
step1: LocalizationValue;
step2: LocalizationValue;
pairs: {
conjunction: LocalizationValue;
email: {
name: LocalizationValue;
expression: LocalizationValue;
};
firstName: {
name: LocalizationValue;
expression: LocalizationValue;
};
lastName: {
name: LocalizationValue;
expression: LocalizationValue;
};
};
};
assignUsers: {
title: LocalizationValue;
paragraph: LocalizationValue;
step1: LocalizationValue;
step2: LocalizationValue;
step3: LocalizationValue;
step4: LocalizationValue;
step5: LocalizationValue;
};
metadataUrl: {
label: LocalizationValue;
placeholder: LocalizationValue;
description: LocalizationValue;
};
};
};
};
apiKeys: {
formTitle: LocalizationValue;
Expand Down
Loading
Loading