0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

feat(core,schemas): introduce new PUT experience API (#6212)

* feat(core,schemas): introduce new PUT experience API

introduce new PUT experience API

* fix(core): fix some comments

fix some comments
This commit is contained in:
simeng-li 2024-07-12 18:16:43 +08:00 committed by GitHub
parent ef33361179
commit dcb62d69d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 160 additions and 33 deletions

View file

@ -38,31 +38,32 @@ const interactionStorageGuard = z.object({
* @see {@link https://github.com/logto-io/rfcs | Logto RFCs} for more information about RFC 0004. * @see {@link https://github.com/logto-io/rfcs | Logto RFCs} for more information about RFC 0004.
*/ */
export default class ExperienceInteraction { 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. */ /** The user verification record list for the current interaction. */
private readonly verificationRecords: Map<VerificationType, VerificationRecord>; private readonly verificationRecords = new Map<VerificationType, VerificationRecord>();
/** The userId of the user for the current interaction. Only available once the user is identified. */ /** The userId of the user for the current interaction. Only available once the user is identified. */
private userId?: string; private userId?: string;
/** The user provided profile data in the current interaction that needs to be stored to database. */ /** The user provided profile data in the current interaction that needs to be stored to database. */
private readonly profile?: Record<string, unknown>; // TODO: Fix the type private readonly profile?: Record<string, unknown>; // 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( constructor(
private readonly ctx: WithLogContext, private readonly ctx: WithLogContext,
private readonly tenant: TenantContext, private readonly tenant: TenantContext,
public interactionDetails: Interaction public interactionDetails?: Interaction
) { ) {
const { libraries, queries } = tenant; const { libraries, queries } = tenant;
if (!interactionDetails) {
return;
}
const result = interactionStorageGuard.safeParse(interactionDetails.result ?? {}); const result = interactionStorageGuard.safeParse(interactionDetails.result ?? {});
assertThat( assertThat(
@ -72,12 +73,10 @@ export default class ExperienceInteraction {
const { verificationRecords = [], profile, userId, interactionEvent } = result.data; const { verificationRecords = [], profile, userId, interactionEvent } = result.data;
this.interactionEvent = interactionEvent; this.#interactionEvent = interactionEvent;
this.userId = userId; this.userId = userId;
this.profile = profile; this.profile = profile;
this.verificationRecords = new Map();
for (const record of verificationRecords) { for (const record of verificationRecords) {
const instance = buildVerificationRecord(libraries, queries, record); const instance = buildVerificationRecord(libraries, queries, record);
this.verificationRecords.set(instance.type, instance); this.verificationRecords.set(instance.type, instance);
@ -88,10 +87,14 @@ export default class ExperienceInteraction {
return this.userId; return this.userId;
} }
get interactionEvent() {
return this.#interactionEvent;
}
/** Set the interaction event for the current interaction */ /** Set the interaction event for the current interaction */
public setInteractionEvent(interactionEvent: InteractionEvent) { public setInteractionEvent(interactionEvent: InteractionEvent) {
// TODO: conflict event check (e.g. reset password session can't be used for sign in) // 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. */ /** Save the current interaction result. */
public async save() { 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. // `mergeWithLastSubmission` will only merge current request's interaction results.
// Manually merge with previous interaction results here. // Manually merge with previous interaction results here.
// @see {@link https://github.com/panva/node-oidc-provider/blob/c243bf6b6663c41ff3e75c09b95fb978eba87381/lib/actions/authorization/interactions.js#L106} // @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( await provider.interactionResult(
this.ctx.req, this.ctx.req,
this.ctx.res, this.ctx.res,
{ ...details.result, ...this.toJson() }, { ...details.result, ...interactionData },
{ mergeWithLastSubmission: true } { 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 */ /** Submit the current interaction result to the OIDC provider and clear the interaction data */

View file

@ -10,7 +10,7 @@
* The experience APIs can be used by developers to build custom user interaction experiences. * 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 type Router from 'koa-router';
import { z } from 'zod'; 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 { type AnonymousRouter, type RouterInitArgs } from '../types.js';
import ExperienceInteraction from './classes/experience-interaction.js';
import { experienceRoutes } from './const.js'; import { experienceRoutes } from './const.js';
import koaExperienceInteraction, { import koaExperienceInteraction, {
type WithExperienceInteractionContext, type WithExperienceInteractionContext,
@ -45,6 +46,62 @@ export default function experienceApiRoutes<T extends AnonymousRouter>(
koaExperienceInteraction(tenant) 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( router.post(
experienceRoutes.identification, experienceRoutes.identification,
koaGuard({ koaGuard({
@ -52,10 +109,7 @@ export default function experienceApiRoutes<T extends AnonymousRouter>(
status: [204, 400, 401, 404], status: [204, 400, 401, 404],
}), }),
async (ctx, next) => { async (ctx, next) => {
const { interactionEvent, verificationId } = ctx.guard.body; const { verificationId } = ctx.guard.body;
// TODO: implement a separate POST interaction route to handle the initiation of the interaction event
ctx.experienceInteraction.setInteractionEvent(interactionEvent);
await ctx.experienceInteraction.identifyUser(verificationId); await ctx.experienceInteraction.identifyUser(verificationId);

View file

@ -25,7 +25,10 @@ export default function koaExperienceInteraction<
tenant: TenantContext tenant: TenantContext
): MiddlewareType<StateT, WithExperienceInteractionContext<ContextT>, ResponseT> { ): MiddlewareType<StateT, WithExperienceInteractionContext<ContextT>, ResponseT> {
return async (ctx, next) => { 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(); return next();
}; };

View file

@ -1,4 +1,5 @@
import { import {
type CreateExperienceApiPayload,
type IdentificationApiPayload, type IdentificationApiPayload,
type InteractionEvent, type InteractionEvent,
type PasswordVerificationPayload, type PasswordVerificationPayload,
@ -33,6 +34,24 @@ export class ExperienceClient extends MockClient {
.json(); .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<RedirectResponse> { public override async submitInteraction(): Promise<RedirectResponse> {
return api return api
.post(`${experienceRoutes.prefix}/submit`, { headers: { cookie: this.interactionCookie } }) .post(`${experienceRoutes.prefix}/submit`, { headers: { cookie: this.interactionCookie } })

View file

@ -27,13 +27,14 @@ export const signInWithPassword = async ({
}) => { }) => {
const client = await initExperienceClient(); const client = await initExperienceClient();
await client.initInteraction({ interactionEvent: InteractionEvent.SignIn });
const { verificationId } = await client.verifyPassword({ const { verificationId } = await client.verifyPassword({
identifier, identifier,
password, password,
}); });
await client.identifyUser({ await client.identifyUser({
interactionEvent: InteractionEvent.SignIn,
verificationId, verificationId,
}); });
@ -46,6 +47,8 @@ export const signInWithPassword = async ({
export const signInWithVerificationCode = async (identifier: VerificationCodeIdentifier) => { export const signInWithVerificationCode = async (identifier: VerificationCodeIdentifier) => {
const client = await initExperienceClient(); const client = await initExperienceClient();
await client.initInteraction({ interactionEvent: InteractionEvent.SignIn });
const { verificationId, code } = await successfullySendVerificationCode(client, { const { verificationId, code } = await successfullySendVerificationCode(client, {
identifier, identifier,
interactionEvent: InteractionEvent.SignIn, interactionEvent: InteractionEvent.SignIn,
@ -58,7 +61,6 @@ export const signInWithVerificationCode = async (identifier: VerificationCodeIde
}); });
await client.identifyUser({ await client.identifyUser({
interactionEvent: InteractionEvent.SignIn,
verificationId: verifiedVerificationId, verificationId: verifiedVerificationId,
}); });
@ -80,6 +82,8 @@ export const identifyUserWithUsernamePassword = async (
username: string, username: string,
password: string password: string
) => { ) => {
await client.initInteraction({ interactionEvent: InteractionEvent.SignIn });
const { verificationId } = await client.verifyPassword({ const { verificationId } = await client.verifyPassword({
identifier: { identifier: {
type: InteractionIdentifierType.Username, type: InteractionIdentifierType.Username,
@ -88,7 +92,7 @@ export const identifyUserWithUsernamePassword = async (
password, password,
}); });
await client.identifyUser({ interactionEvent: InteractionEvent.SignIn, verificationId }); await client.identifyUser({ verificationId });
return { verificationId }; return { verificationId };
}; };

View file

@ -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,
});
});
});

View file

@ -117,14 +117,21 @@ export const backupCodeVerificationVerifyPayloadGuard = z.object({
/** Payload type for `POST /api/experience/identification`. */ /** Payload type for `POST /api/experience/identification`. */
export type IdentificationApiPayload = { export type IdentificationApiPayload = {
interactionEvent: InteractionEvent; /** The ID of the verification record that is used to identify the user. */
verificationId: string; verificationId: string;
}; };
export const identificationApiPayloadGuard = z.object({ export const identificationApiPayloadGuard = z.object({
interactionEvent: z.nativeEnum(InteractionEvent),
verificationId: z.string(), verificationId: z.string(),
}) satisfies ToZodObject<IdentificationApiPayload>; }) satisfies ToZodObject<IdentificationApiPayload>;
/** Payload type for `POST /api/experience`. */
export type CreateExperienceApiPayload = {
interactionEvent: InteractionEvent;
};
export const CreateExperienceApiPayloadGuard = z.object({
interactionEvent: z.nativeEnum(InteractionEvent),
}) satisfies ToZodObject<CreateExperienceApiPayload>;
// ====== Experience API payload guard and types definitions end ====== // ====== Experience API payload guard and types definitions end ======
/** /**