diff --git a/packages/core/src/routes/experience/classes/experience-interaction.ts b/packages/core/src/routes/experience/classes/experience-interaction.ts index b3e1c4857..ec37d2590 100644 --- a/packages/core/src/routes/experience/classes/experience-interaction.ts +++ b/packages/core/src/routes/experience/classes/experience-interaction.ts @@ -38,31 +38,32 @@ const interactionStorageGuard = z.object({ * @see {@link https://github.com/logto-io/rfcs | Logto RFCs} for more information about RFC 0004. */ export default class ExperienceInteraction { - /** - * Factory method to create a new `ExperienceInteraction` using the current context. - */ - static async create(ctx: WithLogContext, tenant: TenantContext) { - const { provider } = tenant; - const interactionDetails = await provider.interactionDetails(ctx.req, ctx.res); - return new ExperienceInteraction(ctx, tenant, interactionDetails); - } - - /** The interaction event for the current interaction. */ - private interactionEvent?: InteractionEvent; /** The user verification record list for the current interaction. */ - private readonly verificationRecords: Map; + private readonly verificationRecords = new Map(); /** The userId of the user for the current interaction. Only available once the user is identified. */ private userId?: string; /** The user provided profile data in the current interaction that needs to be stored to database. */ private readonly profile?: Record; // TODO: Fix the type + /** The interaction event for the current interaction. */ + #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. + */ constructor( private readonly ctx: WithLogContext, private readonly tenant: TenantContext, - public interactionDetails: Interaction + public interactionDetails?: Interaction ) { const { libraries, queries } = tenant; + if (!interactionDetails) { + return; + } + const result = interactionStorageGuard.safeParse(interactionDetails.result ?? {}); assertThat( @@ -72,12 +73,10 @@ export default class ExperienceInteraction { const { verificationRecords = [], profile, userId, interactionEvent } = result.data; - this.interactionEvent = interactionEvent; + this.#interactionEvent = interactionEvent; this.userId = userId; this.profile = profile; - this.verificationRecords = new Map(); - for (const record of verificationRecords) { const instance = buildVerificationRecord(libraries, queries, record); this.verificationRecords.set(instance.type, instance); @@ -88,10 +87,14 @@ export default class ExperienceInteraction { return this.userId; } + get interactionEvent() { + return this.#interactionEvent; + } + /** Set the interaction event for the current interaction */ public setInteractionEvent(interactionEvent: InteractionEvent) { // TODO: conflict event check (e.g. reset password session can't be used for sign in) - this.interactionEvent = interactionEvent; + this.#interactionEvent = interactionEvent; } /** @@ -172,19 +175,22 @@ export default class ExperienceInteraction { /** Save the current interaction result. */ public async save() { + const { provider } = this.tenant; + const details = await provider.interactionDetails(this.ctx.req, this.ctx.res); + const interactionData = this.toJson(); + // `mergeWithLastSubmission` will only merge current request's interaction results. // Manually merge with previous interaction results here. // @see {@link https://github.com/panva/node-oidc-provider/blob/c243bf6b6663c41ff3e75c09b95fb978eba87381/lib/actions/authorization/interactions.js#L106} - - const { provider } = this.tenant; - const details = await provider.interactionDetails(this.ctx.req, this.ctx.res); - await provider.interactionResult( this.ctx.req, this.ctx.res, - { ...details.result, ...this.toJson() }, + { ...details.result, ...interactionData }, { mergeWithLastSubmission: true } ); + + // Prepend the interaction data to all log entries + this.ctx.prependAllLogEntries({ interaction: interactionData }); } /** Submit the current interaction result to the OIDC provider and clear the interaction data */ diff --git a/packages/core/src/routes/experience/index.ts b/packages/core/src/routes/experience/index.ts index 3b5616ed5..5cbe17513 100644 --- a/packages/core/src/routes/experience/index.ts +++ b/packages/core/src/routes/experience/index.ts @@ -10,7 +10,7 @@ * The experience APIs can be used by developers to build custom user interaction experiences. */ -import { identificationApiPayloadGuard } from '@logto/schemas'; +import { identificationApiPayloadGuard, InteractionEvent } from '@logto/schemas'; import type Router from 'koa-router'; import { z } from 'zod'; @@ -19,6 +19,7 @@ import koaGuard from '#src/middleware/koa-guard.js'; import { type AnonymousRouter, type RouterInitArgs } from '../types.js'; +import ExperienceInteraction from './classes/experience-interaction.js'; import { experienceRoutes } from './const.js'; import koaExperienceInteraction, { type WithExperienceInteractionContext, @@ -45,6 +46,62 @@ export default function experienceApiRoutes( koaExperienceInteraction(tenant) ); + router.put( + experienceRoutes.prefix, + koaGuard({ + body: z.object({ + interactionEvent: z.nativeEnum(InteractionEvent), + }), + status: [204], + }), + async (ctx, next) => { + const { interactionEvent } = ctx.guard.body; + const { createLog } = ctx; + + createLog(`Interaction.${interactionEvent}.Update`); + + const experienceInteraction = new ExperienceInteraction(ctx, tenant); + experienceInteraction.setInteractionEvent(interactionEvent); + + await experienceInteraction.save(); + + ctx.experienceInteraction = experienceInteraction; + ctx.status = 204; + + return next(); + } + ); + + router.put( + `${experienceRoutes.prefix}/interaction-event`, + koaGuard({ + body: z.object({ + interactionEvent: z.nativeEnum(InteractionEvent), + }), + status: [204], + }), + async (ctx, next) => { + const { interactionEvent } = ctx.guard.body; + const { createLog, experienceInteraction } = ctx; + + const eventLog = createLog( + `Interaction.${experienceInteraction.interactionEvent ?? interactionEvent}.Update` + ); + + experienceInteraction.setInteractionEvent(interactionEvent); + + eventLog.append({ + interactionEvent, + }); + + await experienceInteraction.save(); + + ctx.status = 204; + + return next(); + } + ); + router.post( experienceRoutes.identification, koaGuard({ @@ -52,10 +109,7 @@ export default function experienceApiRoutes( status: [204, 400, 401, 404], }), async (ctx, next) => { - const { interactionEvent, verificationId } = ctx.guard.body; - - // TODO: implement a separate POST interaction route to handle the initiation of the interaction event - ctx.experienceInteraction.setInteractionEvent(interactionEvent); + const { verificationId } = ctx.guard.body; await ctx.experienceInteraction.identifyUser(verificationId); diff --git a/packages/core/src/routes/experience/middleware/koa-experience-interaction.ts b/packages/core/src/routes/experience/middleware/koa-experience-interaction.ts index 19ec4af51..2cbcb6fd6 100644 --- a/packages/core/src/routes/experience/middleware/koa-experience-interaction.ts +++ b/packages/core/src/routes/experience/middleware/koa-experience-interaction.ts @@ -25,7 +25,10 @@ export default function koaExperienceInteraction< tenant: TenantContext ): MiddlewareType, ResponseT> { return async (ctx, next) => { - ctx.experienceInteraction = await ExperienceInteraction.create(ctx, tenant); + const { provider } = tenant; + const interactionDetails = await provider.interactionDetails(ctx.req, ctx.res); + + ctx.experienceInteraction = new ExperienceInteraction(ctx, tenant, interactionDetails); return next(); }; diff --git a/packages/integration-tests/src/client/experience/index.ts b/packages/integration-tests/src/client/experience/index.ts index 8a6d74d56..2bacb75b6 100644 --- a/packages/integration-tests/src/client/experience/index.ts +++ b/packages/integration-tests/src/client/experience/index.ts @@ -1,4 +1,5 @@ import { + type CreateExperienceApiPayload, type IdentificationApiPayload, type InteractionEvent, type PasswordVerificationPayload, @@ -33,6 +34,24 @@ export class ExperienceClient extends MockClient { .json(); } + public async updateInteractionEvent(payload: { interactionEvent: InteractionEvent }) { + return api + .put(`${experienceRoutes.prefix}/interaction-event`, { + headers: { cookie: this.interactionCookie }, + json: payload, + }) + .json(); + } + + public async initInteraction(payload: CreateExperienceApiPayload) { + return api + .put(experienceRoutes.prefix, { + headers: { cookie: this.interactionCookie }, + json: payload, + }) + .json(); + } + public override async submitInteraction(): Promise { return api .post(`${experienceRoutes.prefix}/submit`, { headers: { cookie: this.interactionCookie } }) diff --git a/packages/integration-tests/src/helpers/experience/index.ts b/packages/integration-tests/src/helpers/experience/index.ts index 803e034a5..af3ebe3e6 100644 --- a/packages/integration-tests/src/helpers/experience/index.ts +++ b/packages/integration-tests/src/helpers/experience/index.ts @@ -27,13 +27,14 @@ export const signInWithPassword = async ({ }) => { const client = await initExperienceClient(); + await client.initInteraction({ interactionEvent: InteractionEvent.SignIn }); + const { verificationId } = await client.verifyPassword({ identifier, password, }); await client.identifyUser({ - interactionEvent: InteractionEvent.SignIn, verificationId, }); @@ -46,6 +47,8 @@ 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, @@ -58,7 +61,6 @@ export const signInWithVerificationCode = async (identifier: VerificationCodeIde }); await client.identifyUser({ - interactionEvent: InteractionEvent.SignIn, verificationId: verifiedVerificationId, }); @@ -80,6 +82,8 @@ export const identifyUserWithUsernamePassword = async ( username: string, password: string ) => { + await client.initInteraction({ interactionEvent: InteractionEvent.SignIn }); + const { verificationId } = await client.verifyPassword({ identifier: { type: InteractionIdentifierType.Username, @@ -88,7 +92,7 @@ export const identifyUserWithUsernamePassword = async ( password, }); - await client.identifyUser({ interactionEvent: InteractionEvent.SignIn, verificationId }); + await client.identifyUser({ verificationId }); return { verificationId }; }; diff --git a/packages/integration-tests/src/tests/api/experience-api/interaction.test.ts b/packages/integration-tests/src/tests/api/experience-api/interaction.test.ts new file mode 100644 index 000000000..37be24186 --- /dev/null +++ b/packages/integration-tests/src/tests/api/experience-api/interaction.test.ts @@ -0,0 +1,34 @@ +import { InteractionEvent, InteractionIdentifierType } from '@logto/schemas'; + +import { initExperienceClient } from '#src/helpers/client.js'; +import { expectRejects } from '#src/helpers/index.js'; +import { generateNewUserProfile, UserApiTest } from '#src/helpers/user.js'; +import { devFeatureTest } from '#src/utils.js'; + +devFeatureTest.describe('PUT /experience API', () => { + const userApi = new UserApiTest(); + + afterAll(async () => { + await userApi.cleanUp(); + }); + + 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 }); + + const client = await initExperienceClient(); + await client.initInteraction({ interactionEvent: InteractionEvent.SignIn }); + const { verificationId } = await client.verifyPassword({ + identifier: { type: InteractionIdentifierType.Username, value: username }, + password, + }); + + // PUT /experience + await client.initInteraction({ interactionEvent: InteractionEvent.SignIn }); + + await expectRejects(client.identifyUser({ verificationId }), { + code: 'session.verification_session_not_found', + status: 404, + }); + }); +}); diff --git a/packages/schemas/src/types/interactions.ts b/packages/schemas/src/types/interactions.ts index 38079bd4d..877974224 100644 --- a/packages/schemas/src/types/interactions.ts +++ b/packages/schemas/src/types/interactions.ts @@ -117,14 +117,21 @@ export const backupCodeVerificationVerifyPayloadGuard = z.object({ /** Payload type for `POST /api/experience/identification`. */ export type IdentificationApiPayload = { - interactionEvent: InteractionEvent; + /** The ID of the verification record that is used to identify the user. */ verificationId: string; }; export const identificationApiPayloadGuard = z.object({ - interactionEvent: z.nativeEnum(InteractionEvent), verificationId: z.string(), }) satisfies ToZodObject; +/** Payload type for `POST /api/experience`. */ +export type CreateExperienceApiPayload = { + interactionEvent: InteractionEvent; +}; +export const CreateExperienceApiPayloadGuard = z.object({ + interactionEvent: z.nativeEnum(InteractionEvent), +}) satisfies ToZodObject; + // ====== Experience API payload guard and types definitions end ====== /**