Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(types,clerkjs,backend): Add support for enterprise_sso strategy #4596

15 changes: 15 additions & 0 deletions .changeset/clever-cougars-argue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@clerk/clerk-js': minor
---

- Update the supported API version to `2024-10-01` that includes the following changes
- Notification for new sign ins to users' accounts feature becomes available.
- The response for Sign Ins with an email address that matches a **SAML connection** is updated. Instead of responding with a status of `needs_identifier` the API will now return a status of `needs_first_factor` and the email address that matched will be returned in the identifier field. the only strategy that will be included in supported first factors is `enterprise_sso`

Read more in the [API Version docs](https://clerk.com/docs/backend-requests/versioning/available-versions#2024-10-01)

- Update components to use the new `enterprise_sso` strategy for sign ins / sign ups that match an enterprise connection and handle the new API response.

This strategy supersedes SAML to provide a single strategy as the entry point for Enterprise SSO regardless of the underlying protocol used to authenticate the user.

For now there are two new types of connections that are supported in addition to SAML, Custom OAuth and EASIE (multi-tenant OAuth).
13 changes: 13 additions & 0 deletions .changeset/famous-experts-begin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@clerk/types': patch
---

Add support for the new `enterprise_sso` strategy.

This strategy supersedes SAML to provide a single strategy as the entry point for Enterprise Single Sign On regardless of the underlying protocol used to authenticate the user.
For now there are two new types of connections that are supported in addition to SAML, Custom OAuth and EASIE (multi-tenant OAuth).

- Add a new user setting `enterpriseSSO`, this gets enabled when there is an active enterprise connection for an instance.
- Add support for signing in / signing up with the new `enterprise_sso` strategy.
- Deprecated `userSettings.saml` in favor of `enterprise_sso`.
- Deprecated `saml` sign in strategy in favor of `enterprise_sso`.
9 changes: 9 additions & 0 deletions .changeset/short-mails-wash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@clerk/backend': patch
---

Update the supported API version to `2024-10-01` that includes the following changes

No changes affecting the Backend API have been made in this version.

Read more in the [API Version docs](https://clerk.com/docs/backend-requests/versioning/available-versions#2024-10-01)
2 changes: 1 addition & 1 deletion packages/backend/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ export const API_VERSION = 'v1';
export const USER_AGENT = `${PACKAGE_NAME}@${PACKAGE_VERSION}`;
export const MAX_CACHE_LAST_UPDATED_AT_SECONDS = 5 * 60;
export const JWKS_CACHE_TTL_MS = 1000 * 60 * 60;
export const SUPPORTED_BAPI_VERSION = '2021-02-05';
export const SUPPORTED_BAPI_VERSION = '2024-10-01';

const Attributes = {
AuthToken: '__clerkAuthToken',
Expand Down
3 changes: 2 additions & 1 deletion packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1382,7 +1382,8 @@ export class Clerk implements ClerkInterface {
return navigateToSignIn();
}

const userHasUnverifiedEmail = si.status === 'needs_first_factor';
const userHasUnverifiedEmail =
si.status === 'needs_first_factor' && !signIn.supportedFirstFactors?.every(f => f.strategy === 'enterprise_sso');

if (userHasUnverifiedEmail) {
return navigateToFactorOne();
Expand Down
2 changes: 1 addition & 1 deletion packages/clerk-js/src/core/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,4 @@ export const SIGN_UP_MODES: Record<string, SignUpModes> = {
};

// This is the currently supported version of the Frontend API
export const SUPPORTED_FAPI_VERSION = '2021-02-05';
export const SUPPORTED_FAPI_VERSION = '2024-10-01';
9 changes: 8 additions & 1 deletion packages/clerk-js/src/core/resources/SignIn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
CreateEmailLinkFlowReturn,
EmailCodeConfig,
EmailLinkConfig,
EnterpriseSSOConfig,
PassKeyConfig,
PasskeyFactor,
PhoneCodeConfig,
Expand Down Expand Up @@ -134,6 +135,12 @@ export class SignIn extends BaseResource implements SignInResource {
actionCompleteRedirectUrl: factor.actionCompleteRedirectUrl,
} as SamlConfig;
break;
case 'enterprise_sso':
config = {
redirectUrl: factor.redirectUrl,
actionCompleteRedirectUrl: factor.actionCompleteRedirectUrl,
} as EnterpriseSSOConfig;
break;
default:
clerkInvalidStrategy('SignIn.prepareFirstFactor', factor.strategy);
}
Expand Down Expand Up @@ -215,7 +222,7 @@ export class SignIn extends BaseResource implements SignInResource {
const { strategy, redirectUrl, redirectUrlComplete, identifier } = params || {};

const { firstFactorVerification } =
strategy === 'saml' && this.id
(strategy === 'saml' || strategy === 'enterprise_sso') && this.id
? await this.prepareFirstFactor({
strategy,
redirectUrl: SignIn.clerk.buildUrlWithAuth(redirectUrl),
Expand Down
3 changes: 3 additions & 0 deletions packages/clerk-js/src/core/resources/UserSettings.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type {
Attributes,
EnterpriseSSOSettings,
OAuthProviders,
OAuthStrategy,
PasskeySettingsData,
Expand Down Expand Up @@ -30,6 +31,7 @@ export class UserSettings extends BaseResource implements UserSettingsResource {
social!: OAuthProviders;

saml!: SamlSettings;
enterpriseSSO!: EnterpriseSSOSettings;

attributes!: Attributes;
actions!: Actions;
Expand Down Expand Up @@ -67,6 +69,7 @@ export class UserSettings extends BaseResource implements UserSettingsResource {

this.social = data.social;
this.saml = data.saml;
this.enterpriseSSO = data.enterprise_sso;
this.attributes = Object.fromEntries(
Object.entries(data.attributes).map(a => [a[0], { ...a[1], name: a[0] }]),
) as Attributes;
Expand Down
33 changes: 20 additions & 13 deletions packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -245,31 +245,34 @@ export function _SignInStart(): JSX.Element {
const hasPassword = fields.some(f => f.name === 'password' && !!f.value);

/**
* FAPI will return an error when password is submitted but the user's email matches requires SAML authentication.
* FAPI will return an error when password is submitted but the user's email matches requires enterprise sso authentication.
* We need to strip password from the create request, and reconstruct it later.
*/
if (!hasPassword || userSettings.saml.enabled) {
if (!hasPassword || userSettings.enterpriseSSO.enabled) {
fields = fields.filter(f => f.name !== 'password');
}
return {
...buildRequest(fields),
...(hasPassword && !userSettings.saml.enabled && { strategy: 'password' }),
...(hasPassword && !userSettings.enterpriseSSO.enabled && { strategy: 'password' }),
} as SignInCreateParams;
};

const safePasswordSignInForSamlInstance = (
const safePasswordSignInForEnterpriseSSOInstance = (
signInCreatePromise: Promise<SignInResource>,
fields: Array<FormControlState<string>>,
) => {
return signInCreatePromise.then(signInResource => {
if (!userSettings.saml.enabled) {
if (!userSettings.enterpriseSSO.enabled) {
return signInResource;
}
/**
* For SAML enabled instances, perform sign in with password only when it is allowed for the identified user.
* For instances with Enterprise SSO enabled, perform sign in with password only when it is allowed for the identified user.
*/
const passwordField = fields.find(f => f.name === 'password')?.value;
if (!passwordField || signInResource.supportedFirstFactors?.some(ff => ff.strategy === 'saml')) {
if (
!passwordField ||
signInResource.supportedFirstFactors?.some(ff => ff.strategy === 'saml' || ff.strategy === 'enterprise_sso')
) {
return signInResource;
}
return signInResource.attemptFirstFactor({ strategy: 'password', password: passwordField });
Expand All @@ -278,16 +281,20 @@ export function _SignInStart(): JSX.Element {

const signInWithFields = async (...fields: Array<FormControlState<string>>) => {
try {
const res = await safePasswordSignInForSamlInstance(signIn.create(buildSignInParams(fields)), fields);
const res = await safePasswordSignInForEnterpriseSSOInstance(signIn.create(buildSignInParams(fields)), fields);

switch (res.status) {
case 'needs_identifier':
// Check if we need to initiate a saml flow
if (res.supportedFirstFactors?.some(ff => ff.strategy === 'saml')) {
await authenticateWithSaml();
// Check if we need to initiate an enterprise sso flow
if (res.supportedFirstFactors?.some(ff => ff.strategy === 'saml' || ff.strategy === 'enterprise_sso')) {
await authenticateWithEnterpriseSSO();
}
break;
case 'needs_first_factor':
if (res.supportedFirstFactors?.every(ff => ff.strategy === 'enterprise_sso')) {
await authenticateWithEnterpriseSSO();
break;
}
return navigate('factor-one');
case 'needs_second_factor':
return navigate('factor-two');
Expand All @@ -306,12 +313,12 @@ export function _SignInStart(): JSX.Element {
}
};

const authenticateWithSaml = async () => {
const authenticateWithEnterpriseSSO = async () => {
const redirectUrl = buildSSOCallbackURL(ctx, displayConfig.signInUrl);
const redirectUrlComplete = ctx.afterSignInUrl || '/';

return signIn.authenticateWithRedirect({
strategy: 'saml',
strategy: 'enterprise_sso',
redirectUrl,
redirectUrlComplete,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,30 @@ describe('SignInStart', () => {
await userEvent.click(screen.getByText('Continue'));
expect(fixtures.signIn.create).toHaveBeenCalled();
expect(fixtures.signIn.authenticateWithRedirect).toHaveBeenCalledWith({
strategy: 'saml',
strategy: 'enterprise_sso',
redirectUrl: 'http://localhost/#/sso-callback',
redirectUrlComplete: '/',
});
});
});

describe('Enterprise SSO', () => {
it('initiates a Enterprise SSO flow if enterprise_sso is listed as the only supported first factor', async () => {
const { wrapper, fixtures } = await createFixtures(f => {
f.withEmailAddress();
});
fixtures.signIn.create.mockReturnValueOnce(
Promise.resolve({
status: 'needs_first_factor',
supportedFirstFactors: [{ strategy: 'enterprise_sso' }],
} as unknown as SignInResource),
);
const { userEvent } = render(<SignInStart />, { wrapper });
await userEvent.type(screen.getByLabelText(/email address/i), '[email protected]');
await userEvent.click(screen.getByText('Continue'));
expect(fixtures.signIn.create).toHaveBeenCalled();
expect(fixtures.signIn.authenticateWithRedirect).toHaveBeenCalledWith({
strategy: 'enterprise_sso',
redirectUrl: 'http://localhost/#/sso-callback',
redirectUrlComplete: '/',
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,15 @@ import { UserProfileSection } from './UserProfileSection';
import { Web3Section } from './Web3Section';

export const AccountPage = withCardStateProvider(() => {
const { attributes, social } = useEnvironment().userSettings;
const { attributes, social, enterpriseSSO } = useEnvironment().userSettings;
const card = useCardState();
const { user } = useUser();

const showUsername = attributes.username.enabled;
const showEmail = attributes.email_address.enabled;
const showPhone = attributes.phone_number.enabled;
const showConnectedAccounts = social && Object.values(social).filter(p => p.enabled).length > 0;
const showEnterpriseAccounts = user && user.enterpriseAccounts.length > 0;
const showEnterpriseAccounts = user && enterpriseSSO.enabled;
const showWeb3 = attributes.web3_wallet.enabled;

const shouldAllowIdentificationCreation =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ describe('AccountPage', () => {

const { wrapper } = await createFixtures(f => {
f.withEmailAddress();
f.withSaml();
f.withEnterpriseSso();
f.withUser({
email_addresses: [emailAddress],
enterprise_accounts: [
Expand Down Expand Up @@ -157,6 +157,7 @@ describe('AccountPage', () => {

const { wrapper } = await createFixtures(f => {
f.withEmailAddress();
f.withEnterpriseSso();
f.withUser({
email_addresses: [emailAddress],
enterprise_accounts: [
Expand Down Expand Up @@ -268,7 +269,7 @@ describe('AccountPage', () => {
f.withEmailAddress();
f.withPhoneNumber();
f.withSocialProvider({ provider: 'google' });
f.withSaml();
f.withEnterpriseSso();
f.withUser({
email_addresses: [emailAddress],
enterprise_accounts: [enterpriseAccount],
Expand All @@ -291,7 +292,7 @@ describe('AccountPage', () => {
f.withEmailAddress();
f.withPhoneNumber();
f.withSocialProvider({ provider: 'google' });
f.withSaml();
f.withEnterpriseSso();
f.withUser({
email_addresses: [emailAddress],
phone_numbers: [phoneNumber],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const withoutEnterpriseConnection = createFixtures.config(f => {
const withInactiveEnterpriseConnection = createFixtures.config(f => {
f.withSocialProvider({ provider: 'google' });
f.withSocialProvider({ provider: 'github' });
f.withEnterpriseSso();
f.withUser({
enterprise_accounts: [
{
Expand Down Expand Up @@ -67,6 +68,7 @@ const withInactiveEnterpriseConnection = createFixtures.config(f => {
});

const withOAuthBuiltInEnterpriseConnection = createFixtures.config(f => {
f.withEnterpriseSso();
f.withUser({
enterprise_accounts: [
{
Expand Down Expand Up @@ -117,6 +119,7 @@ const withOAuthBuiltInEnterpriseConnection = createFixtures.config(f => {

const withOAuthCustomEnterpriseConnection = (logoPublicUrl: string | null) =>
createFixtures.config(f => {
f.withEnterpriseSso();
f.withUser({
enterprise_accounts: [
{
Expand Down Expand Up @@ -166,6 +169,7 @@ const withOAuthCustomEnterpriseConnection = (logoPublicUrl: string | null) =>
});

const withSamlEnterpriseConnection = createFixtures.config(f => {
f.withEnterpriseSso();
f.withUser({
enterprise_accounts: [
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,13 +106,13 @@ describe('PasswordSection', () => {
});
});

describe('with SAML', () => {
describe('with Enterprise SSO', () => {
it('prevents setting a password if user has active enterprise connections', async () => {
const emailAddress = '[email protected]';

const config = createFixtures.config(f => {
f.withEmailAddress();
f.withSaml();
f.withEnterpriseSso();
f.withUser({
email_addresses: [emailAddress],
enterprise_accounts: [
Expand Down Expand Up @@ -185,7 +185,7 @@ describe('PasswordSection', () => {

const config = createFixtures.config(f => {
f.withEmailAddress();
f.withSaml();
f.withEnterpriseSso();
f.withUser({
email_addresses: [emailAddress],
enterprise_accounts: [
Expand Down Expand Up @@ -315,13 +315,13 @@ describe('PasswordSection', () => {
expect(queryByRole('heading', { name: /update password/i })).not.toBeInTheDocument();
});

describe('with SAML', () => {
describe('with Enterprise SSO', () => {
it('prevents changing a password if user has active enterprise connections', async () => {
const emailAddress = '[email protected]';

const config = createFixtures.config(f => {
f.withEmailAddress();
f.withSaml();
f.withEnterpriseSso();
f.withUser({
password_enabled: true,
email_addresses: [emailAddress],
Expand Down Expand Up @@ -395,7 +395,7 @@ describe('PasswordSection', () => {

const config = createFixtures.config(f => {
f.withEmailAddress();
f.withSaml();
f.withEnterpriseSso();
f.withUser({
password_enabled: true,
email_addresses: [emailAddress],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,15 @@ describe('ProfileSection', () => {
});
});

describe('with SAML', () => {
describe('with Enterprise SSO', () => {
it('disables the first & last name inputs if user has active enterprise connections', async () => {
const emailAddress = '[email protected]';
const firstName = 'George';
const lastName = 'Clerk';

const config = createFixtures.config(f => {
f.withEmailAddress();
f.withSaml();
f.withEnterpriseSso();
f.withName();
f.withUser({
first_name: firstName,
Expand Down Expand Up @@ -134,7 +134,7 @@ describe('ProfileSection', () => {

const config = createFixtures.config(f => {
f.withEmailAddress();
f.withSaml();
f.withEnterpriseSso();
f.withName();
f.withUser({
first_name: firstName,
Expand Down
Loading