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 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 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 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) {
|
public async createUser(verificationId?: string, log?: LogEntry) {
|
||||||
assertThat(
|
assertThat(
|
||||||
|
@ -274,6 +275,7 @@ export default class ExperienceInteraction {
|
||||||
verification: verificationRecord.toJson(),
|
verification: verificationRecord.toJson(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await this.signInExperienceValidator.guardSsoOnlyEmailIdentifier(verificationRecord);
|
||||||
const identifierProfile = await getNewUserProfileFromVerificationRecord(verificationRecord);
|
const identifierProfile = await getNewUserProfileFromVerificationRecord(verificationRecord);
|
||||||
|
|
||||||
await this.profile.setProfileWithValidation(identifierProfile);
|
await this.profile.setProfileWithValidation(identifierProfile);
|
||||||
|
|
|
@ -170,7 +170,7 @@ export class SignInExperienceValidator {
|
||||||
*
|
*
|
||||||
* @throws {RequestError} with status 422 if the email identifier is SSO enabled
|
* @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);
|
const emailIdentifier = getEmailIdentifierFromVerificationRecord(verificationRecord);
|
||||||
|
|
||||||
if (!emailIdentifier) {
|
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 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 unique identifier data already exists in another user account.
|
||||||
|
* @throws {RequestError} 422 if the email domain is SSO only.
|
||||||
*/
|
*/
|
||||||
async setProfileByVerificationRecord(
|
async setProfileByVerificationRecord(
|
||||||
type: VerificationType.EmailVerificationCode | VerificationType.PhoneVerificationCode,
|
type: VerificationType.EmailVerificationCode | VerificationType.PhoneVerificationCode,
|
||||||
|
@ -52,6 +53,10 @@ export class Profile {
|
||||||
verification: verificationRecord.toJson(),
|
verification: verificationRecord.toJson(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (verificationRecord.type === VerificationType.EmailVerificationCode) {
|
||||||
|
await this.signInExperienceValidator.guardSsoOnlyEmailIdentifier(verificationRecord);
|
||||||
|
}
|
||||||
|
|
||||||
const profile = verificationRecord.toUserProfile();
|
const profile = verificationRecord.toUserProfile();
|
||||||
|
|
||||||
await this.setProfileWithValidation(profile);
|
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."
|
"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": {
|
"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