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 000000000000..b85d4f8f2143 --- /dev/null +++ b/packages/core/src/routes/experience/classes/verifications/code-verification.ts @@ -0,0 +1,188 @@ +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 Verification } from './verification.js'; + +/** + * 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 eventToTemplateTypeMap: Record = { + SignIn: TemplateType.SignIn, + Register: TemplateType.Register, + ForgotPassword: TemplateType.ForgotPassword, +}; +const getTemplateTypeByEvent = (event: InteractionEvent): TemplateType => + eventToTemplateTypeMap[event]; + +export type CodeVerificationRecordData = { + id: string; + type: VerificationType.VerificationCode; + 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. + */ + interactionEvent: InteractionEvent; + /** The userId of the user that has been verified. Only available after the verification of existing identifier */ + 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 Verification { + /** + * Factory method to create a new CodeVerification record using the given identifier. + * The sendVerificationCode method will be automatically triggered on the creation of the record. + */ + 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; + } + + readonly type = VerificationType.VerificationCode; + public readonly identifier: VerificationCodeIdentifier; + public readonly id: string; + private readonly interactionEvent: InteractionEvent; + 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 */ + 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 session. + */ + 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 cc430b27758d..f006b160b542 100644 --- a/packages/core/src/routes/experience/classes/verifications/index.ts +++ b/packages/core/src/routes/experience/classes/verifications/index.ts @@ -4,6 +4,11 @@ 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, @@ -12,13 +17,14 @@ import { export { PasswordVerification } from './password-verification.js'; -type VerificationRecordData = PasswordVerificationRecordData; +type VerificationRecordData = PasswordVerificationRecordData | CodeVerificationRecordData; export const verificationRecordDataGuard = z.discriminatedUnion('type', [ passwordVerificationRecordDataGuard, + codeVerificationRecordDataGuard, ]); -export type VerificationRecord = PasswordVerification; +export type VerificationRecord = PasswordVerification | CodeVerification; export const buildVerificationRecord = ( libraries: Libraries, @@ -29,5 +35,8 @@ export const buildVerificationRecord = ( 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/const.ts b/packages/core/src/routes/experience/const.ts index 6692bf37515c..56cf08b058f8 100644 --- a/packages/core/src/routes/experience/const.ts +++ b/packages/core/src/routes/experience/const.ts @@ -1 +1,2 @@ export const experienceApiRoutesPrefix = '/experience'; +export const experienceVerificationApiRoutesPrefix = '/experience/verification'; diff --git a/packages/core/src/routes/experience/index.ts b/packages/core/src/routes/experience/index.ts index b0560dd728c5..242d3de26d79 100644 --- a/packages/core/src/routes/experience/index.ts +++ b/packages/core/src/routes/experience/index.ts @@ -23,6 +23,7 @@ import { experienceApiRoutesPrefix } from './const.js'; import koaInteractionSession, { type WithInteractionSessionContext, } from './middleware/koa-interaction-session.js'; +import verificationCodeRoutes from './verification-routes/verification-code.js'; type RouterContext = T extends Router ? Context : never; @@ -54,8 +55,6 @@ export default function experienceApiRoutes( await passwordVerification.verify(password); ctx.interactionSession.appendVerificationRecord(passwordVerification); - ctx.interactionSession.identifyUser(passwordVerification.id); - await ctx.interactionSession.save(); ctx.status = 204; @@ -75,4 +74,6 @@ export default function experienceApiRoutes( return next(); } ); + + 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 000000000000..4da1a16a7860 --- /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 { experienceVerificationApiRoutesPrefix } from '../const.js'; +import { type WithInteractionSessionContext } from '../middleware/koa-interaction-session.js'; + +export default function verificationCodeRoutes( + router: Router>, + { libraries, queries }: TenantContext +) { + router.post( + `${experienceVerificationApiRoutesPrefix}/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.interactionSession.appendVerificationRecord(codeVerification); + + await ctx.interactionSession.save(); + + ctx.body = { + verificationId: codeVerification.id, + }; + + await next(); + } + ); + + router.post( + `${experienceVerificationApiRoutesPrefix}/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.interactionSession.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.interactionSession.save(); + + ctx.body = { + verificationId, + }; + + return next(); + } + ); +} diff --git a/packages/integration-tests/src/api/experience-api/verification-code.ts b/packages/integration-tests/src/api/experience-api/verification-code.ts new file mode 100644 index 000000000000..428bdf582c3a --- /dev/null +++ b/packages/integration-tests/src/api/experience-api/verification-code.ts @@ -0,0 +1,34 @@ +import { type InteractionEvent, type VerificationCodeIdentifier } from '@logto/schemas'; + +import api from '../api.js'; + +import { experienceVerificationApiRoutesPrefix } from './const.js'; + +export const sendVerificationCode = async ( + cookie: string, + payload: { + identifier: VerificationCodeIdentifier; + interactionEvent: InteractionEvent; + } +) => + api + .post(`${experienceVerificationApiRoutesPrefix}/verification-code`, { + headers: { cookie }, + json: payload, + }) + .json<{ verificationId: string }>(); + +export const verifyVerificationCode = async ( + cookie: string, + payload: { + identifier: VerificationCodeIdentifier; + verificationId: string; + code: string; + } +) => + api + .post(`${experienceVerificationApiRoutesPrefix}/verification-code/verify`, { + headers: { cookie }, + json: payload, + }) + .json<{ verificationId: string }>(); 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 000000000000..58fad4428ac5 --- /dev/null +++ b/packages/integration-tests/src/helpers/experience/verification-code.ts @@ -0,0 +1,46 @@ +import { type InteractionEvent, type VerificationCodeIdentifier } from '@logto/schemas'; + +import { + sendVerificationCode, + verifyVerificationCode, +} from '#src/api/experience-api/verification-code.js'; +import type MockClient from '#src/client/index.js'; + +import { readConnectorMessage } from '../index.js'; + +export const successfullySendVerificationCode = async ( + client: MockClient, + payload: { + identifier: VerificationCodeIdentifier; + interactionEvent: InteractionEvent; + } +) => { + const { type } = payload.identifier; + const { verificationId } = await client.send(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: MockClient, + payload: { + identifier: VerificationCodeIdentifier; + verificationId: string; + code: string; + } +) => { + const { verificationId } = await client.send(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-api/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-api/password.test.ts diff --git a/packages/integration-tests/src/tests/api/experience-api/verifications-api/verification-code.test.ts b/packages/integration-tests/src/tests/api/experience-api/verifications-api/verification-code.test.ts new file mode 100644 index 000000000000..49d31d10ed72 --- /dev/null +++ b/packages/integration-tests/src/tests/api/experience-api/verifications-api/verification-code.test.ts @@ -0,0 +1,172 @@ +import { ConnectorType } from '@logto/connector-kit'; +import { InteractionEvent, type VerificationCodeIdentifier } from '@logto/schemas'; + +import { + sendVerificationCode, + verifyVerificationCode, +} from '#src/api/experience-api/verification-code.js'; +import { initClient } 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: 'email', + value: 'foo@logto.io', + }, + { + type: '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 initClient(); + + await expectRejects( + client.send(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 initClient(); + + await successfullySendVerificationCode(client, { + interactionEvent: InteractionEvent.SignIn, + identifier: { + type, + value, + }, + }); + }); + + it('should throw a 404 error if the verificationId is invalid', async () => { + const client = await initClient(); + + const { code } = await successfullySendVerificationCode(client, { + interactionEvent: InteractionEvent.SignIn, + identifier: { + type, + value, + }, + }); + + await expectRejects( + client.send(verifyVerificationCode, { + code, + identifier: { + type, + value, + }, + verificationId: 'invalid_verification_id', + }), + { + code: 'session.verification_session_not_found', + status: 404, + } + ); + }); + + it('should throw a 400 error if the identifier is different', async () => { + const client = await initClient(); + + const { code, verificationId } = await successfullySendVerificationCode(client, { + interactionEvent: InteractionEvent.SignIn, + identifier: { + type, + value, + }, + }); + + await expectRejects( + client.send(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 initClient(); + + const { verificationId } = await successfullySendVerificationCode(client, { + interactionEvent: InteractionEvent.SignIn, + identifier: { + type, + value, + }, + }); + + await expectRejects( + client.send(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 initClient(); + + 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 f127fe6b5c1e..864a8a120792 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, @@ -34,7 +35,18 @@ export type InteractionIdentifier = { export const interactionIdentifierGuard = z.object({ type: z.enum(['username', 'email', 'phone']), value: z.string(), -}); +}) satisfies ToZodObject; + +/** Currently only email and phone are supported for verification code validation */ +export type VerificationCodeIdentifier = { + type: 'email' | 'phone'; + value: string; +}; + +export const verificationCodeIdentifierGuard = z.object({ + type: z.enum(['email', 'phone']), + value: z.string(), +}) satisfies ToZodObject; /** Logto supported interaction verification types */ export enum VerificationType {