0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-24 22:41:28 -05:00

feat(core,schemas): implement profile fulfillment flow (#6293)

* feat(core,schemas): implement profile fulfillment flow

implement profile fulfillment flow

* fix(test): fix integration tests

fix integration tests

* fix(core): fix rebase issue

fix rebase issue

* refactor(core): refactor the interaction set profile flow

refactor the interaction set profile flow
This commit is contained in:
simeng-li 2024-07-25 13:41:10 +08:00 committed by GitHub
parent 89b8662a15
commit 248ee7fb08
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 562 additions and 58 deletions

View file

@ -8,16 +8,21 @@ import { type WithLogContext } from '#src/middleware/koa-audit-log.js';
import type TenantContext from '#src/tenants/TenantContext.js';
import assertThat from '#src/utils/assert-that.js';
import { interactionProfileGuard, type Interaction, type InteractionProfile } from '../types.js';
import {
interactionProfileGuard,
type Interaction,
type InteractionContext,
type InteractionProfile,
} from '../types.js';
import {
getNewUserProfileFromVerificationRecord,
identifyUserByVerificationRecord,
} from './helpers.js';
import { MfaValidator } from './libraries/mfa-validator.js';
import { ProfileValidator } from './libraries/profile-validator.js';
import { ProvisionLibrary } from './libraries/provision-library.js';
import { SignInExperienceValidator } from './libraries/sign-in-experience-validator.js';
import { Profile } from './profile.js';
import { toUserSocialIdentityData } from './utils.js';
import {
buildVerificationRecord,
@ -50,8 +55,8 @@ const interactionStorageGuard = z.object({
*/
export default class ExperienceInteraction {
public readonly signInExperienceValidator: SignInExperienceValidator;
public readonly profileValidator: ProfileValidator;
public readonly provisionLibrary: ProvisionLibrary;
readonly profile: Profile;
/** The user verification record list for the current interaction. */
private readonly verificationRecords = new VerificationRecordsMap();
@ -59,7 +64,6 @@ export default class ExperienceInteraction {
private userId?: string;
private userCache?: User;
/** The user provided profile data in the current interaction that needs to be stored to database. */
#profile?: InteractionProfile;
/** The interaction event for the current interaction. */
#interactionEvent?: InteractionEvent;
@ -79,8 +83,14 @@ export default class ExperienceInteraction {
this.signInExperienceValidator = new SignInExperienceValidator(libraries, queries);
this.provisionLibrary = new ProvisionLibrary(tenant, ctx);
const interactionContext: InteractionContext = {
getIdentifierUser: async () => this.getIdentifiedUser(),
getVerificationRecordByTypeAndId: (type, verificationId) =>
this.getVerificationRecordByTypeAndId(type, verificationId),
};
if (!interactionDetails) {
this.profileValidator = new ProfileValidator(libraries, queries);
this.profile = new Profile(libraries, queries, {}, interactionContext);
return;
}
@ -92,14 +102,11 @@ export default class ExperienceInteraction {
new RequestError({ code: 'session.interaction_not_found', status: 404 })
);
const { verificationRecords = [], profile, userId, interactionEvent } = result.data;
const { verificationRecords = [], profile = {}, userId, interactionEvent } = result.data;
this.#interactionEvent = interactionEvent;
this.userId = userId;
this.#profile = profile;
// Profile validator requires the userId for existing user profile update validation
this.profileValidator = new ProfileValidator(libraries, queries);
this.profile = new Profile(libraries, queries, profile, interactionContext);
for (const record of verificationRecords) {
const instance = buildVerificationRecord(libraries, queries, record);
@ -203,14 +210,15 @@ export default class ExperienceInteraction {
/**
* Validate the interaction verification records against the sign-in experience and user MFA settings.
*
* The interaction is verified if at least one user enabled MFA verification record is present and verified.
*
* @throws RequestError with 404 if the if the user is not identified or not found
* @throws {RequestError} with 403 if the mfa verification is required but not verified
*/
public async guardMfaVerificationStatus() {
const user = await this.getIdentifiedUser();
const mfaSettings = await this.signInExperienceValidator.getMfaSettings();
const mfaValidator = new MfaValidator(mfaSettings, user);
const isVerified = mfaValidator.isMfaVerified(this.verificationRecordsArray);
assertThat(
@ -242,7 +250,16 @@ export default class ExperienceInteraction {
this.ctx.prependAllLogEntries({ interaction: interactionData });
}
/** Submit the current interaction result to the OIDC provider and clear the interaction data */
/**
* Submit the current interaction result to the OIDC provider and clear the interaction data
*
* @throws {RequestError} with 404 if the interaction event is not set
* @throws {RequestError} with 404 if the user is not identified
* @throws {RequestError} with 403 if the mfa verification is required but not verified
* @throws {RequestError} with 422 if the profile data is conflicting with the current user account
* @throws {RequestError} with 422 if the profile data is not unique across users
* @throws {RequestError} with 422 if the required profile fields are missing
**/
public async submit() {
const {
queries: { users: userQueries, userSsoIdentities: userSsoIdentitiesQueries },
@ -257,14 +274,37 @@ export default class ExperienceInteraction {
// Identified
const user = await this.getIdentifiedUser();
// Verified
if (this.#interactionEvent !== InteractionEvent.ForgotPassword) {
await this.guardMfaVerificationStatus();
// Forgot Password: No need to verify MFAs and profile data for forgot password flow
if (this.#interactionEvent === InteractionEvent.ForgotPassword) {
const { passwordEncrypted, passwordEncryptionMethod } = this.profile.data;
assertThat(
passwordEncrypted && passwordEncryptionMethod,
new RequestError({ code: 'user.new_password_required_in_profile', status: 422 })
);
await userQueries.updateUserById(user.id, {
passwordEncrypted,
passwordEncryptionMethod,
});
await this.cleanUp();
// TODO: User data updated hook
return;
}
const { socialIdentity, enterpriseSsoIdentity, ...rest } = this.#profile ?? {};
// Verified
await this.guardMfaVerificationStatus();
// TODO: profile updates validation
// Revalidate the new profile data if any
await this.profile.validateAvailability();
// Profile fulfilled
await this.profile.assertUserMandatoryProfileFulfilled();
const { socialIdentity, enterpriseSsoIdentity, ...rest } = this.profile.data;
// Update user profile
await userQueries.updateUserById(user.id, {
@ -280,8 +320,6 @@ export default class ExperienceInteraction {
lastSignInAt: Date.now(),
});
// TODO: missing profile fields validation
if (enterpriseSsoIdentity) {
await this.provisionLibrary.addSsoIdentityToUser(user.id, enterpriseSsoIdentity);
}
@ -292,6 +330,8 @@ export default class ExperienceInteraction {
login: { accountId: user.id },
});
// TODO: PostInteractionHooks
this.ctx.body = { redirectTo };
}
@ -302,7 +342,7 @@ export default class ExperienceInteraction {
return {
interactionEvent,
userId,
profile: this.#profile,
profile: this.profile.data,
verificationRecords: this.verificationRecordsArray.map((record) => record.toJson()),
};
}
@ -343,44 +383,36 @@ export default class ExperienceInteraction {
return;
}
// Sync social/enterprise SSO identity profile data
if (syncedProfile) {
this.setProfile(syncedProfile);
}
// 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) {
this.profile.unsafeSet(syncedProfile);
}
}
/**
* Create a new user using the verification record.
*
* @throws {RequestError} with 422 if the profile data is not unique across users
* @throws {RequestError} with 400 if the verification record is invalid for creating a new user or not verified
* @throws {RequestError} with 422 if the profile data is not unique across users
*/
private async createNewUser(verificationRecord: VerificationRecord) {
const newProfile = await getNewUserProfileFromVerificationRecord(verificationRecord);
await this.profileValidator.guardProfileUniquenessAcrossUsers(newProfile);
await this.profile.profileValidator.guardProfileUniquenessAcrossUsers(newProfile);
const user = await this.provisionLibrary.createUser(newProfile);
this.userId = user.id;
}
/**
* Private method to set the profile data without validation.
*/
private setProfile(profile: InteractionProfile) {
this.#profile = {
...this.#profile,
...profile,
};
}
/**
* Assert the interaction is identified and return the identified user.
*
* @throws RequestError with 400 if the user is not identified
* @throws RequestError with 404 if the user is not found
* @throws RequestError with 404 if the if the user is not identified or not found
*/
private async getIdentifiedUser(): Promise<User> {
if (this.userCache) {
@ -388,7 +420,13 @@ export default class ExperienceInteraction {
}
// Identified
assertThat(this.userId, 'session.identifier_not_found');
assertThat(
this.userId,
new RequestError({
code: 'session.identifier_not_found',
status: 404,
})
);
const {
queries: { users: userQueries },
@ -403,4 +441,12 @@ export default class ExperienceInteraction {
private getVerificationRecordById(verificationId: string) {
return this.verificationRecordsArray.find((record) => record.id === verificationId);
}
/**
* Clean up the interaction storage.
*/
private async cleanUp() {
const { provider } = this.tenant;
await provider.interactionResult(this.ctx.req, this.ctx.res, {});
}
}

View file

@ -1,8 +1,10 @@
import { PasswordPolicyChecker, type UserInfo } from '@logto/core-kit';
import { type SignInExperience, type User } from '@logto/schemas';
import { UsersPasswordEncryptionMethod, type SignInExperience, type User } from '@logto/schemas';
import { argon2Verify } from 'hash-wasm';
import RequestError from '#src/errors/RequestError/index.js';
import { encryptUserPassword } from '#src/libraries/user.utils.js';
import assertThat from '#src/utils/assert-that.js';
import { type InteractionProfile } from '../../types.js';
@ -32,8 +34,10 @@ export class PasswordValidator {
}
/**
* Validate password against the password policy
* @throws {RequestError} with status 422 if the password does not meet the policy
* Validate password against the given password policy and current user's profile.
*
* @throws {RequestError} with status code 422 if the password is against the policy.
* @throws {RequestError} with status code 422 if the password is the same as the current user's password.
*/
public async validatePassword(password: string, profile: InteractionProfile) {
const userInfo = getUserInfo({
@ -49,6 +53,18 @@ export class PasswordValidator {
if (issues.length > 0) {
throw new RequestError({ code: 'password.rejected', status: 422 }, { issues });
}
if (this.user) {
const { passwordEncrypted: oldPasswordEncrypted, passwordEncryptionMethod } = this.user;
assertThat(
!oldPasswordEncrypted ||
// If the password is not encrypted with Argon2i, allow to reset the same password with Argon2i
passwordEncryptionMethod !== UsersPasswordEncryptionMethod.Argon2i ||
!(await argon2Verify({ password, hash: oldPasswordEncrypted })),
new RequestError({ code: 'user.same_password', status: 422 })
);
}
}
public async createPasswordDigest(password: string) {

View file

@ -1,17 +1,15 @@
import { type User, MissingProfile } from '@logto/schemas';
import RequestError from '#src/errors/RequestError/index.js';
import type Libraries from '#src/tenants/Libraries.js';
import type Queries from '#src/tenants/Queries.js';
import assertThat from '#src/utils/assert-that.js';
import type { InteractionProfile } from '../../types.js';
export class ProfileValidator {
constructor(
private readonly libraries: Libraries,
private readonly queries: Queries
) {}
constructor(private readonly queries: Queries) {}
public async guardProfileUniquenessAcrossUsers(profile: InteractionProfile) {
public async guardProfileUniquenessAcrossUsers(profile: InteractionProfile = {}) {
const { hasUser, hasUserWithEmail, hasUserWithPhone, hasUserWithIdentity } = this.queries.users;
const { userSsoIdentities } = this.queries;
@ -78,4 +76,110 @@ export class ProfileValidator {
);
}
}
/**
* Validate the profile existence in the current user account
*
* @remarks
* This method is used to validate the profile for the register and sign-in interaction only
* In the register and sign-in interaction, password can only be set if it does not exist in the current user account.
*
*/
public guardProfileNotExistInCurrentUserAccount(user: User, profile: InteractionProfile = {}) {
const { username, primaryEmail, primaryPhone, passwordEncrypted } = profile;
if (username) {
assertThat(
!user.username,
new RequestError({
code: 'user.username_exists_in_profile',
status: 422,
})
);
}
if (primaryEmail) {
assertThat(
!user.primaryEmail,
new RequestError({
code: 'user.email_exists_in_profile',
status: 422,
})
);
}
if (primaryPhone) {
assertThat(
!user.primaryPhone,
new RequestError({
code: 'user.phone_exists_in_profile',
status: 422,
})
);
}
if (passwordEncrypted) {
assertThat(
!user.passwordEncrypted,
new RequestError({
code: 'user.password_exists_in_profile',
status: 422,
})
);
}
}
// eslint-disable-next-line complexity
public getMissingUserProfile(
profile: InteractionProfile,
user: User,
mandatoryUserProfile: Set<MissingProfile>
): 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 isProfilePasswordSet = Boolean(
profile.passwordEncrypted ?? profile.socialIdentity ?? profile.enterpriseSsoIdentity
);
if (!isUserPasswordSet && !isProfilePasswordSet) {
missingProfile.add(MissingProfile.password);
}
}
if (mandatoryUserProfile.has(MissingProfile.username) && !user.username && !profile.username) {
missingProfile.add(MissingProfile.username);
}
if (
mandatoryUserProfile.has(MissingProfile.emailOrPhone) &&
!user.primaryPhone &&
!user.primaryEmail &&
!profile.primaryPhone &&
!profile.primaryEmail
) {
missingProfile.add(MissingProfile.emailOrPhone);
}
if (
mandatoryUserProfile.has(MissingProfile.email) &&
!user.primaryEmail &&
!profile.primaryEmail
) {
missingProfile.add(MissingProfile.email);
}
if (
mandatoryUserProfile.has(MissingProfile.phone) &&
!user.primaryPhone &&
!profile.primaryPhone
) {
missingProfile.add(MissingProfile.phone);
}
return missingProfile;
}
}

View file

@ -1,5 +1,6 @@
import {
InteractionEvent,
MissingProfile,
type SignInExperience,
SignInIdentifier,
SignInMode,
@ -128,6 +129,39 @@ export class SignInExperienceValidator {
return this.signInExperienceDataCache;
}
public async getMandatoryUserProfileBySignUpMethods(): Promise<Set<MissingProfile>> {
const {
signUp: { identifiers, password },
} = await this.getSignInExperienceData();
const mandatoryUserProfile = new Set<MissingProfile>();
if (password) {
mandatoryUserProfile.add(MissingProfile.password);
}
if (identifiers.includes(SignInIdentifier.Username)) {
mandatoryUserProfile.add(MissingProfile.username);
}
if (
identifiers.includes(SignInIdentifier.Email) &&
identifiers.includes(SignInIdentifier.Phone)
) {
mandatoryUserProfile.add(MissingProfile.emailOrPhone);
return mandatoryUserProfile;
}
if (identifiers.includes(SignInIdentifier.Email)) {
mandatoryUserProfile.add(MissingProfile.email);
}
if (identifiers.includes(SignInIdentifier.Phone)) {
mandatoryUserProfile.add(MissingProfile.phone);
}
return mandatoryUserProfile;
}
/**
* Guard the verification records contains email identifier with SSO enabled
*

View file

@ -0,0 +1,129 @@
import { type VerificationType } from '@logto/schemas';
import RequestError from '#src/errors/RequestError/index.js';
import type Libraries from '#src/tenants/Libraries.js';
import type Queries from '#src/tenants/Queries.js';
import { type InteractionContext, type InteractionProfile } from '../types.js';
import { PasswordValidator } from './libraries/password-validator.js';
import { ProfileValidator } from './libraries/profile-validator.js';
import { SignInExperienceValidator } from './libraries/sign-in-experience-validator.js';
export class Profile {
readonly profileValidator: ProfileValidator;
private readonly signInExperienceValidator: SignInExperienceValidator;
#data: InteractionProfile;
constructor(
private readonly libraries: Libraries,
queries: Queries,
data: InteractionProfile,
private readonly interactionContext: InteractionContext
) {
this.signInExperienceValidator = new SignInExperienceValidator(libraries, queries);
this.profileValidator = new ProfileValidator(queries);
this.#data = data;
}
get data() {
return this.#data;
}
/**
* Set the identified email or phone to the profile using the verification record.
*
* @throws {RequestError} 422 if the profile data already exists in the current user account.
* @throws {RequestError} 422 if the unique identifier data already exists in another user account.
*/
async setProfileByVerificationRecord(
type: VerificationType.EmailVerificationCode | VerificationType.PhoneVerificationCode,
verificationId: string
) {
const verificationRecord = this.interactionContext.getVerificationRecordByTypeAndId(
type,
verificationId
);
const profile = verificationRecord.toUserProfile();
await this.setProfileWithValidation(profile);
}
/**
* 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 unique identifier data already exists in another user account.
*/
async setProfileWithValidation(profile: InteractionProfile) {
const user = await this.interactionContext.getIdentifierUser();
this.profileValidator.guardProfileNotExistInCurrentUserAccount(user, profile);
await this.profileValidator.guardProfileUniquenessAcrossUsers(profile);
this.unsafeSet(profile);
}
/**
* Set password with password policy validation.
*
* @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.
*/
async setPasswordDigestWithValidation(password: string, reset = false) {
const user = await this.interactionContext.getIdentifierUser();
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) {
this.profileValidator.guardProfileNotExistInCurrentUserAccount(user, passwordDigests);
}
this.unsafeSet(passwordDigests);
}
/**
* 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 unique identifier data already exists in another user account.
*/
async validateAvailability() {
const user = await this.interactionContext.getIdentifierUser();
this.profileValidator.guardProfileNotExistInCurrentUserAccount(user, this.#data);
await this.profileValidator.guardProfileUniquenessAcrossUsers(this.#data);
}
/**
* Checks if the user has fulfilled the mandatory profile fields.
*/
async assertUserMandatoryProfileFulfilled() {
const user = await this.interactionContext.getIdentifierUser();
const mandatoryProfileFields =
await this.signInExperienceValidator.getMandatoryUserProfileBySignUpMethods();
const missingProfile = this.profileValidator.getMissingUserProfile(
this.#data,
user,
mandatoryProfileFields
);
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] }
);
}
unsafeSet(profile: InteractionProfile) {
this.#data = {
...this.#data,
...profile,
};
}
}

View file

@ -5,6 +5,7 @@ import {
VerificationType,
type User,
type VerificationCodeIdentifier,
type VerificationCodeSignInIdentifier,
} from '@logto/schemas';
import { generateStandardId } from '@logto/shared';
import { z } from 'zod';
@ -67,6 +68,11 @@ export type CodeVerificationRecordData<T extends CodeVerificationType = CodeVeri
verified: boolean;
};
export const identifierCodeVerificationTypeMap = Object.freeze({
[SignInIdentifier.Email]: VerificationType.EmailVerificationCode,
[SignInIdentifier.Phone]: VerificationType.PhoneVerificationCode,
}) satisfies Record<VerificationCodeSignInIdentifier, CodeVerificationType>;
/**
* This is the parent class for `EmailCodeVerification` and `PhoneCodeVerification`. Not publicly exposed.
*/

View file

@ -81,7 +81,7 @@ export class NewPasswordIdentityVerification
this.identifier = identifier;
this.passwordEncrypted = passwordEncrypted;
this.passwordEncryptionMethod = passwordEncryptionMethod;
this.profileValidator = new ProfileValidator(libraries, queries);
this.profileValidator = new ProfileValidator(queries);
this.signInExperienceValidator = new SignInExperienceValidator(libraries, queries);
}

View file

@ -4,4 +4,5 @@ export const experienceRoutes = Object.freeze({
prefix,
identification: `${prefix}/identification`,
verification: `${prefix}/verification`,
profile: `${prefix}/profile`,
});

View file

@ -24,6 +24,7 @@ import { experienceRoutes } from './const.js';
import koaExperienceInteraction, {
type WithExperienceInteractionContext,
} from './middleware/koa-experience-interaction.js';
import profileRoutes from './profile-routes.js';
import backupCodeVerificationRoutes from './verification-routes/backup-code-verification.js';
import enterpriseSsoVerificationRoutes from './verification-routes/enterprise-sso-verification.js';
import newPasswordIdentityVerificationRoutes from './verification-routes/new-password-identity-verification.js';
@ -128,10 +129,12 @@ export default function experienceApiRoutes<T extends AnonymousRouter>(
router.post(
`${experienceRoutes.prefix}/submit`,
koaGuard({
status: [200, 400, 403, 422],
response: z.object({
redirectTo: z.string(),
}),
status: [200, 400, 403, 404, 422],
response: z
.object({
redirectTo: z.string(),
})
.optional(),
}),
async (ctx, next) => {
await ctx.experienceInteraction.submit();
@ -147,4 +150,6 @@ export default function experienceApiRoutes<T extends AnonymousRouter>(
totpVerificationRoutes(router, tenant);
backupCodeVerificationRoutes(router, tenant);
newPasswordIdentityVerificationRoutes(router, tenant);
profileRoutes(router, tenant);
}

View file

@ -0,0 +1,108 @@
import { InteractionEvent, SignInIdentifier, updateProfileApiPayloadGuard } from '@logto/schemas';
import type Router from 'koa-router';
import { z } from 'zod';
import RequestError from '#src/errors/RequestError/index.js';
import { type WithLogContext } from '#src/middleware/koa-audit-log.js';
import koaGuard from '#src/middleware/koa-guard.js';
import type TenantContext from '#src/tenants/TenantContext.js';
import assertThat from '#src/utils/assert-that.js';
import { identifierCodeVerificationTypeMap } from './classes/verifications/code-verification.js';
import { experienceRoutes } from './const.js';
import { type WithExperienceInteractionContext } from './middleware/koa-experience-interaction.js';
export default function interactionProfileRoutes<T extends WithLogContext>(
router: Router<unknown, WithExperienceInteractionContext<T>>,
tenant: TenantContext
) {
router.post(
`${experienceRoutes.profile}`,
koaGuard({
body: updateProfileApiPayloadGuard,
status: [204, 400, 403, 404, 422],
}),
async (ctx, next) => {
const { experienceInteraction, guard } = ctx;
// Guard current interaction event is not ForgotPassword
assertThat(
experienceInteraction.interactionEvent !== InteractionEvent.ForgotPassword,
new RequestError({
code: 'session.not_supported_for_forgot_password',
statue: 400,
})
);
// Guard MFA verification status
await experienceInteraction.guardMfaVerificationStatus();
const profilePayload = guard.body;
switch (profilePayload.type) {
case SignInIdentifier.Email:
case SignInIdentifier.Phone: {
const verificationType = identifierCodeVerificationTypeMap[profilePayload.type];
await experienceInteraction.profile.setProfileByVerificationRecord(
verificationType,
profilePayload.verificationId
);
break;
}
case SignInIdentifier.Username: {
await experienceInteraction.profile.setProfileWithValidation({
username: profilePayload.value,
});
break;
}
case 'password': {
await experienceInteraction.profile.setPasswordDigestWithValidation(profilePayload.value);
}
}
await experienceInteraction.save();
ctx.status = 204;
return next();
}
);
router.put(
`${experienceRoutes.profile}/password`,
koaGuard({
body: z.object({
password: z.string(),
}),
status: [204, 400, 404, 422],
}),
async (ctx, next) => {
const { experienceInteraction, guard } = ctx;
const { password } = guard.body;
assertThat(
experienceInteraction.interactionEvent === InteractionEvent.ForgotPassword,
new RequestError({
code: 'session.invalid_interaction_type',
status: 400,
})
);
// Guard interaction is identified
assertThat(
experienceInteraction.identifiedUserId,
new RequestError({
code: 'session.identifier_not_found',
status: 404,
})
);
await experienceInteraction.profile.setPasswordDigestWithValidation(password, true);
await experienceInteraction.save();
ctx.status = 204;
return next();
}
);
}

View file

@ -1,8 +1,16 @@
import { type SocialUserInfo, socialUserInfoGuard, type ToZodObject } from '@logto/connector-kit';
import { type CreateUser, Users, UserSsoIdentities, type UserSsoIdentity } from '@logto/schemas';
import {
type CreateUser,
type User,
Users,
UserSsoIdentities,
type UserSsoIdentity,
} from '@logto/schemas';
import type Provider from 'oidc-provider';
import { z } from 'zod';
import { type VerificationRecordMap } from './classes/verifications/index.js';
export type Interaction = Awaited<ReturnType<Provider['interactionDetails']>>;
export type InteractionProfile = {
@ -51,3 +59,14 @@ export const interactionProfileGuard = Users.createGuard
})
.optional(),
}) satisfies ToZodObject<InteractionProfile>;
/**
* The interaction context provides the callback functions to get the user and verification record from the interaction
*/
export type InteractionContext = {
getIdentifierUser: () => Promise<User>;
getVerificationRecordByTypeAndId: <K extends keyof VerificationRecordMap>(
type: K,
verificationId: string
) => VerificationRecordMap[K];
};

View file

@ -15,7 +15,10 @@ devFeatureTest.describe('enterprise sso sign-in and sign-up', () => {
beforeAll(async () => {
await ssoConnectorApi.createMockOidcConnector([domain]);
await updateSignInExperience({ singleSignOnEnabled: true });
await updateSignInExperience({
singleSignOnEnabled: true,
signUp: { identifiers: [], password: false, verify: false },
});
});
afterAll(async () => {

View file

@ -6,6 +6,7 @@ import {
mockSocialConnectorTarget,
} from '#src/__mocks__/connectors-mock.js';
import { deleteUser, getUser } from '#src/api/admin-user.js';
import { updateSignInExperience } from '#src/api/sign-in-experience.js';
import { clearConnectorsByTypes, setSocialConnector } from '#src/helpers/connector.js';
import { signInWithSocial } from '#src/helpers/experience/index.js';
import { generateNewUser } from '#src/helpers/user.js';
@ -18,6 +19,13 @@ devFeatureTest.describe('social sign-in and sign-up', () => {
beforeAll(async () => {
await clearConnectorsByTypes([ConnectorType.Social]);
await updateSignInExperience({
signUp: {
identifiers: [],
password: false,
verify: false,
},
});
const { id: socialConnectorId } = await setSocialConnector();
connectorIdMap.set(mockSocialConnectorId, socialConnectorId);

View file

@ -22,6 +22,8 @@ const session = {
identifier_not_found: 'User identifier not found. Please go back and sign in again.',
interaction_not_found:
'Interaction session not found. Please go back and start the session again.',
invalid_interaction_type:
'This operation is not supported for the current interaction. Please initiate a new session.',
not_supported_for_forgot_password: 'This operation is not supported for forgot password.',
identity_conflict:
'Identity mismatch detected. Please initiate a new session to proceed with a different identity.',

View file

@ -1,3 +1,4 @@
/* eslint-disable max-lines */
import { emailRegEx, phoneRegEx, usernameRegEx } from '@logto/core-kit';
import { z } from 'zod';
@ -163,6 +164,27 @@ export const CreateExperienceApiPayloadGuard = z.object({
interactionEvent: z.nativeEnum(InteractionEvent),
}) satisfies ToZodObject<CreateExperienceApiPayload>;
/** Payload type for `POST /api/experience/profile */
export const updateProfileApiPayloadGuard = z.discriminatedUnion('type', [
z.object({
type: z.literal(SignInIdentifier.Username),
value: z.string(),
}),
z.object({
type: z.literal('password'),
value: z.string(),
}),
z.object({
type: z.literal(SignInIdentifier.Email),
verificationId: z.string(),
}),
z.object({
type: z.literal(SignInIdentifier.Phone),
verificationId: z.string(),
}),
]);
export type UpdateProfileApiPayload = z.infer<typeof updateProfileApiPayloadGuard>;
// ====== Experience API payload guard and types definitions end ======
/**
@ -410,3 +432,4 @@ export const verifyMfaResultGuard = z.object({
});
export type VerifyMfaResult = z.infer<typeof verifyMfaResultGuard>;
/* eslint-enable max-lines */