diff --git a/packages/core/src/routes/experience/classes/verifications/code-verification.ts b/packages/core/src/routes/experience/classes/verifications/code-verification.ts new file mode 100644 index 00000000000..05bc7fe5dac --- /dev/null +++ b/packages/core/src/routes/experience/classes/verifications/code-verification.ts @@ -0,0 +1,195 @@ +import { TemplateType } from '@logto/connector-kit'; +import { + InteractionEvent, + VerificationType, + verificationCodeIdentifierGuard, + type VerificationCodeIdentifier, +} from '@logto/schemas'; +import { type ToZodObject } from '@logto/schemas/lib/utils/zod.js'; +import { generateStandardId } from '@logto/shared'; +import { z } from 'zod'; + +import { type createPasscodeLibrary } from '#src/libraries/passcode.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'; + +const eventToTemplateTypeMap: Record = { + SignIn: TemplateType.SignIn, + Register: TemplateType.Register, + ForgotPassword: TemplateType.ForgotPassword, +}; + +/** + * To make the typescript type checking work. A valid TemplateType is required. + * This is a work around to map the latest interaction event type to old TemplateType. + * + * @remark This is a temporary solution until the connector-kit is updated to use the latest interaction event types. + **/ +const getTemplateTypeByEvent = (event: InteractionEvent): TemplateType => + eventToTemplateTypeMap[event]; + +/** The JSON data type for the CodeVerification record */ +export type CodeVerificationRecordData = { + id: string; + type: VerificationType.VerificationCode; + identifier: VerificationCodeIdentifier; + interactionEvent: InteractionEvent; + userId?: string; + verified: boolean; +}; + +export const codeVerificationRecordDataGuard = z.object({ + id: z.string(), + type: z.literal(VerificationType.VerificationCode), + identifier: verificationCodeIdentifierGuard, + interactionEvent: z.nativeEnum(InteractionEvent), + userId: z.string().optional(), + verified: z.boolean(), +}) satisfies ToZodObject; + +/** This util method convert the interaction identifier to passcode library payload format */ +const getPasscodeIdentifierPayload = ( + identifier: VerificationCodeIdentifier +): Parameters['createPasscode']>[2] => + identifier.type === 'email' ? { email: identifier.value } : { phone: identifier.value }; + +/** + * CodeVerification is a verification type that verifies a given identifier by sending a verification code + * to the user's email or phone. + * + * @remark The verification code is sent to the user's email or phone and the user is required to enter the code to verify. + * If the identifier is for a existing user, the userId will be set after the verification. + * + * To avoid the redundant naming, the `CodeVerification` is used instead of `VerificationCodeVerification`. + */ +export class CodeVerification implements VerificationRecord { + /** + * Factory method to create a new CodeVerification record using the given identifier. + * The sendVerificationCode method will be automatically triggered. + */ + static async create( + libraries: Libraries, + queries: Queries, + identifier: VerificationCodeIdentifier, + interactionEvent: InteractionEvent + ) { + const record = new CodeVerification(libraries, queries, { + id: generateStandardId(), + type: VerificationType.VerificationCode, + identifier, + interactionEvent, + verified: false, + }); + + await record.sendVerificationCode(); + + return record; + } + + public readonly id: string; + public readonly type = VerificationType.VerificationCode; + public readonly identifier: VerificationCodeIdentifier; + + /** + * The interaction event that triggered the verification. + * This will be used to determine the template type for the verification code. + * + * @remark + * `InteractionEvent.ForgotPassword` triggered verification results can not used as a verification record for other events. + */ + private readonly interactionEvent: InteractionEvent; + /** The userId will be set after the verification if the identifier matches any existing user's record */ + private userId?: string; + private verified: boolean; + + constructor( + private readonly libraries: Libraries, + private readonly queries: Queries, + data: CodeVerificationRecordData + ) { + const { id, identifier, userId, verified, interactionEvent } = data; + + this.id = id; + this.identifier = identifier; + this.interactionEvent = interactionEvent; + this.userId = userId; + this.verified = verified; + } + + /** Returns true if the identifier has been verified by a given code */ + get isVerified() { + return this.verified; + } + + /** + * Returns the userId if it is set + * @deprecated this will be removed in the upcoming PR + */ + get verifiedUserId() { + return this.userId; + } + + /** + * Verify the `identifier` with the given code + * + * @remark The identifier and code will be verified in the passcode library. + * No need to verify the identifier before calling this method. + * + * - `isVerified` will be set to true if the code is verified successfully. + * - `verifiedUserId` will be set if the `identifier` matches any existing user's record. + */ + async verify(identifier: VerificationCodeIdentifier, code?: string) { + // Throw code not found error if the code is not provided + assertThat(code, 'verification_code.not_found'); + + const { verifyPasscode } = this.libraries.passcodes; + + await verifyPasscode( + this.id, + getTemplateTypeByEvent(this.interactionEvent), + code, + getPasscodeIdentifierPayload(identifier) + ); + + this.verified = true; + + // Try to lookup the user by the identifier + const user = await findUserByIdentifier(this.queries.users, this.identifier); + this.userId = user?.id; + } + + toJson(): CodeVerificationRecordData { + return { + id: this.id, + type: this.type, + identifier: this.identifier, + interactionEvent: this.interactionEvent, + userId: this.userId, + verified: this.verified, + }; + } + + /** + * Send the verification code to the current `identifier` + * + * @remark Instead of session jti, + * the verification id is used as `interaction_jti` to uniquely identify the passcode record in DB + * for the current interaction. + */ + private async sendVerificationCode() { + const { createPasscode, sendPasscode } = this.libraries.passcodes; + + const verificationCode = await createPasscode( + this.id, + getTemplateTypeByEvent(this.interactionEvent), + getPasscodeIdentifierPayload(this.identifier) + ); + + await sendPasscode(verificationCode); + } +} diff --git a/packages/core/src/routes/experience/classes/verifications/index.ts b/packages/core/src/routes/experience/classes/verifications/index.ts index 0e7f3de76f8..0eee9557cec 100644 --- a/packages/core/src/routes/experience/classes/verifications/index.ts +++ b/packages/core/src/routes/experience/classes/verifications/index.ts @@ -4,32 +4,48 @@ import { z } from 'zod'; import type Libraries from '#src/tenants/Libraries.js'; import type Queries from '#src/tenants/Queries.js'; +import { + CodeVerification, + codeVerificationRecordDataGuard, + type CodeVerificationRecordData, +} from './code-verification.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 | CodeVerificationRecordData; -type VerificationRecordData = PasswordVerificationRecordData; +/** + * Union type for all verification record types + * + * @remark This is a discriminated union type. + * The VerificationRecord generic class can not narrow down the type of a verification record instance by its type property. + * This union type is used to narrow down the type of the verification record. + * Used in the ExperienceInteraction class to store and manage all the verification records. With a given verification type, we can narrow down the type of the verification record. + */ +export type VerificationRecord = PasswordVerification | CodeVerification; export const verificationRecordDataGuard = z.discriminatedUnion('type', [ passwordVerificationRecordDataGuard, + codeVerificationRecordDataGuard, ]); /** * The factory method to build a new `VerificationRecord` instance based on the provided `VerificationRecordData`. */ -export const buildVerificationRecord = ( +export const buildVerificationRecord = ( libraries: Libraries, queries: Queries, - data: T -): VerificationRecord => { + data: VerificationRecordData +) => { switch (data.type) { case VerificationType.Password: { return new PasswordVerification(libraries, queries, data); } + case VerificationType.VerificationCode: { + return new CodeVerification(libraries, queries, data); + } } }; diff --git a/packages/core/src/routes/experience/index.ts b/packages/core/src/routes/experience/index.ts index 68ec94cd2f8..9d679590022 100644 --- a/packages/core/src/routes/experience/index.ts +++ b/packages/core/src/routes/experience/index.ts @@ -24,6 +24,7 @@ import koaExperienceInteraction, { type WithExperienceInteractionContext, } from './middleware/koa-experience-interaction.js'; import passwordVerificationRoutes from './verification-routes/password-verification.js'; +import verificationCodeRoutes from './verification-routes/verification-code.js'; type RouterContext = T extends Router ? Context : never; @@ -75,5 +76,7 @@ export default function experienceApiRoutes( return next(); } ); + passwordVerificationRoutes(router, tenant); + verificationCodeRoutes(router, tenant); } diff --git a/packages/core/src/routes/experience/verification-routes/verification-code.ts b/packages/core/src/routes/experience/verification-routes/verification-code.ts new file mode 100644 index 00000000000..0ee92b68a81 --- /dev/null +++ b/packages/core/src/routes/experience/verification-routes/verification-code.ts @@ -0,0 +1,96 @@ +import { + InteractionEvent, + VerificationType, + verificationCodeIdentifierGuard, +} from '@logto/schemas'; +import type Router from 'koa-router'; +import { z } from 'zod'; + +import RequestError from '#src/errors/RequestError/index.js'; +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 assertThat from '#src/utils/assert-that.js'; + +import { CodeVerification } from '../classes/verifications/code-verification.js'; +import { experienceRoutes } from '../const.js'; +import { type WithExperienceInteractionContext } from '../middleware/koa-experience-interaction.js'; + +export default function verificationCodeRoutes( + router: Router>, + { libraries, queries }: TenantContext +) { + router.post( + `${experienceRoutes.verification}/verification-code`, + koaGuard({ + body: z.object({ + identifier: verificationCodeIdentifierGuard, + interactionEvent: z.nativeEnum(InteractionEvent), + }), + response: z.object({ + verificationId: z.string(), + }), + // 501: connector not found + status: [200, 400, 404, 501], + }), + async (ctx, next) => { + const { identifier, interactionEvent } = ctx.guard.body; + + const codeVerification = await CodeVerification.create( + libraries, + queries, + identifier, + interactionEvent + ); + + ctx.experienceInteraction.setVerificationRecord(codeVerification); + + await ctx.experienceInteraction.save(); + + ctx.body = { + verificationId: codeVerification.id, + }; + + await next(); + } + ); + + router.post( + `${experienceRoutes.verification}/verification-code/verify`, + koaGuard({ + body: z.object({ + identifier: verificationCodeIdentifierGuard, + verificationId: z.string(), + code: z.string(), + }), + response: z.object({ + verificationId: z.string(), + }), + // 501: connector not found + status: [200, 400, 404, 501], + }), + async (ctx, next) => { + const { verificationId, code, identifier } = ctx.guard.body; + + const codeVerificationRecord = + ctx.experienceInteraction.getVerificationRecordById(verificationId); + + assertThat( + codeVerificationRecord && + // Make the Verification type checker happy + codeVerificationRecord.type === VerificationType.VerificationCode, + new RequestError({ code: 'session.verification_session_not_found', status: 404 }) + ); + + await codeVerificationRecord.verify(identifier, code); + + await ctx.experienceInteraction.save(); + + ctx.body = { + verificationId: codeVerificationRecord.id, + }; + + return next(); + } + ); +} diff --git a/packages/integration-tests/src/client/experience/index.ts b/packages/integration-tests/src/client/experience/index.ts index 449e922f2c5..27dcd6734f9 100644 --- a/packages/integration-tests/src/client/experience/index.ts +++ b/packages/integration-tests/src/client/experience/index.ts @@ -1,4 +1,9 @@ -import { type IdentificationApiPayload, type PasswordVerificationPayload } from '@logto/schemas'; +import { + type IdentificationApiPayload, + type InteractionEvent, + type PasswordVerificationPayload, + type VerificationCodeIdentifier, +} from '@logto/schemas'; import MockClient from '#src/client/index.js'; @@ -42,4 +47,29 @@ export class ExperienceClient extends MockClient { }) .json<{ verificationId: string }>(); } + + public async sendVerificationCode(payload: { + identifier: VerificationCodeIdentifier; + interactionEvent: InteractionEvent; + }) { + return api + .post(`${experienceRoutes.verification}/verification-code`, { + headers: { cookie: this.interactionCookie }, + json: payload, + }) + .json<{ verificationId: string }>(); + } + + public async verifyVerificationCode(payload: { + identifier: VerificationCodeIdentifier; + verificationId: string; + code: string; + }) { + return api + .post(`${experienceRoutes.verification}/verification-code/verify`, { + headers: { cookie: this.interactionCookie }, + json: payload, + }) + .json<{ verificationId: string }>(); + } } diff --git a/packages/integration-tests/src/helpers/experience/index.ts b/packages/integration-tests/src/helpers/experience/index.ts index 0c42d60b3dc..93ddbc15ceb 100644 --- a/packages/integration-tests/src/helpers/experience/index.ts +++ b/packages/integration-tests/src/helpers/experience/index.ts @@ -2,10 +2,19 @@ * @fileoverview This file contains the successful interaction flow helper functions that use the experience APIs. */ -import { InteractionEvent, type InteractionIdentifier } from '@logto/schemas'; +import { + InteractionEvent, + type InteractionIdentifier, + type VerificationCodeIdentifier, +} from '@logto/schemas'; import { initExperienceClient, logoutClient, processSession } from '../client.js'; +import { + successfullySendVerificationCode, + successfullyVerifyVerificationCode, +} from './verification-code.js'; + export const signInWithPassword = async ({ identifier, password, @@ -30,3 +39,28 @@ export const signInWithPassword = async ({ await processSession(client, redirectTo); await logoutClient(client); }; + +export const signInWithVerificationCode = async (identifier: VerificationCodeIdentifier) => { + const client = await initExperienceClient(); + + const { verificationId, code } = await successfullySendVerificationCode(client, { + identifier, + interactionEvent: InteractionEvent.SignIn, + }); + + const verifiedVerificationId = await successfullyVerifyVerificationCode(client, { + identifier, + verificationId, + code, + }); + + await client.identifyUser({ + interactionEvent: InteractionEvent.SignIn, + verificationId: verifiedVerificationId, + }); + + const { redirectTo } = await client.submitInteraction(); + + await processSession(client, redirectTo); + await logoutClient(client); +}; diff --git a/packages/integration-tests/src/helpers/experience/verification-code.ts b/packages/integration-tests/src/helpers/experience/verification-code.ts new file mode 100644 index 00000000000..179a864d5e6 --- /dev/null +++ b/packages/integration-tests/src/helpers/experience/verification-code.ts @@ -0,0 +1,42 @@ +import { type InteractionEvent, type VerificationCodeIdentifier } from '@logto/schemas'; + +import { type ExperienceClient } from '#src/client/experience/index.js'; + +import { readConnectorMessage } from '../index.js'; + +export const successfullySendVerificationCode = async ( + client: ExperienceClient, + payload: { + identifier: VerificationCodeIdentifier; + interactionEvent: InteractionEvent; + } +) => { + const { type } = payload.identifier; + const { verificationId } = await client.sendVerificationCode(payload); + const { code, phone, address } = await readConnectorMessage(type === 'email' ? 'Email' : 'Sms'); + + expect(verificationId).toBeTruthy(); + expect(code).toBeTruthy(); + + expect(payload.identifier.type === 'email' ? address : phone).toBe(payload.identifier.value); + + return { + verificationId, + code, + }; +}; + +export const successfullyVerifyVerificationCode = async ( + client: ExperienceClient, + payload: { + identifier: VerificationCodeIdentifier; + verificationId: string; + code: string; + } +) => { + const { verificationId } = await client.verifyVerificationCode(payload); + + expect(verificationId).toBeTruthy(); + + return verificationId; +}; 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-interaction/password.test.ts similarity index 100% rename from packages/integration-tests/src/tests/api/experience-api/sign-in-with-password-verification/happy-path.test.ts rename to packages/integration-tests/src/tests/api/experience-api/sign-in-interaction/password.test.ts diff --git a/packages/integration-tests/src/tests/api/experience-api/sign-in-interaction/verification-code.test.ts b/packages/integration-tests/src/tests/api/experience-api/sign-in-interaction/verification-code.test.ts new file mode 100644 index 00000000000..d18eb8e5be6 --- /dev/null +++ b/packages/integration-tests/src/tests/api/experience-api/sign-in-interaction/verification-code.test.ts @@ -0,0 +1,42 @@ +import { InteractionIdentifierType } from '@logto/schemas'; + +import { deleteUser } from '#src/api/admin-user.js'; +import { setEmailConnector, setSmsConnector } from '#src/helpers/connector.js'; +import { signInWithVerificationCode } from '#src/helpers/experience/index.js'; +import { enableAllVerificationCodeSignInMethods } from '#src/helpers/sign-in-experience.js'; +import { generateNewUser } from '#src/helpers/user.js'; +import { devFeatureTest } from '#src/utils.js'; + +const verificationIdentifierType: readonly [ + InteractionIdentifierType.Email, + InteractionIdentifierType.Phone, +] = Object.freeze([InteractionIdentifierType.Email, InteractionIdentifierType.Phone]); + +const identifiersTypeToUserProfile = Object.freeze({ + email: 'primaryEmail', + phone: 'primaryPhone', +}); + +devFeatureTest.describe('Sign-in with verification code happy path', () => { + beforeAll(async () => { + await Promise.all([setEmailConnector(), setSmsConnector()]); + await enableAllVerificationCodeSignInMethods(); + }); + + it.each(verificationIdentifierType)( + 'Should sign-in with verification code using %p', + async (identifier) => { + const { userProfile, user } = await generateNewUser({ + [identifiersTypeToUserProfile[identifier]]: true, + password: true, + }); + + await signInWithVerificationCode({ + type: identifier, + value: userProfile[identifiersTypeToUserProfile[identifier]]!, + }); + + await deleteUser(user.id); + } + ); +}); diff --git a/packages/integration-tests/src/tests/api/experience-api/verifications/verification-code.test.ts b/packages/integration-tests/src/tests/api/experience-api/verifications/verification-code.test.ts new file mode 100644 index 00000000000..169d82f8130 --- /dev/null +++ b/packages/integration-tests/src/tests/api/experience-api/verifications/verification-code.test.ts @@ -0,0 +1,208 @@ +import { ConnectorType } from '@logto/connector-kit'; +import { + InteractionEvent, + InteractionIdentifierType, + type VerificationCodeIdentifier, +} from '@logto/schemas'; + +import { initExperienceClient } from '#src/helpers/client.js'; +import { + clearConnectorsByTypes, + setEmailConnector, + setSmsConnector, +} from '#src/helpers/connector.js'; +import { + successfullySendVerificationCode, + successfullyVerifyVerificationCode, +} from '#src/helpers/experience/verification-code.js'; +import { expectRejects } from '#src/helpers/index.js'; +import { devFeatureTest } from '#src/utils.js'; + +devFeatureTest.describe('Verification code verification APIs', () => { + beforeAll(async () => { + await clearConnectorsByTypes([ConnectorType.Email, ConnectorType.Sms]); + }); + + const identifiers: VerificationCodeIdentifier[] = [ + { + type: InteractionIdentifierType.Email, + value: 'foo@logto.io', + }, + { + type: InteractionIdentifierType.Phone, + value: '+1234567890', + }, + ]; + + describe.each(identifiers)('Verification code verification APIs for %p', ({ type, value }) => { + it(`should throw an 501 error if the ${type} connector is not set`, async () => { + const client = await initExperienceClient(); + + await expectRejects( + client.sendVerificationCode({ + interactionEvent: InteractionEvent.SignIn, + identifier: { + type, + value, + }, + }), + { + code: 'connector.not_found', + status: 501, + } + ); + + await (type === 'email' ? setEmailConnector() : setSmsConnector()); + }); + + it(`should send a verification code to the ${type} successfully`, async () => { + const client = await initExperienceClient(); + + await successfullySendVerificationCode(client, { + interactionEvent: InteractionEvent.SignIn, + identifier: { + type, + value, + }, + }); + }); + + it('should throw a 404 error if the verificationId is invalid', async () => { + const client = await initExperienceClient(); + + const { code } = await successfullySendVerificationCode(client, { + interactionEvent: InteractionEvent.SignIn, + identifier: { + type, + value, + }, + }); + + await expectRejects( + client.verifyVerificationCode({ + code, + identifier: { + type, + value, + }, + verificationId: 'invalid_verification_id', + }), + { + code: 'session.verification_session_not_found', + status: 404, + } + ); + }); + + it('should throw a 404 error if the verification record is overwrote by a concurrent verification request', async () => { + const client = await initExperienceClient(); + + const { verificationId, code } = await successfullySendVerificationCode(client, { + interactionEvent: InteractionEvent.SignIn, + identifier: { + type, + value, + }, + }); + + // Resend and recreate the verification record + await successfullySendVerificationCode(client, { + interactionEvent: InteractionEvent.SignIn, + identifier: { + type, + value, + }, + }); + + await expectRejects( + client.verifyVerificationCode({ + code, + identifier: { + type, + value, + }, + verificationId, + }), + { + code: 'session.verification_session_not_found', + status: 404, + } + ); + }); + + it('should throw a 400 error if the identifier is different', async () => { + const client = await initExperienceClient(); + + const { code, verificationId } = await successfullySendVerificationCode(client, { + interactionEvent: InteractionEvent.SignIn, + identifier: { + type, + value, + }, + }); + + await expectRejects( + client.verifyVerificationCode({ + code, + identifier: { + type, + value: 'invalid_identifier', + }, + verificationId, + }), + { + code: `verification_code.${type}_mismatch`, + status: 400, + } + ); + }); + + it('should throw a 400 error if the code is mismatched', async () => { + const client = await initExperienceClient(); + + const { verificationId } = await successfullySendVerificationCode(client, { + interactionEvent: InteractionEvent.SignIn, + identifier: { + type, + value, + }, + }); + + await expectRejects( + client.verifyVerificationCode({ + code: 'invalid_code', + identifier: { + type, + value, + }, + verificationId, + }), + { + code: 'verification_code.code_mismatch', + status: 400, + } + ); + }); + + it('should verify the verification code successfully', async () => { + const client = await initExperienceClient(); + + const { code, verificationId } = await successfullySendVerificationCode(client, { + interactionEvent: InteractionEvent.SignIn, + identifier: { + type, + value, + }, + }); + + await successfullyVerifyVerificationCode(client, { + code, + identifier: { + type, + value, + }, + verificationId, + }); + }); + }); +}); diff --git a/packages/schemas/src/types/interactions.ts b/packages/schemas/src/types/interactions.ts index 98690e8b5d3..ce0bb26fd44 100644 --- a/packages/schemas/src/types/interactions.ts +++ b/packages/schemas/src/types/interactions.ts @@ -41,6 +41,17 @@ export const interactionIdentifierGuard = z.object({ value: z.string(), }) satisfies ToZodObject; +/** Currently only email and phone are supported for verification code validation. */ +export type VerificationCodeIdentifier = { + type: InteractionIdentifierType.Email | InteractionIdentifierType.Phone; + value: string; +}; + +export const verificationCodeIdentifierGuard = z.object({ + type: z.enum([InteractionIdentifierType.Email, InteractionIdentifierType.Phone]), + value: z.string(), +}) satisfies ToZodObject; + /** Logto supported interaction verification types. */ export enum VerificationType { Password = 'Password',