Skip to content

Commit 1a4d7d1

Browse files
authored
feat(ui): build out Okta Configure step UI in ConfigureSSO (#8535)
1 parent 1e2e237 commit 1a4d7d1

9 files changed

Lines changed: 839 additions & 60 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@clerk/localizations': patch
3+
'@clerk/shared': patch
4+
'@clerk/ui': patch
5+
---
6+
7+
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.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/clerk-js': patch
3+
---
4+
5+
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.

packages/clerk-js/src/core/resources/User.ts

Lines changed: 50 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,6 @@ import type {
4444
VerifyTOTPParams,
4545
Web3WalletResource,
4646
} from '@clerk/shared/types';
47-
import { deepCamelToSnake } from '@clerk/shared/underscore';
4847

4948
import { convertPageToOffsetSearchParams } from '../../utils/convertPageToOffsetSearchParams';
5049
import { unixEpochToDate } from '../../utils/date';
@@ -559,25 +558,64 @@ export class User extends BaseResource implements UserResource {
559558
* Serializes `CreateMeEnterpriseConnectionParams` / `UpdateMeEnterpriseConnectionParams`
560559
* for the `/me/enterprise_connections` FAPI endpoints.
561560
*
562-
* Uses `deepCamelToSnake` but preserves `saml.attributeMapping` and `customAttributes` as-is. Their keys are
561+
* The handler expects a flat form body where SAML and OIDC fields are
562+
* prefixed (e.g. `saml_idp_metadata_url`, `oidc_client_id`) rather
563+
* than nested under `saml`/`oidc` objects. `attribute_mapping` and
564+
* `custom_attributes` stay as object values and are JSON-stringified
565+
* by the form serializer downstream — their inner keys are
563566
* user-supplied data and must not be camel→snake transformed.
564567
*/
565568
function toMeEnterpriseConnectionBody(
566569
params: CreateMeEnterpriseConnectionParams | UpdateMeEnterpriseConnectionParams,
567570
): Record<string, unknown> {
568-
const originalAttributeMapping =
569-
params.saml && typeof params.saml === 'object' ? params.saml.attributeMapping : undefined;
570-
const originalCustomAttributes = 'customAttributes' in params ? params.customAttributes : undefined;
571-
572-
const body = deepCamelToSnake(params) as Record<string, any>;
573-
574-
if (originalAttributeMapping !== undefined && body.saml && typeof body.saml === 'object') {
575-
body.saml.attribute_mapping = originalAttributeMapping;
571+
const body: Record<string, unknown> = {};
572+
573+
// Top-level fields. `provider` is only on Create, the rest are shared
574+
setIfDefined(body, 'provider', (params as CreateMeEnterpriseConnectionParams).provider);
575+
setIfDefined(body, 'name', params.name);
576+
setIfDefined(body, 'organization_id', params.organizationId);
577+
setIfDefined(body, 'active', (params as UpdateMeEnterpriseConnectionParams).active);
578+
setIfDefined(body, 'sync_user_attributes', (params as UpdateMeEnterpriseConnectionParams).syncUserAttributes);
579+
setIfDefined(
580+
body,
581+
'disable_additional_identifications',
582+
(params as UpdateMeEnterpriseConnectionParams).disableAdditionalIdentifications,
583+
);
584+
setIfDefined(body, 'custom_attributes', (params as UpdateMeEnterpriseConnectionParams).customAttributes);
585+
586+
if (params.saml) {
587+
setIfDefined(body, 'saml_idp_entity_id', params.saml.idpEntityId);
588+
setIfDefined(body, 'saml_idp_sso_url', params.saml.idpSsoUrl);
589+
setIfDefined(body, 'saml_idp_certificate', params.saml.idpCertificate);
590+
setIfDefined(body, 'saml_idp_metadata_url', params.saml.idpMetadataUrl);
591+
setIfDefined(body, 'saml_idp_metadata', params.saml.idpMetadata);
592+
setIfDefined(body, 'saml_attribute_mapping', params.saml.attributeMapping);
593+
setIfDefined(body, 'saml_allow_subdomains', params.saml.allowSubdomains);
594+
setIfDefined(body, 'saml_allow_idp_initiated', params.saml.allowIdpInitiated);
595+
setIfDefined(body, 'saml_force_authn', params.saml.forceAuthn);
576596
}
577597

578-
if (originalCustomAttributes !== undefined) {
579-
body.custom_attributes = originalCustomAttributes;
598+
if (params.oidc) {
599+
setIfDefined(body, 'oidc_client_id', params.oidc.clientId);
600+
setIfDefined(body, 'oidc_client_secret', params.oidc.clientSecret);
601+
setIfDefined(body, 'oidc_discovery_url', params.oidc.discoveryUrl);
602+
setIfDefined(body, 'oidc_auth_url', params.oidc.authUrl);
603+
setIfDefined(body, 'oidc_token_url', params.oidc.tokenUrl);
604+
setIfDefined(body, 'oidc_user_info_url', params.oidc.userInfoUrl);
605+
setIfDefined(body, 'oidc_requires_pkce', params.oidc.requiresPkce);
580606
}
581607

582608
return body;
583609
}
610+
611+
/**
612+
* Adds `value` under `key` only when the caller actually provided it.
613+
* Mirrors the SDK's existing semantics: `undefined` means "don't send
614+
* this field"; `null` is forwarded so users can explicitly clear a
615+
* value via the form-encoded body
616+
*/
617+
function setIfDefined(target: Record<string, unknown>, key: string, value: unknown): void {
618+
if (value !== undefined) {
619+
target[key] = value;
620+
}
621+
}

packages/clerk-js/src/core/resources/__tests__/User.test.ts

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ describe('User', () => {
184184
provider: 'saml_okta',
185185
name: 'New SSO',
186186
organization_id: 'org_1',
187-
saml: { idp_entity_id: 'https://idp.example.com' },
187+
saml_idp_entity_id: 'https://idp.example.com',
188188
},
189189
});
190190

@@ -291,13 +291,11 @@ describe('User', () => {
291291
body: {
292292
provider: 'saml_okta',
293293
name: 'New SSO',
294-
saml: {
295-
idp_entity_id: 'https://idp.example.com',
296-
attribute_mapping: {
297-
emailAddress: 'mail',
298-
firstName: 'givenName',
299-
'custom:role': 'role',
300-
},
294+
saml_idp_entity_id: 'https://idp.example.com',
295+
saml_attribute_mapping: {
296+
emailAddress: 'mail',
297+
firstName: 'givenName',
298+
'custom:role': 'role',
301299
},
302300
},
303301
});
@@ -359,11 +357,9 @@ describe('User', () => {
359357
CustomValue: 'y',
360358
nestedCamelKey: { innerCamelKey: 'z' },
361359
},
362-
saml: {
363-
attribute_mapping: {
364-
emailAddress: 'mail',
365-
firstName: 'givenName',
366-
},
360+
saml_attribute_mapping: {
361+
emailAddress: 'mail',
362+
firstName: 'givenName',
367363
},
368364
},
369365
});

packages/localizations/src/en-US.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,100 @@ export const enUS: LocalizationResource = {
247247
subtitle: "Contact the application's administrator to get access through the existing connection.",
248248
},
249249
},
250+
configureStep: {
251+
spFields: {
252+
acsUrl: {
253+
label: 'Single sign-on URL',
254+
},
255+
spEntityId: {
256+
label: 'Audience URI',
257+
},
258+
},
259+
attributeMapping: {
260+
title: 'We expect your SAML responses to have the following specific attributes:',
261+
paragraph:
262+
"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:",
263+
columns: {
264+
attribute: 'Attribute',
265+
claimName: 'Claim Name',
266+
},
267+
badges: {
268+
required: 'Required',
269+
optional: 'Optional',
270+
},
271+
rows: {
272+
email: {
273+
attribute: 'Email address',
274+
claim: 'user.email',
275+
},
276+
firstName: {
277+
attribute: 'First Name',
278+
claim: 'user.firstName',
279+
},
280+
lastName: {
281+
attribute: 'Last Name',
282+
claim: 'user.lastName',
283+
},
284+
},
285+
},
286+
samlOkta: {
287+
title: 'Configure Okta Workforce',
288+
subtitle: 'Create a new enterprise application in your Okta Dashboard',
289+
createApp: {
290+
title: 'Create a new enterprise application in Okta',
291+
step1: 'Sign in to Okta and go to Admin → Applications.',
292+
step2: 'Click Create App Integration.',
293+
step3: 'Select SAML 2.0.',
294+
step4: 'Fill in the General Settings (App name is required).',
295+
step5: 'Click Next to complete creating the application.',
296+
},
297+
serviceProvider: {
298+
title: 'Configure service provider',
299+
paragraph1:
300+
'Once you have moved forward from the General Settings instructions, you will be presented with the Configure SAML page.',
301+
paragraph2:
302+
'To configure your service provider (Clerk), you must add these two fields to your Okta application:',
303+
},
304+
completeSamlIntegration: {
305+
title: 'Complete SAML integration',
306+
step1: 'Select This is an internal app that we have created from the options menu.',
307+
step2: 'Complete the form with any comments and select "Finish".',
308+
},
309+
configureAttributes: {
310+
step1: 'In the Okta dashboard, find the Attribute Statements section.',
311+
step2: 'Select Add Expression for each attribute, and enter the following name and expression pairs:',
312+
pairs: {
313+
conjunction: ' and ',
314+
email: {
315+
name: 'mail',
316+
expression: 'user.profile.mail',
317+
},
318+
firstName: {
319+
name: 'firstName',
320+
expression: 'user.profile.firstName',
321+
},
322+
lastName: {
323+
name: 'lastName',
324+
expression: 'user.profile.lastName',
325+
},
326+
},
327+
},
328+
assignUsers: {
329+
title: 'Assign selected user or group in Okta',
330+
paragraph: 'You need to assign users or groups to your enterprise app before they can use it to sign in.',
331+
step1: 'In the Okta dashboard, select the Assignments tab.',
332+
step2: 'Select the Assign dropdown. You can either select Assign to people or Assign to groups.',
333+
step3: 'In the search field, enter the user or group of users that you want to assign to the application.',
334+
step4: 'Select the Assign button next to the user or group that you want to assign.',
335+
step5: 'Select the Done button to complete the assignment.',
336+
},
337+
metadataUrl: {
338+
label: 'Metadata URL',
339+
placeholder: 'Paste URL here...',
340+
description: 'In your Okta SAML app, go to the Sign On tab and retrieve the metadata URL. Paste it below.',
341+
},
342+
},
343+
},
250344
},
251345
createOrganization: {
252346
formButtonSubmit: 'Create organization',

packages/shared/src/types/elementIds.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ export type FieldId =
2626
| 'apiKeyExpirationDate'
2727
| 'apiKeyRevokeConfirmation'
2828
| 'apiKeySecret'
29+
| 'idpMetadataUrl'
30+
| 'acsUrl'
31+
| 'spEntityId'
2932
| 'web3WalletName';
3033
export type ProfileSectionId =
3134
| 'profile'

packages/shared/src/types/localization.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1338,6 +1338,97 @@ export type __internal_LocalizationResource = {
13381338
subtitle: LocalizationValue;
13391339
};
13401340
};
1341+
configureStep: {
1342+
spFields: {
1343+
acsUrl: {
1344+
label: LocalizationValue;
1345+
};
1346+
spEntityId: {
1347+
label: LocalizationValue;
1348+
};
1349+
};
1350+
attributeMapping: {
1351+
title: LocalizationValue;
1352+
paragraph: LocalizationValue;
1353+
columns: {
1354+
attribute: LocalizationValue;
1355+
claimName: LocalizationValue;
1356+
};
1357+
badges: {
1358+
required: LocalizationValue;
1359+
optional: LocalizationValue;
1360+
};
1361+
rows: {
1362+
email: {
1363+
attribute: LocalizationValue;
1364+
claim: LocalizationValue;
1365+
};
1366+
firstName: {
1367+
attribute: LocalizationValue;
1368+
claim: LocalizationValue;
1369+
};
1370+
lastName: {
1371+
attribute: LocalizationValue;
1372+
claim: LocalizationValue;
1373+
};
1374+
};
1375+
};
1376+
samlOkta: {
1377+
title: LocalizationValue;
1378+
subtitle: LocalizationValue;
1379+
createApp: {
1380+
title: LocalizationValue;
1381+
step1: LocalizationValue;
1382+
step2: LocalizationValue;
1383+
step3: LocalizationValue;
1384+
step4: LocalizationValue;
1385+
step5: LocalizationValue;
1386+
};
1387+
serviceProvider: {
1388+
title: LocalizationValue;
1389+
paragraph1: LocalizationValue;
1390+
paragraph2: LocalizationValue;
1391+
};
1392+
completeSamlIntegration: {
1393+
title: LocalizationValue;
1394+
step1: LocalizationValue;
1395+
step2: LocalizationValue;
1396+
};
1397+
configureAttributes: {
1398+
step1: LocalizationValue;
1399+
step2: LocalizationValue;
1400+
pairs: {
1401+
conjunction: LocalizationValue;
1402+
email: {
1403+
name: LocalizationValue;
1404+
expression: LocalizationValue;
1405+
};
1406+
firstName: {
1407+
name: LocalizationValue;
1408+
expression: LocalizationValue;
1409+
};
1410+
lastName: {
1411+
name: LocalizationValue;
1412+
expression: LocalizationValue;
1413+
};
1414+
};
1415+
};
1416+
assignUsers: {
1417+
title: LocalizationValue;
1418+
paragraph: LocalizationValue;
1419+
step1: LocalizationValue;
1420+
step2: LocalizationValue;
1421+
step3: LocalizationValue;
1422+
step4: LocalizationValue;
1423+
step5: LocalizationValue;
1424+
};
1425+
metadataUrl: {
1426+
label: LocalizationValue;
1427+
placeholder: LocalizationValue;
1428+
description: LocalizationValue;
1429+
};
1430+
};
1431+
};
13411432
};
13421433
apiKeys: {
13431434
formTitle: LocalizationValue;

0 commit comments

Comments
 (0)