Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/blue-owls-remember.md
Original file line number Diff line number Diff line change
@@ -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.
56 changes: 56 additions & 0 deletions packages/localizations/src/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
5 changes: 5 additions & 0 deletions packages/shared/src/types/elementIds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ export type FieldId =
| 'idpMetadata'
| 'idpMetadataUrl'
| 'idpSsoUrl'
| 'discoveryUrl'
| 'authUrl'
| 'tokenUrl'
| 'userInfoUrl'
| 'redirectUri'
| 'acsUrl'
| 'spEntityId'
| 'web3WalletName'
Expand Down
54 changes: 54 additions & 0 deletions packages/shared/src/types/localization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 () => {
Expand Down
Loading
Loading