diff --git a/packages/core/src/routes/experience/classes/experience-interaction.test.ts b/packages/core/src/routes/experience/classes/experience-interaction.test.ts index e354cb847..64d442385 100644 --- a/packages/core/src/routes/experience/classes/experience-interaction.test.ts +++ b/packages/core/src/routes/experience/classes/experience-interaction.test.ts @@ -105,7 +105,7 @@ describe('ExperienceInteraction class', () => { ); experienceInteraction.setVerificationRecord(emailVerificationRecord); - await experienceInteraction.identifyUser(emailVerificationRecord.id); + await experienceInteraction.createUser(emailVerificationRecord.id); expect(userLibraries.insertUser).toHaveBeenCalledWith( { diff --git a/packages/core/src/routes/experience/classes/experience-interaction.ts b/packages/core/src/routes/experience/classes/experience-interaction.ts index 79f7ed0a2..8835aadca 100644 --- a/packages/core/src/routes/experience/classes/experience-interaction.ts +++ b/packages/core/src/routes/experience/classes/experience-interaction.ts @@ -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); diff --git a/packages/core/src/routes/experience/classes/libraries/profile-validator.ts b/packages/core/src/routes/experience/classes/libraries/profile-validator.ts index 2fd08232a..1f4e308cf 100644 --- a/packages/core/src/routes/experience/classes/libraries/profile-validator.ts +++ b/packages/core/src/routes/experience/classes/libraries/profile-validator.ts @@ -132,15 +132,17 @@ export class ProfileValidator { // eslint-disable-next-line complexity public getMissingUserProfile( profile: InteractionProfile, - user: User, - mandatoryUserProfile: Set + mandatoryUserProfile: Set, + user?: User ): Set { const missingProfile = new Set(); 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); diff --git a/packages/core/src/routes/experience/classes/libraries/sign-in-experience-validator.test.ts b/packages/core/src/routes/experience/classes/libraries/sign-in-experience-validator.test.ts index e3971e829..c5e216ead 100644 --- a/packages/core/src/routes/experience/classes/libraries/sign-in-experience-validator.test.ts +++ b/packages/core/src/routes/experience/classes/libraries/sign-in-experience-validator.test.ts @@ -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 */ diff --git a/packages/core/src/routes/experience/classes/libraries/sign-in-experience-validator.ts b/packages/core/src/routes/experience/classes/libraries/sign-in-experience-validator.ts index 8db74727f..2501000a7 100644 --- a/packages/core/src/routes/experience/classes/libraries/sign-in-experience-validator.ts +++ b/packages/core/src/routes/experience/classes/libraries/sign-in-experience-validator.ts @@ -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 */ diff --git a/packages/core/src/routes/experience/classes/profile.ts b/packages/core/src/routes/experience/classes/profile.ts index 0a3d1ad16..c60b166b6 100644 --- a/packages/core/src/routes/experience/classes/profile.ts +++ b/packages/core/src/routes/experience/classes/profile.ts @@ -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(); + } } diff --git a/packages/core/src/routes/experience/classes/verifications/new-password-identity-verification.ts b/packages/core/src/routes/experience/classes/verifications/new-password-identity-verification.ts index 771829033..a9cbe7866 100644 --- a/packages/core/src/routes/experience/classes/verifications/new-password-identity-verification.ts +++ b/packages/core/src/routes/experience/classes/verifications/new-password-identity-verification.ts @@ -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); diff --git a/packages/core/src/routes/experience/index.ts b/packages/core/src/routes/experience/index.ts index 27c252eae..1bb2c09bd 100644 --- a/packages/core/src/routes/experience/index.ts +++ b/packages/core/src/routes/experience/index.ts @@ -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( 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( }, }); - 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(); diff --git a/packages/core/src/routes/experience/profile-routes.ts b/packages/core/src/routes/experience/profile-routes.ts index 7619624b4..472b91fc6 100644 --- a/packages/core/src/routes/experience/profile-routes.ts +++ b/packages/core/src/routes/experience/profile-routes.ts @@ -57,12 +57,26 @@ export default function interactionProfileRoutes { 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 { - const { experienceInteraction, guard } = ctx; + const { experienceInteraction } = ctx; await experienceInteraction.mfa.skip(); await experienceInteraction.save(); diff --git a/packages/core/src/routes/experience/types.ts b/packages/core/src/routes/experience/types.ts index eb7486412..bf0685a7d 100644 --- a/packages/core/src/routes/experience/types.ts +++ b/packages/core/src/routes/experience/types.ts @@ -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; getVerificationRecordByTypeAndId: ( type: K, diff --git a/packages/core/src/routes/experience/verification-routes/new-password-identity-verification.ts b/packages/core/src/routes/experience/verification-routes/new-password-identity-verification.ts index aa4be2564..dd1127c08 100644 --- a/packages/core/src/routes/experience/verification-routes/new-password-identity-verification.ts +++ b/packages/core/src/routes/experience/verification-routes/new-password-identity-verification.ts @@ -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({ diff --git a/packages/integration-tests/src/client/experience/index.ts b/packages/integration-tests/src/client/experience/index.ts index 546d023c7..22eb46620 100644 --- a/packages/integration-tests/src/client/experience/index.ts +++ b/packages/integration-tests/src/client/experience/index.ts @@ -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, diff --git a/packages/integration-tests/src/helpers/experience/index.ts b/packages/integration-tests/src/helpers/experience/index.ts index c3db52436..129be9f03 100644 --- a/packages/integration-tests/src/helpers/experience/index.ts +++ b/packages/integration-tests/src/helpers/experience/index.ts @@ -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 }); } diff --git a/packages/integration-tests/src/tests/api/experience-api/register-interaction/username-password.test.ts b/packages/integration-tests/src/tests/api/experience-api/register-interaction/username-password.test.ts index 076cad733..768040d34 100644 --- a/packages/integration-tests/src/tests/api/experience-api/register-interaction/username-password.test.ts +++ b/packages/integration-tests/src/tests/api/experience-api/register-interaction/username-password.test.ts @@ -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); + }); }); diff --git a/packages/integration-tests/src/tests/api/experience-api/register-interaction/verification-code.test.ts b/packages/integration-tests/src/tests/api/experience-api/register-interaction/verification-code.test.ts index 0df478cd5..1246f1f35 100644 --- a/packages/integration-tests/src/tests/api/experience-api/register-interaction/verification-code.test.ts +++ b/packages/integration-tests/src/tests/api/experience-api/register-interaction/verification-code.test.ts @@ -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, diff --git a/packages/integration-tests/src/tests/api/experience-api/sign-in-interaction/enterprise-sso.test.ts b/packages/integration-tests/src/tests/api/experience-api/sign-in-interaction/enterprise-sso.test.ts index 1bd82bb83..95534b64b 100644 --- a/packages/integration-tests/src/tests/api/experience-api/sign-in-interaction/enterprise-sso.test.ts +++ b/packages/integration-tests/src/tests/api/experience-api/sign-in-interaction/enterprise-sso.test.ts @@ -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, + } + ); + }); + }); }); diff --git a/packages/integration-tests/src/tests/api/experience-api/sign-in-interaction/social.test.ts b/packages/integration-tests/src/tests/api/experience-api/sign-in-interaction/social.test.ts index f4ee99f7b..e13774518 100644 --- a/packages/integration-tests/src/tests/api/experience-api/sign-in-interaction/social.test.ts +++ b/packages/integration-tests/src/tests/api/experience-api/sign-in-interaction/social.test.ts @@ -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); + }); }); }); }); diff --git a/packages/integration-tests/src/tests/api/experience-api/verifications/new-password-identity-verification.test.ts b/packages/integration-tests/src/tests/api/experience-api/verifications/new-password-identity-verification.test.ts index fa0df227e..086628624 100644 --- a/packages/integration-tests/src/tests/api/experience-api/verifications/new-password-identity-verification.test.ts +++ b/packages/integration-tests/src/tests/api/experience-api/verifications/new-password-identity-verification.test.ts @@ -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, } ); }); diff --git a/packages/schemas/src/types/interactions.ts b/packages/schemas/src/types/interactions.ts index 199d00fa1..ed008729e 100644 --- a/packages/schemas/src/types/interactions.ts +++ b/packages/schemas/src/types/interactions.ts @@ -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; @@ -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;