From aec2cf4f5b6b421661256650cb80622c800ab4e8 Mon Sep 17 00:00:00 2001 From: simeng-li Date: Fri, 5 Jul 2024 11:02:36 +0800 Subject: [PATCH] feat(core): implement new experience API routes (#5992) * feat(core): implement new interaction-session management flow implement a new interaction-session management flow for experience api use * feat(core): implement password sign-in flow implement password sign-in flow * test(core,schemas): add sign-in password tests add sign-in password tests * chore(core): update comments update comments * refactor(core): rename the password input value key rename the password input value key * refactor(core,schemas): refactor the experience API refactor the exerpience API structure * chore(test): add devFeature test add devFeature test * refactor(core): rename the path rename the path * refactor(core,schemas): refactor using the latest API design refactor using the latest API design * chore(test): replace using devFeature test statement replace using devFeature test statement * fix(core): fix lint error fix lint error * refactor(core): refactor experience API implementations refactor experience API implementations * refactor(core): replace with switch replace object map with switch * refactor: apply suggestions from code review * refactor(core): refactor the interaction class refactor the interaction class * refactor(core): update the user identification logic update the user identification logic --------- Co-authored-by: Gao Sun --- .../classes/experience-interaction.ts | 171 ++++++++++++++++++ .../experience/classes/verifications/index.ts | 35 ++++ .../verifications/password-verification.ts | 102 +++++++++++ .../verifications/verification-record.ts | 21 +++ packages/core/src/routes/experience/const.ts | 7 + packages/core/src/routes/experience/index.ts | 79 ++++++++ .../middleware/koa-experience-interaction.ts | 32 ++++ packages/core/src/routes/experience/types.ts | 3 + packages/core/src/routes/experience/utils.ts | 20 ++ .../password-verification.ts | 41 +++++ packages/core/src/routes/init.ts | 9 +- .../routes/interaction/utils/interaction.ts | 15 +- .../src/client/experience/const.ts | 7 + .../src/client/experience/index.ts | 45 +++++ .../integration-tests/src/helpers/client.ts | 13 ++ .../src/helpers/experience/index.ts | 32 ++++ .../happy-path.test.ts | 39 ++++ .../password-verification.test.ts | 38 ++++ .../phrases/src/locales/en/errors/session.ts | 2 + packages/schemas/src/types/interactions.ts | 83 ++++++++- 20 files changed, 780 insertions(+), 14 deletions(-) create mode 100644 packages/core/src/routes/experience/classes/experience-interaction.ts create mode 100644 packages/core/src/routes/experience/classes/verifications/index.ts create mode 100644 packages/core/src/routes/experience/classes/verifications/password-verification.ts create mode 100644 packages/core/src/routes/experience/classes/verifications/verification-record.ts create mode 100644 packages/core/src/routes/experience/const.ts create mode 100644 packages/core/src/routes/experience/index.ts create mode 100644 packages/core/src/routes/experience/middleware/koa-experience-interaction.ts create mode 100644 packages/core/src/routes/experience/types.ts create mode 100644 packages/core/src/routes/experience/utils.ts create mode 100644 packages/core/src/routes/experience/verification-routes/password-verification.ts create mode 100644 packages/integration-tests/src/client/experience/const.ts create mode 100644 packages/integration-tests/src/client/experience/index.ts create mode 100644 packages/integration-tests/src/helpers/experience/index.ts create mode 100644 packages/integration-tests/src/tests/api/experience-api/sign-in-with-password-verification/happy-path.test.ts create mode 100644 packages/integration-tests/src/tests/api/experience-api/verifications/password-verification.test.ts diff --git a/packages/core/src/routes/experience/classes/experience-interaction.ts b/packages/core/src/routes/experience/classes/experience-interaction.ts new file mode 100644 index 000000000..0e28075df --- /dev/null +++ b/packages/core/src/routes/experience/classes/experience-interaction.ts @@ -0,0 +1,171 @@ +import { InteractionEvent, type VerificationType } from '@logto/schemas'; +import { z } from 'zod'; + +import RequestError from '#src/errors/RequestError/index.js'; +import { type WithLogContext } from '#src/middleware/koa-audit-log.js'; +import type TenantContext from '#src/tenants/TenantContext.js'; +import assertThat from '#src/utils/assert-that.js'; + +import type { Interaction } from '../types.js'; + +import { + buildVerificationRecord, + verificationRecordDataGuard, + type VerificationRecord, +} from './verifications/index.js'; + +const interactionStorageGuard = z.object({ + event: z.nativeEnum(InteractionEvent).optional(), + accountId: z.string().optional(), + profile: z.object({}).optional(), + verificationRecords: verificationRecordDataGuard.array().optional(), +}); + +/** + * Interaction is a short-lived session session that is initiated when a user starts an interaction flow with the Logto platform. + * This class is used to manage all the interaction data and status. + * + * @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; + /** The accountId of the user for the current interaction. Only available once the user is identified. */ + private accountId?: 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 + + constructor( + private readonly ctx: WithLogContext, + private readonly tenant: TenantContext, + interactionDetails: Interaction + ) { + const { libraries, queries } = tenant; + + const result = interactionStorageGuard.safeParse(interactionDetails.result ?? {}); + + assertThat( + result.success, + new RequestError({ code: 'session.interaction_not_found', status: 404 }) + ); + + const { verificationRecords = [], profile, accountId, event } = result.data; + + this.interactionEvent = event; + this.accountId = accountId; // TODO: @simeng-li replace with 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); + } + } + + /** Set the interaction event for the current interaction */ + public setInteractionEvent(event: InteractionEvent) { + // TODO: conflict event check (e.g. reset password session can't be used for sign in) + this.interactionEvent = event; + } + + /** Set the verified `accountId` of the current interaction from the verification record */ + public identifyUser(verificationId: string) { + const verificationRecord = this.getVerificationRecordById(verificationId); + + assertThat( + verificationRecord, + new RequestError({ code: 'session.verification_session_not_found', status: 404 }) + ); + + // Throws an 404 error if the user is not found by the given verification record + assertThat( + verificationRecord.verifiedUserId, + new RequestError({ + code: 'user.user_not_exist', + status: 404, + }) + ); + + // Throws an 409 error if the current session has already identified a different user + if (this.accountId) { + assertThat( + this.accountId === verificationRecord.verifiedUserId, + new RequestError({ code: 'session.identity_conflict', status: 409 }) + ); + return; + } + + this.accountId = verificationRecord.verifiedUserId; + } + + /** + * Append a new verification record to the current interaction. + * If a record with the same type already exists, it will be replaced. + */ + public setVerificationRecord(record: VerificationRecord) { + const { type } = record; + + this.verificationRecords.set(type, record); + } + + public getVerificationRecordById(verificationId: string) { + return this.verificationRecordsArray.find((record) => record.id === verificationId); + } + + /** Save the current interaction result. */ + public async save() { + // `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() }, + { mergeWithLastSubmission: true } + ); + } + + /** Submit the current interaction result to the OIDC provider and clear the interaction data */ + public async submit() { + // TODO: refine the error code + assertThat(this.accountId, 'session.verification_session_not_found'); + + const { provider } = this.tenant; + + const redirectTo = await provider.interactionResult(this.ctx.req, this.ctx.res, { + login: { accountId: this.accountId }, + }); + + this.ctx.body = { redirectTo }; + } + + private get verificationRecordsArray() { + return [...this.verificationRecords.values()]; + } + + /** Convert the current interaction to JSON, so that it can be stored as the OIDC provider interaction result */ + public toJson() { + return { + event: this.interactionEvent, + accountId: this.accountId, + profile: this.profile, + verificationRecords: this.verificationRecordsArray.map((record) => record.toJson()), + }; + } +} diff --git a/packages/core/src/routes/experience/classes/verifications/index.ts b/packages/core/src/routes/experience/classes/verifications/index.ts new file mode 100644 index 000000000..0e7f3de76 --- /dev/null +++ b/packages/core/src/routes/experience/classes/verifications/index.ts @@ -0,0 +1,35 @@ +import { VerificationType } from '@logto/schemas'; +import { z } from 'zod'; + +import type Libraries from '#src/tenants/Libraries.js'; +import type Queries from '#src/tenants/Queries.js'; + +import { + PasswordVerification, + passwordVerificationRecordDataGuard, + type PasswordVerificationRecordData, +} from './password-verification.js'; +import { type VerificationRecord } from './verification-record.js'; + +export { type VerificationRecord } from './verification-record.js'; + +type VerificationRecordData = PasswordVerificationRecordData; + +export const verificationRecordDataGuard = z.discriminatedUnion('type', [ + passwordVerificationRecordDataGuard, +]); + +/** + * The factory method to build a new `VerificationRecord` instance based on the provided `VerificationRecordData`. + */ +export const buildVerificationRecord = ( + libraries: Libraries, + queries: Queries, + data: T +): VerificationRecord => { + switch (data.type) { + case VerificationType.Password: { + return new PasswordVerification(libraries, queries, data); + } + } +}; diff --git a/packages/core/src/routes/experience/classes/verifications/password-verification.ts b/packages/core/src/routes/experience/classes/verifications/password-verification.ts new file mode 100644 index 000000000..245dfcc5d --- /dev/null +++ b/packages/core/src/routes/experience/classes/verifications/password-verification.ts @@ -0,0 +1,102 @@ +import { + VerificationType, + interactionIdentifierGuard, + type InteractionIdentifier, +} from '@logto/schemas'; +import { type ToZodObject } from '@logto/schemas/lib/utils/zod.js'; +import { generateStandardId } from '@logto/shared'; +import { z } from 'zod'; + +import RequestError from '#src/errors/RequestError/index.js'; +import type Libraries from '#src/tenants/Libraries.js'; +import type Queries from '#src/tenants/Queries.js'; +import assertThat from '#src/utils/assert-that.js'; + +import { findUserByIdentifier } from '../../utils.js'; + +import { type VerificationRecord } from './verification-record.js'; + +export type PasswordVerificationRecordData = { + id: string; + type: VerificationType.Password; + identifier: InteractionIdentifier; + /** The unique identifier of the user that has been verified. */ + userId?: string; +}; + +export const passwordVerificationRecordDataGuard = z.object({ + id: z.string(), + type: z.literal(VerificationType.Password), + identifier: interactionIdentifierGuard, + userId: z.string().optional(), +}) satisfies ToZodObject; + +export class PasswordVerification implements VerificationRecord { + /** Factory method to create a new `PasswordVerification` record using an identifier */ + static create(libraries: Libraries, queries: Queries, identifier: InteractionIdentifier) { + return new PasswordVerification(libraries, queries, { + id: generateStandardId(), + type: VerificationType.Password, + identifier, + }); + } + + readonly type = VerificationType.Password; + public readonly identifier: InteractionIdentifier; + public readonly id: string; + private userId?: string; + + /** + * The constructor method is intended to be used internally by the interaction class + * to instantiate a `VerificationRecord` object from existing `PasswordVerificationRecordData`. + * It directly sets the instance properties based on the provided data. + * For creating a new verification record from context, use the static `create` method instead. + */ + constructor( + private readonly libraries: Libraries, + private readonly queries: Queries, + data: PasswordVerificationRecordData + ) { + const { id, identifier, userId } = data; + + this.id = id; + this.identifier = identifier; + this.userId = userId; + } + + /** Returns true if a userId is set */ + get isVerified() { + return this.userId !== undefined; + } + + get verifiedUserId() { + return this.userId; + } + + /** + * Verifies if the password matches the record in database with the current identifier. + * `userId` will be set if the password can be verified. + * + * @throws RequestError with 401 status if user id suspended. + * @throws RequestError with 422 status if the user is not found or the password is incorrect. + */ + async verify(password: string) { + const user = await findUserByIdentifier(this.queries.users, this.identifier); + const { isSuspended, id } = await this.libraries.users.verifyUserPassword(user, password); + + assertThat(!isSuspended, new RequestError({ code: 'user.suspended', status: 401 })); + + this.userId = id; + } + + toJson(): PasswordVerificationRecordData { + const { id, type, identifier, userId } = this; + + return { + id, + type, + identifier, + userId, + }; + } +} diff --git a/packages/core/src/routes/experience/classes/verifications/verification-record.ts b/packages/core/src/routes/experience/classes/verifications/verification-record.ts new file mode 100644 index 000000000..56a621fa6 --- /dev/null +++ b/packages/core/src/routes/experience/classes/verifications/verification-record.ts @@ -0,0 +1,21 @@ +import type { VerificationType } from '@logto/schemas'; + +type Data = { + id: string; + type: T; +}; + +/** The abstract class for all verification records. */ +export abstract class VerificationRecord< + T extends VerificationType = VerificationType, + Json extends Data = Data, +> { + abstract readonly id: string; + abstract readonly type: T; + + abstract get isVerified(): boolean; + /** @deprecated will be removed in the coming PR */ + abstract get verifiedUserId(): string | undefined; + + abstract toJson(): Json; +} diff --git a/packages/core/src/routes/experience/const.ts b/packages/core/src/routes/experience/const.ts new file mode 100644 index 000000000..6eaac0ffe --- /dev/null +++ b/packages/core/src/routes/experience/const.ts @@ -0,0 +1,7 @@ +const prefix = '/experience'; + +export const experienceRoutes = Object.freeze({ + prefix, + identification: `${prefix}/identification`, + verification: `${prefix}/verification`, +}); diff --git a/packages/core/src/routes/experience/index.ts b/packages/core/src/routes/experience/index.ts new file mode 100644 index 000000000..68ec94cd2 --- /dev/null +++ b/packages/core/src/routes/experience/index.ts @@ -0,0 +1,79 @@ +/** + * @overview This file implements the routes for the user interaction experience (RFC 0004). + * + * Note the experience APIs also known as interaction APIs v2, + * are the new version of the interaction APIs with design improvements. + * + * @see {@link https://github.com/logto-io/rfcs | Logto RFCs} for more information about RFC 0004. + * + * @remarks + * The experience APIs can be used by developers to build custom user interaction experiences. + */ + +import { identificationApiPayloadGuard } from '@logto/schemas'; +import type Router from 'koa-router'; +import { z } from 'zod'; + +import koaAuditLog from '#src/middleware/koa-audit-log.js'; +import koaGuard from '#src/middleware/koa-guard.js'; + +import { type AnonymousRouter, type RouterInitArgs } from '../types.js'; + +import { experienceRoutes } from './const.js'; +import koaExperienceInteraction, { + type WithExperienceInteractionContext, +} from './middleware/koa-experience-interaction.js'; +import passwordVerificationRoutes from './verification-routes/password-verification.js'; + +type RouterContext = T extends Router ? Context : never; + +export default function experienceApiRoutes( + ...[anonymousRouter, tenant]: RouterInitArgs +) { + const { queries, libraries } = tenant; + + const router = + // @ts-expect-error for good koa types + // eslint-disable-next-line no-restricted-syntax + (anonymousRouter as Router>>).use( + koaAuditLog(queries), + koaExperienceInteraction(tenant) + ); + + router.post( + experienceRoutes.identification, + koaGuard({ + body: identificationApiPayloadGuard, + status: [204, 400, 404], + }), + async (ctx, next) => { + const { interactionEvent, verificationId } = ctx.guard.body; + + ctx.experienceInteraction.setInteractionEvent(interactionEvent); + + ctx.experienceInteraction.identifyUser(verificationId); + + await ctx.experienceInteraction.save(); + + ctx.status = 204; + + return next(); + } + ); + + router.post( + `${experienceRoutes.prefix}/submit`, + koaGuard({ + status: [200], + response: z.object({ + redirectTo: z.string(), + }), + }), + async (ctx, next) => { + await ctx.experienceInteraction.submit(); + ctx.status = 200; + return next(); + } + ); + passwordVerificationRoutes(router, tenant); +} diff --git a/packages/core/src/routes/experience/middleware/koa-experience-interaction.ts b/packages/core/src/routes/experience/middleware/koa-experience-interaction.ts new file mode 100644 index 000000000..19ec4af51 --- /dev/null +++ b/packages/core/src/routes/experience/middleware/koa-experience-interaction.ts @@ -0,0 +1,32 @@ +import type { MiddlewareType } from 'koa'; + +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'; + +export type WithExperienceInteractionContext = + ContextT & { + experienceInteraction: ExperienceInteraction; + }; + +/** + * @overview This middleware initializes the `ExperienceInteraction` for the current request. + * The `ExperienceInteraction` instance is used to manage all the data related to the current interaction. + * All the interaction data is stored using oidc-provider's interaction session. + * + * @see {@link https://github.com/panva/node-oidc-provider/blob/main/docs/README.md#user-flows} + */ +export default function koaExperienceInteraction< + StateT, + ContextT extends WithLogContext, + ResponseT, +>( + tenant: TenantContext +): MiddlewareType, ResponseT> { + return async (ctx, next) => { + ctx.experienceInteraction = await ExperienceInteraction.create(ctx, tenant); + + return next(); + }; +} diff --git a/packages/core/src/routes/experience/types.ts b/packages/core/src/routes/experience/types.ts new file mode 100644 index 000000000..8100dfcdf --- /dev/null +++ b/packages/core/src/routes/experience/types.ts @@ -0,0 +1,3 @@ +import type Provider from 'oidc-provider'; + +export type Interaction = Awaited>; diff --git a/packages/core/src/routes/experience/utils.ts b/packages/core/src/routes/experience/utils.ts new file mode 100644 index 000000000..e8985de24 --- /dev/null +++ b/packages/core/src/routes/experience/utils.ts @@ -0,0 +1,20 @@ +import { InteractionIdentifierType, type InteractionIdentifier } from '@logto/schemas'; + +import type Queries from '#src/tenants/Queries.js'; + +export const findUserByIdentifier = async ( + userQuery: Queries['users'], + { type, value }: InteractionIdentifier +) => { + switch (type) { + case InteractionIdentifierType.Username: { + return userQuery.findUserByUsername(value); + } + case InteractionIdentifierType.Email: { + return userQuery.findUserByEmail(value); + } + case InteractionIdentifierType.Phone: { + return userQuery.findUserByPhone(value); + } + } +}; diff --git a/packages/core/src/routes/experience/verification-routes/password-verification.ts b/packages/core/src/routes/experience/verification-routes/password-verification.ts new file mode 100644 index 000000000..871f6b682 --- /dev/null +++ b/packages/core/src/routes/experience/verification-routes/password-verification.ts @@ -0,0 +1,41 @@ +import { passwordVerificationPayloadGuard } from '@logto/schemas'; +import type Router from 'koa-router'; +import { z } from 'zod'; + +import { type WithLogContext } from '#src/middleware/koa-audit-log.js'; +import koaGuard from '#src/middleware/koa-guard.js'; +import type TenantContext from '#src/tenants/TenantContext.js'; + +import { PasswordVerification } from '../classes/verifications/password-verification.js'; +import { experienceRoutes } from '../const.js'; +import { type WithExperienceInteractionContext } from '../middleware/koa-experience-interaction.js'; + +export default function passwordVerificationRoutes( + router: Router>, + { libraries, queries }: TenantContext +) { + router.post( + `${experienceRoutes.verification}/password`, + koaGuard({ + body: passwordVerificationPayloadGuard, + status: [200, 400, 422], + response: z.object({ + verificationId: z.string(), + }), + }), + async (ctx, next) => { + const { identifier, password } = ctx.guard.body; + + const passwordVerification = PasswordVerification.create(libraries, queries, identifier); + await passwordVerification.verify(password); + ctx.experienceInteraction.setVerificationRecord(passwordVerification); + await ctx.experienceInteraction.save(); + + ctx.body = { verificationId: passwordVerification.id }; + + ctx.status = 200; + + return next(); + } + ); +} diff --git a/packages/core/src/routes/init.ts b/packages/core/src/routes/init.ts index 13c02824a..557004a27 100644 --- a/packages/core/src/routes/init.ts +++ b/packages/core/src/routes/init.ts @@ -24,6 +24,7 @@ import connectorRoutes from './connector/index.js'; import customPhraseRoutes from './custom-phrase.js'; import dashboardRoutes from './dashboard.js'; import domainRoutes from './domain.js'; +import experienceApiRoutes from './experience/index.js'; import hookRoutes from './hook.js'; import interactionRoutes from './interaction/index.js'; import logRoutes from './log.js'; @@ -46,8 +47,14 @@ import wellKnownRoutes from './well-known.js'; const createRouters = (tenant: TenantContext) => { const interactionRouter: AnonymousRouter = new Router(); + /** @deprecated */ interactionRoutes(interactionRouter, tenant); + const experienceRouter: AnonymousRouter = new Router(); + if (EnvSet.values.isDevFeaturesEnabled) { + experienceApiRoutes(experienceRouter, tenant); + } + const managementRouter: ManagementApiRouter = new Router(); managementRouter.use(koaAuth(tenant.envSet, getManagementApiResourceIndicator(tenant.id))); managementRouter.use(koaTenantGuard(tenant.id, tenant.queries)); @@ -91,7 +98,7 @@ const createRouters = (tenant: TenantContext) => { // The swagger.json should contain all API routers. swaggerRoutes(anonymousRouter, [interactionRouter, managementRouter, anonymousRouter]); - return [interactionRouter, managementRouter, anonymousRouter]; + return [experienceRouter, interactionRouter, managementRouter, anonymousRouter]; }; export default function initApis(tenant: TenantContext): Koa { diff --git a/packages/core/src/routes/interaction/utils/interaction.ts b/packages/core/src/routes/interaction/utils/interaction.ts index 151c203bf..370818bac 100644 --- a/packages/core/src/routes/interaction/utils/interaction.ts +++ b/packages/core/src/routes/interaction/utils/interaction.ts @@ -2,21 +2,21 @@ import type { Profile } from '@logto/schemas'; import { InteractionEvent } from '@logto/schemas'; import { assert } from '@silverhand/essentials'; import type { Context } from 'koa'; -import { errors } from 'oidc-provider'; -import type { InteractionResults } from 'oidc-provider'; import type Provider from 'oidc-provider'; +import type { InteractionResults } from 'oidc-provider'; +import { errors } from 'oidc-provider'; import RequestError from '#src/errors/RequestError/index.js'; import assertThat from '#src/utils/assert-that.js'; import { anonymousInteractionResultGuard } from '../types/guard.js'; import type { - Identifier, + AccountVerifiedInteractionResult, AnonymousInteractionResult, + Identifier, + RegisterInteractionResult, VerifiedForgotPasswordInteractionResult, VerifiedInteractionResult, - RegisterInteractionResult, - AccountVerifiedInteractionResult, VerifiedRegisterInteractionResult, } from '../types/index.js'; @@ -170,6 +170,11 @@ export const getInteractionFromProviderByJti = async ( return interaction; }; +/** + * Since we don't have the OIDC provider context here, `provider.interactionResult` cannot be used. + * This method is forked from the original implementation in `provide.interactionResult` in oidc-provider. + * Assign the result to the interaction and save it. + */ export const assignResultToInteraction = async ( interaction: Interaction, result: InteractionResults diff --git a/packages/integration-tests/src/client/experience/const.ts b/packages/integration-tests/src/client/experience/const.ts new file mode 100644 index 000000000..00b4b9935 --- /dev/null +++ b/packages/integration-tests/src/client/experience/const.ts @@ -0,0 +1,7 @@ +const prefix = 'experience'; + +export const experienceRoutes = { + verification: `${prefix}/verification`, + identification: `${prefix}/identification`, + prefix, +}; diff --git a/packages/integration-tests/src/client/experience/index.ts b/packages/integration-tests/src/client/experience/index.ts new file mode 100644 index 000000000..449e922f2 --- /dev/null +++ b/packages/integration-tests/src/client/experience/index.ts @@ -0,0 +1,45 @@ +import { type IdentificationApiPayload, type PasswordVerificationPayload } from '@logto/schemas'; + +import MockClient from '#src/client/index.js'; + +import api from '../../api/api.js'; + +import { experienceRoutes } from './const.js'; + +type RedirectResponse = { + redirectTo: string; +}; + +export const identifyUser = async (cookie: string, payload: IdentificationApiPayload) => + api + .post(experienceRoutes.identification, { + headers: { cookie }, + json: payload, + }) + .json(); + +export class ExperienceClient extends MockClient { + public async identifyUser(payload: IdentificationApiPayload) { + return api + .post(experienceRoutes.identification, { + headers: { cookie: this.interactionCookie }, + json: payload, + }) + .json(); + } + + public override async submitInteraction(): Promise { + return api + .post(`${experienceRoutes.prefix}/submit`, { headers: { cookie: this.interactionCookie } }) + .json(); + } + + public async verifyPassword(payload: PasswordVerificationPayload) { + return api + .post(`${experienceRoutes.verification}/password`, { + headers: { cookie: this.interactionCookie }, + json: payload, + }) + .json<{ verificationId: string }>(); + } +} diff --git a/packages/integration-tests/src/helpers/client.ts b/packages/integration-tests/src/helpers/client.ts index 652f113f8..b63709efd 100644 --- a/packages/integration-tests/src/helpers/client.ts +++ b/packages/integration-tests/src/helpers/client.ts @@ -1,6 +1,7 @@ import type { LogtoConfig, SignInOptions } from '@logto/node'; import { assert } from '@silverhand/essentials'; +import { ExperienceClient } from '#src/client/experience/index.js'; import MockClient from '#src/client/index.js'; export const initClient = async ( @@ -15,6 +16,18 @@ export const initClient = async ( return client; }; +export const initExperienceClient = async ( + config?: Partial, + redirectUri?: string, + options: Omit = {} +) => { + const client = new ExperienceClient(config); + await client.initSession(redirectUri, options); + assert(client.interactionCookie, new Error('Session not found')); + + return client; +}; + export const processSession = async (client: MockClient, redirectTo: string) => { await client.processSession(redirectTo); diff --git a/packages/integration-tests/src/helpers/experience/index.ts b/packages/integration-tests/src/helpers/experience/index.ts new file mode 100644 index 000000000..0c42d60b3 --- /dev/null +++ b/packages/integration-tests/src/helpers/experience/index.ts @@ -0,0 +1,32 @@ +/** + * @fileoverview This file contains the successful interaction flow helper functions that use the experience APIs. + */ + +import { InteractionEvent, type InteractionIdentifier } from '@logto/schemas'; + +import { initExperienceClient, logoutClient, processSession } from '../client.js'; + +export const signInWithPassword = async ({ + identifier, + password, +}: { + identifier: InteractionIdentifier; + password: string; +}) => { + const client = await initExperienceClient(); + + const { verificationId } = await client.verifyPassword({ + identifier, + password, + }); + + await client.identifyUser({ + interactionEvent: InteractionEvent.SignIn, + verificationId, + }); + + const { redirectTo } = await client.submitInteraction(); + + await processSession(client, redirectTo); + await logoutClient(client); +}; diff --git a/packages/integration-tests/src/tests/api/experience-api/sign-in-with-password-verification/happy-path.test.ts b/packages/integration-tests/src/tests/api/experience-api/sign-in-with-password-verification/happy-path.test.ts new file mode 100644 index 000000000..0663ca7ff --- /dev/null +++ b/packages/integration-tests/src/tests/api/experience-api/sign-in-with-password-verification/happy-path.test.ts @@ -0,0 +1,39 @@ +import { InteractionIdentifierType } from '@logto/schemas'; + +import { deleteUser } from '#src/api/admin-user.js'; +import { signInWithPassword } from '#src/helpers/experience/index.js'; +import { enableAllPasswordSignInMethods } from '#src/helpers/sign-in-experience.js'; +import { generateNewUser } from '#src/helpers/user.js'; +import { devFeatureTest } from '#src/utils.js'; + +const identifiersTypeToUserProfile = Object.freeze({ + username: 'username', + email: 'primaryEmail', + phone: 'primaryPhone', +}); + +devFeatureTest.describe('sign-in with password verification happy path', () => { + beforeAll(async () => { + await enableAllPasswordSignInMethods(); + }); + + it.each(Object.values(InteractionIdentifierType))( + 'should sign-in with password using %p', + async (identifier) => { + const { userProfile, user } = await generateNewUser({ + [identifiersTypeToUserProfile[identifier]]: true, + password: true, + }); + + await signInWithPassword({ + identifier: { + type: identifier, + value: userProfile[identifiersTypeToUserProfile[identifier]]!, + }, + password: userProfile.password, + }); + + await deleteUser(user.id); + } + ); +}); diff --git a/packages/integration-tests/src/tests/api/experience-api/verifications/password-verification.test.ts b/packages/integration-tests/src/tests/api/experience-api/verifications/password-verification.test.ts new file mode 100644 index 000000000..372a80a0f --- /dev/null +++ b/packages/integration-tests/src/tests/api/experience-api/verifications/password-verification.test.ts @@ -0,0 +1,38 @@ +import { InteractionIdentifierType } from '@logto/schemas'; + +import { deleteUser } from '#src/api/admin-user.js'; +import { initExperienceClient } from '#src/helpers/client.js'; +import { generateNewUser } from '#src/helpers/user.js'; +import { devFeatureTest } from '#src/utils.js'; + +const identifiersTypeToUserProfile = Object.freeze({ + username: 'username', + email: 'primaryEmail', + phone: 'primaryPhone', +}); + +devFeatureTest.describe('password verifications', () => { + it.each(Object.values(InteractionIdentifierType))( + 'should verify with password successfully using %p', + async (identifier) => { + const { userProfile, user } = await generateNewUser({ + [identifiersTypeToUserProfile[identifier]]: true, + password: true, + }); + + const client = await initExperienceClient(); + + const { verificationId } = await client.verifyPassword({ + identifier: { + type: identifier, + value: userProfile[identifiersTypeToUserProfile[identifier]]!, + }, + password: userProfile.password, + }); + + expect(verificationId).toBeTruthy(); + + await deleteUser(user.id); + } + ); +}); diff --git a/packages/phrases/src/locales/en/errors/session.ts b/packages/phrases/src/locales/en/errors/session.ts index cdc4cb259..ab6e00e11 100644 --- a/packages/phrases/src/locales/en/errors/session.ts +++ b/packages/phrases/src/locales/en/errors/session.ts @@ -23,6 +23,8 @@ const session = { interaction_not_found: 'Interaction session not found. Please go back and start the session again.', not_supported_for_forgot_password: 'This operation is not supported for forgot password.', + identity_conflict: + 'Identity mismatch detected. Please initiate a new session to proceed with a different identity.', mfa: { require_mfa_verification: 'Mfa verification is required to sign in.', mfa_sign_in_only: 'Mfa is only available for sign-in interaction.', diff --git a/packages/schemas/src/types/interactions.ts b/packages/schemas/src/types/interactions.ts index 2d36be97d..98690e8b5 100644 --- a/packages/schemas/src/types/interactions.ts +++ b/packages/schemas/src/types/interactions.ts @@ -2,6 +2,7 @@ import { emailRegEx, phoneRegEx, usernameRegEx } from '@logto/core-kit'; import { z } from 'zod'; import { MfaFactor, jsonObjectGuard, webAuthnTransportGuard } from '../foundations/index.js'; +import { type ToZodObject } from '../utils/zod.js'; import type { EmailVerificationCodePayload, @@ -12,13 +13,86 @@ import { phoneVerificationCodePayloadGuard, } from './verification-code.js'; +/** + * User interaction events defined in Logto RFC 0004. + * @see {@link https://github.com/logto-io/rfcs | Logto RFCs} for more information. + */ +export enum InteractionEvent { + SignIn = 'SignIn', + Register = 'Register', + ForgotPassword = 'ForgotPassword', +} + +// ====== Experience API payload guards and type definitions start ====== +export enum InteractionIdentifierType { + Username = 'username', + Email = 'email', + Phone = 'phone', +} + +/** Identifiers that can be used to uniquely identify a user. */ +export type InteractionIdentifier = { + type: InteractionIdentifierType; + value: string; +}; + +export const interactionIdentifierGuard = z.object({ + type: z.nativeEnum(InteractionIdentifierType), + value: z.string(), +}) satisfies ToZodObject; + +/** Logto supported interaction verification types. */ +export enum VerificationType { + Password = 'Password', + VerificationCode = 'VerificationCode', + Social = 'Social', + TOTP = 'Totp', + WebAuthn = 'WebAuthn', + BackupCode = 'BackupCode', +} + +// REMARK: API payload guard + +/** Payload type for `POST /api/experience/verification/password`. */ +export type PasswordVerificationPayload = { + identifier: InteractionIdentifier; + password: string; +}; +export const passwordVerificationPayloadGuard = z.object({ + identifier: interactionIdentifierGuard, + password: z.string().min(1), +}) satisfies ToZodObject; + +/** Payload type for `POST /api/experience/identification`. */ +export type IdentificationApiPayload = { + interactionEvent: InteractionEvent; + verificationId: string; +}; + +export const identificationApiPayloadGuard = z.object({ + interactionEvent: z.nativeEnum(InteractionEvent), + verificationId: z.string(), +}) satisfies ToZodObject; + +// ====== Experience API payload guards and type definitions end ====== + +/** + * Legacy interaction identifier payload guard + * + * @remark + * Following are the types for legacy interaction APIs. They are all treated as deprecated, and can be removed + * once the new Experience API are fully implemented and migrated. + * ================================================================================================================= + */ + /** * Detailed interaction identifier payload guard */ -export const usernamePasswordPayloadGuard = z.object({ +const usernamePasswordPayloadGuard = z.object({ username: z.string().min(1), password: z.string().min(1), }); + export type UsernamePasswordPayload = z.infer; export const emailPasswordPayloadGuard = z.object({ @@ -53,13 +127,6 @@ export const socialPhonePayloadGuard = z.object({ export type SocialPhonePayload = z.infer; -// Interaction flow event types -export enum InteractionEvent { - SignIn = 'SignIn', - Register = 'Register', - ForgotPassword = 'ForgotPassword', -} - export const eventGuard = z.nativeEnum(InteractionEvent); export const identifierPayloadGuard = z.union([