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

fix(core): add sso only email guard #6576

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@
*
* @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 @@
verification: verificationRecord.toJson(),
});

if (verificationRecord.type === VerificationType.EmailVerificationCode) {
await this.signInExperienceValidator.guardSsoOnlyEmailIdentifier(verificationRecord);
}

Check warning on line 59 in packages/core/src/routes/experience/classes/profile.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/profile.ts#L56-L59

Added lines #L56 - L59 were not covered by tests
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,
}
);
});
});
});
Loading