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:
parent
d7663db6cf
commit
5aab7c01bf
6 changed files with 213 additions and 2 deletions
12
.changeset/witty-rivers-laugh.md
Normal file
12
.changeset/witty-rivers-laugh.md
Normal 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`.
|
|
@ -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);
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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…
Reference in a new issue