Skip to content

Commit

Permalink
fix(core): add sso only email guard (#6576)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
simeng-li authored Sep 13, 2024
1 parent d7663db commit 5aab7c0
Show file tree
Hide file tree
Showing 6 changed files with 213 additions and 2 deletions.
12 changes: 12 additions & 0 deletions .changeset/witty-rivers-laugh.md
Original file line number Diff line number Diff line change
@@ -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`.
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/routes/experience/classes/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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. <br/>- The profile data is invalid or conflicts with existing user data. <br/>- 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. <br/>- The profile data is invalid or conflicts with existing user data. <br/>- The profile data is already in use by another user account. <br/>- The email address is enterprise SSO enabled, can only be linked through the SSO connector."
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, string>();
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,
}
);
});
});
});

0 comments on commit 5aab7c0

Please sign in to comment.