Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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 @@ -43,7 +43,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 @@ -551,25 +550,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
150 changes: 150 additions & 0 deletions packages/localizations/src/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,156 @@ export const enUS: LocalizationResource = {
},
warning: 'Once a provider is selected you cannot change again until the configuration is over',
},
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: {
prefix: 'Sign in to Okta and go to ',
bold: 'Admin → Applications',
suffix: '.',
},
step2: {
prefix: 'Click ',
bold: 'Create App Integration',
suffix: '.',
},
step3: {
prefix: 'Select ',
bold: 'SAML 2.0',
suffix: '.',
},
step4: {
prefix: 'Fill in the ',
bold: 'General Settings',
suffix: ' (App name is required).',
},
step5: {
prefix: 'Click ',
bold: 'Next',
suffix: ' 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: {
prefix: 'Select ',
bold: 'This is an internal app that we have created',
suffix: ' from the options menu.',
},
step2: {
prefix: 'Complete the form with any comments and select ',
bold: '"Finish"',
suffix: '.',
},
},
configureAttributes: {
step1: {
prefix: 'In the Okta dashboard, find the ',
bold: 'Attribute Statements',
suffix: ' section.',
},
step2: {
prefix: 'Select ',
bold: 'Add Expression',
suffix: ' 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: {
prefix: 'In the Okta dashboard, select the ',
bold: 'Assignments',
suffix: ' tab.',
},
step2: {
prefix: 'Select the ',
bold1: 'Assign',
middle1: ' dropdown. You can either select ',
bold2: 'Assign to people',
middle2: ' or ',
bold3: 'Assign to groups',
suffix: '.',
},
step3: 'In the search field, enter the user or group of users that you want to assign to the application.',
step4: {
prefix: 'Select the ',
bold: 'Assign',
suffix: ' button next to the user or group that you want to assign.',
},
step5: {
prefix: 'Select the ',
bold: 'Done',
suffix: ' 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
2 changes: 2 additions & 0 deletions packages/shared/src/types/elementIds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ export type FieldId =
| 'apiKeyExpirationDate'
| 'apiKeyRevokeConfirmation'
| 'apiKeySecret'
| 'idpMetadataUrl'
| 'acsUrl'
| 'web3WalletName';
export type ProfileSectionId =
| 'profile'
Expand Down
Loading
Loading