From 5aab7c01bf7eae1249880207f48376224d148ef8 Mon Sep 17 00:00:00 2001 From: simeng-li Date: Fri, 13 Sep 2024 17:34:37 +0800 Subject: [PATCH] fix(core): add sso only email guard (#6576) * fix(core): add sso only email guard add sso only email guard to registration and profile fulfilling flow * chore: update changeset update changeset * chore(core): update content update content * fix(core): update content update content --- .changeset/witty-rivers-laugh.md | 12 ++ .../classes/experience-interaction.ts | 2 + .../libraries/sign-in-experience-validator.ts | 2 +- .../src/routes/experience/classes/profile.ts | 5 + .../experience/profile-routes.openapi.json | 2 +- .../enterprise-sso.test.ts | 192 ++++++++++++++++++ 6 files changed, 213 insertions(+), 2 deletions(-) create mode 100644 .changeset/witty-rivers-laugh.md create mode 100644 packages/integration-tests/src/tests/api/experience-api/register-interaction/enterprise-sso.test.ts diff --git a/.changeset/witty-rivers-laugh.md b/.changeset/witty-rivers-laugh.md new file mode 100644 index 000000000..90da06aca --- /dev/null +++ b/.changeset/witty-rivers-laugh.md @@ -0,0 +1,12 @@ +--- +"@logto/core": patch +--- + +prevent user registration and profile fulfillment with SSO-only email domains + +Emails associated with SSO-enabled domains should only be used through the SSO authentication process. + +Bug fix: + +- Creating a new user with a verification record that contains an SSO-only email domain should return a 422 `RequestError` with the error code `session.sso_required`. +- Updating a user profile with an SSO-only email domain should return a 422 `RequestError` with the error code `session.sso_required`. diff --git a/packages/core/src/routes/experience/classes/experience-interaction.ts b/packages/core/src/routes/experience/classes/experience-interaction.ts index e74dcad3f..eb4adfd34 100644 --- a/packages/core/src/routes/experience/classes/experience-interaction.ts +++ b/packages/core/src/routes/experience/classes/experience-interaction.ts @@ -253,6 +253,7 @@ export default class ExperienceInteraction { * @throws {RequestError} with 400 if the verification record can not be used for creating a new user or not verified * @throws {RequestError} with 422 if the profile data is not unique across users * @throws {RequestError} with 422 if any of required profile fields are missing + * @throws {RequestError} with 422 if the email domain is SSO only */ public async createUser(verificationId?: string, log?: LogEntry) { assertThat( @@ -274,6 +275,7 @@ export default class ExperienceInteraction { verification: verificationRecord.toJson(), }); + await this.signInExperienceValidator.guardSsoOnlyEmailIdentifier(verificationRecord); const identifierProfile = await getNewUserProfileFromVerificationRecord(verificationRecord); await this.profile.setProfileWithValidation(identifierProfile); diff --git a/packages/core/src/routes/experience/classes/libraries/sign-in-experience-validator.ts b/packages/core/src/routes/experience/classes/libraries/sign-in-experience-validator.ts index 2501000a7..cd73a5ec9 100644 --- a/packages/core/src/routes/experience/classes/libraries/sign-in-experience-validator.ts +++ b/packages/core/src/routes/experience/classes/libraries/sign-in-experience-validator.ts @@ -170,7 +170,7 @@ export class SignInExperienceValidator { * * @throws {RequestError} with status 422 if the email identifier is SSO enabled **/ - private async guardSsoOnlyEmailIdentifier(verificationRecord: VerificationRecord) { + public async guardSsoOnlyEmailIdentifier(verificationRecord: VerificationRecord) { const emailIdentifier = getEmailIdentifierFromVerificationRecord(verificationRecord); if (!emailIdentifier) { diff --git a/packages/core/src/routes/experience/classes/profile.ts b/packages/core/src/routes/experience/classes/profile.ts index 6146c709b..24171065a 100644 --- a/packages/core/src/routes/experience/classes/profile.ts +++ b/packages/core/src/routes/experience/classes/profile.ts @@ -37,6 +37,7 @@ export class Profile { * * @throws {RequestError} 422 if the profile data already exists in the current user account. * @throws {RequestError} 422 if the unique identifier data already exists in another user account. + * @throws {RequestError} 422 if the email domain is SSO only. */ async setProfileByVerificationRecord( type: VerificationType.EmailVerificationCode | VerificationType.PhoneVerificationCode, @@ -52,6 +53,10 @@ export class Profile { verification: verificationRecord.toJson(), }); + if (verificationRecord.type === VerificationType.EmailVerificationCode) { + await this.signInExperienceValidator.guardSsoOnlyEmailIdentifier(verificationRecord); + } + const profile = verificationRecord.toUserProfile(); await this.setProfileWithValidation(profile); diff --git a/packages/core/src/routes/experience/profile-routes.openapi.json b/packages/core/src/routes/experience/profile-routes.openapi.json index b3014d6a5..5632165d7 100644 --- a/packages/core/src/routes/experience/profile-routes.openapi.json +++ b/packages/core/src/routes/experience/profile-routes.openapi.json @@ -38,7 +38,7 @@ "description": "`SignIn` interaction only: MFA is enabled for the user but has not been verified. The user must verify the MFA before updating the profile data." }, "422": { - "description": "The user profile can not been processed, check error message for more details.
- The profile data is invalid or conflicts with existing user data.
- The profile data is already in use by another user account." + "description": "The user profile can not been processed, check error message for more details.
- The profile data is invalid or conflicts with existing user data.
- The profile data is already in use by another user account.
- The email address is enterprise SSO enabled, can only be linked through the SSO connector." } } } diff --git a/packages/integration-tests/src/tests/api/experience-api/register-interaction/enterprise-sso.test.ts b/packages/integration-tests/src/tests/api/experience-api/register-interaction/enterprise-sso.test.ts new file mode 100644 index 000000000..6d5e4acba --- /dev/null +++ b/packages/integration-tests/src/tests/api/experience-api/register-interaction/enterprise-sso.test.ts @@ -0,0 +1,192 @@ +import { ConnectorType, InteractionEvent, SignInIdentifier } from '@logto/schemas'; +import { generateStandardId } from '@logto/shared'; + +import { mockSocialConnectorId } from '#src/__mocks__/connectors-mock.js'; +import { updateSignInExperience } from '#src/api/sign-in-experience.js'; +import { SsoConnectorApi } from '#src/api/sso-connector.js'; +import { initExperienceClient } from '#src/helpers/client.js'; +import { + clearConnectorsByTypes, + setEmailConnector, + setSocialConnector, +} from '#src/helpers/connector.js'; +import { + successFullyCreateSocialVerification, + successFullyVerifySocialAuthorization, +} from '#src/helpers/experience/social-verification.js'; +import { + successfullySendVerificationCode, + successfullyVerifyVerificationCode, +} from '#src/helpers/experience/verification-code.js'; +import { expectRejects } from '#src/helpers/index.js'; +import { UserApiTest } from '#src/helpers/user.js'; +import { generateEmail } from '#src/utils.js'; + +describe('should reject the email registration if the email domain is enabled for SSO only', () => { + const ssoConnectorApi = new SsoConnectorApi(); + const domain = 'foo.com'; + const email = generateEmail(domain); + const userApi = new UserApiTest(); + const identifier = Object.freeze({ type: SignInIdentifier.Email, value: email }); + + beforeAll(async () => { + await Promise.all([setEmailConnector(), ssoConnectorApi.createMockOidcConnector([domain])]); + await updateSignInExperience({ + singleSignOnEnabled: true, + signUp: { identifiers: [SignInIdentifier.Email], password: false, verify: true }, + }); + }); + + afterAll(async () => { + await Promise.all([ssoConnectorApi.cleanUp(), userApi.cleanUp()]); + }); + + it('should block email verification code registration', async () => { + const client = await initExperienceClient(InteractionEvent.Register); + + const { verificationId, code } = await successfullySendVerificationCode(client, { + identifier, + interactionEvent: InteractionEvent.Register, + }); + + await successfullyVerifyVerificationCode(client, { + identifier, + verificationId, + code, + }); + + await expectRejects( + client.identifyUser({ + verificationId, + }), + { + code: `session.sso_enabled`, + status: 422, + } + ); + }); + + it('should block email profile update', async () => { + const client = await initExperienceClient(InteractionEvent.Register); + + const { verificationId, code } = await successfullySendVerificationCode(client, { + identifier, + interactionEvent: InteractionEvent.Register, + }); + + await successfullyVerifyVerificationCode(client, { + identifier, + verificationId, + code, + }); + + await expectRejects( + client.updateProfile({ + type: SignInIdentifier.Email, + verificationId, + }), + { + code: `session.sso_enabled`, + status: 422, + } + ); + }); + + describe('social register and link account', () => { + const connectorIdMap = new Map(); + const state = 'state'; + const redirectUri = 'http://localhost:3000'; + const socialUserId = generateStandardId(); + + beforeAll(async () => { + await clearConnectorsByTypes([ConnectorType.Social]); + const { id: socialConnectorId } = await setSocialConnector(); + connectorIdMap.set(mockSocialConnectorId, socialConnectorId); + }); + + afterAll(async () => { + await clearConnectorsByTypes([ConnectorType.Social]); + }); + + it('should block social register with SSO only email identifier', async () => { + const connectorId = connectorIdMap.get(mockSocialConnectorId)!; + const client = await initExperienceClient(InteractionEvent.Register); + + const { verificationId } = await successFullyCreateSocialVerification(client, connectorId, { + redirectUri, + state, + }); + + await successFullyVerifySocialAuthorization(client, connectorId, { + verificationId, + connectorData: { + state, + redirectUri, + code: 'fake_code', + userId: socialUserId, + email, + }, + }); + + await expectRejects( + client.identifyUser({ + verificationId, + }), + { + code: `session.sso_enabled`, + status: 422, + } + ); + }); + + it('should block social link email with SSO only email identifier', async () => { + const connectorId = connectorIdMap.get(mockSocialConnectorId)!; + const client = await initExperienceClient(InteractionEvent.Register); + + const { verificationId } = await successFullyCreateSocialVerification(client, connectorId, { + redirectUri, + state, + }); + + await successFullyVerifySocialAuthorization(client, connectorId, { + verificationId, + connectorData: { + state, + redirectUri, + code: 'fake_code', + userId: socialUserId, + }, + }); + + await expectRejects(client.identifyUser({ verificationId }), { + code: 'user.missing_profile', + status: 422, + }); + + const { code, verificationId: emailVerificationId } = await successfullySendVerificationCode( + client, + { + identifier, + interactionEvent: InteractionEvent.Register, + } + ); + + await successfullyVerifyVerificationCode(client, { + identifier, + verificationId: emailVerificationId, + code, + }); + + await expectRejects( + client.updateProfile({ + type: SignInIdentifier.Email, + verificationId: emailVerificationId, + }), + { + code: `session.sso_enabled`, + status: 422, + } + ); + }); + }); +});