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:
parent
ef33361179
commit
dcb62d69d4
7 changed files with 160 additions and 33 deletions
|
@ -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<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. */
|
||||
private userId?: string;
|
||||
/** 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
|
||||
/** 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 */
|
||||
|
|
|
@ -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<T extends AnonymousRouter>(
|
|||
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<T extends AnonymousRouter>(
|
|||
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);
|
||||
|
||||
|
|
|
@ -25,7 +25,10 @@ export default function koaExperienceInteraction<
|
|||
tenant: TenantContext
|
||||
): MiddlewareType<StateT, WithExperienceInteractionContext<ContextT>, 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();
|
||||
};
|
||||
|
|
|
@ -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<RedirectResponse> {
|
||||
return api
|
||||
.post(`${experienceRoutes.prefix}/submit`, { headers: { cookie: this.interactionCookie } })
|
||||
|
|
|
@ -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 };
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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<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 ======
|
||||
|
||||
/**
|
||||
|
|
Loading…
Reference in a new issue