From 674669ec207a26795bdd7d6174d8855d8c2de580 Mon Sep 17 00:00:00 2001 From: Nicolas Lopes Date: Tue, 21 Jul 2026 15:19:01 -0300 Subject: [PATCH 01/11] feat(ui): display OIDC authorized redirect URI --- .changeset/blue-owls-remember.md | 7 ++++ packages/localizations/src/en-US.ts | 5 +++ packages/shared/src/types/elementIds.ts | 1 + packages/shared/src/types/localization.ts | 4 ++ .../__tests__/ConfigureStep.test.tsx | 3 ++ .../oidc/OidcCustomConfigureSteps.tsx | 37 ++++++++++++++++++- 6 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 .changeset/blue-owls-remember.md diff --git a/.changeset/blue-owls-remember.md b/.changeset/blue-owls-remember.md new file mode 100644 index 00000000000..c225243addf --- /dev/null +++ b/.changeset/blue-owls-remember.md @@ -0,0 +1,7 @@ +--- +'@clerk/localizations': patch +'@clerk/shared': patch +'@clerk/ui': patch +--- + +Display a copyable authorized redirect URI in the first step of the experimental OIDC self-serve SSO setup flow. diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index e7419100697..fed9c32e9e8 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -252,6 +252,11 @@ export const enUS: LocalizationResource = { mainHeaderTitle: 'Configure your identity provider', redirectUriStep: { headerSubtitle: 'Create a new OIDC application in your identity provider’s dashboard', + paragraph: + 'In your identity provider’s dashboard, create a new OIDC application that supports the authorization code grant type, and use the following redirect URI:', + redirectUri: { + label: 'Authorized redirect URI', + }, }, }, samlCustom: { diff --git a/packages/shared/src/types/elementIds.ts b/packages/shared/src/types/elementIds.ts index b685d908db7..2ba342ba43c 100644 --- a/packages/shared/src/types/elementIds.ts +++ b/packages/shared/src/types/elementIds.ts @@ -31,6 +31,7 @@ export type FieldId = | 'idpMetadata' | 'idpMetadataUrl' | 'idpSsoUrl' + | 'redirectUri' | 'acsUrl' | 'spEntityId' | 'web3WalletName' diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index 88df4c9b294..b391aae00f0 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -1529,6 +1529,10 @@ export type __internal_LocalizationResource = { mainHeaderTitle: LocalizationValue; redirectUriStep: { headerSubtitle: LocalizationValue; + paragraph: LocalizationValue; + redirectUri: { + label: LocalizationValue; + }; }; claimsStep: { headerSubtitle: LocalizationValue; diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/__tests__/ConfigureStep.test.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/__tests__/ConfigureStep.test.tsx index f4b4c5c0d8c..05101fe30c9 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/__tests__/ConfigureStep.test.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/__tests__/ConfigureStep.test.tsx @@ -77,6 +77,9 @@ describe('ConfigureProviderStep', () => { // The OIDC sub-flow mounts on its first step. Before the fix this threw // `No steps found for provider: oidc_clerk_dev` and white-screened the wizard. expect(await screen.findByText(/create a new oidc application/i)).toBeInTheDocument(); + const redirectUri = screen.getByRole('textbox') as HTMLInputElement; + expect(redirectUri).toHaveAttribute('readonly'); + expect(redirectUri.value).toMatch(/^https:\/\/.+\/v1\/oauth_callback$/); }); it('degrades to the unsupported-provider state for a provider the SDK does not recognize', async () => { diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx index d5100e7c2ca..336850787a2 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx @@ -1,6 +1,11 @@ +import { useClerk } from '@clerk/shared/react'; import { type JSX } from 'react'; -import { localizationKeys } from '@/customizables'; +import { Col, localizationKeys, Text } from '@/customizables'; +import { ClipboardInput } from '@/elements/ClipboardInput'; +import { Form } from '@/elements/Form'; +import { Checkmark, Clipboard } from '@/icons'; +import { useFormControl } from '@/ui/utils/useFormControl'; import { Step } from '../../../elements/Step'; import { useWizard, Wizard, type WizardStepConfig } from '../../../elements/Wizard'; @@ -41,6 +46,13 @@ export const OidcCustomConfigureSteps = (): JSX.Element => { const OidcRedirectUriStep = (): JSX.Element => { const { goNext, goPrev, isFirstStep, isLastStep } = useWizard(); + const { frontendApi } = useClerk(); + const redirectUri = `https://${frontendApi}/v1/oauth_callback`; + const redirectUriField = useFormControl('redirectUri', redirectUri, { + type: 'text', + label: localizationKeys('configureSSO.configureStep.oidcCustom.redirectUriStep.redirectUri.label'), + isRequired: false, + }); return ( <> @@ -51,7 +63,28 @@ const OidcRedirectUriStep = (): JSX.Element => { - + + ({ gap: theme.space.$5 })}> + ({ gap: theme.space.$1x5 })}> + + + + + + + + + + From 7b8a08843dfddd6b87c6350ea57ab2eeebcef1ac Mon Sep 17 00:00:00 2001 From: Nicolas Lopes Date: Tue, 21 Jul 2026 15:42:47 -0300 Subject: [PATCH 02/11] feat(ui): add OIDC claims configuration step --- .changeset/blue-owls-remember.md | 2 +- packages/localizations/src/en-US.ts | 25 ++++ packages/shared/src/types/localization.ts | 25 ++++ .../__tests__/ConfigureStep.test.tsx | 8 +- .../oidc/OidcCustomConfigureSteps.tsx | 111 +++++++++++++++++- 5 files changed, 167 insertions(+), 4 deletions(-) diff --git a/.changeset/blue-owls-remember.md b/.changeset/blue-owls-remember.md index c225243addf..147a14f263f 100644 --- a/.changeset/blue-owls-remember.md +++ b/.changeset/blue-owls-remember.md @@ -4,4 +4,4 @@ '@clerk/ui': patch --- -Display a copyable authorized redirect URI in the first step of the experimental OIDC self-serve SSO setup flow. +Add the first two setup steps to the experimental OIDC self-serve SSO configuration flow, including a copyable authorized redirect URI and required ID-token claims. diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index fed9c32e9e8..88a63c9164c 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -241,7 +241,32 @@ export const enUS: LocalizationResource = { }, oidcCustom: { claimsStep: { + attributeMappingTable: { + columns: { + attributeName: 'Attribute Name', + userAttribute: 'User Attribute', + }, + rows: { + subject: { + attributeName: 'External user ID', + userAttribute: 'sub', + }, + email: { + attributeName: 'Primary email', + userAttribute: 'email', + }, + firstName: { + attributeName: 'First name', + userAttribute: 'given_name', + }, + lastName: { + attributeName: 'Last name', + userAttribute: 'family_name', + }, + }, + }, headerSubtitle: 'Set the claims your identity provider includes in the ID token', + paragraph: 'Your user ID token must include the following claims:', }, credentialsStep: { headerSubtitle: 'Add your application credentials', diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index b391aae00f0..21da71ecbe9 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -1535,7 +1535,32 @@ export type __internal_LocalizationResource = { }; }; claimsStep: { + attributeMappingTable: { + columns: { + attributeName: LocalizationValue; + userAttribute: LocalizationValue; + }; + rows: { + subject: { + attributeName: LocalizationValue; + userAttribute: LocalizationValue; + }; + email: { + attributeName: LocalizationValue; + userAttribute: LocalizationValue; + }; + firstName: { + attributeName: LocalizationValue; + userAttribute: LocalizationValue; + }; + lastName: { + attributeName: LocalizationValue; + userAttribute: LocalizationValue; + }; + }; + }; headerSubtitle: LocalizationValue; + paragraph: LocalizationValue; }; endpointsStep: { headerSubtitle: LocalizationValue; diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/__tests__/ConfigureStep.test.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/__tests__/ConfigureStep.test.tsx index 05101fe30c9..e2b0852e56e 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/__tests__/ConfigureStep.test.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/__tests__/ConfigureStep.test.tsx @@ -72,7 +72,7 @@ describe('ConfigureProviderStep', () => { contextState.provider = 'oidc_clerk_dev'; const { wrapper } = await createFixtures(); - renderStep(wrapper); + const { userEvent } = renderStep(wrapper); // The OIDC sub-flow mounts on its first step. Before the fix this threw // `No steps found for provider: oidc_clerk_dev` and white-screened the wizard. @@ -80,6 +80,12 @@ describe('ConfigureProviderStep', () => { const redirectUri = screen.getByRole('textbox') as HTMLInputElement; expect(redirectUri).toHaveAttribute('readonly'); expect(redirectUri.value).toMatch(/^https:\/\/.+\/v1\/oauth_callback$/); + + await userEvent.click(screen.getByRole('button', { name: 'Continue' })); + + expect(await screen.findByRole('table')).toBeInTheDocument(); + expect(screen.getAllByRole('row')).toHaveLength(5); + expect(screen.queryByRole('checkbox')).not.toBeInTheDocument(); }); it('degrades to the unsupported-provider state for a provider the SDK does not recognize', async () => { diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx index 336850787a2..0d4b70535e1 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx @@ -1,7 +1,20 @@ import { useClerk } from '@clerk/shared/react'; import { type JSX } from 'react'; -import { Col, localizationKeys, Text } from '@/customizables'; +import { + Badge, + Col, + descriptors, + Flex, + localizationKeys, + Table, + Tbody, + Td, + Text, + Th, + Thead, + Tr, +} from '@/customizables'; import { ClipboardInput } from '@/elements/ClipboardInput'; import { Form } from '@/elements/Form'; import { Checkmark, Clipboard } from '@/icons'; @@ -113,7 +126,17 @@ const OidcClaimsStep = (): JSX.Element => { - + + ({ gap: theme.space.$3 })}> + + + + + @@ -130,6 +153,90 @@ const OidcClaimsStep = (): JSX.Element => { ); }; +type OidcClaimRow = { + id: 'subject' | 'email' | 'firstName' | 'lastName'; + isRequired: boolean; +}; + +const OIDC_CLAIM_ROWS: ReadonlyArray = [ + { id: 'subject', isRequired: true }, + { id: 'email', isRequired: true }, + { id: 'firstName', isRequired: false }, + { id: 'lastName', isRequired: false }, +]; + +const OidcClaimsTable = (): JSX.Element => ( + ({ + 'tr > th:first-of-type': { paddingInlineStart: theme.space.$4 }, + })} + > + + + + + + + + {OIDC_CLAIM_ROWS.map(row => ( + + + + + ))} + +
+ ({ fontSize: theme.fontSizes.$xs })} + localizationKey={localizationKeys( + 'configureSSO.configureStep.oidcCustom.claimsStep.attributeMappingTable.columns.attributeName', + )} + /> + + ({ fontSize: theme.fontSizes.$xs })} + localizationKey={localizationKeys( + 'configureSSO.configureStep.oidcCustom.claimsStep.attributeMappingTable.columns.userAttribute', + )} + /> +
+ ({ gap: theme.space.$2 })} + > + + + + + +
+); + const OidcEndpointsStep = (): JSX.Element => { const { goNext, goPrev, isFirstStep, isLastStep } = useWizard(); From 069f908fbf69b8307ff41b817fcf056e71ad4d2b Mon Sep 17 00:00:00 2001 From: Nicolas Lopes Date: Tue, 21 Jul 2026 15:52:11 -0300 Subject: [PATCH 03/11] feat(ui): add OIDC endpoint configuration step --- .changeset/blue-owls-remember.md | 2 +- packages/localizations/src/en-US.ts | 26 ++++ packages/shared/src/types/elementIds.ts | 4 + packages/shared/src/types/localization.ts | 25 +++ .../__tests__/ConfigureStep.test.tsx | 11 ++ .../oidc/OidcCustomConfigureSteps.tsx | 144 +++++++++++++++++- .../IdentityProviderConfigurationModes.tsx | 3 +- 7 files changed, 207 insertions(+), 8 deletions(-) diff --git a/.changeset/blue-owls-remember.md b/.changeset/blue-owls-remember.md index 147a14f263f..13002dccbfe 100644 --- a/.changeset/blue-owls-remember.md +++ b/.changeset/blue-owls-remember.md @@ -4,4 +4,4 @@ '@clerk/ui': patch --- -Add the first two setup steps to the experimental OIDC self-serve SSO configuration flow, including a copyable authorized redirect URI and required ID-token claims. +Add the first three setup steps to the experimental OIDC self-serve SSO configuration flow, including a copyable authorized redirect URI, required ID-token claims, and identity provider endpoint configuration. diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 88a63c9164c..e27648ddc57 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -273,6 +273,32 @@ export const enUS: LocalizationResource = { }, endpointsStep: { headerSubtitle: 'Add your identity provider’s endpoints', + discoveryUrl: { + description: + 'In your identity provider’s OIDC application, retrieve the discovery endpoint. Paste it below.', + label: 'Discovery endpoint', + placeholder: 'Paste URL here...', + }, + manual: { + authUrl: { + label: 'Authorization URL', + placeholder: 'Paste URL here...', + }, + description: 'In your identity provider’s OIDC application, retrieve these values.', + tokenUrl: { + label: 'Token URL', + placeholder: 'Paste URL here...', + }, + userInfoUrl: { + label: 'User Info URL', + placeholder: 'Paste URL here...', + }, + }, + modes: { + ariaLabel: 'OIDC endpoint configuration method', + discoveryUrl: 'Add via discovery endpoint', + manual: 'Configure manually', + }, }, mainHeaderTitle: 'Configure your identity provider', redirectUriStep: { diff --git a/packages/shared/src/types/elementIds.ts b/packages/shared/src/types/elementIds.ts index 2ba342ba43c..82128ffaaf1 100644 --- a/packages/shared/src/types/elementIds.ts +++ b/packages/shared/src/types/elementIds.ts @@ -31,6 +31,10 @@ export type FieldId = | 'idpMetadata' | 'idpMetadataUrl' | 'idpSsoUrl' + | 'discoveryUrl' + | 'authUrl' + | 'tokenUrl' + | 'userInfoUrl' | 'redirectUri' | 'acsUrl' | 'spEntityId' diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index 21da71ecbe9..a62701fb8b1 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -1564,6 +1564,31 @@ export type __internal_LocalizationResource = { }; endpointsStep: { headerSubtitle: LocalizationValue; + discoveryUrl: { + description: LocalizationValue; + label: LocalizationValue; + placeholder: LocalizationValue; + }; + manual: { + authUrl: { + label: LocalizationValue; + placeholder: LocalizationValue; + }; + description: LocalizationValue; + tokenUrl: { + label: LocalizationValue; + placeholder: LocalizationValue; + }; + userInfoUrl: { + label: LocalizationValue; + placeholder: LocalizationValue; + }; + }; + modes: { + ariaLabel: LocalizationValue; + discoveryUrl: LocalizationValue; + manual: LocalizationValue; + }; }; credentialsStep: { headerSubtitle: LocalizationValue; diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/__tests__/ConfigureStep.test.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/__tests__/ConfigureStep.test.tsx index e2b0852e56e..300aec9aca4 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/__tests__/ConfigureStep.test.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/__tests__/ConfigureStep.test.tsx @@ -86,6 +86,17 @@ describe('ConfigureProviderStep', () => { expect(await screen.findByRole('table')).toBeInTheDocument(); expect(screen.getAllByRole('row')).toHaveLength(5); expect(screen.queryByRole('checkbox')).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: 'Continue' })); + + const [discoveryMode, manualMode] = await screen.findAllByRole('radio'); + expect(discoveryMode).toBeChecked(); + expect(screen.getAllByRole('textbox')).toHaveLength(1); + expect(screen.getByRole('button', { name: 'Continue' })).toBeDisabled(); + + await userEvent.click(manualMode); + + expect(screen.getAllByRole('textbox')).toHaveLength(3); }); it('degrades to the unsupported-provider state for a provider the SDK does not recognize', async () => { diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx index 0d4b70535e1..c5c837268ed 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx @@ -1,5 +1,5 @@ import { useClerk } from '@clerk/shared/react'; -import { type JSX } from 'react'; +import React, { type JSX } from 'react'; import { Badge, @@ -20,9 +20,15 @@ import { Form } from '@/elements/Form'; import { Checkmark, Clipboard } from '@/icons'; import { useFormControl } from '@/ui/utils/useFormControl'; +import { useConfigureSSO } from '../../../ConfigureSSOContext'; import { Step } from '../../../elements/Step'; import { useWizard, Wizard, type WizardStepConfig } from '../../../elements/Wizard'; import { InnerStepCounter } from '../../../elements/Wizard/InnerStepCounter'; +import { ActiveConnectionAlert } from '../saml/shared/ActiveConnectionAlert'; +import { + IdentityProviderConfigurationModes, + type IdpConfigurationMode, +} from '../saml/shared/IdentityProviderConfigurationModes'; const OIDC_STEPS: WizardStepConfig[] = [ { id: 'redirect-uri' }, @@ -32,6 +38,14 @@ const OIDC_STEPS: WizardStepConfig[] = [ ]; export const OidcCustomConfigureSteps = (): JSX.Element => { + const { enterpriseConnection } = useConfigureSSO(); + const [endpoints, setEndpoints] = React.useState(() => ({ + discoveryUrl: enterpriseConnection?.oauthConfig?.discoveryUrl ?? '', + authUrl: '', + tokenUrl: '', + userInfoUrl: '', + })); + return ( // Linear, guard-less sub-flow: mount on the first step, mirroring the SAML custom inner wizard. { - + @@ -237,8 +254,71 @@ const OidcClaimsTable = (): JSX.Element => ( ); -const OidcEndpointsStep = (): JSX.Element => { +type OidcEndpointConfiguration = { + discoveryUrl: string; + authUrl: string; + tokenUrl: string; + userInfoUrl: string; +}; + +type OidcEndpointsStepProps = { + endpoints: OidcEndpointConfiguration; + onEndpointsChange: React.Dispatch>; +}; + +const OIDC_ENDPOINT_MODES = ['discoveryUrl', 'manual'] as const satisfies readonly IdpConfigurationMode[]; + +const OidcEndpointsStep = ({ endpoints, onEndpointsChange }: OidcEndpointsStepProps): JSX.Element => { const { goNext, goPrev, isFirstStep, isLastStep } = useWizard(); + const [mode, setMode] = React.useState( + endpoints.authUrl || endpoints.tokenUrl || endpoints.userInfoUrl ? 'manual' : 'discoveryUrl', + ); + + const discoveryUrlField = useFormControl('discoveryUrl', endpoints.discoveryUrl, { + type: 'text', + label: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.discoveryUrl.label'), + placeholder: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.discoveryUrl.placeholder'), + isRequired: true, + }); + const authUrlField = useFormControl('authUrl', endpoints.authUrl, { + type: 'text', + label: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.manual.authUrl.label'), + placeholder: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.manual.authUrl.placeholder'), + isRequired: true, + }); + const tokenUrlField = useFormControl('tokenUrl', endpoints.tokenUrl, { + type: 'text', + label: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.manual.tokenUrl.label'), + placeholder: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.manual.tokenUrl.placeholder'), + isRequired: true, + }); + const userInfoUrlField = useFormControl('userInfoUrl', endpoints.userInfoUrl, { + type: 'text', + label: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.manual.userInfoUrl.label'), + placeholder: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.manual.userInfoUrl.placeholder'), + isRequired: true, + }); + + const isValid = + mode === 'discoveryUrl' + ? discoveryUrlField.value.trim().length > 0 + : authUrlField.value.trim().length > 0 && + tokenUrlField.value.trim().length > 0 && + userInfoUrlField.value.trim().length > 0; + + const handleContinue = (): void => { + if (!isValid) { + return; + } + + onEndpointsChange({ + discoveryUrl: discoveryUrlField.value.trim(), + authUrl: authUrlField.value.trim(), + tokenUrl: tokenUrlField.value.trim(), + userInfoUrl: userInfoUrlField.value.trim(), + }); + void goNext(); + }; return ( <> @@ -249,7 +329,59 @@ const OidcEndpointsStep = (): JSX.Element => { - + + + + + {mode === 'discoveryUrl' ? ( + <> + + + + + + ) : ( + <> + + + + + + + + + + + + )} + + + + @@ -258,8 +390,8 @@ const OidcEndpointsStep = (): JSX.Element => { isDisabled={isFirstStep} /> goNext()} - isDisabled={isLastStep} + onClick={handleContinue} + isDisabled={!isValid || isLastStep} /> diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/shared/IdentityProviderConfigurationModes.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/shared/IdentityProviderConfigurationModes.tsx index 1b75ea36aee..b34a3a597a1 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/shared/IdentityProviderConfigurationModes.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/shared/IdentityProviderConfigurationModes.tsx @@ -8,9 +8,10 @@ import { SegmentedControl } from '@/elements/SegmentedControl'; * * metadataUrl: Fetch IdP configuration via metadata URL * metadataFile: Upload IdP configuration via metadata file + * discoveryUrl: Fetch IdP configuration via OIDC discovery URL * manual: Configure manually each field, such as sign on URL, issuer, and signing certificate */ -export type IdpConfigurationMode = 'metadataUrl' | 'metadataFile' | 'manual'; +export type IdpConfigurationMode = 'metadataUrl' | 'metadataFile' | 'discoveryUrl' | 'manual'; type ModeLocalizationKeys = Partial>; From ae23318e7b9b418108161133b351034ba4f6ffaf Mon Sep 17 00:00:00 2001 From: Nicolas Lopes Date: Tue, 21 Jul 2026 17:00:42 -0300 Subject: [PATCH 04/11] feat(ui): save OIDC endpoints before credentials --- .../__tests__/ConfigureStep.test.tsx | 63 ++++++++++++- .../oidc/OidcCustomConfigureSteps.tsx | 93 ++++++++++--------- 2 files changed, 109 insertions(+), 47 deletions(-) diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/__tests__/ConfigureStep.test.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/__tests__/ConfigureStep.test.tsx index 300aec9aca4..116b2c1bc54 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/__tests__/ConfigureStep.test.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/__tests__/ConfigureStep.test.tsx @@ -10,13 +10,17 @@ import type { EnterpriseConnectionProviderType } from '../../../types'; // The dispatch reads `organizationEnterpriseConnection.provider`. The nested // sub-flows also read `enterpriseConnection` (via `Step.Footer.Reset`), which is // left undefined so that footer self-hides in this isolated render. -const contextState = vi.hoisted(() => ({ provider: undefined as string | undefined })); +const contextState = vi.hoisted(() => ({ + provider: undefined as string | undefined, + enterpriseConnection: undefined as { id: string; oauthConfig: { discoveryUrl?: string } | null } | undefined, +})); +const updateConnection = vi.hoisted(() => vi.fn()); vi.mock('../../../ConfigureSSOContext', () => ({ useConfigureSSO: () => ({ - enterpriseConnection: undefined, + enterpriseConnection: contextState.enterpriseConnection, contentRef: { current: null }, - enterpriseConnectionMutations: {}, + enterpriseConnectionMutations: { updateConnection }, organizationEnterpriseConnection: { provider: contextState.provider, hasConnection: true, @@ -70,6 +74,7 @@ describe('ConfigureProviderStep', () => { it('renders the OIDC configure steps for a derived provider key without throwing', async () => { contextState.provider = 'oidc_clerk_dev'; + contextState.enterpriseConnection = undefined; const { wrapper } = await createFixtures(); const { userEvent } = renderStep(wrapper); @@ -99,6 +104,58 @@ describe('ConfigureProviderStep', () => { expect(screen.getAllByRole('textbox')).toHaveLength(3); }); + it('saves the discovery endpoint before advancing to credentials', async () => { + contextState.provider = 'oidc_clerk_dev'; + contextState.enterpriseConnection = { id: 'ent_123', oauthConfig: null }; + updateConnection.mockReset(); + updateConnection.mockResolvedValue({}); + const { wrapper } = await createFixtures(); + + const { userEvent } = renderStep(wrapper); + + await userEvent.click(await screen.findByRole('button', { name: 'Continue' })); + await userEvent.click(screen.getByRole('button', { name: 'Continue' })); + await userEvent.type(screen.getByRole('textbox'), 'https://idp.example/.well-known/openid-configuration'); + await userEvent.click(screen.getByRole('button', { name: 'Continue' })); + + await vi.waitFor(() => { + expect(updateConnection).toHaveBeenCalledWith('ent_123', { + oidc: { discoveryUrl: 'https://idp.example/.well-known/openid-configuration' }, + }); + }); + }); + + it('saves manual endpoints before advancing to credentials', async () => { + contextState.provider = 'oidc_clerk_dev'; + contextState.enterpriseConnection = { id: 'ent_123', oauthConfig: null }; + updateConnection.mockReset(); + updateConnection.mockResolvedValue({}); + const { wrapper } = await createFixtures(); + + const { userEvent } = renderStep(wrapper); + + await userEvent.click(await screen.findByRole('button', { name: 'Continue' })); + await userEvent.click(screen.getByRole('button', { name: 'Continue' })); + const [, manualMode] = await screen.findAllByRole('radio'); + await userEvent.click(manualMode); + + const [authUrl, tokenUrl, userInfoUrl] = screen.getAllByRole('textbox'); + await userEvent.type(authUrl, 'https://idp.example/authorize'); + await userEvent.type(tokenUrl, 'https://idp.example/token'); + await userEvent.type(userInfoUrl, 'https://idp.example/userinfo'); + await userEvent.click(screen.getByRole('button', { name: 'Continue' })); + + await vi.waitFor(() => { + expect(updateConnection).toHaveBeenCalledWith('ent_123', { + oidc: { + authUrl: 'https://idp.example/authorize', + tokenUrl: 'https://idp.example/token', + userInfoUrl: 'https://idp.example/userinfo', + }, + }); + }); + }); + it('degrades to the unsupported-provider state for a provider the SDK does not recognize', async () => { contextState.provider = 'ldap_enterprise'; const { wrapper } = await createFixtures(); diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx index c5c837268ed..0e15f91100a 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx @@ -16,9 +16,11 @@ import { Tr, } from '@/customizables'; import { ClipboardInput } from '@/elements/ClipboardInput'; +import { useCardState } from '@/elements/contexts'; import { Form } from '@/elements/Form'; import { Checkmark, Clipboard } from '@/icons'; import { useFormControl } from '@/ui/utils/useFormControl'; +import { handleError } from '@/utils/errorHandler'; import { useConfigureSSO } from '../../../ConfigureSSOContext'; import { Step } from '../../../elements/Step'; @@ -38,14 +40,6 @@ const OIDC_STEPS: WizardStepConfig[] = [ ]; export const OidcCustomConfigureSteps = (): JSX.Element => { - const { enterpriseConnection } = useConfigureSSO(); - const [endpoints, setEndpoints] = React.useState(() => ({ - discoveryUrl: enterpriseConnection?.oauthConfig?.discoveryUrl ?? '', - authUrl: '', - tokenUrl: '', - userInfoUrl: '', - })); - return ( // Linear, guard-less sub-flow: mount on the first step, mirroring the SAML custom inner wizard. { - + @@ -254,45 +245,38 @@ const OidcClaimsTable = (): JSX.Element => ( ); -type OidcEndpointConfiguration = { - discoveryUrl: string; - authUrl: string; - tokenUrl: string; - userInfoUrl: string; -}; - -type OidcEndpointsStepProps = { - endpoints: OidcEndpointConfiguration; - onEndpointsChange: React.Dispatch>; -}; - const OIDC_ENDPOINT_MODES = ['discoveryUrl', 'manual'] as const satisfies readonly IdpConfigurationMode[]; -const OidcEndpointsStep = ({ endpoints, onEndpointsChange }: OidcEndpointsStepProps): JSX.Element => { +const OidcEndpointsStep = (): JSX.Element => { + const card = useCardState(); const { goNext, goPrev, isFirstStep, isLastStep } = useWizard(); - const [mode, setMode] = React.useState( - endpoints.authUrl || endpoints.tokenUrl || endpoints.userInfoUrl ? 'manual' : 'discoveryUrl', - ); - - const discoveryUrlField = useFormControl('discoveryUrl', endpoints.discoveryUrl, { + const { + enterpriseConnection, + enterpriseConnectionMutations: { updateConnection }, + } = useConfigureSSO(); + const oauthConfig = enterpriseConnection?.oauthConfig; + const [mode, setMode] = React.useState('discoveryUrl'); + const [isSubmitting, setIsSubmitting] = React.useState(false); + + const discoveryUrlField = useFormControl('discoveryUrl', oauthConfig?.discoveryUrl ?? '', { type: 'text', label: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.discoveryUrl.label'), placeholder: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.discoveryUrl.placeholder'), isRequired: true, }); - const authUrlField = useFormControl('authUrl', endpoints.authUrl, { + const authUrlField = useFormControl('authUrl', '', { type: 'text', label: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.manual.authUrl.label'), placeholder: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.manual.authUrl.placeholder'), isRequired: true, }); - const tokenUrlField = useFormControl('tokenUrl', endpoints.tokenUrl, { + const tokenUrlField = useFormControl('tokenUrl', '', { type: 'text', label: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.manual.tokenUrl.label'), placeholder: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.manual.tokenUrl.placeholder'), isRequired: true, }); - const userInfoUrlField = useFormControl('userInfoUrl', endpoints.userInfoUrl, { + const userInfoUrlField = useFormControl('userInfoUrl', '', { type: 'text', label: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.manual.userInfoUrl.label'), placeholder: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.manual.userInfoUrl.placeholder'), @@ -306,18 +290,38 @@ const OidcEndpointsStep = ({ endpoints, onEndpointsChange }: OidcEndpointsStepPr tokenUrlField.value.trim().length > 0 && userInfoUrlField.value.trim().length > 0; - const handleContinue = (): void => { - if (!isValid) { + const canSubmit = isValid && !isSubmitting; + + const handleContinue = async (): Promise => { + if (!enterpriseConnection || !canSubmit) { return; } - onEndpointsChange({ - discoveryUrl: discoveryUrlField.value.trim(), - authUrl: authUrlField.value.trim(), - tokenUrl: tokenUrlField.value.trim(), - userInfoUrl: userInfoUrlField.value.trim(), - }); - void goNext(); + card.setError(undefined); + setIsSubmitting(true); + + try { + await updateConnection( + enterpriseConnection.id, + mode === 'discoveryUrl' + ? { oidc: { discoveryUrl: discoveryUrlField.value.trim() } } + : { + oidc: { + authUrl: authUrlField.value.trim(), + tokenUrl: tokenUrlField.value.trim(), + userInfoUrl: userInfoUrlField.value.trim(), + }, + }, + ); + void goNext(); + } catch (err) { + handleError( + err as Error, + mode === 'discoveryUrl' ? [discoveryUrlField] : [authUrlField, tokenUrlField, userInfoUrlField], + card.setError, + ); + setIsSubmitting(false); + } }; return ( @@ -387,11 +391,12 @@ const OidcEndpointsStep = ({ endpoints, onEndpointsChange }: OidcEndpointsStepPr goPrev()} - isDisabled={isFirstStep} + isDisabled={isFirstStep || isSubmitting} />
From f8bbba84860ae55fe9449715a9d15b1a84f76189 Mon Sep 17 00:00:00 2001 From: Nicolas Lopes Date: Wed, 22 Jul 2026 11:51:59 -0300 Subject: [PATCH 05/11] feat(ui): add OIDC credentials configuration step --- .changeset/blue-owls-remember.md | 2 +- packages/localizations/src/en-US.ts | 14 +++ packages/shared/src/types/elementIds.ts | 2 + packages/shared/src/types/localization.ts | 13 +++ .../__tests__/ConfigureStep.test.tsx | 36 ++++++ .../oidc/OidcCustomConfigureSteps.tsx | 110 +++++++++++++++++- 6 files changed, 170 insertions(+), 7 deletions(-) diff --git a/.changeset/blue-owls-remember.md b/.changeset/blue-owls-remember.md index 13002dccbfe..837fc8bcf6b 100644 --- a/.changeset/blue-owls-remember.md +++ b/.changeset/blue-owls-remember.md @@ -4,4 +4,4 @@ '@clerk/ui': patch --- -Add the first three setup steps to the experimental OIDC self-serve SSO configuration flow, including a copyable authorized redirect URI, required ID-token claims, and identity provider endpoint configuration. +Add the four setup steps to the experimental OIDC self-serve SSO configuration flow, including a copyable authorized redirect URI, required ID-token claims and scopes, identity provider endpoint configuration, and application credentials. diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index e27648ddc57..09b6b056ab2 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -270,6 +270,20 @@ export const enUS: LocalizationResource = { }, credentialsStep: { headerSubtitle: 'Add your application credentials', + clientId: { + label: 'Client ID', + placeholder: 'Paste client ID here...', + }, + clientSecret: { + label: 'Client secret', + placeholder: 'Paste client secret here...', + }, + paragraph: + 'In your identity provider’s OIDC application, retrieve your application credentials and paste them below.', + scopes: { + description: 'Make sure your application requests the following scopes:', + title: 'Required scopes', + }, }, endpointsStep: { headerSubtitle: 'Add your identity provider’s endpoints', diff --git a/packages/shared/src/types/elementIds.ts b/packages/shared/src/types/elementIds.ts index 82128ffaaf1..6975892a769 100644 --- a/packages/shared/src/types/elementIds.ts +++ b/packages/shared/src/types/elementIds.ts @@ -35,6 +35,8 @@ export type FieldId = | 'authUrl' | 'tokenUrl' | 'userInfoUrl' + | 'clientId' + | 'clientSecret' | 'redirectUri' | 'acsUrl' | 'spEntityId' diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index a62701fb8b1..801ce9df102 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -1592,6 +1592,19 @@ export type __internal_LocalizationResource = { }; credentialsStep: { headerSubtitle: LocalizationValue; + clientId: { + label: LocalizationValue; + placeholder: LocalizationValue; + }; + clientSecret: { + label: LocalizationValue; + placeholder: LocalizationValue; + }; + paragraph: LocalizationValue; + scopes: { + description: LocalizationValue; + title: LocalizationValue; + }; }; }; samlOkta: { diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/__tests__/ConfigureStep.test.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/__tests__/ConfigureStep.test.tsx index 116b2c1bc54..320fa1a413d 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/__tests__/ConfigureStep.test.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/__tests__/ConfigureStep.test.tsx @@ -156,6 +156,42 @@ describe('ConfigureProviderStep', () => { }); }); + it('saves credentials and displays the required OIDC scopes', async () => { + contextState.provider = 'oidc_clerk_dev'; + contextState.enterpriseConnection = { id: 'ent_123', oauthConfig: null }; + updateConnection.mockReset(); + updateConnection.mockResolvedValue({}); + const { wrapper } = await createFixtures(); + + const { userEvent } = renderStep(wrapper); + + await userEvent.click(await screen.findByRole('button', { name: 'Continue' })); + await userEvent.click(screen.getByRole('button', { name: 'Continue' })); + await userEvent.type(screen.getByRole('textbox'), 'https://idp.example/.well-known/openid-configuration'); + await userEvent.click(screen.getByRole('button', { name: 'Continue' })); + + await vi.waitFor(() => { + expect(document.querySelector('input[name="clientId"]')).not.toBeNull(); + }); + + const clientId = document.querySelector('input[name="clientId"]') as HTMLInputElement; + const clientSecret = document.querySelector('input[name="clientSecret"]') as HTMLInputElement; + await userEvent.type(clientId, 'client_123'); + await userEvent.type(clientSecret, 'secret_456'); + + expect(screen.getByText('openid')).toBeInTheDocument(); + expect(screen.getByText('profile')).toBeInTheDocument(); + expect(screen.getByText('email')).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: 'Continue' })); + + await vi.waitFor(() => { + expect(updateConnection).toHaveBeenLastCalledWith('ent_123', { + oidc: { clientId: 'client_123', clientSecret: 'secret_456' }, + }); + }); + }); + it('degrades to the unsupported-provider state for a provider the SDK does not recognize', async () => { contextState.provider = 'ldap_enterprise'; const { wrapper } = await createFixtures(); diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx index 0e15f91100a..c433976a668 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx @@ -404,7 +404,49 @@ const OidcEndpointsStep = (): JSX.Element => { }; const OidcCredentialsStep = (): JSX.Element => { - const { goNext, goPrev, isFirstStep, isLastStep } = useWizard(); + const card = useCardState(); + const { goNext, goPrev, isFirstStep } = useWizard(); + const { + enterpriseConnection, + enterpriseConnectionMutations: { updateConnection }, + } = useConfigureSSO(); + const clientIdField = useFormControl('clientId', enterpriseConnection?.oauthConfig?.clientId ?? '', { + type: 'text', + label: localizationKeys('configureSSO.configureStep.oidcCustom.credentialsStep.clientId.label'), + placeholder: localizationKeys('configureSSO.configureStep.oidcCustom.credentialsStep.clientId.placeholder'), + isRequired: true, + }); + const clientSecretField = useFormControl('clientSecret', '', { + type: 'password', + label: localizationKeys('configureSSO.configureStep.oidcCustom.credentialsStep.clientSecret.label'), + placeholder: localizationKeys('configureSSO.configureStep.oidcCustom.credentialsStep.clientSecret.placeholder'), + isRequired: true, + }); + const [isSubmitting, setIsSubmitting] = React.useState(false); + + const canSubmit = clientIdField.value.trim().length > 0 && clientSecretField.value.trim().length > 0 && !isSubmitting; + + const handleContinue = async (): Promise => { + if (!enterpriseConnection || !canSubmit) { + return; + } + + card.setError(undefined); + setIsSubmitting(true); + + try { + await updateConnection(enterpriseConnection.id, { + oidc: { + clientId: clientIdField.value.trim(), + clientSecret: clientSecretField.value.trim(), + }, + }); + void goNext(); + } catch (err) { + handleError(err as Error, [clientIdField, clientSecretField], card.setError); + setIsSubmitting(false); + } + }; return ( <> @@ -415,20 +457,76 @@ const OidcCredentialsStep = (): JSX.Element => { - + + + + + + + + + + + + + + + + ({ gap: theme.space.$2 })} + > + {OIDC_REQUIRED_SCOPES.map(scope => ( + + + {scope} + + + ))} + + + + + + goPrev()} - isDisabled={isFirstStep} + isDisabled={isFirstStep || isSubmitting} /> - {/* Terminal step: the connection submit lands with the credentials step ticket; disabled as a placeholder. */} goNext()} - isDisabled={isLastStep} + onClick={handleContinue} + isLoading={isSubmitting} + isDisabled={!canSubmit} /> ); }; + +const OIDC_REQUIRED_SCOPES = ['openid', 'profile', 'email'] as const; From b8a40677984e5ce5abc3923218103d55186b3b95 Mon Sep 17 00:00:00 2001 From: Nicolas Lopes Date: Wed, 22 Jul 2026 12:08:06 -0300 Subject: [PATCH 06/11] fix(ui): align OIDC credentials configuration --- .changeset/blue-owls-remember.md | 3 +- .../core/resources/EnterpriseConnection.ts | 6 + packages/localizations/src/en-US.ts | 9 +- packages/shared/src/types/elementIds.ts | 1 + .../shared/src/types/enterpriseConnection.ts | 6 + packages/shared/src/types/localization.ts | 6 +- .../__tests__/ConfigureStep.test.tsx | 41 +++++- .../oidc/OidcCustomConfigureSteps.tsx | 125 +++++++++++++----- 8 files changed, 156 insertions(+), 41 deletions(-) diff --git a/.changeset/blue-owls-remember.md b/.changeset/blue-owls-remember.md index 837fc8bcf6b..0ff13508bbe 100644 --- a/.changeset/blue-owls-remember.md +++ b/.changeset/blue-owls-remember.md @@ -1,7 +1,8 @@ --- +'@clerk/clerk-js': patch '@clerk/localizations': patch '@clerk/shared': patch '@clerk/ui': patch --- -Add the four setup steps to the experimental OIDC self-serve SSO configuration flow, including a copyable authorized redirect URI, required ID-token claims and scopes, identity provider endpoint configuration, and application credentials. +Add the four setup steps to the experimental OIDC self-serve SSO configuration flow, including a copyable authorized redirect URI, ID-token claims, endpoint configuration, application credentials, and optional scopes. OIDC connections now expose resolved authorization, token, and user-info endpoints. diff --git a/packages/clerk-js/src/core/resources/EnterpriseConnection.ts b/packages/clerk-js/src/core/resources/EnterpriseConnection.ts index 109e7775ce1..c82ce1649ee 100644 --- a/packages/clerk-js/src/core/resources/EnterpriseConnection.ts +++ b/packages/clerk-js/src/core/resources/EnterpriseConnection.ts @@ -60,6 +60,9 @@ function oauthConfigFromJSON(data: EnterpriseOAuthConfigJSON): EnterpriseOAuthCo clientId: data.client_id, providerKey: data.provider_key, discoveryUrl: data.discovery_url, + authUrl: data.auth_url, + tokenUrl: data.token_url, + userInfoUrl: data.user_info_url, logoPublicUrl: data.logo_public_url, requiresPkce: data.requires_pkce, createdAt: unixEpochToDate(data.created_at), @@ -74,6 +77,9 @@ function oauthConfigToJSON(data: EnterpriseOAuthConfigResource): EnterpriseOAuth client_id: data.clientId, provider_key: data.providerKey, discovery_url: data.discoveryUrl, + auth_url: data.authUrl, + token_url: data.tokenUrl, + user_info_url: data.userInfoUrl, logo_public_url: data.logoPublicUrl, requires_pkce: data.requiresPkce, created_at: data.createdAt?.getTime() ?? 0, diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 09b6b056ab2..43816f07b6f 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -278,11 +278,12 @@ export const enUS: LocalizationResource = { label: 'Client secret', placeholder: 'Paste client secret here...', }, - paragraph: - 'In your identity provider’s OIDC application, retrieve your application credentials and paste them below.', + paragraph: 'In your identity provider’s OIDC application, retrieve these values.', scopes: { - description: 'Make sure your application requests the following scopes:', - title: 'Required scopes', + addButton: 'Add', + label: 'Scopes', + optional: 'Optional', + placeholder: 'Paste scope here...', }, }, endpointsStep: { diff --git a/packages/shared/src/types/elementIds.ts b/packages/shared/src/types/elementIds.ts index 6975892a769..41ad3141a13 100644 --- a/packages/shared/src/types/elementIds.ts +++ b/packages/shared/src/types/elementIds.ts @@ -37,6 +37,7 @@ export type FieldId = | 'userInfoUrl' | 'clientId' | 'clientSecret' + | 'scopes' | 'redirectUri' | 'acsUrl' | 'spEntityId' diff --git a/packages/shared/src/types/enterpriseConnection.ts b/packages/shared/src/types/enterpriseConnection.ts index 35a3f8a336f..e218f559503 100644 --- a/packages/shared/src/types/enterpriseConnection.ts +++ b/packages/shared/src/types/enterpriseConnection.ts @@ -101,6 +101,9 @@ export interface EnterpriseOAuthConfigJSON { provider_key?: string; client_id: string; discovery_url?: string; + auth_url?: string; + token_url?: string; + user_info_url?: string; logo_public_url?: string | null; requires_pkce?: boolean; created_at: number; @@ -113,6 +116,9 @@ export interface EnterpriseOAuthConfigResource { clientId: string; providerKey?: string; discoveryUrl?: string; + authUrl?: string; + tokenUrl?: string; + userInfoUrl?: string; logoPublicUrl?: string | null; requiresPkce?: boolean; createdAt: Date | null; diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index 801ce9df102..1a5ee6a6125 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -1602,8 +1602,10 @@ export type __internal_LocalizationResource = { }; paragraph: LocalizationValue; scopes: { - description: LocalizationValue; - title: LocalizationValue; + addButton: LocalizationValue; + label: LocalizationValue; + optional: LocalizationValue; + placeholder: LocalizationValue; }; }; }; diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/__tests__/ConfigureStep.test.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/__tests__/ConfigureStep.test.tsx index 320fa1a413d..945de78a240 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/__tests__/ConfigureStep.test.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/__tests__/ConfigureStep.test.tsx @@ -12,7 +12,12 @@ import type { EnterpriseConnectionProviderType } from '../../../types'; // left undefined so that footer self-hides in this isolated render. const contextState = vi.hoisted(() => ({ provider: undefined as string | undefined, - enterpriseConnection: undefined as { id: string; oauthConfig: { discoveryUrl?: string } | null } | undefined, + enterpriseConnection: undefined as + | { + id: string; + oauthConfig: { discoveryUrl?: string; authUrl?: string; tokenUrl?: string; userInfoUrl?: string } | null; + } + | undefined, })); const updateConnection = vi.hoisted(() => vi.fn()); @@ -81,7 +86,7 @@ describe('ConfigureProviderStep', () => { // The OIDC sub-flow mounts on its first step. Before the fix this threw // `No steps found for provider: oidc_clerk_dev` and white-screened the wizard. - expect(await screen.findByText(/create a new oidc application/i)).toBeInTheDocument(); + expect(await screen.findAllByText(/create a new oidc application/i)).not.toHaveLength(0); const redirectUri = screen.getByRole('textbox') as HTMLInputElement; expect(redirectUri).toHaveAttribute('readonly'); expect(redirectUri.value).toMatch(/^https:\/\/.+\/v1\/oauth_callback$/); @@ -156,7 +161,7 @@ describe('ConfigureProviderStep', () => { }); }); - it('saves credentials and displays the required OIDC scopes', async () => { + it('saves credentials and lets the user manage optional OIDC scopes', async () => { contextState.provider = 'oidc_clerk_dev'; contextState.enterpriseConnection = { id: 'ent_123', oauthConfig: null }; updateConnection.mockReset(); @@ -181,6 +186,11 @@ describe('ConfigureProviderStep', () => { expect(screen.getByText('openid')).toBeInTheDocument(); expect(screen.getByText('profile')).toBeInTheDocument(); + expect(screen.getByText('Optional')).toBeInTheDocument(); + + const scopeField = document.querySelector('input[name="scopes"]') as HTMLInputElement; + await userEvent.type(scopeField, 'email'); + await userEvent.click(screen.getByRole('button', { name: 'Add' })); expect(screen.getByText('email')).toBeInTheDocument(); await userEvent.click(screen.getByRole('button', { name: 'Continue' })); @@ -192,6 +202,31 @@ describe('ConfigureProviderStep', () => { }); }); + it('populates manual endpoints resolved from discovery', async () => { + contextState.provider = 'oidc_clerk_dev'; + contextState.enterpriseConnection = { + id: 'ent_123', + oauthConfig: { + discoveryUrl: 'https://idp.example/.well-known/openid-configuration', + authUrl: 'https://idp.example/authorize', + tokenUrl: 'https://idp.example/token', + userInfoUrl: 'https://idp.example/userinfo', + }, + }; + const { wrapper } = await createFixtures(); + + const { userEvent } = renderStep(wrapper); + + await userEvent.click(await screen.findByRole('button', { name: 'Continue' })); + await userEvent.click(screen.getByRole('button', { name: 'Continue' })); + const [, manualMode] = await screen.findAllByRole('radio'); + await userEvent.click(manualMode); + + expect(document.querySelector('input[name="authUrl"]')).toHaveValue('https://idp.example/authorize'); + expect(document.querySelector('input[name="tokenUrl"]')).toHaveValue('https://idp.example/token'); + expect(document.querySelector('input[name="userInfoUrl"]')).toHaveValue('https://idp.example/userinfo'); + }); + it('degrades to the unsupported-provider state for a provider the SDK does not recognize', async () => { contextState.provider = 'ldap_enterprise'; const { wrapper } = await createFixtures(); diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx index c433976a668..64122eb7e93 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx @@ -3,9 +3,12 @@ import React, { type JSX } from 'react'; import { Badge, + Box, + Button, Col, descriptors, Flex, + Icon, localizationKeys, Table, Tbody, @@ -17,8 +20,9 @@ import { } from '@/customizables'; import { ClipboardInput } from '@/elements/ClipboardInput'; import { useCardState } from '@/elements/contexts'; +import { Field } from '@/elements/FieldControl'; import { Form } from '@/elements/Form'; -import { Checkmark, Clipboard } from '@/icons'; +import { Checkmark, Clipboard, Close } from '@/icons'; import { useFormControl } from '@/ui/utils/useFormControl'; import { handleError } from '@/utils/errorHandler'; @@ -264,19 +268,19 @@ const OidcEndpointsStep = (): JSX.Element => { placeholder: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.discoveryUrl.placeholder'), isRequired: true, }); - const authUrlField = useFormControl('authUrl', '', { + const authUrlField = useFormControl('authUrl', oauthConfig?.authUrl ?? '', { type: 'text', label: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.manual.authUrl.label'), placeholder: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.manual.authUrl.placeholder'), isRequired: true, }); - const tokenUrlField = useFormControl('tokenUrl', '', { + const tokenUrlField = useFormControl('tokenUrl', oauthConfig?.tokenUrl ?? '', { type: 'text', label: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.manual.tokenUrl.label'), placeholder: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.manual.tokenUrl.placeholder'), isRequired: true, }); - const userInfoUrlField = useFormControl('userInfoUrl', '', { + const userInfoUrlField = useFormControl('userInfoUrl', oauthConfig?.userInfoUrl ?? '', { type: 'text', label: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.manual.userInfoUrl.label'), placeholder: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.manual.userInfoUrl.placeholder'), @@ -422,10 +426,27 @@ const OidcCredentialsStep = (): JSX.Element => { placeholder: localizationKeys('configureSSO.configureStep.oidcCustom.credentialsStep.clientSecret.placeholder'), isRequired: true, }); + const scopeField = useFormControl('scopes', '', { + type: 'text', + label: localizationKeys('configureSSO.configureStep.oidcCustom.credentialsStep.scopes.label'), + placeholder: localizationKeys('configureSSO.configureStep.oidcCustom.credentialsStep.scopes.placeholder'), + isRequired: false, + }); + const [scopes, setScopes] = React.useState(['openid', 'profile']); const [isSubmitting, setIsSubmitting] = React.useState(false); const canSubmit = clientIdField.value.trim().length > 0 && clientSecretField.value.trim().length > 0 && !isSubmitting; + const addScope = (): void => { + const scope = scopeField.value.trim(); + if (!scope || scopes.includes(scope)) { + return; + } + + setScopes(previousScopes => [...previousScopes, scope]); + scopeField.setValue(''); + }; + const handleContinue = async (): Promise => { if (!enterpriseConnection || !canSubmit) { return; @@ -476,27 +497,58 @@ const OidcCredentialsStep = (): JSX.Element => { - - - - ({ gap: theme.space.$2 })} - > - {OIDC_REQUIRED_SCOPES.map(scope => ( - + + ({ flex: 1, gap: theme.space.$2 })}> + + ({ gap: theme.space.$2 })} + > + + + + + ({ gap: theme.space.$2 })} + > + + + + + + + ))} + @@ -528,5 +593,3 @@ const OidcCredentialsStep = (): JSX.Element => { ); }; - -const OIDC_REQUIRED_SCOPES = ['openid', 'profile', 'email'] as const; From 0f4794067d4be665c2ed6a4a925b381bd9d0af9a Mon Sep 17 00:00:00 2001 From: Nicolas Lopes Date: Wed, 22 Jul 2026 13:22:50 -0300 Subject: [PATCH 07/11] fix(ui): use OIDC redirect URI from FAPI --- .changeset/blue-owls-remember.md | 2 +- .../src/core/resources/EnterpriseConnection.ts | 2 ++ packages/shared/src/types/enterpriseConnection.ts | 2 ++ .../__tests__/ConfigureStep.test.tsx | 15 ++++++++++++--- .../oidc/OidcCustomConfigureSteps.tsx | 5 ++--- 5 files changed, 19 insertions(+), 7 deletions(-) diff --git a/.changeset/blue-owls-remember.md b/.changeset/blue-owls-remember.md index 0ff13508bbe..6b01a87ff7f 100644 --- a/.changeset/blue-owls-remember.md +++ b/.changeset/blue-owls-remember.md @@ -5,4 +5,4 @@ '@clerk/ui': patch --- -Add the four setup steps to the experimental OIDC self-serve SSO configuration flow, including a copyable authorized redirect URI, ID-token claims, endpoint configuration, application credentials, and optional scopes. OIDC connections now expose resolved authorization, token, and user-info endpoints. +Add the four setup steps to the experimental OIDC self-serve SSO configuration flow, including a copyable authorized redirect URI, ID-token claims, endpoint configuration, application credentials, and optional scopes. OIDC connections now expose their callback, authorization, token, and user-info endpoints. diff --git a/packages/clerk-js/src/core/resources/EnterpriseConnection.ts b/packages/clerk-js/src/core/resources/EnterpriseConnection.ts index c82ce1649ee..f50213ded64 100644 --- a/packages/clerk-js/src/core/resources/EnterpriseConnection.ts +++ b/packages/clerk-js/src/core/resources/EnterpriseConnection.ts @@ -59,6 +59,7 @@ function oauthConfigFromJSON(data: EnterpriseOAuthConfigJSON): EnterpriseOAuthCo name: data.name, clientId: data.client_id, providerKey: data.provider_key, + redirectUri: data.redirect_uri, discoveryUrl: data.discovery_url, authUrl: data.auth_url, tokenUrl: data.token_url, @@ -76,6 +77,7 @@ function oauthConfigToJSON(data: EnterpriseOAuthConfigResource): EnterpriseOAuth name: data.name, client_id: data.clientId, provider_key: data.providerKey, + redirect_uri: data.redirectUri, discovery_url: data.discoveryUrl, auth_url: data.authUrl, token_url: data.tokenUrl, diff --git a/packages/shared/src/types/enterpriseConnection.ts b/packages/shared/src/types/enterpriseConnection.ts index e218f559503..7944f483159 100644 --- a/packages/shared/src/types/enterpriseConnection.ts +++ b/packages/shared/src/types/enterpriseConnection.ts @@ -100,6 +100,7 @@ export interface EnterpriseOAuthConfigJSON { name: string; provider_key?: string; client_id: string; + redirect_uri?: string; discovery_url?: string; auth_url?: string; token_url?: string; @@ -115,6 +116,7 @@ export interface EnterpriseOAuthConfigResource { name: string; clientId: string; providerKey?: string; + redirectUri?: string; discoveryUrl?: string; authUrl?: string; tokenUrl?: string; diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/__tests__/ConfigureStep.test.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/__tests__/ConfigureStep.test.tsx index 945de78a240..c548d49cb98 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/__tests__/ConfigureStep.test.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/__tests__/ConfigureStep.test.tsx @@ -15,7 +15,13 @@ const contextState = vi.hoisted(() => ({ enterpriseConnection: undefined as | { id: string; - oauthConfig: { discoveryUrl?: string; authUrl?: string; tokenUrl?: string; userInfoUrl?: string } | null; + oauthConfig: { + redirectUri?: string; + discoveryUrl?: string; + authUrl?: string; + tokenUrl?: string; + userInfoUrl?: string; + } | null; } | undefined, })); @@ -79,7 +85,10 @@ describe('ConfigureProviderStep', () => { it('renders the OIDC configure steps for a derived provider key without throwing', async () => { contextState.provider = 'oidc_clerk_dev'; - contextState.enterpriseConnection = undefined; + contextState.enterpriseConnection = { + id: 'ent_123', + oauthConfig: { redirectUri: 'https://instance.example/v1/oauth_callback' }, + }; const { wrapper } = await createFixtures(); const { userEvent } = renderStep(wrapper); @@ -89,7 +98,7 @@ describe('ConfigureProviderStep', () => { expect(await screen.findAllByText(/create a new oidc application/i)).not.toHaveLength(0); const redirectUri = screen.getByRole('textbox') as HTMLInputElement; expect(redirectUri).toHaveAttribute('readonly'); - expect(redirectUri.value).toMatch(/^https:\/\/.+\/v1\/oauth_callback$/); + expect(redirectUri).toHaveValue('https://instance.example/v1/oauth_callback'); await userEvent.click(screen.getByRole('button', { name: 'Continue' })); diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx index 64122eb7e93..c67bf5f5b2c 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx @@ -1,4 +1,3 @@ -import { useClerk } from '@clerk/shared/react'; import React, { type JSX } from 'react'; import { @@ -71,8 +70,8 @@ export const OidcCustomConfigureSteps = (): JSX.Element => { const OidcRedirectUriStep = (): JSX.Element => { const { goNext, goPrev, isFirstStep, isLastStep } = useWizard(); - const { frontendApi } = useClerk(); - const redirectUri = `https://${frontendApi}/v1/oauth_callback`; + const { enterpriseConnection } = useConfigureSSO(); + const redirectUri = enterpriseConnection?.oauthConfig?.redirectUri ?? ''; const redirectUriField = useFormControl('redirectUri', redirectUri, { type: 'text', label: localizationKeys('configureSSO.configureStep.oidcCustom.redirectUriStep.redirectUri.label'), From 84e8714c1158b85db65be4426179c645bb550f4d Mon Sep 17 00:00:00 2001 From: Nicolas Lopes Date: Wed, 22 Jul 2026 13:30:44 -0300 Subject: [PATCH 08/11] fix(ui): show OIDC debug redirect URI --- .changeset/blue-owls-remember.md | 2 +- .../src/core/resources/EnterpriseConnection.ts | 2 ++ packages/localizations/src/en-US.ts | 3 +++ packages/shared/src/types/elementIds.ts | 1 + .../shared/src/types/enterpriseConnection.ts | 2 ++ packages/shared/src/types/localization.ts | 3 +++ .../__tests__/ConfigureStep.test.tsx | 10 ++++++++-- .../oidc/OidcCustomConfigureSteps.tsx | 17 +++++++++++++++++ 8 files changed, 37 insertions(+), 3 deletions(-) diff --git a/.changeset/blue-owls-remember.md b/.changeset/blue-owls-remember.md index 6b01a87ff7f..c6fa75a1971 100644 --- a/.changeset/blue-owls-remember.md +++ b/.changeset/blue-owls-remember.md @@ -5,4 +5,4 @@ '@clerk/ui': patch --- -Add the four setup steps to the experimental OIDC self-serve SSO configuration flow, including a copyable authorized redirect URI, ID-token claims, endpoint configuration, application credentials, and optional scopes. OIDC connections now expose their callback, authorization, token, and user-info endpoints. +Add the four setup steps to the experimental OIDC self-serve SSO configuration flow, including copyable authorized and debug redirect URIs, ID-token claims, endpoint configuration, application credentials, and optional scopes. OIDC connections now expose their callback, authorization, token, and user-info endpoints. diff --git a/packages/clerk-js/src/core/resources/EnterpriseConnection.ts b/packages/clerk-js/src/core/resources/EnterpriseConnection.ts index f50213ded64..a95007d6d4f 100644 --- a/packages/clerk-js/src/core/resources/EnterpriseConnection.ts +++ b/packages/clerk-js/src/core/resources/EnterpriseConnection.ts @@ -60,6 +60,7 @@ function oauthConfigFromJSON(data: EnterpriseOAuthConfigJSON): EnterpriseOAuthCo clientId: data.client_id, providerKey: data.provider_key, redirectUri: data.redirect_uri, + debugRedirectUri: data.debug_redirect_uri, discoveryUrl: data.discovery_url, authUrl: data.auth_url, tokenUrl: data.token_url, @@ -78,6 +79,7 @@ function oauthConfigToJSON(data: EnterpriseOAuthConfigResource): EnterpriseOAuth client_id: data.clientId, provider_key: data.providerKey, redirect_uri: data.redirectUri, + debug_redirect_uri: data.debugRedirectUri, discovery_url: data.discoveryUrl, auth_url: data.authUrl, token_url: data.tokenUrl, diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 43816f07b6f..456ae98403f 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -323,6 +323,9 @@ export const enUS: LocalizationResource = { redirectUri: { label: 'Authorized redirect URI', }, + debugRedirectUri: { + label: 'Authorized redirect URL (only for debug)', + }, }, }, samlCustom: { diff --git a/packages/shared/src/types/elementIds.ts b/packages/shared/src/types/elementIds.ts index 41ad3141a13..a9d108b668c 100644 --- a/packages/shared/src/types/elementIds.ts +++ b/packages/shared/src/types/elementIds.ts @@ -39,6 +39,7 @@ export type FieldId = | 'clientSecret' | 'scopes' | 'redirectUri' + | 'debugRedirectUri' | 'acsUrl' | 'spEntityId' | 'web3WalletName' diff --git a/packages/shared/src/types/enterpriseConnection.ts b/packages/shared/src/types/enterpriseConnection.ts index 7944f483159..3b88be56a1f 100644 --- a/packages/shared/src/types/enterpriseConnection.ts +++ b/packages/shared/src/types/enterpriseConnection.ts @@ -101,6 +101,7 @@ export interface EnterpriseOAuthConfigJSON { provider_key?: string; client_id: string; redirect_uri?: string; + debug_redirect_uri?: string; discovery_url?: string; auth_url?: string; token_url?: string; @@ -117,6 +118,7 @@ export interface EnterpriseOAuthConfigResource { clientId: string; providerKey?: string; redirectUri?: string; + debugRedirectUri?: string; discoveryUrl?: string; authUrl?: string; tokenUrl?: string; diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index 1a5ee6a6125..66a20dc550f 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -1533,6 +1533,9 @@ export type __internal_LocalizationResource = { redirectUri: { label: LocalizationValue; }; + debugRedirectUri: { + label: LocalizationValue; + }; }; claimsStep: { attributeMappingTable: { diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/__tests__/ConfigureStep.test.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/__tests__/ConfigureStep.test.tsx index c548d49cb98..1871c7fb6ac 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/__tests__/ConfigureStep.test.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/__tests__/ConfigureStep.test.tsx @@ -17,6 +17,7 @@ const contextState = vi.hoisted(() => ({ id: string; oauthConfig: { redirectUri?: string; + debugRedirectUri?: string; discoveryUrl?: string; authUrl?: string; tokenUrl?: string; @@ -87,7 +88,10 @@ describe('ConfigureProviderStep', () => { contextState.provider = 'oidc_clerk_dev'; contextState.enterpriseConnection = { id: 'ent_123', - oauthConfig: { redirectUri: 'https://instance.example/v1/oauth_callback' }, + oauthConfig: { + redirectUri: 'https://instance.example/v1/oauth_callback', + debugRedirectUri: 'https://instance.example/v1/oauth_callback/debug', + }, }; const { wrapper } = await createFixtures(); @@ -96,9 +100,11 @@ describe('ConfigureProviderStep', () => { // The OIDC sub-flow mounts on its first step. Before the fix this threw // `No steps found for provider: oidc_clerk_dev` and white-screened the wizard. expect(await screen.findAllByText(/create a new oidc application/i)).not.toHaveLength(0); - const redirectUri = screen.getByRole('textbox') as HTMLInputElement; + const [redirectUri, debugRedirectUri] = screen.getAllByRole('textbox') as HTMLInputElement[]; expect(redirectUri).toHaveAttribute('readonly'); expect(redirectUri).toHaveValue('https://instance.example/v1/oauth_callback'); + expect(debugRedirectUri).toHaveAttribute('readonly'); + expect(debugRedirectUri).toHaveValue('https://instance.example/v1/oauth_callback/debug'); await userEvent.click(screen.getByRole('button', { name: 'Continue' })); diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx index c67bf5f5b2c..e4fd25a94dc 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx @@ -72,11 +72,17 @@ const OidcRedirectUriStep = (): JSX.Element => { const { goNext, goPrev, isFirstStep, isLastStep } = useWizard(); const { enterpriseConnection } = useConfigureSSO(); const redirectUri = enterpriseConnection?.oauthConfig?.redirectUri ?? ''; + const debugRedirectUri = enterpriseConnection?.oauthConfig?.debugRedirectUri ?? ''; const redirectUriField = useFormControl('redirectUri', redirectUri, { type: 'text', label: localizationKeys('configureSSO.configureStep.oidcCustom.redirectUriStep.redirectUri.label'), isRequired: false, }); + const debugRedirectUriField = useFormControl('debugRedirectUri', debugRedirectUri, { + type: 'text', + label: localizationKeys('configureSSO.configureStep.oidcCustom.redirectUriStep.debugRedirectUri.label'), + isRequired: false, + }); return ( <> @@ -107,6 +113,17 @@ const OidcRedirectUriStep = (): JSX.Element => { /> + + + + + + From 0f9ef241891315a49d0b1a86af1a57b4ad037c5a Mon Sep 17 00:00:00 2001 From: Nicolas Lopes Date: Thu, 23 Jul 2026 09:06:06 -0300 Subject: [PATCH 09/11] remove debug URI field --- .changeset/blue-owls-remember.md | 2 +- AGENTS.md | 17 +++++++++++++++++ .../src/core/resources/EnterpriseConnection.ts | 2 -- packages/localizations/src/en-US.ts | 3 --- packages/shared/src/types/elementIds.ts | 1 - .../shared/src/types/enterpriseConnection.ts | 2 -- packages/shared/src/types/localization.ts | 3 --- .../__tests__/ConfigureStep.test.tsx | 6 +----- .../oidc/OidcCustomConfigureSteps.tsx | 17 ----------------- 9 files changed, 19 insertions(+), 34 deletions(-) diff --git a/.changeset/blue-owls-remember.md b/.changeset/blue-owls-remember.md index c6fa75a1971..9924a1e2fb4 100644 --- a/.changeset/blue-owls-remember.md +++ b/.changeset/blue-owls-remember.md @@ -5,4 +5,4 @@ '@clerk/ui': patch --- -Add the four setup steps to the experimental OIDC self-serve SSO configuration flow, including copyable authorized and debug redirect URIs, ID-token claims, endpoint configuration, application credentials, and optional scopes. OIDC connections now expose their callback, authorization, token, and user-info endpoints. +Add the four setup steps to the experimental OIDC self-serve SSO configuration flow, including copyable authorized redirect URI, ID-token claims, endpoint configuration, application credentials, and optional scopes. OIDC connections now expose their callback, authorization, token, and user-info endpoints. diff --git a/AGENTS.md b/AGENTS.md index 65565c4662b..6961534141a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,3 +17,20 @@ Clerk's JavaScript SDK and library monorepo. - For the Mosaic design system (tokens, CVA utility, `MosaicProvider`, migration from existing system), see `references/mosaic-architecture.md`. - For dev setup, testing, JSDoc/Typedoc, publishing, changesets, and commit conventions, see `docs/CONTRIBUTING.md`. - For working in the repo day to day (setup ordering and footguns, the package map, dev-loop recipes, and the breaking-change checklist), the `clerk-monorepo` Claude Code skill in `.claude/skills/clerk-monorepo/` restates these rules in actionable form. + + +# Memory Context + +# claude-mem status + +This project has no memory yet. The current session will seed it; subsequent sessions will receive auto-injected context for relevant past work. + +Memory injection starts on your second session in a project. + +`/learn-codebase` is available if the user wants to front-load the entire repo into memory in a single pass (~5 minutes on a typical repo, optional). Otherwise memory builds passively as work happens. + +Live activity: http://localhost:37701 +How it works: `/how-it-works` + +This message disappears once the first observation lands. + diff --git a/packages/clerk-js/src/core/resources/EnterpriseConnection.ts b/packages/clerk-js/src/core/resources/EnterpriseConnection.ts index a95007d6d4f..f50213ded64 100644 --- a/packages/clerk-js/src/core/resources/EnterpriseConnection.ts +++ b/packages/clerk-js/src/core/resources/EnterpriseConnection.ts @@ -60,7 +60,6 @@ function oauthConfigFromJSON(data: EnterpriseOAuthConfigJSON): EnterpriseOAuthCo clientId: data.client_id, providerKey: data.provider_key, redirectUri: data.redirect_uri, - debugRedirectUri: data.debug_redirect_uri, discoveryUrl: data.discovery_url, authUrl: data.auth_url, tokenUrl: data.token_url, @@ -79,7 +78,6 @@ function oauthConfigToJSON(data: EnterpriseOAuthConfigResource): EnterpriseOAuth client_id: data.clientId, provider_key: data.providerKey, redirect_uri: data.redirectUri, - debug_redirect_uri: data.debugRedirectUri, discovery_url: data.discoveryUrl, auth_url: data.authUrl, token_url: data.tokenUrl, diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 456ae98403f..43816f07b6f 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -323,9 +323,6 @@ export const enUS: LocalizationResource = { redirectUri: { label: 'Authorized redirect URI', }, - debugRedirectUri: { - label: 'Authorized redirect URL (only for debug)', - }, }, }, samlCustom: { diff --git a/packages/shared/src/types/elementIds.ts b/packages/shared/src/types/elementIds.ts index a9d108b668c..41ad3141a13 100644 --- a/packages/shared/src/types/elementIds.ts +++ b/packages/shared/src/types/elementIds.ts @@ -39,7 +39,6 @@ export type FieldId = | 'clientSecret' | 'scopes' | 'redirectUri' - | 'debugRedirectUri' | 'acsUrl' | 'spEntityId' | 'web3WalletName' diff --git a/packages/shared/src/types/enterpriseConnection.ts b/packages/shared/src/types/enterpriseConnection.ts index 3b88be56a1f..7944f483159 100644 --- a/packages/shared/src/types/enterpriseConnection.ts +++ b/packages/shared/src/types/enterpriseConnection.ts @@ -101,7 +101,6 @@ export interface EnterpriseOAuthConfigJSON { provider_key?: string; client_id: string; redirect_uri?: string; - debug_redirect_uri?: string; discovery_url?: string; auth_url?: string; token_url?: string; @@ -118,7 +117,6 @@ export interface EnterpriseOAuthConfigResource { clientId: string; providerKey?: string; redirectUri?: string; - debugRedirectUri?: string; discoveryUrl?: string; authUrl?: string; tokenUrl?: string; diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index 66a20dc550f..1a5ee6a6125 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -1533,9 +1533,6 @@ export type __internal_LocalizationResource = { redirectUri: { label: LocalizationValue; }; - debugRedirectUri: { - label: LocalizationValue; - }; }; claimsStep: { attributeMappingTable: { diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/__tests__/ConfigureStep.test.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/__tests__/ConfigureStep.test.tsx index 1871c7fb6ac..50c35d77166 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/__tests__/ConfigureStep.test.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/__tests__/ConfigureStep.test.tsx @@ -17,7 +17,6 @@ const contextState = vi.hoisted(() => ({ id: string; oauthConfig: { redirectUri?: string; - debugRedirectUri?: string; discoveryUrl?: string; authUrl?: string; tokenUrl?: string; @@ -90,7 +89,6 @@ describe('ConfigureProviderStep', () => { id: 'ent_123', oauthConfig: { redirectUri: 'https://instance.example/v1/oauth_callback', - debugRedirectUri: 'https://instance.example/v1/oauth_callback/debug', }, }; const { wrapper } = await createFixtures(); @@ -100,11 +98,9 @@ describe('ConfigureProviderStep', () => { // The OIDC sub-flow mounts on its first step. Before the fix this threw // `No steps found for provider: oidc_clerk_dev` and white-screened the wizard. expect(await screen.findAllByText(/create a new oidc application/i)).not.toHaveLength(0); - const [redirectUri, debugRedirectUri] = screen.getAllByRole('textbox') as HTMLInputElement[]; + const [redirectUri] = screen.getAllByRole('textbox') as HTMLInputElement[]; expect(redirectUri).toHaveAttribute('readonly'); expect(redirectUri).toHaveValue('https://instance.example/v1/oauth_callback'); - expect(debugRedirectUri).toHaveAttribute('readonly'); - expect(debugRedirectUri).toHaveValue('https://instance.example/v1/oauth_callback/debug'); await userEvent.click(screen.getByRole('button', { name: 'Continue' })); diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx index e4fd25a94dc..c67bf5f5b2c 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx @@ -72,17 +72,11 @@ const OidcRedirectUriStep = (): JSX.Element => { const { goNext, goPrev, isFirstStep, isLastStep } = useWizard(); const { enterpriseConnection } = useConfigureSSO(); const redirectUri = enterpriseConnection?.oauthConfig?.redirectUri ?? ''; - const debugRedirectUri = enterpriseConnection?.oauthConfig?.debugRedirectUri ?? ''; const redirectUriField = useFormControl('redirectUri', redirectUri, { type: 'text', label: localizationKeys('configureSSO.configureStep.oidcCustom.redirectUriStep.redirectUri.label'), isRequired: false, }); - const debugRedirectUriField = useFormControl('debugRedirectUri', debugRedirectUri, { - type: 'text', - label: localizationKeys('configureSSO.configureStep.oidcCustom.redirectUriStep.debugRedirectUri.label'), - isRequired: false, - }); return ( <> @@ -113,17 +107,6 @@ const OidcRedirectUriStep = (): JSX.Element => { /> - - - - - -
From eb3360ea7ee53da03d55cef1002cf2a392b11d44 Mon Sep 17 00:00:00 2001 From: Nicolas Lopes Date: Thu, 23 Jul 2026 09:11:25 -0300 Subject: [PATCH 10/11] refactor: move step into separate components rather than single-file --- AGENTS.md | 17 - .../oidc/OidcCustomConfigureSteps.tsx | 564 +----------------- .../oidc/shared/OidcClaimsStep.tsx | 130 ++++ .../oidc/shared/OidcCredentialsStep.tsx | 202 +++++++ .../shared/OidcEndpointsConfigurationForm.tsx | 75 +++ .../oidc/shared/OidcEndpointsStep.tsx | 162 +++++ .../oidc/shared/OidcRedirectUriStep.tsx | 69 +++ 7 files changed, 643 insertions(+), 576 deletions(-) create mode 100644 packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/shared/OidcClaimsStep.tsx create mode 100644 packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/shared/OidcCredentialsStep.tsx create mode 100644 packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/shared/OidcEndpointsConfigurationForm.tsx create mode 100644 packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/shared/OidcEndpointsStep.tsx create mode 100644 packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/shared/OidcRedirectUriStep.tsx diff --git a/AGENTS.md b/AGENTS.md index 6961534141a..65565c4662b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,20 +17,3 @@ Clerk's JavaScript SDK and library monorepo. - For the Mosaic design system (tokens, CVA utility, `MosaicProvider`, migration from existing system), see `references/mosaic-architecture.md`. - For dev setup, testing, JSDoc/Typedoc, publishing, changesets, and commit conventions, see `docs/CONTRIBUTING.md`. - For working in the repo day to day (setup ordering and footguns, the package map, dev-loop recipes, and the breaking-change checklist), the `clerk-monorepo` Claude Code skill in `.claude/skills/clerk-monorepo/` restates these rules in actionable form. - - -# Memory Context - -# claude-mem status - -This project has no memory yet. The current session will seed it; subsequent sessions will receive auto-injected context for relevant past work. - -Memory injection starts on your second session in a project. - -`/learn-codebase` is available if the user wants to front-load the entire repo into memory in a single pass (~5 minutes on a typical repo, optional). Otherwise memory builds passively as work happens. - -Live activity: http://localhost:37701 -How it works: `/how-it-works` - -This message disappears once the first observation lands. - diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx index c67bf5f5b2c..96de6ff8430 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx @@ -1,39 +1,10 @@ import React, { type JSX } from 'react'; -import { - Badge, - Box, - Button, - Col, - descriptors, - Flex, - Icon, - localizationKeys, - Table, - Tbody, - Td, - Text, - Th, - Thead, - Tr, -} from '@/customizables'; -import { ClipboardInput } from '@/elements/ClipboardInput'; -import { useCardState } from '@/elements/contexts'; -import { Field } from '@/elements/FieldControl'; -import { Form } from '@/elements/Form'; -import { Checkmark, Clipboard, Close } from '@/icons'; -import { useFormControl } from '@/ui/utils/useFormControl'; -import { handleError } from '@/utils/errorHandler'; - -import { useConfigureSSO } from '../../../ConfigureSSOContext'; -import { Step } from '../../../elements/Step'; -import { useWizard, Wizard, type WizardStepConfig } from '../../../elements/Wizard'; -import { InnerStepCounter } from '../../../elements/Wizard/InnerStepCounter'; -import { ActiveConnectionAlert } from '../saml/shared/ActiveConnectionAlert'; -import { - IdentityProviderConfigurationModes, - type IdpConfigurationMode, -} from '../saml/shared/IdentityProviderConfigurationModes'; +import { Wizard, type WizardStepConfig } from '../../../elements/Wizard'; +import { OidcClaimsStep } from './shared/OidcClaimsStep'; +import { OidcCredentialsStep } from './shared/OidcCredentialsStep'; +import { OidcEndpointsStep } from './shared/OidcEndpointsStep'; +import { OidcRedirectUriStep } from './shared/OidcRedirectUriStep'; const OIDC_STEPS: WizardStepConfig[] = [ { id: 'redirect-uri' }, @@ -67,528 +38,3 @@ export const OidcCustomConfigureSteps = (): JSX.Element => { ); }; - -const OidcRedirectUriStep = (): JSX.Element => { - const { goNext, goPrev, isFirstStep, isLastStep } = useWizard(); - const { enterpriseConnection } = useConfigureSSO(); - const redirectUri = enterpriseConnection?.oauthConfig?.redirectUri ?? ''; - const redirectUriField = useFormControl('redirectUri', redirectUri, { - type: 'text', - label: localizationKeys('configureSSO.configureStep.oidcCustom.redirectUriStep.redirectUri.label'), - isRequired: false, - }); - - return ( - <> - - - - - - ({ gap: theme.space.$5 })}> - ({ gap: theme.space.$1x5 })}> - - - - - - - - - - - - - - goPrev()} - isDisabled={isFirstStep} - /> - goNext()} - isDisabled={isLastStep} - /> - - - ); -}; - -const OidcClaimsStep = (): JSX.Element => { - const { goNext, goPrev, isFirstStep, isLastStep } = useWizard(); - - return ( - <> - - - - - - ({ gap: theme.space.$3 })}> - - - - - - - - - goPrev()} - isDisabled={isFirstStep} - /> - goNext()} - isDisabled={isLastStep} - /> - - - ); -}; - -type OidcClaimRow = { - id: 'subject' | 'email' | 'firstName' | 'lastName'; - isRequired: boolean; -}; - -const OIDC_CLAIM_ROWS: ReadonlyArray = [ - { id: 'subject', isRequired: true }, - { id: 'email', isRequired: true }, - { id: 'firstName', isRequired: false }, - { id: 'lastName', isRequired: false }, -]; - -const OidcClaimsTable = (): JSX.Element => ( - ({ - 'tr > th:first-of-type': { paddingInlineStart: theme.space.$4 }, - })} - > - - - - - - - - {OIDC_CLAIM_ROWS.map(row => ( - - - - - ))} - -
- ({ fontSize: theme.fontSizes.$xs })} - localizationKey={localizationKeys( - 'configureSSO.configureStep.oidcCustom.claimsStep.attributeMappingTable.columns.attributeName', - )} - /> - - ({ fontSize: theme.fontSizes.$xs })} - localizationKey={localizationKeys( - 'configureSSO.configureStep.oidcCustom.claimsStep.attributeMappingTable.columns.userAttribute', - )} - /> -
- ({ gap: theme.space.$2 })} - > - - - - - -
-); - -const OIDC_ENDPOINT_MODES = ['discoveryUrl', 'manual'] as const satisfies readonly IdpConfigurationMode[]; - -const OidcEndpointsStep = (): JSX.Element => { - const card = useCardState(); - const { goNext, goPrev, isFirstStep, isLastStep } = useWizard(); - const { - enterpriseConnection, - enterpriseConnectionMutations: { updateConnection }, - } = useConfigureSSO(); - const oauthConfig = enterpriseConnection?.oauthConfig; - const [mode, setMode] = React.useState('discoveryUrl'); - const [isSubmitting, setIsSubmitting] = React.useState(false); - - const discoveryUrlField = useFormControl('discoveryUrl', oauthConfig?.discoveryUrl ?? '', { - type: 'text', - label: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.discoveryUrl.label'), - placeholder: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.discoveryUrl.placeholder'), - isRequired: true, - }); - const authUrlField = useFormControl('authUrl', oauthConfig?.authUrl ?? '', { - type: 'text', - label: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.manual.authUrl.label'), - placeholder: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.manual.authUrl.placeholder'), - isRequired: true, - }); - const tokenUrlField = useFormControl('tokenUrl', oauthConfig?.tokenUrl ?? '', { - type: 'text', - label: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.manual.tokenUrl.label'), - placeholder: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.manual.tokenUrl.placeholder'), - isRequired: true, - }); - const userInfoUrlField = useFormControl('userInfoUrl', oauthConfig?.userInfoUrl ?? '', { - type: 'text', - label: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.manual.userInfoUrl.label'), - placeholder: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.manual.userInfoUrl.placeholder'), - isRequired: true, - }); - - const isValid = - mode === 'discoveryUrl' - ? discoveryUrlField.value.trim().length > 0 - : authUrlField.value.trim().length > 0 && - tokenUrlField.value.trim().length > 0 && - userInfoUrlField.value.trim().length > 0; - - const canSubmit = isValid && !isSubmitting; - - const handleContinue = async (): Promise => { - if (!enterpriseConnection || !canSubmit) { - return; - } - - card.setError(undefined); - setIsSubmitting(true); - - try { - await updateConnection( - enterpriseConnection.id, - mode === 'discoveryUrl' - ? { oidc: { discoveryUrl: discoveryUrlField.value.trim() } } - : { - oidc: { - authUrl: authUrlField.value.trim(), - tokenUrl: tokenUrlField.value.trim(), - userInfoUrl: userInfoUrlField.value.trim(), - }, - }, - ); - void goNext(); - } catch (err) { - handleError( - err as Error, - mode === 'discoveryUrl' ? [discoveryUrlField] : [authUrlField, tokenUrlField, userInfoUrlField], - card.setError, - ); - setIsSubmitting(false); - } - }; - - return ( - <> - - - - - - - - - {mode === 'discoveryUrl' ? ( - <> - - - - - - ) : ( - <> - - - - - - - - - - - - )} - - - - - - - - goPrev()} - isDisabled={isFirstStep || isSubmitting} - /> - - - - ); -}; - -const OidcCredentialsStep = (): JSX.Element => { - const card = useCardState(); - const { goNext, goPrev, isFirstStep } = useWizard(); - const { - enterpriseConnection, - enterpriseConnectionMutations: { updateConnection }, - } = useConfigureSSO(); - const clientIdField = useFormControl('clientId', enterpriseConnection?.oauthConfig?.clientId ?? '', { - type: 'text', - label: localizationKeys('configureSSO.configureStep.oidcCustom.credentialsStep.clientId.label'), - placeholder: localizationKeys('configureSSO.configureStep.oidcCustom.credentialsStep.clientId.placeholder'), - isRequired: true, - }); - const clientSecretField = useFormControl('clientSecret', '', { - type: 'password', - label: localizationKeys('configureSSO.configureStep.oidcCustom.credentialsStep.clientSecret.label'), - placeholder: localizationKeys('configureSSO.configureStep.oidcCustom.credentialsStep.clientSecret.placeholder'), - isRequired: true, - }); - const scopeField = useFormControl('scopes', '', { - type: 'text', - label: localizationKeys('configureSSO.configureStep.oidcCustom.credentialsStep.scopes.label'), - placeholder: localizationKeys('configureSSO.configureStep.oidcCustom.credentialsStep.scopes.placeholder'), - isRequired: false, - }); - const [scopes, setScopes] = React.useState(['openid', 'profile']); - const [isSubmitting, setIsSubmitting] = React.useState(false); - - const canSubmit = clientIdField.value.trim().length > 0 && clientSecretField.value.trim().length > 0 && !isSubmitting; - - const addScope = (): void => { - const scope = scopeField.value.trim(); - if (!scope || scopes.includes(scope)) { - return; - } - - setScopes(previousScopes => [...previousScopes, scope]); - scopeField.setValue(''); - }; - - const handleContinue = async (): Promise => { - if (!enterpriseConnection || !canSubmit) { - return; - } - - card.setError(undefined); - setIsSubmitting(true); - - try { - await updateConnection(enterpriseConnection.id, { - oidc: { - clientId: clientIdField.value.trim(), - clientSecret: clientSecretField.value.trim(), - }, - }); - void goNext(); - } catch (err) { - handleError(err as Error, [clientIdField, clientSecretField], card.setError); - setIsSubmitting(false); - } - }; - - return ( - <> - - - - - - - - - - - - - - - - - - - ({ flex: 1, gap: theme.space.$2 })}> - - ({ gap: theme.space.$2 })} - > - - - - - ({ gap: theme.space.$2 })} - > - - - - - - - ))} - - - - - - - - - goPrev()} - isDisabled={isFirstStep || isSubmitting} - /> - - - - ); -}; diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/shared/OidcClaimsStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/shared/OidcClaimsStep.tsx new file mode 100644 index 00000000000..8c4133efeb8 --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/shared/OidcClaimsStep.tsx @@ -0,0 +1,130 @@ +import React, { type JSX } from 'react'; + +import { Badge, descriptors, Flex, localizationKeys, Table, Tbody, Td, Text, Th, Thead, Tr } from '@/customizables'; + +import { Step } from '../../../../elements/Step'; +import { useWizard } from '../../../../elements/Wizard'; +import { InnerStepCounter } from '../../../../elements/Wizard/InnerStepCounter'; + +type OidcClaimRow = { + id: 'subject' | 'email' | 'firstName' | 'lastName'; + isRequired: boolean; +}; + +const OIDC_CLAIM_ROWS: ReadonlyArray = [ + { id: 'subject', isRequired: true }, + { id: 'email', isRequired: true }, + { id: 'firstName', isRequired: false }, + { id: 'lastName', isRequired: false }, +]; + +const OidcClaimsTable = (): JSX.Element => ( + ({ + 'tr > th:first-of-type': { paddingInlineStart: theme.space.$4 }, + })} + > + + + + + + + + {OIDC_CLAIM_ROWS.map(row => ( + + + + + ))} + +
+ ({ fontSize: theme.fontSizes.$xs })} + localizationKey={localizationKeys( + 'configureSSO.configureStep.oidcCustom.claimsStep.attributeMappingTable.columns.attributeName', + )} + /> + + ({ fontSize: theme.fontSizes.$xs })} + localizationKey={localizationKeys( + 'configureSSO.configureStep.oidcCustom.claimsStep.attributeMappingTable.columns.userAttribute', + )} + /> +
+ ({ gap: theme.space.$2 })} + > + + + + + +
+); + +export const OidcClaimsStep = (): JSX.Element => { + const { goNext, goPrev, isFirstStep, isLastStep } = useWizard(); + + return ( + <> + + + + + + ({ gap: theme.space.$3 })}> + + + + + + + + + goPrev()} + isDisabled={isFirstStep} + /> + goNext()} + isDisabled={isLastStep} + /> + + + ); +}; diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/shared/OidcCredentialsStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/shared/OidcCredentialsStep.tsx new file mode 100644 index 00000000000..79127c36eaa --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/shared/OidcCredentialsStep.tsx @@ -0,0 +1,202 @@ +import React, { type JSX } from 'react'; + +import { Badge, Box, Button, Col, Flex, Icon, localizationKeys, Text } from '@/customizables'; +import { useCardState } from '@/elements/contexts'; +import { Field } from '@/elements/FieldControl'; +import { Form } from '@/elements/Form'; +import { Close } from '@/icons'; +import { useFormControl } from '@/ui/utils/useFormControl'; +import { handleError } from '@/utils/errorHandler'; + +import { useConfigureSSO } from '../../../../ConfigureSSOContext'; +import { Step } from '../../../../elements/Step'; +import { useWizard } from '../../../../elements/Wizard'; +import { InnerStepCounter } from '../../../../elements/Wizard/InnerStepCounter'; +import { ActiveConnectionAlert } from '../../saml/shared/ActiveConnectionAlert'; + +export const OidcCredentialsStep = (): JSX.Element => { + const card = useCardState(); + const { goNext, goPrev, isFirstStep } = useWizard(); + const { + enterpriseConnection, + enterpriseConnectionMutations: { updateConnection }, + } = useConfigureSSO(); + const clientIdField = useFormControl('clientId', enterpriseConnection?.oauthConfig?.clientId ?? '', { + type: 'text', + label: localizationKeys('configureSSO.configureStep.oidcCustom.credentialsStep.clientId.label'), + placeholder: localizationKeys('configureSSO.configureStep.oidcCustom.credentialsStep.clientId.placeholder'), + isRequired: true, + }); + const clientSecretField = useFormControl('clientSecret', '', { + type: 'password', + label: localizationKeys('configureSSO.configureStep.oidcCustom.credentialsStep.clientSecret.label'), + placeholder: localizationKeys('configureSSO.configureStep.oidcCustom.credentialsStep.clientSecret.placeholder'), + isRequired: true, + }); + const scopeField = useFormControl('scopes', '', { + type: 'text', + label: localizationKeys('configureSSO.configureStep.oidcCustom.credentialsStep.scopes.label'), + placeholder: localizationKeys('configureSSO.configureStep.oidcCustom.credentialsStep.scopes.placeholder'), + isRequired: false, + }); + const [scopes, setScopes] = React.useState(['openid', 'profile']); + const [isSubmitting, setIsSubmitting] = React.useState(false); + + const canSubmit = clientIdField.value.trim().length > 0 && clientSecretField.value.trim().length > 0 && !isSubmitting; + + const addScope = (): void => { + const scope = scopeField.value.trim(); + if (!scope || scopes.includes(scope)) { + return; + } + + setScopes(previousScopes => [...previousScopes, scope]); + scopeField.setValue(''); + }; + + const handleContinue = async (): Promise => { + if (!enterpriseConnection || !canSubmit) { + return; + } + + card.setError(undefined); + setIsSubmitting(true); + + try { + await updateConnection(enterpriseConnection.id, { + oidc: { + clientId: clientIdField.value.trim(), + clientSecret: clientSecretField.value.trim(), + }, + }); + void goNext(); + } catch (err) { + handleError(err as Error, [clientIdField, clientSecretField], card.setError); + setIsSubmitting(false); + } + }; + + return ( + <> + + + + + + + + + + + + + + + + + + + ({ flex: 1, gap: theme.space.$2 })}> + + ({ gap: theme.space.$2 })} + > + + + + + ({ gap: theme.space.$2 })} + > + + + + + + + ))} + + + + + + + + + goPrev()} + isDisabled={isFirstStep || isSubmitting} + /> + + + + ); +}; diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/shared/OidcEndpointsConfigurationForm.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/shared/OidcEndpointsConfigurationForm.tsx new file mode 100644 index 00000000000..470dcf340fe --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/shared/OidcEndpointsConfigurationForm.tsx @@ -0,0 +1,75 @@ +import type { FieldId } from '@clerk/shared/types'; +import React, { type JSX } from 'react'; + +import { type LocalizationKey, Text } from '@/customizables'; +import { Form } from '@/elements/Form'; +import type { FormControlState } from '@/ui/utils/useFormControl'; + +import { ActiveConnectionAlert } from '../../saml/shared/ActiveConnectionAlert'; + +type FormControl = FormControlState; + +type DiscoveryUrlForm = { + discoveryUrlField: FormControl; +}; + +type DiscoveryUrlLabels = { + description: LocalizationKey; +}; + +type ManualEndpointsForm = { + authUrlField: FormControl; + tokenUrlField: FormControl; + userInfoUrlField: FormControl; +}; + +type ManualEndpointsLabels = { + description: LocalizationKey; +}; + +export type OidcEndpointsConfigurationFormProps = + | { mode: 'discoveryUrl'; form: DiscoveryUrlForm; labels: DiscoveryUrlLabels } + | { mode: 'manual'; form: ManualEndpointsForm; labels: ManualEndpointsLabels }; + +export const OidcEndpointsConfigurationForm = (props: OidcEndpointsConfigurationFormProps): JSX.Element => { + switch (props.mode) { + case 'discoveryUrl': + return ( + <> + + + + + + + ); + case 'manual': + return ( + <> + + + + + + + + + + + + + ); + default: { + const unhandledMode: never = props; + throw new Error(`Unhandled OIDC endpoints configuration mode: ${(unhandledMode as { mode: string }).mode}`); + } + } +}; diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/shared/OidcEndpointsStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/shared/OidcEndpointsStep.tsx new file mode 100644 index 00000000000..81eca4bf3af --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/shared/OidcEndpointsStep.tsx @@ -0,0 +1,162 @@ +import React, { type JSX } from 'react'; + +import { localizationKeys } from '@/customizables'; +import { useCardState } from '@/elements/contexts'; +import { useFormControl } from '@/ui/utils/useFormControl'; +import { handleError } from '@/utils/errorHandler'; + +import { useConfigureSSO } from '../../../../ConfigureSSOContext'; +import { Step } from '../../../../elements/Step'; +import { useWizard } from '../../../../elements/Wizard'; +import { InnerStepCounter } from '../../../../elements/Wizard/InnerStepCounter'; +import { + IdentityProviderConfigurationModes, + type IdpConfigurationMode, +} from '../../saml/shared/IdentityProviderConfigurationModes'; +import { + OidcEndpointsConfigurationForm, + type OidcEndpointsConfigurationFormProps, +} from './OidcEndpointsConfigurationForm'; + +const OIDC_ENDPOINT_MODES = ['discoveryUrl', 'manual'] as const satisfies readonly IdpConfigurationMode[]; + +export const OidcEndpointsStep = (): JSX.Element => { + const card = useCardState(); + const { goNext, goPrev, isFirstStep, isLastStep } = useWizard(); + const { + enterpriseConnection, + enterpriseConnectionMutations: { updateConnection }, + } = useConfigureSSO(); + const oauthConfig = enterpriseConnection?.oauthConfig; + const [mode, setMode] = React.useState('discoveryUrl'); + const [isSubmitting, setIsSubmitting] = React.useState(false); + + const discoveryUrlField = useFormControl('discoveryUrl', oauthConfig?.discoveryUrl ?? '', { + type: 'text', + label: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.discoveryUrl.label'), + placeholder: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.discoveryUrl.placeholder'), + isRequired: true, + }); + const authUrlField = useFormControl('authUrl', oauthConfig?.authUrl ?? '', { + type: 'text', + label: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.manual.authUrl.label'), + placeholder: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.manual.authUrl.placeholder'), + isRequired: true, + }); + const tokenUrlField = useFormControl('tokenUrl', oauthConfig?.tokenUrl ?? '', { + type: 'text', + label: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.manual.tokenUrl.label'), + placeholder: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.manual.tokenUrl.placeholder'), + isRequired: true, + }); + const userInfoUrlField = useFormControl('userInfoUrl', oauthConfig?.userInfoUrl ?? '', { + type: 'text', + label: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.manual.userInfoUrl.label'), + placeholder: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.manual.userInfoUrl.placeholder'), + isRequired: true, + }); + + const isValid = + mode === 'discoveryUrl' + ? discoveryUrlField.value.trim().length > 0 + : authUrlField.value.trim().length > 0 && + tokenUrlField.value.trim().length > 0 && + userInfoUrlField.value.trim().length > 0; + + const canSubmit = isValid && !isSubmitting; + + const formProps: OidcEndpointsConfigurationFormProps = + mode === 'discoveryUrl' + ? { + mode: 'discoveryUrl', + form: { discoveryUrlField }, + labels: { + description: localizationKeys( + 'configureSSO.configureStep.oidcCustom.endpointsStep.discoveryUrl.description', + ), + }, + } + : { + mode: 'manual', + form: { authUrlField, tokenUrlField, userInfoUrlField }, + labels: { + description: localizationKeys('configureSSO.configureStep.oidcCustom.endpointsStep.manual.description'), + }, + }; + + const handleContinue = async (): Promise => { + if (!enterpriseConnection || !canSubmit) { + return; + } + + card.setError(undefined); + setIsSubmitting(true); + + try { + await updateConnection( + enterpriseConnection.id, + mode === 'discoveryUrl' + ? { oidc: { discoveryUrl: discoveryUrlField.value.trim() } } + : { + oidc: { + authUrl: authUrlField.value.trim(), + tokenUrl: tokenUrlField.value.trim(), + userInfoUrl: userInfoUrlField.value.trim(), + }, + }, + ); + void goNext(); + } catch (err) { + handleError( + err as Error, + mode === 'discoveryUrl' ? [discoveryUrlField] : [authUrlField, tokenUrlField, userInfoUrlField], + card.setError, + ); + setIsSubmitting(false); + } + }; + + return ( + <> + + + + + + + + + + + + + + + goPrev()} + isDisabled={isFirstStep || isSubmitting} + /> + + + + ); +}; diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/shared/OidcRedirectUriStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/shared/OidcRedirectUriStep.tsx new file mode 100644 index 00000000000..ad53c3e2923 --- /dev/null +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/shared/OidcRedirectUriStep.tsx @@ -0,0 +1,69 @@ +import React, { type JSX } from 'react'; + +import { Col, localizationKeys, Text } from '@/customizables'; +import { ClipboardInput } from '@/elements/ClipboardInput'; +import { Form } from '@/elements/Form'; +import { Checkmark, Clipboard } from '@/icons'; +import { useFormControl } from '@/ui/utils/useFormControl'; + +import { useConfigureSSO } from '../../../../ConfigureSSOContext'; +import { Step } from '../../../../elements/Step'; +import { useWizard } from '../../../../elements/Wizard'; +import { InnerStepCounter } from '../../../../elements/Wizard/InnerStepCounter'; + +export const OidcRedirectUriStep = (): JSX.Element => { + const { goNext, goPrev, isFirstStep, isLastStep } = useWizard(); + const { enterpriseConnection } = useConfigureSSO(); + const redirectUri = enterpriseConnection?.oauthConfig?.redirectUri ?? ''; + const redirectUriField = useFormControl('redirectUri', redirectUri, { + type: 'text', + label: localizationKeys('configureSSO.configureStep.oidcCustom.redirectUriStep.redirectUri.label'), + isRequired: false, + }); + + return ( + <> + + + + + + ({ gap: theme.space.$5 })}> + ({ gap: theme.space.$1x5 })}> + + + + + + + + + + + + + + goPrev()} + isDisabled={isFirstStep} + /> + goNext()} + isDisabled={isLastStep} + /> + + + ); +}; From 67dc5cdc8e399311532c83dc5a52271f7aeaa668 Mon Sep 17 00:00:00 2001 From: Nicolas Lopes Date: Thu, 23 Jul 2026 09:19:23 -0300 Subject: [PATCH 11/11] gymnastics --- .../oidc/OidcCustomConfigureSteps.tsx | 1 - .../IdentityProviderConfigurationModes.tsx | 58 +++++++++++++++++++ .../oidc/shared/OidcCredentialsStep.tsx | 2 +- .../shared/OidcEndpointsConfigurationForm.tsx | 2 +- .../oidc/shared/OidcEndpointsStep.tsx | 2 +- .../IdentityProviderConfigurationForm.tsx | 2 +- .../IdentityProviderConfigurationModes.tsx | 3 +- .../shared/ActiveConnectionAlert.tsx | 2 +- 8 files changed, 64 insertions(+), 8 deletions(-) create mode 100644 packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/shared/IdentityProviderConfigurationModes.tsx rename packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/{saml => }/shared/ActiveConnectionAlert.tsx (91%) diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx index 96de6ff8430..140b3666af9 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx @@ -15,7 +15,6 @@ const OIDC_STEPS: WizardStepConfig[] = [ export const OidcCustomConfigureSteps = (): JSX.Element => { return ( - // Linear, guard-less sub-flow: mount on the first step, mirroring the SAML custom inner wizard. >; + +type IdentityProviderConfigurationModesProps = { + modes: readonly IdpConfigurationMode[]; + value: IdpConfigurationMode; + onChange: (mode: IdpConfigurationMode) => void; + labels: { + ariaLabel: LocalizationKey; + } & ModeLocalizationKeys; +}; + +export const IdentityProviderConfigurationModes = ({ + modes, + value, + onChange, + labels, +}: IdentityProviderConfigurationModesProps): JSX.Element => { + const { t } = useLocalizations(); + + return ( + onChange(next as IdpConfigurationMode)} + fullWidth + size='lg' + > + {modes.map(mode => { + const label = labels[mode]; + if (!label) { + return null; + } + + return ( + + ); + })} + + ); +}; diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/shared/OidcCredentialsStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/shared/OidcCredentialsStep.tsx index 79127c36eaa..37fabe83ca8 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/shared/OidcCredentialsStep.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/shared/OidcCredentialsStep.tsx @@ -12,7 +12,7 @@ import { useConfigureSSO } from '../../../../ConfigureSSOContext'; import { Step } from '../../../../elements/Step'; import { useWizard } from '../../../../elements/Wizard'; import { InnerStepCounter } from '../../../../elements/Wizard/InnerStepCounter'; -import { ActiveConnectionAlert } from '../../saml/shared/ActiveConnectionAlert'; +import { ActiveConnectionAlert } from '../../shared/ActiveConnectionAlert'; export const OidcCredentialsStep = (): JSX.Element => { const card = useCardState(); diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/shared/OidcEndpointsConfigurationForm.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/shared/OidcEndpointsConfigurationForm.tsx index 470dcf340fe..d4865a84831 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/shared/OidcEndpointsConfigurationForm.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/shared/OidcEndpointsConfigurationForm.tsx @@ -5,7 +5,7 @@ import { type LocalizationKey, Text } from '@/customizables'; import { Form } from '@/elements/Form'; import type { FormControlState } from '@/ui/utils/useFormControl'; -import { ActiveConnectionAlert } from '../../saml/shared/ActiveConnectionAlert'; +import { ActiveConnectionAlert } from '../../shared/ActiveConnectionAlert'; type FormControl = FormControlState; diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/shared/OidcEndpointsStep.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/shared/OidcEndpointsStep.tsx index 81eca4bf3af..312a705a6cd 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/shared/OidcEndpointsStep.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/shared/OidcEndpointsStep.tsx @@ -12,7 +12,7 @@ import { InnerStepCounter } from '../../../../elements/Wizard/InnerStepCounter'; import { IdentityProviderConfigurationModes, type IdpConfigurationMode, -} from '../../saml/shared/IdentityProviderConfigurationModes'; +} from '../shared/IdentityProviderConfigurationModes'; import { OidcEndpointsConfigurationForm, type OidcEndpointsConfigurationFormProps, diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/shared/IdentityProviderConfigurationForm.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/shared/IdentityProviderConfigurationForm.tsx index ed8ac096250..52fcd44e5a9 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/shared/IdentityProviderConfigurationForm.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/shared/IdentityProviderConfigurationForm.tsx @@ -21,7 +21,7 @@ import { ArrowUpTray, Close } from '@/icons'; import type { FormControlState } from '@/ui/utils/useFormControl'; import { handleError } from '@/utils/errorHandler'; -import { ActiveConnectionAlert } from './ActiveConnectionAlert'; +import { ActiveConnectionAlert } from '../../shared/ActiveConnectionAlert'; import type { IdpConfigurationMode } from './IdentityProviderConfigurationModes'; type CardState = ReturnType; diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/shared/IdentityProviderConfigurationModes.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/shared/IdentityProviderConfigurationModes.tsx index b34a3a597a1..1b75ea36aee 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/shared/IdentityProviderConfigurationModes.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/shared/IdentityProviderConfigurationModes.tsx @@ -8,10 +8,9 @@ import { SegmentedControl } from '@/elements/SegmentedControl'; * * metadataUrl: Fetch IdP configuration via metadata URL * metadataFile: Upload IdP configuration via metadata file - * discoveryUrl: Fetch IdP configuration via OIDC discovery URL * manual: Configure manually each field, such as sign on URL, issuer, and signing certificate */ -export type IdpConfigurationMode = 'metadataUrl' | 'metadataFile' | 'discoveryUrl' | 'manual'; +export type IdpConfigurationMode = 'metadataUrl' | 'metadataFile' | 'manual'; type ModeLocalizationKeys = Partial>; diff --git a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/shared/ActiveConnectionAlert.tsx b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/shared/ActiveConnectionAlert.tsx similarity index 91% rename from packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/shared/ActiveConnectionAlert.tsx rename to packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/shared/ActiveConnectionAlert.tsx index 36fd518e72b..281cc069a5b 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/saml/shared/ActiveConnectionAlert.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/shared/ActiveConnectionAlert.tsx @@ -3,7 +3,7 @@ import React, { type JSX } from 'react'; import { localizationKeys } from '@/customizables'; import { Alert } from '@/elements/Alert'; -import { useConfigureSSO } from '../../../../ConfigureSSOContext'; +import { useConfigureSSO } from '../../../ConfigureSSOContext'; export const ActiveConnectionAlert = (): JSX.Element | null => { const { enterpriseConnection } = useConfigureSSO();