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.
|
* @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 */
|
||||||
|
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
@ -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();
|
||||||
};
|
};
|
||||||
|
|
|
@ -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 } })
|
||||||
|
|
|
@ -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 };
|
||||||
};
|
};
|
||||||
|
|
|
@ -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`. */
|
/** 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 ======
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
Loading…
Reference in a new issue