0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

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
This commit is contained in:
simeng-li 2024-09-13 17:34:37 +08:00 committed by GitHub
parent d7663db6cf
commit 5aab7c01bf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 213 additions and 2 deletions

View file

@ -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`.

View file

@ -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);

View file

@ -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) {

View file

@ -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);

View file

@ -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."
}
}
}

View file

@ -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,
}
);
});
});
});