Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
76 changes: 76 additions & 0 deletions packages/localizations/src/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,82 @@ 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: 'Sign in to Okta and go to <strong>Admin → Applications</strong>.',
step2: 'Click <strong>Create App Integration</strong>.',
step3: 'Select <strong>SAML 2.0</strong>.',
step4: 'Fill in the <strong>General Settings</strong> (App name is required).',
step5: 'Click <strong>Next</strong> 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 <strong>This is an internal app that we have created</strong> from the options menu.',
step2: 'Complete the form with any comments and select <strong>"Finish"</strong>.',
},
configureAttributes: {
step1: 'In the Okta dashboard, find the <strong>Attribute Statements</strong> section.',
step2:
'Select <strong>Add Expression</strong> for each attribute, and enter the following name and expression pairs:',
pairs: {
email: '<code>mail</code> and <code>user.profile.mail</code>',
firstName: '<code>firstName</code> and <code>user.profile.firstName</code>',
lastName: '<code>lastName</code> and <code>user.profile.lastName</code>',
},
},
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
72 changes: 72 additions & 0 deletions packages/shared/src/types/localization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1314,6 +1314,78 @@ export type __internal_LocalizationResource = {
};
warning: 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: {
email: LocalizationValue;
firstName: LocalizationValue;
lastName: LocalizationValue;
};
};
metadataUrl: {
label: LocalizationValue;
placeholder: LocalizationValue;
description: LocalizationValue;
};
};
};
};
apiKeys: {
formTitle: LocalizationValue;
Expand Down
2 changes: 2 additions & 0 deletions packages/ui/src/components/ConfigureSSO/ConfigureSSO.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ const ConfigureSSOCardContent = () => {
data: enterpriseConnections,
isLoading,
createEnterpriseConnection,
updateEnterpriseConnection,
} = __internal_useUserEnterpriseConnections({ enabled: true });

// Currently FAPI only supports one enterprise connection per user
Expand All @@ -81,6 +82,7 @@ const ConfigureSSOCardContent = () => {
<ConfigureSSOProvider
enterpriseConnection={enterpriseConnection}
createEnterpriseConnection={createEnterpriseConnection}
updateEnterpriseConnection={updateEnterpriseConnection}
>
<ConfigureSSOSteps />
</ConfigureSSOProvider>
Expand Down
Loading
Loading