mirror of
https://github.com/logto-io/logto.git
synced 2025-01-06 20:40:08 -05:00
refactor(core,schemas): refactor the registration flow
refactor the registraction flow
This commit is contained in:
parent
d74fb15aab
commit
7fb00bca7a
19 changed files with 582 additions and 497 deletions
|
@ -105,7 +105,7 @@ describe('ExperienceInteraction class', () => {
|
|||
);
|
||||
|
||||
experienceInteraction.setVerificationRecord(emailVerificationRecord);
|
||||
await experienceInteraction.identifyUser(emailVerificationRecord.id);
|
||||
await experienceInteraction.createUser(emailVerificationRecord.id);
|
||||
|
||||
expect(userLibraries.insertUser).toHaveBeenCalledWith(
|
||||
{
|
||||
|
|
|
@ -1,12 +1,6 @@
|
|||
/* eslint-disable max-lines */
|
||||
import { type ToZodObject } from '@logto/connector-kit';
|
||||
import {
|
||||
InteractionEvent,
|
||||
SignInIdentifier,
|
||||
VerificationType,
|
||||
type InteractionIdentifier,
|
||||
type User,
|
||||
} from '@logto/schemas';
|
||||
import { InteractionEvent, VerificationType, type User } from '@logto/schemas';
|
||||
import { conditional } from '@silverhand/essentials';
|
||||
import { z } from 'zod';
|
||||
|
||||
|
@ -34,7 +28,6 @@ import { SignInExperienceValidator } from './libraries/sign-in-experience-valida
|
|||
import { Mfa, mfaDataGuard, userMfaDataKey, type MfaData } from './mfa.js';
|
||||
import { Profile } from './profile.js';
|
||||
import { toUserSocialIdentityData } from './utils.js';
|
||||
import { identifierCodeVerificationTypeMap } from './verifications/code-verification.js';
|
||||
import {
|
||||
buildVerificationRecord,
|
||||
verificationRecordDataGuard,
|
||||
|
@ -105,6 +98,7 @@ export default class ExperienceInteraction {
|
|||
this.provisionLibrary = new ProvisionLibrary(tenant, ctx);
|
||||
|
||||
const interactionContext: InteractionContext = {
|
||||
getInteractionEvent: () => this.#interactionEvent,
|
||||
getIdentifiedUser: async () => this.getIdentifiedUser(),
|
||||
getVerificationRecordByTypeAndId: (type, verificationId) =>
|
||||
this.getVerificationRecordByTypeAndId(type, verificationId),
|
||||
|
@ -153,7 +147,9 @@ export default class ExperienceInteraction {
|
|||
}
|
||||
|
||||
/**
|
||||
* Set the interaction event for the current interaction
|
||||
* Switch the interaction event for the current interaction sign-in <> register
|
||||
*
|
||||
* - any pending profile data will be cleared
|
||||
*
|
||||
* @throws RequestError with 403 if the interaction event is not allowed by the `SignInExperienceValidator`
|
||||
* @throws RequestError with 400 if the interaction event is `ForgotPassword` and the current interaction event is not `ForgotPassword`
|
||||
|
@ -170,6 +166,10 @@ export default class ExperienceInteraction {
|
|||
new RequestError({ code: 'session.not_supported_for_forgot_password', status: 400 })
|
||||
);
|
||||
|
||||
if (this.#interactionEvent !== interactionEvent) {
|
||||
this.profile.cleanUp();
|
||||
}
|
||||
|
||||
this.#interactionEvent = interactionEvent;
|
||||
}
|
||||
|
||||
|
@ -178,44 +178,117 @@ export default class ExperienceInteraction {
|
|||
*
|
||||
* - Check if the verification record exists.
|
||||
* - Verify the verification record with {@link SignInExperienceValidator}.
|
||||
* - Create a new user using the verification record if the current interaction event is `Register`.
|
||||
* - Identify the user using the verification record if the current interaction event is `SignIn` or `ForgotPassword`.
|
||||
* - Set the user id to the current interaction.
|
||||
*
|
||||
* @throws RequestError with 404 if the interaction event is not set.
|
||||
* @throws RequestError with 404 if the verification record is not found.
|
||||
* @throws RequestError with 422 if the verification record is not enabled in the SIE settings.
|
||||
* @see {@link identifyExistingUser} for more exceptions that can be thrown in the SignIn and ForgotPassword events.
|
||||
* @see {@link createNewUser} for more exceptions that can be thrown in the Register event.
|
||||
* @param linkSocialIdentity Applies only to the SocialIdentity verification record sign-in events only.
|
||||
* If true, the social identity will be linked to related user.
|
||||
*
|
||||
* @throws {RequestError} with 400 if the verification record is not verified or not valid for identifying a user
|
||||
* @throws {RequestError} with 403 if the interaction event is not allowed
|
||||
* @throws {RequestError} with 404 if the user is not found
|
||||
* @throws {RequestError} with 401 if the user is suspended
|
||||
* @throws {RequestError} with 409 if the current session has already identified a different user
|
||||
**/
|
||||
public async identifyUser(verificationId: string, linkSocialIdentity?: boolean, log?: LogEntry) {
|
||||
assertThat(
|
||||
this.interactionEvent !== InteractionEvent.Register,
|
||||
new RequestError({ code: 'session.invalid_interaction_type', status: 400 })
|
||||
);
|
||||
|
||||
const verificationRecord = this.getVerificationRecordById(verificationId);
|
||||
|
||||
log?.append({
|
||||
verification: verificationRecord?.toJson(),
|
||||
});
|
||||
|
||||
assertThat(
|
||||
this.interactionEvent,
|
||||
new RequestError({ code: 'session.interaction_not_found', status: 404 })
|
||||
);
|
||||
|
||||
assertThat(
|
||||
verificationRecord,
|
||||
new RequestError({ code: 'session.verification_session_not_found', status: 404 })
|
||||
);
|
||||
|
||||
await this.signInExperienceValidator.verifyIdentificationMethod(
|
||||
await this.signInExperienceValidator.guardIdentificationMethod(
|
||||
this.interactionEvent,
|
||||
verificationRecord
|
||||
);
|
||||
|
||||
if (this.interactionEvent === InteractionEvent.Register) {
|
||||
await this.createNewUser(verificationRecord);
|
||||
const { user, syncedProfile } = await identifyUserByVerificationRecord(
|
||||
verificationRecord,
|
||||
linkSocialIdentity
|
||||
);
|
||||
|
||||
const { id, isSuspended } = user;
|
||||
assertThat(!isSuspended, new RequestError({ code: 'user.suspended', status: 401 }));
|
||||
|
||||
// Throws an 409 error if the current session has already identified a different user
|
||||
if (this.userId) {
|
||||
assertThat(
|
||||
this.userId === id,
|
||||
new RequestError({ code: 'session.identity_conflict', status: 409 })
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.identifyExistingUser(verificationRecord, linkSocialIdentity);
|
||||
// Update the current interaction with the identified user
|
||||
this.userCache = user;
|
||||
this.userId = id;
|
||||
|
||||
// Sync social/enterprise SSO identity profile data.
|
||||
// Note: The profile data is not saved to the user profile until the user submits the interaction.
|
||||
// Also no need to validate the synced profile data availability as it is already validated during the identification process.
|
||||
if (syncedProfile) {
|
||||
const log = this.ctx.createLog(`Interaction.${this.interactionEvent}.Profile.Update`);
|
||||
log.append({ syncedProfile });
|
||||
this.profile.unsafeSet(syncedProfile);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new user using the profile data in the current interaction.
|
||||
*
|
||||
* - if a `verificationId` is provided, the profile data will be updated with the verification record data.
|
||||
* - id no `verificationId` is provided, directly create a new user with the current profile data.
|
||||
*
|
||||
* @throws {RequestError} with 403 if the register is not allowed by the sign-in experience settings
|
||||
* @throws {RequestError} with 404 if a `verificationId` is provided but the verification record is not found
|
||||
* @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
|
||||
*/
|
||||
public async createUser(verificationId?: string, log?: LogEntry) {
|
||||
assertThat(
|
||||
this.interactionEvent === InteractionEvent.Register,
|
||||
new RequestError({ code: 'session.invalid_interaction_type', status: 400 })
|
||||
);
|
||||
|
||||
await this.signInExperienceValidator.guardInteractionEvent(InteractionEvent.Register);
|
||||
|
||||
if (verificationId) {
|
||||
const verificationRecord = this.getVerificationRecordById(verificationId);
|
||||
|
||||
assertThat(
|
||||
verificationRecord,
|
||||
new RequestError({ code: 'session.verification_session_not_found', status: 404 })
|
||||
);
|
||||
|
||||
log?.append({
|
||||
verification: verificationRecord.toJson(),
|
||||
});
|
||||
|
||||
const identifierProfile = await getNewUserProfileFromVerificationRecord(verificationRecord);
|
||||
|
||||
await this.profile.setProfileWithValidation(identifierProfile);
|
||||
|
||||
// Save the updated profile data to the interaction storage
|
||||
await this.save();
|
||||
}
|
||||
|
||||
await this.profile.assertUserMandatoryProfileFulfilled();
|
||||
|
||||
const user = await this.provisionLibrary.createUser(this.profile.data);
|
||||
|
||||
this.userId = user.id;
|
||||
this.userCache = user;
|
||||
this.profile.cleanUp();
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -425,80 +498,6 @@ export default class ExperienceInteraction {
|
|||
return this.verificationRecords.array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify the existing user using the verification record.
|
||||
*
|
||||
* @param linkSocialIdentity Applies only to the SocialIdentity verification record sign-in events only.
|
||||
* If true, the social identity will be linked to related user.
|
||||
*
|
||||
* @throws RequestError with 400 if the verification record is not verified or not valid for identifying a user
|
||||
* @throws RequestError with 404 if the user is not found
|
||||
* @throws RequestError with 401 if the user is suspended
|
||||
* @throws RequestError with 409 if the current session has already identified a different user
|
||||
*/
|
||||
private async identifyExistingUser(
|
||||
verificationRecord: VerificationRecord,
|
||||
linkSocialIdentity?: boolean
|
||||
) {
|
||||
const { user, syncedProfile } = await identifyUserByVerificationRecord(
|
||||
verificationRecord,
|
||||
linkSocialIdentity
|
||||
);
|
||||
|
||||
const { id, isSuspended } = user;
|
||||
assertThat(!isSuspended, new RequestError({ code: 'user.suspended', status: 401 }));
|
||||
|
||||
// Throws an 409 error if the current session has already identified a different user
|
||||
if (this.userId) {
|
||||
assertThat(
|
||||
this.userId === id,
|
||||
new RequestError({ code: 'session.identity_conflict', status: 409 })
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the current interaction with the identified user
|
||||
this.userCache = user;
|
||||
this.userId = id;
|
||||
|
||||
// Sync social/enterprise SSO identity profile data.
|
||||
// Note: The profile data is not saved to the user profile until the user submits the interaction.
|
||||
// Also no need to validate the synced profile data availability as it is already validated during the identification process.
|
||||
if (syncedProfile) {
|
||||
const log = this.ctx.createLog(`Interaction.${this.interactionEvent}.Profile.Update`);
|
||||
log.append({ syncedProfile });
|
||||
this.profile.unsafeSet(syncedProfile);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new user using the verification record.
|
||||
*
|
||||
* @throws {RequestError} with 422 if a new password identity verification is provided, but identifier (email/phone) is 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 password is required for the sign-up settings but only email/phone verification record is provided
|
||||
*/
|
||||
private async createNewUser(verificationRecord: VerificationRecord) {
|
||||
if (verificationRecord.type === VerificationType.NewPasswordIdentity) {
|
||||
const { identifier } = verificationRecord;
|
||||
assertThat(
|
||||
this.isIdentifierVerified(identifier),
|
||||
new RequestError(
|
||||
{ code: 'session.identifier_not_verified', status: 422 },
|
||||
{ identifier: identifier.value }
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const newProfile = await getNewUserProfileFromVerificationRecord(verificationRecord);
|
||||
await this.profile.profileValidator.guardProfileUniquenessAcrossUsers(newProfile);
|
||||
|
||||
const user = await this.provisionLibrary.createUser(newProfile);
|
||||
|
||||
this.userId = user.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert the interaction is identified and return the identified user.
|
||||
* @throws RequestError with 404 if the if the user is not identified or not found
|
||||
|
@ -531,20 +530,6 @@ export default class ExperienceInteraction {
|
|||
return this.verificationRecordsArray.find((record) => record.id === verificationId);
|
||||
}
|
||||
|
||||
private isIdentifierVerified(identifier: InteractionIdentifier) {
|
||||
const { type, value } = identifier;
|
||||
|
||||
if (type === SignInIdentifier.Username) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const verificationRecord = this.verificationRecords.get(
|
||||
identifierCodeVerificationTypeMap[type]
|
||||
);
|
||||
|
||||
return verificationRecord?.identifier.value === value && verificationRecord.isVerified;
|
||||
}
|
||||
|
||||
private get hasVerifiedSsoIdentity() {
|
||||
const ssoVerificationRecord = this.verificationRecords.get(VerificationType.EnterpriseSso);
|
||||
|
||||
|
|
|
@ -132,15 +132,17 @@ export class ProfileValidator {
|
|||
// eslint-disable-next-line complexity
|
||||
public getMissingUserProfile(
|
||||
profile: InteractionProfile,
|
||||
user: User,
|
||||
mandatoryUserProfile: Set<MissingProfile>
|
||||
mandatoryUserProfile: Set<MissingProfile>,
|
||||
user?: User
|
||||
): Set<MissingProfile> {
|
||||
const missingProfile = new Set<MissingProfile>();
|
||||
|
||||
if (mandatoryUserProfile.has(MissingProfile.password)) {
|
||||
// Social and enterprise SSO identities can take place the role of password
|
||||
const isUserPasswordSet =
|
||||
Boolean(user.passwordEncrypted) || Object.keys(user.identities).length > 0;
|
||||
const isUserPasswordSet = user
|
||||
? // Social and enterprise SSO identities can take place the role of password
|
||||
Boolean(user.passwordEncrypted) || Object.keys(user.identities).length > 0
|
||||
: false;
|
||||
|
||||
const isProfilePasswordSet = Boolean(
|
||||
profile.passwordEncrypted ?? profile.socialIdentity ?? profile.enterpriseSsoIdentity
|
||||
);
|
||||
|
@ -150,14 +152,14 @@ export class ProfileValidator {
|
|||
}
|
||||
}
|
||||
|
||||
if (mandatoryUserProfile.has(MissingProfile.username) && !user.username && !profile.username) {
|
||||
if (mandatoryUserProfile.has(MissingProfile.username) && !user?.username && !profile.username) {
|
||||
missingProfile.add(MissingProfile.username);
|
||||
}
|
||||
|
||||
if (
|
||||
mandatoryUserProfile.has(MissingProfile.emailOrPhone) &&
|
||||
!user.primaryPhone &&
|
||||
!user.primaryEmail &&
|
||||
!user?.primaryPhone &&
|
||||
!user?.primaryEmail &&
|
||||
!profile.primaryPhone &&
|
||||
!profile.primaryEmail
|
||||
) {
|
||||
|
@ -166,7 +168,7 @@ export class ProfileValidator {
|
|||
|
||||
if (
|
||||
mandatoryUserProfile.has(MissingProfile.email) &&
|
||||
!user.primaryEmail &&
|
||||
!user?.primaryEmail &&
|
||||
!profile.primaryEmail
|
||||
) {
|
||||
missingProfile.add(MissingProfile.email);
|
||||
|
@ -174,7 +176,7 @@ export class ProfileValidator {
|
|||
|
||||
if (
|
||||
mandatoryUserProfile.has(MissingProfile.phone) &&
|
||||
!user.primaryPhone &&
|
||||
!user?.primaryPhone &&
|
||||
!profile.primaryPhone
|
||||
) {
|
||||
missingProfile.add(MissingProfile.phone);
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
/* eslint-disable max-lines */
|
||||
import { type LogtoErrorCode } from '@logto/phrases';
|
||||
import {
|
||||
InteractionEvent,
|
||||
type SignInExperience,
|
||||
|
@ -318,13 +316,13 @@ describe('SignInExperienceValidator', () => {
|
|||
|
||||
await (accepted
|
||||
? expect(
|
||||
signInExperienceSettings.verifyIdentificationMethod(
|
||||
signInExperienceSettings.guardIdentificationMethod(
|
||||
InteractionEvent.SignIn,
|
||||
verificationRecord
|
||||
)
|
||||
).resolves.not.toThrow()
|
||||
: expect(
|
||||
signInExperienceSettings.verifyIdentificationMethod(
|
||||
signInExperienceSettings.guardIdentificationMethod(
|
||||
InteractionEvent.SignIn,
|
||||
verificationRecord
|
||||
)
|
||||
|
@ -335,162 +333,6 @@ describe('SignInExperienceValidator', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('verifyIdentificationMethod (Register)', () => {
|
||||
const registerVerificationTestCases: Record<
|
||||
string,
|
||||
{
|
||||
signInExperience: SignInExperience;
|
||||
cases: Array<{
|
||||
verificationRecord: VerificationRecord;
|
||||
accepted: boolean;
|
||||
errorCode?: LogtoErrorCode;
|
||||
}>;
|
||||
}
|
||||
> = Object.freeze({
|
||||
'only username is enabled for sign-up': {
|
||||
signInExperience: mockSignInExperience,
|
||||
cases: [
|
||||
{
|
||||
verificationRecord: newPasswordIdentityVerificationRecord,
|
||||
accepted: true,
|
||||
},
|
||||
{
|
||||
verificationRecord: verificationCodeVerificationRecords[SignInIdentifier.Email],
|
||||
accepted: false,
|
||||
},
|
||||
{
|
||||
verificationRecord: verificationCodeVerificationRecords[SignInIdentifier.Phone],
|
||||
accepted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
'email is enabled for sign-up': {
|
||||
signInExperience: {
|
||||
...mockSignInExperience,
|
||||
signUp: {
|
||||
identifiers: [SignInIdentifier.Email],
|
||||
password: false,
|
||||
verify: true,
|
||||
},
|
||||
},
|
||||
cases: [
|
||||
{
|
||||
verificationRecord: verificationCodeVerificationRecords[SignInIdentifier.Email],
|
||||
accepted: true,
|
||||
},
|
||||
{
|
||||
verificationRecord: verificationCodeVerificationRecords[SignInIdentifier.Phone],
|
||||
accepted: false,
|
||||
},
|
||||
{
|
||||
verificationRecord: emailNewPasswordIdentityVerificationRecord,
|
||||
accepted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
'email and phone are enabled for sign-up': {
|
||||
signInExperience: {
|
||||
...mockSignInExperience,
|
||||
signUp: {
|
||||
identifiers: [SignInIdentifier.Email, SignInIdentifier.Phone],
|
||||
password: false,
|
||||
verify: true,
|
||||
},
|
||||
},
|
||||
cases: [
|
||||
{
|
||||
verificationRecord: verificationCodeVerificationRecords[SignInIdentifier.Email],
|
||||
accepted: true,
|
||||
},
|
||||
{
|
||||
verificationRecord: verificationCodeVerificationRecords[SignInIdentifier.Phone],
|
||||
accepted: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
'email are enabled for sign-up but password is required': {
|
||||
signInExperience: {
|
||||
...mockSignInExperience,
|
||||
signUp: {
|
||||
identifiers: [SignInIdentifier.Email],
|
||||
password: true,
|
||||
verify: true,
|
||||
},
|
||||
},
|
||||
cases: [
|
||||
{
|
||||
verificationRecord: verificationCodeVerificationRecords[SignInIdentifier.Email],
|
||||
accepted: false,
|
||||
errorCode: 'user.password_required_in_profile',
|
||||
},
|
||||
{
|
||||
verificationRecord: emailNewPasswordIdentityVerificationRecord,
|
||||
accepted: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
'enterprise sso enabled': {
|
||||
signInExperience: {
|
||||
...mockSignInExperience,
|
||||
singleSignOnEnabled: true,
|
||||
},
|
||||
cases: [
|
||||
{
|
||||
verificationRecord: enterpriseSsoVerificationRecords,
|
||||
accepted: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
'enterprise sso disabled': {
|
||||
signInExperience: {
|
||||
...mockSignInExperience,
|
||||
singleSignOnEnabled: false,
|
||||
},
|
||||
cases: [
|
||||
{
|
||||
verificationRecord: enterpriseSsoVerificationRecords,
|
||||
accepted: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
describe.each(Object.keys(registerVerificationTestCases))(`%s`, (testCase) => {
|
||||
const { signInExperience, cases } = registerVerificationTestCases[testCase]!;
|
||||
|
||||
it.each(cases)(
|
||||
'guard verification record %p',
|
||||
async ({ verificationRecord, accepted, errorCode }) => {
|
||||
signInExperiences.findDefaultSignInExperience.mockResolvedValueOnce(signInExperience);
|
||||
|
||||
const signInExperienceSettings = new SignInExperienceValidator(
|
||||
mockTenant.libraries,
|
||||
mockTenant.queries
|
||||
);
|
||||
|
||||
await (accepted
|
||||
? expect(
|
||||
signInExperienceSettings.verifyIdentificationMethod(
|
||||
InteractionEvent.Register,
|
||||
verificationRecord
|
||||
)
|
||||
).resolves.not.toThrow()
|
||||
: expect(
|
||||
signInExperienceSettings.verifyIdentificationMethod(
|
||||
InteractionEvent.Register,
|
||||
verificationRecord
|
||||
)
|
||||
).rejects.toMatchError(
|
||||
new RequestError({
|
||||
code: errorCode ?? 'user.sign_up_method_not_enabled',
|
||||
status: 422,
|
||||
})
|
||||
));
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('guardSsoOnlyEmailIdentifier: identifier with SSO enabled domain should throw', () => {
|
||||
const mockSsoConnector = {
|
||||
domains: [emailDomain],
|
||||
|
@ -515,7 +357,7 @@ describe('SignInExperienceValidator', () => {
|
|||
);
|
||||
|
||||
await expect(
|
||||
signInExperienceSettings.verifyIdentificationMethod(
|
||||
signInExperienceSettings.guardIdentificationMethod(
|
||||
InteractionEvent.SignIn,
|
||||
passwordVerificationRecords[SignInIdentifier.Email]
|
||||
)
|
||||
|
@ -531,7 +373,7 @@ describe('SignInExperienceValidator', () => {
|
|||
);
|
||||
|
||||
await expect(
|
||||
signInExperienceSettings.verifyIdentificationMethod(
|
||||
signInExperienceSettings.guardIdentificationMethod(
|
||||
InteractionEvent.SignIn,
|
||||
verificationCodeVerificationRecords[SignInIdentifier.Email]
|
||||
)
|
||||
|
@ -547,7 +389,7 @@ describe('SignInExperienceValidator', () => {
|
|||
);
|
||||
|
||||
await expect(
|
||||
signInExperienceSettings.verifyIdentificationMethod(
|
||||
signInExperienceSettings.guardIdentificationMethod(
|
||||
InteractionEvent.SignIn,
|
||||
socialVerificationRecord
|
||||
)
|
||||
|
@ -555,4 +397,3 @@ describe('SignInExperienceValidator', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
/* eslint-enable max-lines */
|
||||
|
|
|
@ -50,6 +50,9 @@ export class SignInExperienceValidator {
|
|||
private readonly queries: Queries
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @throws {RequestError} with status 403 if the interaction event is not allowed
|
||||
*/
|
||||
public async guardInteractionEvent(event: InteractionEvent) {
|
||||
const { signInMode } = await this.getSignInExperienceData();
|
||||
|
||||
|
@ -74,26 +77,22 @@ export class SignInExperienceValidator {
|
|||
}
|
||||
}
|
||||
|
||||
public async verifyIdentificationMethod(
|
||||
event: InteractionEvent,
|
||||
public async guardIdentificationMethod(
|
||||
event: InteractionEvent.ForgotPassword | InteractionEvent.SignIn,
|
||||
verificationRecord: VerificationRecord
|
||||
) {
|
||||
await this.guardInteractionEvent(event);
|
||||
|
||||
switch (event) {
|
||||
case InteractionEvent.SignIn: {
|
||||
await this.guardSignInVerificationMethod(verificationRecord);
|
||||
break;
|
||||
}
|
||||
case InteractionEvent.Register: {
|
||||
await this.guardRegisterVerificationMethod(verificationRecord);
|
||||
break;
|
||||
}
|
||||
case InteractionEvent.ForgotPassword: {
|
||||
this.guardForgotPasswordVerificationMethod(verificationRecord);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await this.guardSsoOnlyEmailIdentifier(verificationRecord);
|
||||
}
|
||||
|
||||
public async getEnabledSsoConnectorsByEmail(email: string) {
|
||||
|
@ -169,8 +168,7 @@ export class SignInExperienceValidator {
|
|||
* Email identifier with SSO enabled domain will be blocked.
|
||||
* Can only verify/identify via SSO verification record.
|
||||
*
|
||||
* - VerificationCode with email identifier
|
||||
* - Social userinfo with email
|
||||
* @throws {RequestError} with status 422 if the email identifier is SSO enabled
|
||||
**/
|
||||
private async guardSsoOnlyEmailIdentifier(verificationRecord: VerificationRecord) {
|
||||
const emailIdentifier = getEmailIdentifierFromVerificationRecord(verificationRecord);
|
||||
|
@ -195,6 +193,10 @@ export class SignInExperienceValidator {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws {RequestError} with status 422 if the verification record type is not enabled
|
||||
* @throws {RequestError} with status 422 if the email identifier is SSO enabled
|
||||
*/
|
||||
private async guardSignInVerificationMethod(verificationRecord: VerificationRecord) {
|
||||
const {
|
||||
signIn: { methods: signInMethods },
|
||||
|
@ -236,56 +238,8 @@ export class SignInExperienceValidator {
|
|||
throw new RequestError({ code: 'user.sign_in_method_not_enabled', status: 422 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async guardRegisterVerificationMethod(verificationRecord: VerificationRecord) {
|
||||
const { signUp, singleSignOnEnabled } = await this.getSignInExperienceData();
|
||||
|
||||
switch (verificationRecord.type) {
|
||||
// Username and password registration
|
||||
case VerificationType.NewPasswordIdentity: {
|
||||
const {
|
||||
identifier: { type },
|
||||
} = verificationRecord;
|
||||
|
||||
assertThat(
|
||||
signUp.identifiers.includes(type) && signUp.password,
|
||||
new RequestError({ code: 'user.sign_up_method_not_enabled', status: 422 })
|
||||
);
|
||||
break;
|
||||
}
|
||||
case VerificationType.EmailVerificationCode:
|
||||
case VerificationType.PhoneVerificationCode: {
|
||||
const {
|
||||
identifier: { type },
|
||||
} = verificationRecord;
|
||||
|
||||
assertThat(
|
||||
signUp.identifiers.includes(type) && signUp.verify,
|
||||
new RequestError({ code: 'user.sign_up_method_not_enabled', status: 422 })
|
||||
);
|
||||
|
||||
assertThat(
|
||||
!signUp.password,
|
||||
new RequestError({ code: 'user.password_required_in_profile', status: 422 })
|
||||
);
|
||||
break;
|
||||
}
|
||||
case VerificationType.Social: {
|
||||
// No need to verify social verification method
|
||||
break;
|
||||
}
|
||||
case VerificationType.EnterpriseSso: {
|
||||
assertThat(
|
||||
singleSignOnEnabled,
|
||||
new RequestError({ code: 'user.sign_up_method_not_enabled', status: 422 })
|
||||
);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
throw new RequestError({ code: 'user.sign_up_method_not_enabled', status: 422 });
|
||||
}
|
||||
}
|
||||
await this.guardSsoOnlyEmailIdentifier(verificationRecord);
|
||||
}
|
||||
|
||||
/** Forgot password only supports verification code type verification record */
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { type VerificationType } from '@logto/schemas';
|
||||
import { InteractionEvent, VerificationType } from '@logto/schemas';
|
||||
import { trySafe } from '@silverhand/essentials';
|
||||
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import { type LogEntry } from '#src/middleware/koa-audit-log.js';
|
||||
|
@ -52,18 +53,44 @@ export class Profile {
|
|||
});
|
||||
|
||||
const profile = verificationRecord.toUserProfile();
|
||||
|
||||
await this.setProfileWithValidation(profile);
|
||||
}
|
||||
|
||||
async setProfileBySocialVerificationRecord(verificationId: string, log?: LogEntry) {
|
||||
const verificationRecord = this.interactionContext.getVerificationRecordByTypeAndId(
|
||||
VerificationType.Social,
|
||||
verificationId
|
||||
);
|
||||
|
||||
log?.append({
|
||||
verification: verificationRecord.toJson(),
|
||||
});
|
||||
|
||||
const profile = await verificationRecord.toUserProfile();
|
||||
await this.setProfileWithValidation(profile);
|
||||
|
||||
const user = await this.safeGetIdentifiedUser();
|
||||
const isNewUserIdentity = !user;
|
||||
|
||||
// Sync the email and phone to the user profile only for new user identity
|
||||
const syncedProfile = await verificationRecord.toSyncedProfile(isNewUserIdentity);
|
||||
this.unsafePrepend(syncedProfile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the profile data with validation.
|
||||
*
|
||||
* @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. (Existing user profile only)
|
||||
* @throws {RequestError} 422 if the unique identifier data already exists in another user account.
|
||||
*/
|
||||
async setProfileWithValidation(profile: InteractionProfile) {
|
||||
const user = await this.interactionContext.getIdentifiedUser();
|
||||
this.profileValidator.guardProfileNotExistInCurrentUserAccount(user, profile);
|
||||
const user = await this.safeGetIdentifiedUser();
|
||||
|
||||
if (user) {
|
||||
this.profileValidator.guardProfileNotExistInCurrentUserAccount(user, profile);
|
||||
}
|
||||
|
||||
await this.profileValidator.guardProfileUniquenessAcrossUsers(profile);
|
||||
this.unsafeSet(profile);
|
||||
}
|
||||
|
@ -73,16 +100,16 @@ export class Profile {
|
|||
*
|
||||
* @param reset - If true the password will be set without checking if it already exists in the current user account.
|
||||
* @throws {RequestError} 422 if the password does not meet the password policy.
|
||||
* @throws {RequestError} 422 if the password is the same as the current user's password.
|
||||
* @throws {RequestError} 422 if the password is the same as the current user's password. (Existing user profile only)
|
||||
*/
|
||||
async setPasswordDigestWithValidation(password: string, reset = false) {
|
||||
const user = await this.interactionContext.getIdentifiedUser();
|
||||
const user = await this.safeGetIdentifiedUser();
|
||||
const passwordPolicy = await this.signInExperienceValidator.getPasswordPolicy();
|
||||
const passwordValidator = new PasswordValidator(passwordPolicy, user);
|
||||
await passwordValidator.validatePassword(password, this.#data);
|
||||
const passwordDigests = await passwordValidator.createPasswordDigest(password);
|
||||
|
||||
if (!reset) {
|
||||
if (user && !reset) {
|
||||
this.profileValidator.guardProfileNotExistInCurrentUserAccount(user, passwordDigests);
|
||||
}
|
||||
|
||||
|
@ -92,35 +119,44 @@ export class Profile {
|
|||
/**
|
||||
* Verifies the profile data is valid.
|
||||
*
|
||||
* @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. (Existing user profile only)
|
||||
* @throws {RequestError} 422 if the unique identifier data already exists in another user account.
|
||||
*/
|
||||
async validateAvailability() {
|
||||
const user = await this.interactionContext.getIdentifiedUser();
|
||||
this.profileValidator.guardProfileNotExistInCurrentUserAccount(user, this.#data);
|
||||
const user = await this.safeGetIdentifiedUser();
|
||||
|
||||
if (user) {
|
||||
this.profileValidator.guardProfileNotExistInCurrentUserAccount(user, this.#data);
|
||||
}
|
||||
|
||||
await this.profileValidator.guardProfileUniquenessAcrossUsers(this.#data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the user has fulfilled the mandatory profile fields.
|
||||
*
|
||||
* - Skip the check if the profile contains an enterprise SSO identity.
|
||||
*/
|
||||
async assertUserMandatoryProfileFulfilled() {
|
||||
const user = await this.interactionContext.getIdentifiedUser();
|
||||
const user = await this.safeGetIdentifiedUser();
|
||||
|
||||
if (this.#data.enterpriseSsoIdentity) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mandatoryProfileFields =
|
||||
await this.signInExperienceValidator.getMandatoryUserProfileBySignUpMethods();
|
||||
|
||||
const missingProfile = this.profileValidator.getMissingUserProfile(
|
||||
this.#data,
|
||||
user,
|
||||
mandatoryProfileFields
|
||||
mandatoryProfileFields,
|
||||
user
|
||||
);
|
||||
|
||||
if (missingProfile.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: find missing profile fields from the social identity if any
|
||||
|
||||
throw new RequestError(
|
||||
{ code: 'user.missing_profile', status: 422 },
|
||||
{ missingProfile: [...missingProfile] }
|
||||
|
@ -133,4 +169,41 @@ export class Profile {
|
|||
...profile,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepend the profile data to the existing profile data.
|
||||
* Avoid overwriting the existing profile data.
|
||||
*/
|
||||
unsafePrepend(profile: InteractionProfile) {
|
||||
this.#data = {
|
||||
...profile,
|
||||
...this.#data,
|
||||
};
|
||||
}
|
||||
|
||||
cleanUp() {
|
||||
this.#data = {};
|
||||
}
|
||||
|
||||
get notEmpty() {
|
||||
return Object.keys(this.#data).length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely get the identified user from the interaction context.
|
||||
* If the interaction event is register, the user will be retrieved safely.
|
||||
*
|
||||
* @returns The identified user from the interaction context.
|
||||
*/
|
||||
private async safeGetIdentifiedUser() {
|
||||
const { getInteractionEvent, getIdentifiedUser } = this.interactionContext;
|
||||
|
||||
const interactionEvent = getInteractionEvent();
|
||||
|
||||
if (interactionEvent === InteractionEvent.Register) {
|
||||
return trySafe(async () => getIdentifiedUser());
|
||||
}
|
||||
|
||||
return getIdentifiedUser();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -98,7 +98,7 @@ export class NewPasswordIdentityVerification
|
|||
* @throws {RequestError} with status 422 if the password is not provided
|
||||
* @throws {RequestError} with status 422 if the password does not meet the password policy
|
||||
*/
|
||||
async verify(password?: string) {
|
||||
async verify(password: string) {
|
||||
const { identifier } = this;
|
||||
const identifierProfile = interactionIdentifierToUserProfile(identifier);
|
||||
await this.profileValidator.guardProfileUniquenessAcrossUsers(identifierProfile);
|
||||
|
|
|
@ -14,8 +14,10 @@ import { identificationApiPayloadGuard, InteractionEvent } from '@logto/schemas'
|
|||
import type Router from 'koa-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import koaGuard from '#src/middleware/koa-guard.js';
|
||||
import koaInteractionDetails from '#src/middleware/koa-interaction-details.js';
|
||||
import assertThat from '#src/utils/assert-that.js';
|
||||
|
||||
import { type AnonymousRouter, type RouterInitArgs } from '../types.js';
|
||||
|
||||
|
@ -109,7 +111,7 @@ export default function experienceApiRoutes<T extends AnonymousRouter>(
|
|||
experienceRoutes.identification,
|
||||
koaGuard({
|
||||
body: identificationApiPayloadGuard,
|
||||
status: [201, 204, 400, 401, 404, 409, 422],
|
||||
status: [201, 204, 400, 401, 403, 404, 409, 422],
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const { verificationId, linkSocialIdentity } = ctx.guard.body;
|
||||
|
@ -126,7 +128,19 @@ export default function experienceApiRoutes<T extends AnonymousRouter>(
|
|||
},
|
||||
});
|
||||
|
||||
await experienceInteraction.identifyUser(verificationId, linkSocialIdentity, log);
|
||||
if (experienceInteraction.interactionEvent === InteractionEvent.Register) {
|
||||
await experienceInteraction.createUser(verificationId, log);
|
||||
} else {
|
||||
assertThat(
|
||||
verificationId,
|
||||
new RequestError({
|
||||
code: 'guard.invalid_input',
|
||||
status: 400,
|
||||
details: 'verificationId is missing',
|
||||
})
|
||||
);
|
||||
await experienceInteraction.identifyUser(verificationId, linkSocialIdentity, log);
|
||||
}
|
||||
|
||||
await experienceInteraction.save();
|
||||
|
||||
|
|
|
@ -57,12 +57,26 @@ export default function interactionProfileRoutes<T extends ExperienceInteraction
|
|||
body: updateProfileApiPayloadGuard,
|
||||
status: [204, 400, 403, 404, 422],
|
||||
}),
|
||||
verifiedInteractionGuard(),
|
||||
async (ctx, next) => {
|
||||
const { experienceInteraction, guard, createLog } = ctx;
|
||||
const profilePayload = guard.body;
|
||||
const { interactionEvent } = experienceInteraction;
|
||||
|
||||
const log = createLog(`Interaction.${experienceInteraction.interactionEvent}.Profile.Update`);
|
||||
const log = createLog(`Interaction.${interactionEvent}.Profile.Update`);
|
||||
|
||||
// Guard current interaction event is not ForgotPassword
|
||||
assertThat(
|
||||
interactionEvent !== InteractionEvent.ForgotPassword,
|
||||
new RequestError({
|
||||
code: 'session.not_supported_for_forgot_password',
|
||||
statue: 400,
|
||||
})
|
||||
);
|
||||
|
||||
// Guard MFA verification status for SignIn interaction only
|
||||
if (interactionEvent === InteractionEvent.SignIn) {
|
||||
await experienceInteraction.guardMfaVerificationStatus();
|
||||
}
|
||||
|
||||
log.append({
|
||||
payload: profilePayload,
|
||||
|
@ -74,7 +88,8 @@ export default function interactionProfileRoutes<T extends ExperienceInteraction
|
|||
const verificationType = identifierCodeVerificationTypeMap[profilePayload.type];
|
||||
await experienceInteraction.profile.setProfileByVerificationRecord(
|
||||
verificationType,
|
||||
profilePayload.verificationId
|
||||
profilePayload.verificationId,
|
||||
log
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
@ -86,6 +101,13 @@ export default function interactionProfileRoutes<T extends ExperienceInteraction
|
|||
}
|
||||
case 'password': {
|
||||
await experienceInteraction.profile.setPasswordDigestWithValidation(profilePayload.value);
|
||||
break;
|
||||
}
|
||||
case 'social': {
|
||||
await experienceInteraction.profile.setProfileBySocialVerificationRecord(
|
||||
profilePayload.verificationId,
|
||||
log
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -142,7 +164,7 @@ export default function interactionProfileRoutes<T extends ExperienceInteraction
|
|||
koaGuard({ status: [204, 400, 403, 404, 422] }),
|
||||
verifiedInteractionGuard(),
|
||||
async (ctx, next) => {
|
||||
const { experienceInteraction, guard } = ctx;
|
||||
const { experienceInteraction } = ctx;
|
||||
|
||||
await experienceInteraction.mfa.skip();
|
||||
await experienceInteraction.save();
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { type SocialUserInfo, socialUserInfoGuard, type ToZodObject } from '@logto/connector-kit';
|
||||
import {
|
||||
type CreateUser,
|
||||
type InteractionEvent,
|
||||
type User,
|
||||
Users,
|
||||
UserSsoIdentities,
|
||||
|
@ -69,6 +70,7 @@ export const interactionProfileGuard = Users.createGuard
|
|||
* The interaction context provides the callback functions to get the user and verification record from the interaction
|
||||
*/
|
||||
export type InteractionContext = {
|
||||
getInteractionEvent: () => InteractionEvent;
|
||||
getIdentifiedUser: () => Promise<User>;
|
||||
getVerificationRecordByTypeAndId: <K extends keyof VerificationRecordMap>(
|
||||
type: K,
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { interactionIdentifierGuard, VerificationType } from '@logto/schemas';
|
||||
import { usernameRegEx } from '@logto/core-kit';
|
||||
import { SignInIdentifier, VerificationType } from '@logto/schemas';
|
||||
import { Action } from '@logto/schemas/lib/types/log/interaction.js';
|
||||
import type Router from 'koa-router';
|
||||
import { z } from 'zod';
|
||||
|
@ -18,8 +19,12 @@ export default function newPasswordIdentityVerificationRoutes<
|
|||
`${experienceRoutes.verification}/new-password-identity`,
|
||||
koaGuard({
|
||||
body: z.object({
|
||||
identifier: interactionIdentifierGuard,
|
||||
password: z.string().optional(),
|
||||
identifier: z.object({
|
||||
// Only username is supported for now
|
||||
type: z.literal(SignInIdentifier.Username),
|
||||
value: z.string().regex(usernameRegEx),
|
||||
}),
|
||||
password: z.string(),
|
||||
}),
|
||||
status: [200, 400, 422],
|
||||
response: z.object({
|
||||
|
|
|
@ -27,7 +27,7 @@ export const identifyUser = async (cookie: string, payload: IdentificationApiPay
|
|||
.json();
|
||||
|
||||
export class ExperienceClient extends MockClient {
|
||||
public async identifyUser(payload: IdentificationApiPayload) {
|
||||
public async identifyUser(payload: IdentificationApiPayload = {}) {
|
||||
return api.post(experienceRoutes.identification, {
|
||||
headers: { cookie: this.interactionCookie },
|
||||
json: payload,
|
||||
|
|
|
@ -117,19 +117,15 @@ export const registerNewUserWithVerificationCode = async (
|
|||
|
||||
if (options?.fulfillPassword) {
|
||||
await expectRejects(client.identifyUser({ verificationId }), {
|
||||
code: 'user.password_required_in_profile',
|
||||
code: 'user.missing_profile',
|
||||
status: 422,
|
||||
});
|
||||
|
||||
const password = generatePassword();
|
||||
|
||||
const { verificationId: newPasswordIdentityVerificationId } =
|
||||
await client.createNewPasswordIdentityVerification({
|
||||
identifier,
|
||||
password,
|
||||
});
|
||||
await client.updateProfile({ type: 'password', value: password });
|
||||
|
||||
await client.identifyUser({ verificationId: newPasswordIdentityVerificationId });
|
||||
await client.identifyUser();
|
||||
} else {
|
||||
await client.identifyUser({ verificationId });
|
||||
}
|
||||
|
|
|
@ -1,16 +1,18 @@
|
|||
import { SignInIdentifier } from '@logto/schemas';
|
||||
import { InteractionEvent, SignInIdentifier } from '@logto/schemas';
|
||||
|
||||
import { deleteUser } from '#src/api/admin-user.js';
|
||||
import { updateSignInExperience } from '#src/api/sign-in-experience.js';
|
||||
import { initExperienceClient, logoutClient, processSession } from '#src/helpers/client.js';
|
||||
import {
|
||||
registerNewUserUsernamePassword,
|
||||
signInWithPassword,
|
||||
} from '#src/helpers/experience/index.js';
|
||||
import { generateNewUserProfile } from '#src/helpers/user.js';
|
||||
import { devFeatureTest } from '#src/utils.js';
|
||||
import { expectRejects } from '#src/helpers/index.js';
|
||||
import { generateNewUserProfile, UserApiTest } from '#src/helpers/user.js';
|
||||
import { devFeatureTest, generateUsername } from '#src/utils.js';
|
||||
|
||||
devFeatureTest.describe('register new user with username and password', () => {
|
||||
const { username, password } = generateNewUserProfile({ username: true, password: true });
|
||||
const userApi = new UserApiTest();
|
||||
|
||||
beforeAll(async () => {
|
||||
// Disable password policy here to make sure the test is not affected by the password policy.
|
||||
|
@ -24,7 +26,12 @@ devFeatureTest.describe('register new user with username and password', () => {
|
|||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await userApi.cleanUp();
|
||||
});
|
||||
|
||||
it('should register new user with username and password and able to sign-in using the same credentials', async () => {
|
||||
const { username, password } = generateNewUserProfile({ username: true, password: true });
|
||||
const userId = await registerNewUserUsernamePassword(username, password);
|
||||
|
||||
await signInWithPassword({
|
||||
|
@ -37,4 +44,42 @@ devFeatureTest.describe('register new user with username and password', () => {
|
|||
|
||||
await deleteUser(userId);
|
||||
});
|
||||
|
||||
it('should register new user with username and password step by step and able to sign-in using the same credentials', async () => {
|
||||
const { username, password } = generateNewUserProfile({ username: true, password: true });
|
||||
const existUsername = generateUsername();
|
||||
await userApi.create({ username: existUsername });
|
||||
|
||||
const client = await initExperienceClient(InteractionEvent.Register);
|
||||
|
||||
await expectRejects(
|
||||
client.updateProfile({ type: SignInIdentifier.Username, value: existUsername }),
|
||||
{
|
||||
status: 422,
|
||||
code: 'user.username_already_in_use',
|
||||
}
|
||||
);
|
||||
|
||||
await client.updateProfile({ type: SignInIdentifier.Username, value: username });
|
||||
|
||||
await expectRejects(client.identifyUser(), {
|
||||
status: 422,
|
||||
code: 'user.missing_profile',
|
||||
});
|
||||
await client.updateProfile({ type: 'password', value: password });
|
||||
await client.identifyUser();
|
||||
const { redirectTo } = await client.submitInteraction();
|
||||
const userId = await processSession(client, redirectTo);
|
||||
await logoutClient(client);
|
||||
|
||||
await signInWithPassword({
|
||||
identifier: {
|
||||
type: SignInIdentifier.Username,
|
||||
value: username,
|
||||
},
|
||||
password,
|
||||
});
|
||||
|
||||
await deleteUser(userId);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -15,7 +15,7 @@ import {
|
|||
import { expectRejects } from '#src/helpers/index.js';
|
||||
import { enableAllVerificationCodeSignInMethods } from '#src/helpers/sign-in-experience.js';
|
||||
import { generateNewUser } from '#src/helpers/user.js';
|
||||
import { devFeatureTest, generateEmail, generatePassword, generatePhone } from '#src/utils.js';
|
||||
import { devFeatureTest, generateEmail, generatePhone } from '#src/utils.js';
|
||||
|
||||
const verificationIdentifierType: readonly [SignInIdentifier.Email, SignInIdentifier.Phone] =
|
||||
Object.freeze([SignInIdentifier.Email, SignInIdentifier.Phone]);
|
||||
|
@ -119,50 +119,6 @@ devFeatureTest.describe('Register interaction with verification code happy path'
|
|||
});
|
||||
});
|
||||
|
||||
it.each(verificationIdentifierType)(
|
||||
'Should throw identifier not verified error when trying to fulfill password without verifying %p identifier',
|
||||
async (identifier) => {
|
||||
const client = await initExperienceClient(InteractionEvent.Register);
|
||||
const interactionIdentifier = {
|
||||
type: identifier,
|
||||
value: identifier === SignInIdentifier.Email ? generateEmail() : generatePhone(),
|
||||
};
|
||||
|
||||
const { verificationId } = await client.createNewPasswordIdentityVerification({
|
||||
identifier: interactionIdentifier,
|
||||
password: generatePassword(),
|
||||
});
|
||||
|
||||
await expectRejects(client.identifyUser({ verificationId }), {
|
||||
code: 'session.identifier_not_verified',
|
||||
status: 422,
|
||||
});
|
||||
|
||||
const { verificationId: codeVerificationId, code } = await successfullySendVerificationCode(
|
||||
client,
|
||||
{
|
||||
identifier: interactionIdentifier,
|
||||
interactionEvent: InteractionEvent.Register,
|
||||
}
|
||||
);
|
||||
|
||||
await successfullyVerifyVerificationCode(client, {
|
||||
identifier: interactionIdentifier,
|
||||
verificationId: codeVerificationId,
|
||||
code,
|
||||
});
|
||||
|
||||
await client.identifyUser({ verificationId });
|
||||
|
||||
const { redirectTo } = await client.submitInteraction();
|
||||
|
||||
const userId = await processSession(client, redirectTo);
|
||||
await logoutClient(client);
|
||||
|
||||
await deleteUser(userId);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(verificationIdentifierType)(
|
||||
'Should fail to sign-up with existing %p identifier and directly sign-in instead',
|
||||
async (identifierType) => {
|
||||
|
@ -193,16 +149,6 @@ devFeatureTest.describe('Register interaction with verification code happy path'
|
|||
client.identifyUser({
|
||||
verificationId,
|
||||
}),
|
||||
{
|
||||
code: `user.password_required_in_profile`,
|
||||
status: 422,
|
||||
}
|
||||
);
|
||||
|
||||
await expectRejects(
|
||||
client.createNewPasswordIdentityVerification({
|
||||
identifier,
|
||||
}),
|
||||
{
|
||||
code: `user.${identifierType}_already_in_use`,
|
||||
status: 422,
|
||||
|
|
|
@ -1,19 +1,30 @@
|
|||
import { MfaFactor, SignInIdentifier } from '@logto/schemas';
|
||||
import { InteractionEvent, MfaFactor, SignInIdentifier } from '@logto/schemas';
|
||||
import { generateStandardId } from '@logto/shared';
|
||||
|
||||
import { createUserMfaVerification, deleteUser, getUser } from '#src/api/admin-user.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 { setEmailConnector, setSmsConnector } from '#src/helpers/connector.js';
|
||||
import { signInWithEnterpriseSso } from '#src/helpers/experience/index.js';
|
||||
import { enableMandatoryMfaWithTotp } from '#src/helpers/sign-in-experience.js';
|
||||
import { generateNewUser } from '#src/helpers/user.js';
|
||||
import { devFeatureTest, generateEmail } from '#src/utils.js';
|
||||
import {
|
||||
successfullySendVerificationCode,
|
||||
successfullyVerifyVerificationCode,
|
||||
} from '#src/helpers/experience/verification-code.js';
|
||||
import { expectRejects } from '#src/helpers/index.js';
|
||||
import {
|
||||
enableAllVerificationCodeSignInMethods,
|
||||
enableMandatoryMfaWithTotp,
|
||||
} from '#src/helpers/sign-in-experience.js';
|
||||
import { generateNewUser, UserApiTest } from '#src/helpers/user.js';
|
||||
import { devFeatureTest, generateEmail, generatePassword } from '#src/utils.js';
|
||||
|
||||
devFeatureTest.describe('enterprise sso sign-in and sign-up', () => {
|
||||
const ssoConnectorApi = new SsoConnectorApi();
|
||||
const domain = 'foo.com';
|
||||
const enterpriseSsoIdentityId = generateStandardId();
|
||||
const email = generateEmail(domain);
|
||||
const userApi = new UserApiTest();
|
||||
|
||||
beforeAll(async () => {
|
||||
await ssoConnectorApi.createMockOidcConnector([domain]);
|
||||
|
@ -24,7 +35,7 @@ devFeatureTest.describe('enterprise sso sign-in and sign-up', () => {
|
|||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await ssoConnectorApi.cleanUp();
|
||||
await Promise.all([ssoConnectorApi.cleanUp(), userApi.cleanUp()]);
|
||||
});
|
||||
|
||||
it('should successfully sign-up with enterprise sso and sync email', async () => {
|
||||
|
@ -121,4 +132,60 @@ devFeatureTest.describe('enterprise sso sign-in and sign-up', () => {
|
|||
await deleteUser(userId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('should block email identifier from non-enterprise sso verifications if the SSO is enabled', () => {
|
||||
const password = generatePassword();
|
||||
const email = generateEmail(domain);
|
||||
const identifier = Object.freeze({ type: SignInIdentifier.Email, value: email });
|
||||
|
||||
beforeAll(async () => {
|
||||
await Promise.all([setEmailConnector(), setSmsConnector()]);
|
||||
await enableAllVerificationCodeSignInMethods();
|
||||
await userApi.create({ primaryEmail: email, password });
|
||||
});
|
||||
|
||||
it('should reject when trying to sign-in with email verification code', async () => {
|
||||
const client = await initExperienceClient();
|
||||
|
||||
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 reject when trying to sign-in with email password', async () => {
|
||||
const client = await initExperienceClient();
|
||||
|
||||
const { verificationId } = await client.verifyPassword({
|
||||
identifier,
|
||||
password,
|
||||
});
|
||||
|
||||
await expectRejects(
|
||||
client.identifyUser({
|
||||
verificationId,
|
||||
}),
|
||||
{
|
||||
code: `session.sso_enabled`,
|
||||
status: 422,
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -18,8 +18,15 @@ 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 { enableAllPasswordSignInMethods } from '#src/helpers/sign-in-experience.js';
|
||||
import {
|
||||
enableAllPasswordSignInMethods,
|
||||
enableAllVerificationCodeSignInMethods,
|
||||
} from '#src/helpers/sign-in-experience.js';
|
||||
import { generateNewUser } from '#src/helpers/user.js';
|
||||
import { devFeatureTest, generateEmail, generateUsername } from '#src/utils.js';
|
||||
|
||||
|
@ -158,43 +165,187 @@ devFeatureTest.describe('social sign-in and sign-up', () => {
|
|||
});
|
||||
|
||||
await client.updateInteractionEvent({ interactionEvent: InteractionEvent.Register });
|
||||
await client.identifyUser({ verificationId });
|
||||
|
||||
await expectRejects(client.submitInteraction(), {
|
||||
await expectRejects(client.identifyUser({ verificationId }), {
|
||||
code: 'user.missing_profile',
|
||||
status: 422,
|
||||
});
|
||||
|
||||
await client.updateProfile({ type: SignInIdentifier.Username, value: generateUsername() });
|
||||
|
||||
await client.identifyUser();
|
||||
|
||||
const { redirectTo } = await client.submitInteraction();
|
||||
const userId = await processSession(client, redirectTo);
|
||||
await logoutClient(client);
|
||||
await deleteUser(userId);
|
||||
});
|
||||
|
||||
it('should directly sync trusted email', async () => {
|
||||
await enableAllPasswordSignInMethods({
|
||||
identifiers: [SignInIdentifier.Email],
|
||||
password: true,
|
||||
verify: true,
|
||||
describe('fulfill missing email', () => {
|
||||
beforeAll(async () => {
|
||||
await enableAllVerificationCodeSignInMethods({
|
||||
identifiers: [SignInIdentifier.Email],
|
||||
password: true,
|
||||
verify: true,
|
||||
});
|
||||
});
|
||||
|
||||
const userId = await signInWithSocial(
|
||||
connectorIdMap.get(mockSocialConnectorId)!,
|
||||
{
|
||||
id: socialUserId,
|
||||
email,
|
||||
},
|
||||
{
|
||||
registerNewUser: true,
|
||||
}
|
||||
);
|
||||
it('should directly sync trusted email', async () => {
|
||||
const userId = await signInWithSocial(
|
||||
connectorIdMap.get(mockSocialConnectorId)!,
|
||||
{
|
||||
id: socialUserId,
|
||||
email,
|
||||
},
|
||||
{
|
||||
registerNewUser: true,
|
||||
}
|
||||
);
|
||||
|
||||
const { primaryEmail } = await getUser(userId);
|
||||
expect(primaryEmail).toBe(email);
|
||||
const { primaryEmail } = await getUser(userId);
|
||||
expect(primaryEmail).toBe(email);
|
||||
|
||||
await deleteUser(userId);
|
||||
await deleteUser(userId);
|
||||
});
|
||||
|
||||
it('should ask to provide email if no verified email is returned from social', async () => {
|
||||
const connectorId = connectorIdMap.get(mockSocialConnectorId)!;
|
||||
|
||||
const client = await initExperienceClient();
|
||||
|
||||
const { verificationId } = await successFullyCreateSocialVerification(client, connectorId, {
|
||||
redirectUri,
|
||||
state,
|
||||
});
|
||||
|
||||
await successFullyVerifySocialAuthorization(client, connectorId, {
|
||||
verificationId,
|
||||
connectorData: {
|
||||
state,
|
||||
redirectUri,
|
||||
code: 'fake_code',
|
||||
userId: generateStandardId(),
|
||||
},
|
||||
});
|
||||
|
||||
await expectRejects(client.identifyUser({ verificationId }), {
|
||||
code: 'user.identity_not_exist',
|
||||
status: 404,
|
||||
});
|
||||
|
||||
await client.updateInteractionEvent({ interactionEvent: InteractionEvent.Register });
|
||||
|
||||
await expectRejects(client.identifyUser({ verificationId }), {
|
||||
code: 'user.missing_profile',
|
||||
status: 422,
|
||||
});
|
||||
|
||||
const identifier = Object.freeze({ type: SignInIdentifier.Email, value: generateEmail() });
|
||||
|
||||
const { code, verificationId: emailVerificationId } =
|
||||
await successfullySendVerificationCode(client, {
|
||||
identifier,
|
||||
interactionEvent: InteractionEvent.Register,
|
||||
});
|
||||
|
||||
await successfullyVerifyVerificationCode(client, {
|
||||
identifier,
|
||||
verificationId: emailVerificationId,
|
||||
code,
|
||||
});
|
||||
|
||||
await client.updateProfile({
|
||||
type: SignInIdentifier.Email,
|
||||
verificationId: emailVerificationId,
|
||||
});
|
||||
|
||||
await client.identifyUser();
|
||||
|
||||
const { redirectTo } = await client.submitInteraction();
|
||||
const userId = await processSession(client, redirectTo);
|
||||
await logoutClient(client);
|
||||
await deleteUser(userId);
|
||||
});
|
||||
|
||||
it('should ask to sign-in and link social if the email is already in use', async () => {
|
||||
const { userProfile, user } = await generateNewUser({
|
||||
primaryEmail: true,
|
||||
});
|
||||
|
||||
const { primaryEmail } = userProfile;
|
||||
|
||||
const connectorId = connectorIdMap.get(mockSocialConnectorId)!;
|
||||
|
||||
const client = await initExperienceClient();
|
||||
|
||||
const { verificationId } = await successFullyCreateSocialVerification(client, connectorId, {
|
||||
redirectUri,
|
||||
state,
|
||||
});
|
||||
|
||||
await successFullyVerifySocialAuthorization(client, connectorId, {
|
||||
verificationId,
|
||||
connectorData: {
|
||||
state,
|
||||
redirectUri,
|
||||
code: 'fake_code',
|
||||
userId: generateStandardId(),
|
||||
},
|
||||
});
|
||||
|
||||
await expectRejects(client.identifyUser({ verificationId }), {
|
||||
code: 'user.identity_not_exist',
|
||||
status: 404,
|
||||
});
|
||||
|
||||
await client.updateInteractionEvent({ interactionEvent: InteractionEvent.Register });
|
||||
|
||||
await expectRejects(client.identifyUser({ verificationId }), {
|
||||
code: 'user.missing_profile',
|
||||
status: 422,
|
||||
});
|
||||
|
||||
const identifier = Object.freeze({ type: SignInIdentifier.Email, value: primaryEmail });
|
||||
|
||||
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: 'user.email_already_in_use',
|
||||
status: 422,
|
||||
}
|
||||
);
|
||||
|
||||
await client.updateInteractionEvent({ interactionEvent: InteractionEvent.SignIn });
|
||||
|
||||
await client.identifyUser({ verificationId: emailVerificationId });
|
||||
await client.updateProfile({ type: 'social', verificationId });
|
||||
|
||||
const { redirectTo } = await client.submitInteraction();
|
||||
const userId = await processSession(client, redirectTo);
|
||||
await logoutClient(client);
|
||||
|
||||
expect(userId).toBe(user.id);
|
||||
|
||||
const { identities } = await getUser(userId);
|
||||
expect(identities[mockSocialConnectorTarget]).toBeTruthy();
|
||||
|
||||
await deleteUser(userId);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -58,7 +58,7 @@ devFeatureTest.describe('password verifications', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('should throw error if email is registered', async () => {
|
||||
it('email password is not supported', async () => {
|
||||
const { primaryEmail, password } = generateNewUserProfile({
|
||||
primaryEmail: true,
|
||||
password: true,
|
||||
|
@ -77,33 +77,8 @@ devFeatureTest.describe('password verifications', () => {
|
|||
password,
|
||||
}),
|
||||
{
|
||||
code: 'user.email_already_in_use',
|
||||
status: 422,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error if phone is registered', async () => {
|
||||
const { primaryPhone, password } = generateNewUserProfile({
|
||||
primaryPhone: true,
|
||||
password: true,
|
||||
});
|
||||
|
||||
await userApi.create({ primaryPhone, password });
|
||||
|
||||
const client = await initExperienceClient();
|
||||
|
||||
await expectRejects(
|
||||
client.createNewPasswordIdentityVerification({
|
||||
identifier: {
|
||||
type: SignInIdentifier.Phone,
|
||||
value: primaryPhone,
|
||||
},
|
||||
password,
|
||||
}),
|
||||
{
|
||||
code: 'user.phone_already_in_use',
|
||||
status: 422,
|
||||
code: 'guard.invalid_input',
|
||||
status: 400,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
|
|
@ -122,8 +122,11 @@ export const backupCodeVerificationVerifyPayloadGuard = z.object({
|
|||
|
||||
/** Payload type for `POST /api/experience/identification`. */
|
||||
export type IdentificationApiPayload = {
|
||||
/** The ID of the verification record that is used to identify the user. */
|
||||
verificationId: string;
|
||||
/**
|
||||
* The ID of the verification record that is used to identify the user.
|
||||
* Optional for the register interaction event
|
||||
*/
|
||||
verificationId?: string;
|
||||
/**
|
||||
* Link social identity to a related user account with the same email or phone.
|
||||
* Only applicable for social verification records and a related user account is found.
|
||||
|
@ -131,7 +134,7 @@ export type IdentificationApiPayload = {
|
|||
linkSocialIdentity?: boolean;
|
||||
};
|
||||
export const identificationApiPayloadGuard = z.object({
|
||||
verificationId: z.string(),
|
||||
verificationId: z.string().optional(),
|
||||
linkSocialIdentity: z.boolean().optional(),
|
||||
}) satisfies ToZodObject<IdentificationApiPayload>;
|
||||
|
||||
|
@ -147,7 +150,7 @@ export const CreateExperienceApiPayloadGuard = z.object({
|
|||
export const updateProfileApiPayloadGuard = z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal(SignInIdentifier.Username),
|
||||
value: z.string(),
|
||||
value: z.string().regex(usernameRegEx),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('password'),
|
||||
|
@ -161,6 +164,10 @@ export const updateProfileApiPayloadGuard = z.discriminatedUnion('type', [
|
|||
type: z.literal(SignInIdentifier.Phone),
|
||||
verificationId: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('social'),
|
||||
verificationId: z.string(),
|
||||
}),
|
||||
]);
|
||||
export type UpdateProfileApiPayload = z.infer<typeof updateProfileApiPayloadGuard>;
|
||||
|
||||
|
|
Loading…
Reference in a new issue