diff --git a/.changeset/blue-owls-remember.md b/.changeset/blue-owls-remember.md new file mode 100644 index 00000000000..13002dccbfe --- /dev/null +++ b/.changeset/blue-owls-remember.md @@ -0,0 +1,7 @@ +--- +'@clerk/localizations': patch +'@clerk/shared': patch +'@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. diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index e7419100697..e27648ddc57 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -241,17 +241,73 @@ 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', }, 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: { 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..82128ffaaf1 100644 --- a/packages/shared/src/types/elementIds.ts +++ b/packages/shared/src/types/elementIds.ts @@ -31,6 +31,11 @@ export type FieldId = | 'idpMetadata' | 'idpMetadataUrl' | 'idpSsoUrl' + | 'discoveryUrl' + | 'authUrl' + | 'tokenUrl' + | 'userInfoUrl' + | 'redirectUri' | 'acsUrl' | 'spEntityId' | 'web3WalletName' diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index 88df4c9b294..a62701fb8b1 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -1529,12 +1529,66 @@ export type __internal_LocalizationResource = { mainHeaderTitle: LocalizationValue; redirectUriStep: { headerSubtitle: LocalizationValue; + paragraph: LocalizationValue; + redirectUri: { + label: LocalizationValue; + }; }; 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; + 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 f4b4c5c0d8c..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,13 +74,86 @@ 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(); - 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. 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$/); + + 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(); + + 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('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 () => { 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..0e15f91100a 100644 --- a/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx +++ b/packages/ui/src/components/ConfigureSSO/steps/ConfigureStep/oidc/OidcCustomConfigureSteps.tsx @@ -1,10 +1,36 @@ -import { type JSX } from 'react'; +import { useClerk } from '@clerk/shared/react'; +import React, { type JSX } from 'react'; -import { localizationKeys } from '@/customizables'; +import { + Badge, + Col, + descriptors, + Flex, + localizationKeys, + Table, + Tbody, + Td, + Text, + Th, + Thead, + 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'; 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' }, @@ -41,6 +67,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 +84,28 @@ const OidcRedirectUriStep = (): JSX.Element => { - + + ({ gap: theme.space.$5 })}> + ({ gap: theme.space.$1x5 })}> + + + + + + + + + + @@ -80,7 +134,17 @@ const OidcClaimsStep = (): JSX.Element => { - + + ({ gap: theme.space.$3 })}> + + + + + @@ -97,8 +161,168 @@ 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 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', '', { + 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', '', { + 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', '', { + 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 ( <> @@ -109,17 +333,70 @@ const OidcEndpointsStep = (): JSX.Element => { - + + + + + {mode === 'discoveryUrl' ? ( + <> + + + + + + ) : ( + <> + + + + + + + + + + + + )} + + + + goPrev()} - isDisabled={isFirstStep} + isDisabled={isFirstStep || isSubmitting} /> goNext()} - isDisabled={isLastStep} + onClick={handleContinue} + isLoading={isSubmitting} + isDisabled={!canSubmit || 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>;