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

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

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

1. Notification for new sign ins to users' accounts feature becomes available.
2. 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)
9 changes: 9 additions & 0 deletions .changeset/famous-experts-begin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@clerk/clerk-js': patch
Nikpolik marked this conversation as resolved.
Show resolved Hide resolved
'@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).
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
24 changes: 14 additions & 10 deletions packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -245,15 +245,15 @@ 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;
};

Expand All @@ -262,11 +262,11 @@ export function _SignInStart(): JSX.Element {
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 EnterpriseSSO enabled instances, perform sign in with password only when it is allowed for the identified user.
LauraBeatris marked this conversation as resolved.
Show resolved Hide resolved
*/
const passwordField = fields.find(f => f.name === 'password')?.value;
if (!passwordField || signInResource.supportedFirstFactors?.some(ff => ff.strategy === 'saml')) {
Expand All @@ -282,12 +282,16 @@ export function _SignInStart(): JSX.Element {

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 +310,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
5 changes: 3 additions & 2 deletions packages/clerk-js/src/ui/utils/test/fixtureHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -466,8 +466,9 @@ const createUserSettingsFixtureHelpers = (environment: EnvironmentJSON) => {
};
};

const withSaml = () => {
const withEnterpriseSso = () => {
us.saml = { enabled: true };
us.enterprise_sso = { enabled: true };
};

const withBackupCode = (opts?: Partial<UserSettingsJSON['attributes']['backup_code']>) => {
Expand Down Expand Up @@ -524,7 +525,7 @@ const createUserSettingsFixtureHelpers = (environment: EnvironmentJSON) => {
withPassword,
withPasswordComplexity,
withSocialProvider,
withSaml,
withEnterpriseSso,
withBackupCode,
withAuthenticatorApp,
withPasskey,
Expand Down
1 change: 1 addition & 0 deletions packages/clerk-js/src/ui/utils/test/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ const createBaseUserSettings = (): UserSettingsJSON => {
actions: { delete_self: false, create_organization: false },
social: { ...socialConfig },
saml: { enabled: false },
enterprise_sso: { enabled: false },
sign_in: {
second_factor: {
required: false,
Expand Down
Loading
Loading