diff --git a/.changeset/witty-rivers-laugh.md b/.changeset/witty-rivers-laugh.md
new file mode 100644
index 00000000000..90da06aca6a
--- /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 e74dcad3f0f..eb4adfd3497 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 2501000a701..cd73a5ec935 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 6146c709ba0..24171065ab2 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 b3014d6a5a0..5632165d7df 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 00000000000..6d5e4acba23
--- /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,
+ }
+ );
+ });
+ });
+});