0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-13 21:30:30 -05:00

refactor(core): make the interaction event mandatory (#6337)

* refactor(core): refactor backup code generate flow

refactor backup code generate flow

* fix(core): fix api payload

fix api payload

* fix(core): fix rebase issue

fix rebase issue

* refactor(core): make the interaction event mandatory

make the interaction event mandatory

* test: update integration tests

update integration tests

* fix(core): fix the middleware apply bug

fix the koaExperienceInteraction middleware apply bug
This commit is contained in:
simeng-li 2024-07-31 13:43:27 +08:00 committed by GitHub
parent d932e304cb
commit dbc5512c0b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 105 additions and 93 deletions

View file

@ -92,9 +92,12 @@ describe('ExperienceInteraction class', () => {
describe('new user registration', () => {
it('First admin user provisioning', async () => {
const experienceInteraction = new ExperienceInteraction(ctx, tenant);
const experienceInteraction = new ExperienceInteraction(
ctx,
tenant,
InteractionEvent.Register
);
await experienceInteraction.setInteractionEvent(InteractionEvent.Register);
experienceInteraction.setVerificationRecord(emailVerificationRecord);
await experienceInteraction.identifyUser(emailVerificationRecord.id);

View file

@ -37,7 +37,7 @@ import {
import { VerificationRecordsMap } from './verifications/verification-records-map.js';
type InteractionStorage = {
interactionEvent?: InteractionEvent;
interactionEvent: InteractionEvent;
userId?: string;
profile?: InteractionProfile;
mfa?: MfaData;
@ -45,7 +45,7 @@ type InteractionStorage = {
};
const interactionStorageGuard = z.object({
interactionEvent: z.nativeEnum(InteractionEvent).optional(),
interactionEvent: z.nativeEnum(InteractionEvent),
userId: z.string().optional(),
profile: interactionProfileGuard.optional(),
mfa: mfaDataGuard.optional(),
@ -72,18 +72,20 @@ export default class ExperienceInteraction {
private userId?: string;
private userCache?: User;
/** The interaction event for the current interaction. */
#interactionEvent?: InteractionEvent;
#interactionEvent: InteractionEvent;
/**
* Create a new `ExperienceInteraction` instance.
*
* If the `interactionDetails` is provided, the instance will be initialized with the data from the `interactionDetails` storage.
* Otherwise, a brand new instance will be created.
* Restore experience interaction from the interaction storage.
*/
constructor(ctx: WithLogContext, tenant: TenantContext, interactionDetails: Interaction);
/**
* Create a new `ExperienceInteraction` instance.
*/
constructor(ctx: WithLogContext, tenant: TenantContext, interactionEvent: InteractionEvent);
constructor(
private readonly ctx: WithLogContext,
private readonly tenant: TenantContext,
interactionDetails?: Interaction
interactionData: Interaction | InteractionEvent
) {
const { libraries, queries } = tenant;
@ -96,13 +98,14 @@ export default class ExperienceInteraction {
this.getVerificationRecordByTypeAndId(type, verificationId),
};
if (!interactionDetails) {
if (typeof interactionData === 'string') {
this.#interactionEvent = interactionData;
this.profile = new Profile(libraries, queries, {}, interactionContext);
this.mfa = new Mfa(libraries, queries, {}, interactionContext);
return;
}
const result = interactionStorageGuard.safeParse(interactionDetails.result ?? {});
const result = interactionStorageGuard.safeParse(interactionData.result ?? {});
// `interactionDetails.result` is not a valid experience interaction storage
assertThat(
@ -148,14 +151,12 @@ export default class ExperienceInteraction {
await this.signInExperienceValidator.guardInteractionEvent(interactionEvent);
// `ForgotPassword` interaction event can not interchanged with other events
if (this.interactionEvent) {
assertThat(
interactionEvent === InteractionEvent.ForgotPassword
? this.interactionEvent === InteractionEvent.ForgotPassword
: this.interactionEvent !== InteractionEvent.ForgotPassword,
new RequestError({ code: 'session.not_supported_for_forgot_password', status: 400 })
);
}
this.#interactionEvent = interactionEvent;
}

View file

@ -41,7 +41,7 @@ export default function experienceApiRoutes<T extends AnonymousRouter>(
) {
const { queries } = tenant;
const router =
const experienceRouter =
// @ts-expect-error for good koa types
// eslint-disable-next-line no-restricted-syntax
(anonymousRouter as Router<unknown, WithExperienceInteractionContext<RouterContext<T>>>).use(
@ -49,7 +49,7 @@ export default function experienceApiRoutes<T extends AnonymousRouter>(
koaExperienceInteraction(tenant)
);
router.put(
experienceRouter.put(
experienceRoutes.prefix,
koaGuard({
body: z.object({
@ -63,20 +63,19 @@ export default function experienceApiRoutes<T extends AnonymousRouter>(
createLog(`Interaction.${interactionEvent}.Update`);
const experienceInteraction = new ExperienceInteraction(ctx, tenant);
await experienceInteraction.setInteractionEvent(interactionEvent);
const experienceInteraction = new ExperienceInteraction(ctx, tenant, interactionEvent);
// Save new experience interaction instance.
// This will overwrite any existing interaction data in the storage.
await experienceInteraction.save();
ctx.experienceInteraction = experienceInteraction;
ctx.status = 204;
return next();
}
);
router.put(
experienceRouter.put(
`${experienceRoutes.prefix}/interaction-event`,
koaGuard({
body: z.object({
@ -88,9 +87,7 @@ export default function experienceApiRoutes<T extends AnonymousRouter>(
const { interactionEvent } = ctx.guard.body;
const { createLog, experienceInteraction } = ctx;
const eventLog = createLog(
`Interaction.${experienceInteraction.interactionEvent ?? interactionEvent}.Update`
);
const eventLog = createLog(`Interaction.${experienceInteraction.interactionEvent}.Update`);
await experienceInteraction.setInteractionEvent(interactionEvent);
@ -106,7 +103,7 @@ export default function experienceApiRoutes<T extends AnonymousRouter>(
}
);
router.post(
experienceRouter.post(
experienceRoutes.identification,
koaGuard({
body: identificationApiPayloadGuard,
@ -127,7 +124,7 @@ export default function experienceApiRoutes<T extends AnonymousRouter>(
}
);
router.post(
experienceRouter.post(
`${experienceRoutes.prefix}/submit`,
koaGuard({
status: [200, 400, 403, 404, 422],
@ -144,14 +141,14 @@ export default function experienceApiRoutes<T extends AnonymousRouter>(
}
);
passwordVerificationRoutes(router, tenant);
verificationCodeRoutes(router, tenant);
socialVerificationRoutes(router, tenant);
enterpriseSsoVerificationRoutes(router, tenant);
totpVerificationRoutes(router, tenant);
webAuthnVerificationRoute(router, tenant);
backupCodeVerificationRoutes(router, tenant);
newPasswordIdentityVerificationRoutes(router, tenant);
passwordVerificationRoutes(experienceRouter, tenant);
verificationCodeRoutes(experienceRouter, tenant);
socialVerificationRoutes(experienceRouter, tenant);
enterpriseSsoVerificationRoutes(experienceRouter, tenant);
totpVerificationRoutes(experienceRouter, tenant);
webAuthnVerificationRoute(experienceRouter, tenant);
backupCodeVerificationRoutes(experienceRouter, tenant);
newPasswordIdentityVerificationRoutes(experienceRouter, tenant);
profileRoutes(router, tenant);
profileRoutes(experienceRouter, tenant);
}

View file

@ -4,6 +4,7 @@ import { type WithLogContext } from '#src/middleware/koa-audit-log.js';
import type TenantContext from '#src/tenants/TenantContext.js';
import ExperienceInteraction from '../classes/experience-interaction.js';
import { experienceRoutes } from '../const.js';
export type WithExperienceInteractionContext<ContextT extends WithLogContext = WithLogContext> =
ContextT & {
@ -25,6 +26,14 @@ export default function koaExperienceInteraction<
tenant: TenantContext
): MiddlewareType<StateT, WithExperienceInteractionContext<ContextT>, ResponseT> {
return async (ctx, next) => {
const { method, path } = ctx.request;
// Should not apply the koaExperienceInteraction middleware to the PUT /experience route.
// New ExperienceInteraction instance are supposed to be created in the PUT /experience route.
if (method === 'PUT' && path === `${experienceRoutes.prefix}`) {
return next();
}
const { provider } = tenant;
const interactionDetails = await provider.interactionDetails(ctx.req, ctx.res);

View file

@ -1,4 +1,5 @@
import type { LogtoConfig, SignInOptions } from '@logto/node';
import { InteractionEvent } from '@logto/schemas';
import { assert } from '@silverhand/essentials';
import { ExperienceClient } from '#src/client/experience/index.js';
@ -17,6 +18,7 @@ export const initClient = async (
};
export const initExperienceClient = async (
interactionEvent: InteractionEvent = InteractionEvent.SignIn,
config?: Partial<LogtoConfig>,
redirectUri?: string,
options: Omit<SignInOptions, 'redirectUri'> = {}
@ -24,6 +26,7 @@ export const initExperienceClient = async (
const client = new ExperienceClient(config);
await client.initSession(redirectUri, options);
assert(client.interactionCookie, new Error('Session not found'));
await client.initInteraction({ interactionEvent });
return client;
};

View file

@ -34,8 +34,6 @@ export const signInWithPassword = async ({
}) => {
const client = await initExperienceClient();
await client.initInteraction({ interactionEvent: InteractionEvent.SignIn });
const { verificationId } = await client.verifyPassword({
identifier,
password,
@ -54,8 +52,6 @@ export const signInWithPassword = async ({
export const signInWithVerificationCode = async (identifier: VerificationCodeIdentifier) => {
const client = await initExperienceClient();
await client.initInteraction({ interactionEvent: InteractionEvent.SignIn });
const { verificationId, code } = await successfullySendVerificationCode(client, {
identifier,
interactionEvent: InteractionEvent.SignIn,
@ -89,8 +85,6 @@ export const identifyUserWithUsernamePassword = async (
username: string,
password: string
) => {
await client.initInteraction({ interactionEvent: InteractionEvent.SignIn });
const { verificationId } = await client.verifyPassword({
identifier: {
type: SignInIdentifier.Username,
@ -108,9 +102,7 @@ export const registerNewUserWithVerificationCode = async (
identifier: VerificationCodeIdentifier,
options?: { fulfillPassword?: boolean }
) => {
const client = await initExperienceClient();
await client.initInteraction({ interactionEvent: InteractionEvent.Register });
const client = await initExperienceClient(InteractionEvent.Register);
const { verificationId, code } = await successfullySendVerificationCode(client, {
identifier,
@ -166,7 +158,6 @@ export const signInWithSocial = async (
const redirectUri = 'http://localhost:3000';
const client = await initExperienceClient();
await client.initInteraction({ interactionEvent: InteractionEvent.SignIn });
const { verificationId } = await successFullyCreateSocialVerification(client, connectorId, {
redirectUri,
@ -223,7 +214,6 @@ export const signInWithEnterpriseSso = async (
const redirectUri = 'http://localhost:3000';
const client = await initExperienceClient();
await client.initInteraction({ interactionEvent: InteractionEvent.SignIn });
const { verificationId } = await client.getEnterpriseSsoAuthorizationUri(connectorId, {
redirectUri,
@ -257,8 +247,7 @@ export const signInWithEnterpriseSso = async (
};
export const registerNewUserUsernamePassword = async (username: string, password: string) => {
const client = await initExperienceClient();
await client.initInteraction({ interactionEvent: InteractionEvent.Register });
const client = await initExperienceClient(InteractionEvent.Register);
const { verificationId } = await client.createNewPasswordIdentityVerification({
identifier: {

View file

@ -43,8 +43,7 @@ devFeatureTest.describe('Bind MFA APIs happy path', () => {
it('should bind TOTP on register', async () => {
const { username, password } = generateNewUserProfile({ username: true, password: true });
const client = await initExperienceClient();
await client.initInteraction({ interactionEvent: InteractionEvent.Register });
const client = await initExperienceClient(InteractionEvent.Register);
const { verificationId } = await client.createNewPasswordIdentityVerification({
identifier: {
@ -131,8 +130,7 @@ devFeatureTest.describe('Bind MFA APIs happy path', () => {
it('should able to skip MFA binding on register', async () => {
const { username, password } = generateNewUserProfile({ username: true, password: true });
const client = await initExperienceClient();
await client.initInteraction({ interactionEvent: InteractionEvent.Register });
const client = await initExperienceClient(InteractionEvent.Register);
const { verificationId } = await client.createNewPasswordIdentityVerification({
identifier: {
@ -193,8 +191,7 @@ devFeatureTest.describe('Bind MFA APIs happy path', () => {
it('should bind TOTP and backup codes on register', async () => {
const { username, password } = generateNewUserProfile({ username: true, password: true });
const client = await initExperienceClient();
await client.initInteraction({ interactionEvent: InteractionEvent.Register });
const client = await initExperienceClient(InteractionEvent.Register);
const { verificationId } = await client.createNewPasswordIdentityVerification({
identifier: {

View file

@ -53,8 +53,7 @@ devFeatureTest.describe('Bind MFA APIs sad path', () => {
it('should throw not supported error when binding TOTP on ForgotPassword interaction', async () => {
const { username, password } = generateNewUserProfile({ username: true, password: true });
await userApi.create({ username, password });
const client = await initExperienceClient();
await client.initInteraction({ interactionEvent: InteractionEvent.ForgotPassword });
const client = await initExperienceClient(InteractionEvent.ForgotPassword);
await expectRejects(client.skipMfaBinding(), {
code: 'session.not_supported_for_forgot_password',
@ -69,7 +68,6 @@ devFeatureTest.describe('Bind MFA APIs sad path', () => {
it('should throw identifier_not_found error, if user has not been identified', async () => {
const client = await initExperienceClient();
await client.initInteraction({ interactionEvent: InteractionEvent.SignIn });
await expectRejects(client.bindMfa(MfaFactor.TOTP, 'dummy_verification_id'), {
code: 'session.identifier_not_found',
status: 404,

View file

@ -14,10 +14,9 @@ devFeatureTest.describe('PUT /experience API', () => {
it('PUT new experience API should reset all existing verification records', async () => {
const { username, password } = generateNewUserProfile({ username: true, password: true });
const user = await userApi.create({ username, password });
await userApi.create({ username, password });
const client = await initExperienceClient();
await client.initInteraction({ interactionEvent: InteractionEvent.SignIn });
const { verificationId } = await client.verifyPassword({
identifier: { type: SignInIdentifier.Username, value: username },
password,
@ -31,4 +30,36 @@ devFeatureTest.describe('PUT /experience API', () => {
status: 404,
});
});
it('should throw if trying to update interaction event from ForgotPassword to others', async () => {
const client = await initExperienceClient(InteractionEvent.ForgotPassword);
await expectRejects(
client.updateInteractionEvent({ interactionEvent: InteractionEvent.SignIn }),
{
code: 'session.not_supported_for_forgot_password',
status: 400,
}
);
});
it('should throw if trying to update interaction event from SignIn and Register to ForgotPassword', async () => {
const client = await initExperienceClient();
await expectRejects(
client.updateInteractionEvent({ interactionEvent: InteractionEvent.ForgotPassword }),
{
code: 'session.not_supported_for_forgot_password',
status: 400,
}
);
});
it('should update interaction event from SignIn to Register', async () => {
const client = await initExperienceClient();
await expect(
client.updateInteractionEvent({ interactionEvent: InteractionEvent.Register })
).resolves.not.toThrow();
});
});

View file

@ -38,9 +38,7 @@ devFeatureTest.describe('Fulfill User Profiles', () => {
});
it('should throw 400 if the interaction event is ForgotPassword', async () => {
const client = await initExperienceClient();
await client.initInteraction({ interactionEvent: InteractionEvent.ForgotPassword });
const client = await initExperienceClient(InteractionEvent.ForgotPassword);
await expectRejects(
client.updateProfile({ type: SignInIdentifier.Username, value: 'username' }),
@ -54,8 +52,6 @@ devFeatureTest.describe('Fulfill User Profiles', () => {
it('should throw 404 if the interaction is not identified', async () => {
const client = await initExperienceClient();
await client.initInteraction({ interactionEvent: InteractionEvent.SignIn });
await expectRejects(
client.updateProfile({ type: SignInIdentifier.Username, value: 'username' }),
{

View file

@ -14,11 +14,7 @@ import { enableAllPasswordSignInMethods } from '#src/helpers/sign-in-experience.
import { generateNewUserProfile, UserApiTest } from '#src/helpers/user.js';
import { devFeatureTest, generatePassword } from '#src/utils.js';
const initAndIdentifyForgotPasswordInteraction = async (
client: ExperienceClient,
email: string
) => {
await client.initInteraction({ interactionEvent: InteractionEvent.ForgotPassword });
const identifyForgotPasswordInteraction = async (client: ExperienceClient, email: string) => {
const { verificationId, code } = await successfullySendVerificationCode(client, {
identifier: { type: SignInIdentifier.Email, value: email },
interactionEvent: InteractionEvent.ForgotPassword,
@ -52,8 +48,6 @@ devFeatureTest.describe('Reset Password', () => {
it('should 400 if the interaction is not ForgotPassword', async () => {
const client = await initExperienceClient();
await client.initInteraction({ interactionEvent: InteractionEvent.SignIn });
await expectRejects(client.resetPassword({ password: 'password' }), {
status: 400,
code: 'session.invalid_interaction_type',
@ -61,9 +55,7 @@ devFeatureTest.describe('Reset Password', () => {
});
it('should throw 404 if the interaction is not identified', async () => {
const client = await initExperienceClient();
await client.initInteraction({ interactionEvent: InteractionEvent.ForgotPassword });
const client = await initExperienceClient(InteractionEvent.ForgotPassword);
await expectRejects(client.resetPassword({ password: 'password' }), {
status: 404,
@ -74,8 +66,8 @@ devFeatureTest.describe('Reset Password', () => {
it('should throw 422 if identify the user using VerificationType other than CodeVerification', async () => {
const { username, password } = generateNewUserProfile({ username: true, password: true });
await userApi.create({ username, password });
const client = await initExperienceClient();
await client.initInteraction({ interactionEvent: InteractionEvent.ForgotPassword });
const client = await initExperienceClient(InteractionEvent.ForgotPassword);
const { verificationId } = await client.verifyPassword({
identifier: { type: SignInIdentifier.Username, value: username },
password,
@ -93,9 +85,9 @@ devFeatureTest.describe('Reset Password', () => {
password: true,
});
await userApi.create({ primaryEmail, password });
const client = await initExperienceClient();
const client = await initExperienceClient(InteractionEvent.ForgotPassword);
await initAndIdentifyForgotPasswordInteraction(client, primaryEmail);
await identifyForgotPasswordInteraction(client, primaryEmail);
await expectRejects(client.resetPassword({ password }), {
status: 422,
@ -123,9 +115,9 @@ devFeatureTest.describe('Reset Password', () => {
await userApi.create({ primaryEmail, password });
const client = await initExperienceClient();
const client = await initExperienceClient(InteractionEvent.ForgotPassword);
await initAndIdentifyForgotPasswordInteraction(client, primaryEmail);
await identifyForgotPasswordInteraction(client, primaryEmail);
await expectRejects(client.resetPassword({ password: primaryEmail }), {
status: 422,
@ -142,9 +134,9 @@ devFeatureTest.describe('Reset Password', () => {
const newPassword = generatePassword();
const client = await initExperienceClient();
const client = await initExperienceClient(InteractionEvent.ForgotPassword);
await initAndIdentifyForgotPasswordInteraction(client, primaryEmail);
await identifyForgotPasswordInteraction(client, primaryEmail);
await client.resetPassword({ password: newPassword });

View file

@ -59,8 +59,7 @@ devFeatureTest.describe('Register interaction with verification code happy path'
value: userProfile[identifiersTypeToUserProfile[identifierType]]!,
};
const client = await initExperienceClient();
await client.initInteraction({ interactionEvent: InteractionEvent.Register });
const client = await initExperienceClient(InteractionEvent.Register);
const { verificationId, code } = await successfullySendVerificationCode(client, {
identifier,

View file

@ -137,8 +137,6 @@ devFeatureTest.describe('social sign-in and sign-up', () => {
});
const client = await initExperienceClient();
await client.initInteraction({ interactionEvent: InteractionEvent.SignIn });
const { verificationId } = await successFullyCreateSocialVerification(client, connectorId, {
redirectUri,
state,

View file

@ -61,7 +61,6 @@ devFeatureTest.describe('Sign-in with verification code', () => {
};
const client = await initExperienceClient();
await client.initInteraction({ interactionEvent: InteractionEvent.SignIn });
const { verificationId, code } = await successfullySendVerificationCode(client, {
identifier,