From 10ea2307ad76417cb75d46e91cc282482357be67 Mon Sep 17 00:00:00 2001 From: Charles Zhao Date: Thu, 12 Jan 2023 11:44:07 +0800 Subject: [PATCH] feat(core): add send and verify APIs for generic type verification code (#2849) --- .changeset-staged/unlucky-months-clap.md | 5 + .vscode/settings.json | 3 +- packages/core/src/libraries/passcode.test.ts | 63 ++++++++++++- packages/core/src/libraries/passcode.ts | 18 +++- packages/core/src/queries/passcode.test.ts | 52 ++++++++++ packages/core/src/queries/passcode.ts | 59 ++++++++---- packages/core/src/routes/init.ts | 2 + packages/core/src/routes/interaction/index.ts | 15 +-- .../src/routes/interaction/types/guard.ts | 12 +-- .../src/routes/interaction/types/index.ts | 9 -- .../src/routes/interaction/utils/index.ts | 14 +-- .../utils/verification-code-validation.ts | 15 ++- .../identifier-payload-verification.ts | 4 +- .../core/src/routes/verification-code.test.ts | 94 +++++++++++++++++++ packages/core/src/routes/verification-code.ts | 49 ++++++++++ packages/schemas/src/types/index.ts | 1 + packages/schemas/src/types/interactions.ts | 20 ++-- .../schemas/src/types/verification-code.ts | 36 +++++++ 18 files changed, 393 insertions(+), 78 deletions(-) create mode 100644 .changeset-staged/unlucky-months-clap.md create mode 100644 packages/core/src/routes/verification-code.test.ts create mode 100644 packages/core/src/routes/verification-code.ts create mode 100644 packages/schemas/src/types/verification-code.ts diff --git a/.changeset-staged/unlucky-months-clap.md b/.changeset-staged/unlucky-months-clap.md new file mode 100644 index 000000000..377e8cd52 --- /dev/null +++ b/.changeset-staged/unlucky-months-clap.md @@ -0,0 +1,5 @@ +--- +"@logto/core": minor +--- + +Add support to send and verify verification code in management APIs diff --git a/.vscode/settings.json b/.vscode/settings.json index c1fcccb05..3f1126a4d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -27,16 +27,17 @@ "Alipay", "CIAM", "codecov", + "hasura", "Logto", "oidc", "passcode", + "passcodes", "Passwordless", "pnpm", "silverhand", "slonik", "stylelint", "topbar", - "hasura", "withtyped" ] } diff --git a/packages/core/src/libraries/passcode.test.ts b/packages/core/src/libraries/passcode.test.ts index b53e90e13..d5b2c7333 100644 --- a/packages/core/src/libraries/passcode.test.ts +++ b/packages/core/src/libraries/passcode.test.ts @@ -19,6 +19,8 @@ const { jest } = import.meta; const passcodeQueries = { findUnconsumedPasscodesByJtiAndType: jest.fn(), findUnconsumedPasscodeByJtiAndType: jest.fn(), + findUnconsumedPasscodeByIdentifierAndType: jest.fn(), + findUnconsumedPasscodesByIdentifierAndType: jest.fn(), deletePasscodesByIds: jest.fn(), insertPasscode: jest.fn(), consumePasscode: jest.fn(), @@ -27,6 +29,8 @@ const passcodeQueries = { const { findUnconsumedPasscodeByJtiAndType, findUnconsumedPasscodesByJtiAndType, + findUnconsumedPasscodeByIdentifierAndType, + findUnconsumedPasscodesByIdentifierAndType, deletePasscodesByIds, increasePasscodeTryCount, insertPasscode, @@ -43,6 +47,7 @@ const { createPasscode, sendPasscode, verifyPasscode } = createPasscodeLibrary( beforeAll(() => { findUnconsumedPasscodesByJtiAndType.mockResolvedValue([]); + findUnconsumedPasscodesByIdentifierAndType.mockResolvedValue([]); insertPasscode.mockImplementation(async (data): Promise => { return { phone: null, @@ -60,7 +65,7 @@ afterEach(() => { }); describe('createPasscode', () => { - it('should generate `passcodeLength` digits code for phone and insert to database', async () => { + it('should generate `passcodeLength` digits code for phone with valid session and insert to database', async () => { const phone = '13000000000'; const passcode = await createPasscode('jti', VerificationCodeType.SignIn, { phone, @@ -69,7 +74,7 @@ describe('createPasscode', () => { expect(passcode.phone).toEqual(phone); }); - it('should generate `passcodeLength` digits code for email and insert to database', async () => { + it('should generate `passcodeLength` digits code for email with valid session and insert to database', async () => { const email = 'jony@example.com'; const passcode = await createPasscode('jti', VerificationCodeType.SignIn, { email, @@ -78,7 +83,25 @@ describe('createPasscode', () => { expect(passcode.email).toEqual(email); }); - it('should disable existing passcode', async () => { + it('should generate `passcodeLength` digits code for phone and insert to database, without session', async () => { + const phone = '13000000000'; + const passcode = await createPasscode(undefined, VerificationCodeType.Generic, { + phone, + }); + expect(new RegExp(`^\\d{${passcodeLength}}$`).test(passcode.code)).toBeTruthy(); + expect(passcode.phone).toEqual(phone); + }); + + it('should generate `passcodeLength` digits code for email and insert to database, without session', async () => { + const email = 'jony@example.com'; + const passcode = await createPasscode(undefined, VerificationCodeType.Generic, { + email, + }); + expect(new RegExp(`^\\d{${passcodeLength}}$`).test(passcode.code)).toBeTruthy(); + expect(passcode.email).toEqual(email); + }); + + it('should remove unconsumed passcode from the same device before sending a new one', async () => { const email = 'jony@example.com'; const jti = 'jti'; findUnconsumedPasscodesByJtiAndType.mockResolvedValue([ @@ -99,6 +122,27 @@ describe('createPasscode', () => { }); expect(deletePasscodesByIds).toHaveBeenCalledWith(['id']); }); + + it('should remove unconsumed passcode from the same device before sending a new one, without session', async () => { + const phone = '1234567890'; + findUnconsumedPasscodesByIdentifierAndType.mockResolvedValue([ + { + id: 'id', + interactionJti: null, + code: '123456', + type: VerificationCodeType.Generic, + createdAt: Date.now(), + phone, + email: null, + consumed: false, + tryCount: 0, + }, + ]); + await createPasscode(undefined, VerificationCodeType.Generic, { + phone, + }); + expect(deletePasscodesByIds).toHaveBeenCalledWith(['id']); + }); }); describe('sendPasscode', () => { @@ -235,6 +279,19 @@ describe('verifyPasscode', () => { ).rejects.toThrow(new RequestError('verification_code.not_found')); }); + it('should mark as consumed on successful verification without jti', async () => { + const passcodeWithoutJti = { + ...passcode, + type: VerificationCodeType.Generic, + interactionJti: null, + }; + findUnconsumedPasscodeByIdentifierAndType.mockResolvedValue(passcodeWithoutJti); + await verifyPasscode(undefined, passcodeWithoutJti.type, passcodeWithoutJti.code, { + phone: 'phone', + }); + expect(consumePasscode).toHaveBeenCalledWith(passcodeWithoutJti.id); + }); + it('should fail when phone mismatch', async () => { findUnconsumedPasscodeByJtiAndType.mockResolvedValue(passcode); await expect( diff --git a/packages/core/src/libraries/passcode.ts b/packages/core/src/libraries/passcode.ts index 4aa631c14..b1534b1e6 100644 --- a/packages/core/src/libraries/passcode.ts +++ b/packages/core/src/libraries/passcode.ts @@ -28,18 +28,24 @@ export const createPasscodeLibrary = (queries: Queries, connectorLibrary: Connec deletePasscodesByIds, findUnconsumedPasscodeByJtiAndType, findUnconsumedPasscodesByJtiAndType, + findUnconsumedPasscodeByIdentifierAndType, + findUnconsumedPasscodesByIdentifierAndType, increasePasscodeTryCount, insertPasscode, } = queries.passcodes; const { getLogtoConnectors } = connectorLibrary; const createPasscode = async ( - jti: string, + jti: string | undefined, type: VerificationCodeType, payload: { phone: string } | { email: string } ) => { // Disable existing passcodes. - const passcodes = await findUnconsumedPasscodesByJtiAndType(jti, type); + const passcodes = jti + ? // Session based flows. E.g. SignIn, Register, etc. + await findUnconsumedPasscodesByJtiAndType(jti, type) + : // Generic flow. E.g. Triggered by management API + await findUnconsumedPasscodesByIdentifierAndType({ type, ...payload }); if (passcodes.length > 0) { await deletePasscodesByIds(passcodes.map(({ id }) => id)); @@ -97,12 +103,16 @@ export const createPasscodeLibrary = (queries: Queries, connectorLibrary: Connec }; const verifyPasscode = async ( - sessionId: string, + jti: string | undefined, type: VerificationCodeType, code: string, payload: { phone: string } | { email: string } ): Promise => { - const passcode = await findUnconsumedPasscodeByJtiAndType(sessionId, type); + const passcode = jti + ? // Session based flows. E.g. SignIn, Register, etc. + await findUnconsumedPasscodeByJtiAndType(jti, type) + : // Generic flow. E.g. Triggered by management API + await findUnconsumedPasscodeByIdentifierAndType({ type, ...payload }); if (!passcode) { throw new RequestError('verification_code.not_found'); diff --git a/packages/core/src/queries/passcode.test.ts b/packages/core/src/queries/passcode.test.ts index 25755e50f..94c6c647e 100644 --- a/packages/core/src/queries/passcode.test.ts +++ b/packages/core/src/queries/passcode.test.ts @@ -23,6 +23,8 @@ const { createPasscodeQueries } = await import('./passcode.js'); const { findUnconsumedPasscodeByJtiAndType, findUnconsumedPasscodesByJtiAndType, + findUnconsumedPasscodeByIdentifierAndType, + findUnconsumedPasscodesByIdentifierAndType, insertPasscode, deletePasscodeById, deletePasscodesByIds, @@ -71,6 +73,56 @@ describe('passcode query', () => { await expect(findUnconsumedPasscodesByJtiAndType(jti, type)).resolves.toEqual([mockPasscode]); }); + it('findUnconsumedPasscodeByIdentifierAndType', async () => { + const type = VerificationCodeType.Generic; + const phone = '1234567890'; + const mockGenericPasscode = { ...mockPasscode, interactionJti: null, type, phone }; + + const expectSql = sql` + select ${sql.join(Object.values(fields), sql`, `)} + from ${table} + where + ${fields.phone}=$1 + and ${fields.type}=$2 and ${fields.consumed} = false + `; + + mockQuery.mockImplementationOnce(async (sql, values) => { + expectSqlAssert(sql, expectSql.sql); + expect(values).toEqual([phone, type]); + + return createMockQueryResult([mockGenericPasscode]); + }); + + await expect(findUnconsumedPasscodeByIdentifierAndType({ phone, type })).resolves.toEqual( + mockGenericPasscode + ); + }); + + it('findUnconsumedPasscodesByIdentifierAndType', async () => { + const type = VerificationCodeType.Generic; + const email = 'johndoe@example.com'; + const mockGenericPasscode = { ...mockPasscode, interactionJti: null, type, email }; + + const expectSql = sql` + select ${sql.join(Object.values(fields), sql`, `)} + from ${table} + where + ${fields.email}=$1 + and ${fields.type}=$2 and ${fields.consumed} = false + `; + + mockQuery.mockImplementationOnce(async (sql, values) => { + expectSqlAssert(sql, expectSql.sql); + expect(values).toEqual([email, type]); + + return createMockQueryResult([mockGenericPasscode]); + }); + + await expect(findUnconsumedPasscodesByIdentifierAndType({ email, type })).resolves.toEqual([ + mockGenericPasscode, + ]); + }); + it('insertPasscode', async () => { const keys = excludeAutoSetFields(Passcodes.fieldKeys); diff --git a/packages/core/src/queries/passcode.ts b/packages/core/src/queries/passcode.ts index bbd49c507..06db24d8c 100644 --- a/packages/core/src/queries/passcode.ts +++ b/packages/core/src/queries/passcode.ts @@ -1,7 +1,7 @@ import type { VerificationCodeType } from '@logto/connector-kit'; -import type { Passcode, CreatePasscode } from '@logto/schemas'; +import type { Passcode, CreatePasscode, RequestVerificationCodePayload } from '@logto/schemas'; import { Passcodes } from '@logto/schemas'; -import { convertToIdentifiers } from '@logto/shared'; +import { conditionalSql, convertToIdentifiers } from '@logto/shared'; import type { CommonQueryMethods } from 'slonik'; import { sql } from 'slonik'; @@ -10,24 +10,49 @@ import { DeletionError } from '#src/errors/SlonikError/index.js'; const { table, fields } = convertToIdentifiers(Passcodes); +type FindByIdentifierAndTypeProperties = { + type: VerificationCodeType; +} & RequestVerificationCodePayload; + +const buildSqlForFindByJtiAndType = (jti: string, type: VerificationCodeType) => sql` + select ${sql.join(Object.values(fields), sql`, `)} + from ${table} + where ${fields.interactionJti}=${jti} and ${fields.type}=${type} and ${fields.consumed} = false +`; + +// Identifier requires either a valid email address or phone number +const buildSqlForFindByIdentifierAndType = ({ + type, + ...identifier +}: FindByIdentifierAndTypeProperties) => sql` + select ${sql.join(Object.values(fields), sql`, `)} + from ${table} + where + ${conditionalSql( + 'email' in identifier && identifier.email, + (email) => sql`${fields.email}=${email}` + )} + ${conditionalSql( + 'phone' in identifier && identifier.phone, + (phone) => sql`${fields.phone}=${phone}` + )} + and ${fields.type}=${type} and ${fields.consumed} = false +`; + export const createPasscodeQueries = (pool: CommonQueryMethods) => { const findUnconsumedPasscodeByJtiAndType = async (jti: string, type: VerificationCodeType) => - pool.maybeOne(sql` - select ${sql.join(Object.values(fields), sql`, `)} - from ${table} - where ${fields.interactionJti}=${jti} and ${fields.type}=${type} and ${ - fields.consumed - } = false - `); + pool.maybeOne(buildSqlForFindByJtiAndType(jti, type)); const findUnconsumedPasscodesByJtiAndType = async (jti: string, type: VerificationCodeType) => - pool.any(sql` - select ${sql.join(Object.values(fields), sql`, `)} - from ${table} - where ${fields.interactionJti}=${jti} and ${fields.type}=${type} and ${ - fields.consumed - } = false - `); + pool.any(buildSqlForFindByJtiAndType(jti, type)); + + const findUnconsumedPasscodeByIdentifierAndType = async ( + properties: FindByIdentifierAndTypeProperties + ) => pool.maybeOne(buildSqlForFindByIdentifierAndType(properties)); + + const findUnconsumedPasscodesByIdentifierAndType = async ( + properties: FindByIdentifierAndTypeProperties + ) => pool.any(buildSqlForFindByIdentifierAndType(properties)); const insertPasscode = buildInsertIntoWithPool(pool)(Passcodes, { returning: true, @@ -74,6 +99,8 @@ export const createPasscodeQueries = (pool: CommonQueryMethods) => { return { findUnconsumedPasscodeByJtiAndType, findUnconsumedPasscodesByJtiAndType, + findUnconsumedPasscodeByIdentifierAndType, + findUnconsumedPasscodesByIdentifierAndType, insertPasscode, consumePasscode, increasePasscodeTryCount, diff --git a/packages/core/src/routes/init.ts b/packages/core/src/routes/init.ts index b1ce68e51..a0d6c2a71 100644 --- a/packages/core/src/routes/init.ts +++ b/packages/core/src/routes/init.ts @@ -23,6 +23,7 @@ import signInExperiencesRoutes from './sign-in-experience/index.js'; import statusRoutes from './status.js'; import swaggerRoutes from './swagger.js'; import type { AnonymousRouter, AuthedRouter } from './types.js'; +import verificationCodeRoutes from './verification-code.js'; import wellKnownRoutes from './well-known.js'; const createRouters = (tenant: TenantContext) => { @@ -43,6 +44,7 @@ const createRouters = (tenant: TenantContext) => { dashboardRoutes(managementRouter, tenant); customPhraseRoutes(managementRouter, tenant); hookRoutes(managementRouter, tenant); + verificationCodeRoutes(managementRouter, tenant); const anonymousRouter: AnonymousRouter = new Router(); phraseRoutes(anonymousRouter, tenant); diff --git a/packages/core/src/routes/interaction/index.ts b/packages/core/src/routes/interaction/index.ts index db1ac4500..663c475aa 100644 --- a/packages/core/src/routes/interaction/index.ts +++ b/packages/core/src/routes/interaction/index.ts @@ -1,5 +1,11 @@ import type { LogtoErrorCode } from '@logto/phrases'; -import { InteractionEvent, eventGuard, identifierPayloadGuard, profileGuard } from '@logto/schemas'; +import { + InteractionEvent, + eventGuard, + identifierPayloadGuard, + profileGuard, + requestVerificationCodePayloadGuard, +} from '@logto/schemas'; import type Router from 'koa-router'; import { z } from 'zod'; @@ -17,10 +23,7 @@ import koaInteractionDetails from './middleware/koa-interaction-details.js'; import type { WithInteractionDetailsContext } from './middleware/koa-interaction-details.js'; import koaInteractionHooks from './middleware/koa-interaction-hooks.js'; import koaInteractionSie from './middleware/koa-interaction-sie.js'; -import { - sendVerificationCodePayloadGuard, - socialAuthorizationUrlPayloadGuard, -} from './types/guard.js'; +import { socialAuthorizationUrlPayloadGuard } from './types/guard.js'; import { getInteractionStorage, storeInteractionResult, @@ -328,7 +331,7 @@ export default function interactionRoutes( router.post( `${interactionPrefix}/${verificationPath}/verification-code`, koaGuard({ - body: sendVerificationCodePayloadGuard, + body: requestVerificationCodePayloadGuard, }), async (ctx, next) => { const { interactionDetails, guard, createLog } = ctx; diff --git a/packages/core/src/routes/interaction/types/guard.ts b/packages/core/src/routes/interaction/types/guard.ts index 9d76a2973..664c683b2 100644 --- a/packages/core/src/routes/interaction/types/guard.ts +++ b/packages/core/src/routes/interaction/types/guard.ts @@ -1,18 +1,8 @@ import { socialUserInfoGuard } from '@logto/connector-kit'; -import { emailRegEx, phoneRegEx, validateRedirectUrl } from '@logto/core-kit'; +import { validateRedirectUrl } from '@logto/core-kit'; import { eventGuard, profileGuard, InteractionEvent } from '@logto/schemas'; import { z } from 'zod'; -// Verification Send Route Payload Guard -export const sendVerificationCodePayloadGuard = z.union([ - z.object({ - email: z.string().regex(emailRegEx), - }), - z.object({ - phone: z.string().regex(phoneRegEx), - }), -]); - // Social Authorization Uri Route Payload Guard export const socialAuthorizationUrlPayloadGuard = z.object({ connectorId: z.string(), diff --git a/packages/core/src/routes/interaction/types/index.ts b/packages/core/src/routes/interaction/types/index.ts index bfe630b36..22ffdbc06 100644 --- a/packages/core/src/routes/interaction/types/index.ts +++ b/packages/core/src/routes/interaction/types/index.ts @@ -2,15 +2,12 @@ import type { SocialUserInfo } from '@logto/connector-kit'; import type { UsernamePasswordPayload, EmailPasswordPayload, - EmailVerificationCodePayload, PhonePasswordPayload, - PhoneVerificationCodePayload, InteractionEvent, } from '@logto/schemas'; import type { z } from 'zod'; import type { - sendVerificationCodePayloadGuard, socialAuthorizationUrlPayloadGuard, accountIdIdentifierGuard, verifiedEmailIdentifierGuard, @@ -30,12 +27,6 @@ export type PasswordIdentifierPayload = | EmailPasswordPayload | PhonePasswordPayload; -export type VerificationCodeIdentifierPayload = - | EmailVerificationCodePayload - | PhoneVerificationCodePayload; - -export type SendVerificationCodePayload = z.infer; - export type SocialAuthorizationUrlPayload = z.infer; /* Interaction Types */ diff --git a/packages/core/src/routes/interaction/utils/index.ts b/packages/core/src/routes/interaction/utils/index.ts index eb9ee9187..abd157ee4 100644 --- a/packages/core/src/routes/interaction/utils/index.ts +++ b/packages/core/src/routes/interaction/utils/index.ts @@ -1,9 +1,11 @@ -import type { SocialConnectorPayload, User, IdentifierPayload } from '@logto/schemas'; - import type { - VerificationCodeIdentifierPayload, - PasswordIdentifierPayload, -} from '../types/index.js'; + SocialConnectorPayload, + User, + IdentifierPayload, + VerifyVerificationCodePayload, +} from '@logto/schemas'; + +import type { PasswordIdentifierPayload } from '../types/index.js'; export const isPasswordIdentifier = ( identifier: IdentifierPayload @@ -11,7 +13,7 @@ export const isPasswordIdentifier = ( export const isVerificationCodeIdentifier = ( identifier: IdentifierPayload -): identifier is VerificationCodeIdentifierPayload => 'verificationCode' in identifier; +): identifier is VerifyVerificationCodePayload => 'verificationCode' in identifier; export const isSocialIdentifier = ( identifier: IdentifierPayload diff --git a/packages/core/src/routes/interaction/utils/verification-code-validation.ts b/packages/core/src/routes/interaction/utils/verification-code-validation.ts index 8a53d3591..c0bcc643c 100644 --- a/packages/core/src/routes/interaction/utils/verification-code-validation.ts +++ b/packages/core/src/routes/interaction/utils/verification-code-validation.ts @@ -1,14 +1,13 @@ import { VerificationCodeType } from '@logto/connector-kit'; -import type { InteractionEvent } from '@logto/schemas'; +import type { + InteractionEvent, + RequestVerificationCodePayload, + VerifyVerificationCodePayload, +} from '@logto/schemas'; import type { PasscodeLibrary } from '#src/libraries/passcode.js'; import type { LogContext } from '#src/middleware/koa-audit-log.js'; -import type { - SendVerificationCodePayload, - VerificationCodeIdentifierPayload, -} from '../types/index.js'; - /** * Refactor Needed: * This is a work around to map the latest interaction event type to old VerificationCodeType @@ -23,7 +22,7 @@ const getVerificationCodeTypeByEvent = (event: InteractionEvent): VerificationCo eventToVerificationCodeTypeMap[event]; export const sendVerificationCodeToIdentifier = async ( - payload: SendVerificationCodePayload & { event: InteractionEvent }, + payload: RequestVerificationCodePayload & { event: InteractionEvent }, jti: string, createLog: LogContext['createLog'], { createPasscode, sendPasscode }: PasscodeLibrary @@ -41,7 +40,7 @@ export const sendVerificationCodeToIdentifier = async ( }; export const verifyIdentifierByVerificationCode = async ( - payload: VerificationCodeIdentifierPayload & { event: InteractionEvent }, + payload: VerifyVerificationCodePayload & { event: InteractionEvent }, jti: string, createLog: LogContext['createLog'], passcodeLibrary: PasscodeLibrary diff --git a/packages/core/src/routes/interaction/verifications/identifier-payload-verification.ts b/packages/core/src/routes/interaction/verifications/identifier-payload-verification.ts index a7a7a1ef0..cadc2708b 100644 --- a/packages/core/src/routes/interaction/verifications/identifier-payload-verification.ts +++ b/packages/core/src/routes/interaction/verifications/identifier-payload-verification.ts @@ -3,6 +3,7 @@ import type { IdentifierPayload, SocialConnectorPayload, SocialIdentityPayload, + VerifyVerificationCodePayload, } from '@logto/schemas'; import RequestError from '#src/errors/RequestError/index.js'; @@ -13,7 +14,6 @@ import assertThat from '#src/utils/assert-that.js'; import type { PasswordIdentifierPayload, - VerificationCodeIdentifierPayload, SocialIdentifier, VerifiedEmailIdentifier, VerifiedPhoneIdentifier, @@ -53,7 +53,7 @@ const verifyPasswordIdentifier = async ( const verifyVerificationCodeIdentifier = async ( event: InteractionEvent, - identifier: VerificationCodeIdentifierPayload, + identifier: VerifyVerificationCodePayload, ctx: WithLogContext, { provider, libraries }: TenantContext ): Promise => { diff --git a/packages/core/src/routes/verification-code.test.ts b/packages/core/src/routes/verification-code.test.ts new file mode 100644 index 000000000..c890903f7 --- /dev/null +++ b/packages/core/src/routes/verification-code.test.ts @@ -0,0 +1,94 @@ +import { VerificationCodeType } from '@logto/connector-kit'; +import { createMockUtils, pickDefault } from '@logto/shared/esm'; + +import { MockTenant } from '#src/test-utils/tenant.js'; +import { createRequester } from '#src/utils/test-utils.js'; + +const { jest } = import.meta; +const { mockEsmWithActual } = createMockUtils(jest); + +const passcodeLibraries = await mockEsmWithActual('#src/libraries/passcode.js', () => ({ + createPasscode: jest.fn(), + sendPasscode: jest.fn(), + verifyPasscode: jest.fn(), +})); + +const { createPasscode, sendPasscode, verifyPasscode } = passcodeLibraries; + +const passcodeQueries = await mockEsmWithActual('#src/queries/passcode.js', () => ({ + findUnconsumedPasscodeByIdentifierAndType: jest.fn(async () => null), + findUnconsumedPasscodesByIdentifierAndType: jest.fn(), +})); + +const verificationCodeRoutes = await pickDefault(import('./verification-code.js')); + +describe('Generic verification code flow triggered by management API', () => { + const tenantContext = new MockTenant( + undefined, + { passcodes: passcodeQueries }, + { + passcodes: passcodeLibraries, + } + ); + const verificationCodeRequest = createRequester({ + authedRoutes: verificationCodeRoutes, + tenantContext, + }); + const type = VerificationCodeType.Generic; + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('POST /verification-codes with email should not throw', async () => { + const email = 'test@abc.com'; + const response = await verificationCodeRequest.post('/verification-codes').send({ email }); + expect(response.status).toEqual(204); + expect(createPasscode).toBeCalledWith(undefined, type, { email }); + expect(sendPasscode).toBeCalled(); + }); + + test('POST /verification-codes with phone number should not throw', async () => { + const phone = '1234567890'; + const response = await verificationCodeRequest.post('/verification-codes').send({ phone }); + expect(response.status).toEqual(204); + expect(createPasscode).toBeCalledWith(undefined, type, { phone }); + expect(sendPasscode).toBeCalled(); + }); + + test('POST /verification-codes with invalid payload should throw', async () => { + await expect( + verificationCodeRequest.post('/verification-codes').send({ + foo: 'bar', + }) + ).resolves.toHaveProperty('status', 400); + }); + + test('POST /verification-codes/verify with email and code should not throw', async () => { + const email = 'test@abc.com'; + const verificationCode = '000000'; + const response = await verificationCodeRequest + .post('/verification-codes/verify') + .send({ email, verificationCode }); + expect(response.status).toEqual(204); + expect(verifyPasscode).toBeCalledWith(undefined, type, verificationCode, { email }); + }); + + test('POST /verification-codes/verify with phone number and code should not throw', async () => { + const phone = '1234567890'; + const verificationCode = '123456'; + const response = await verificationCodeRequest + .post('/verification-codes/verify') + .send({ phone, verificationCode }); + expect(response.status).toEqual(204); + expect(verifyPasscode).toBeCalledWith(undefined, type, verificationCode, { phone }); + }); + + test('POST /verification-codes/verify with invalid payload should throw', async () => { + await expect( + verificationCodeRequest.post('/verification-codes/verify').send({ + foo: 'bar', + }) + ).resolves.toHaveProperty('status', 400); + }); +}); diff --git a/packages/core/src/routes/verification-code.ts b/packages/core/src/routes/verification-code.ts new file mode 100644 index 000000000..966b4006a --- /dev/null +++ b/packages/core/src/routes/verification-code.ts @@ -0,0 +1,49 @@ +import { VerificationCodeType } from '@logto/connector-kit'; +import { + requestVerificationCodePayloadGuard, + verifyVerificationCodePayloadGuard, +} from '@logto/schemas'; + +import koaGuard from '#src/middleware/koa-guard.js'; + +import type { AuthedRouter, RouterInitArgs } from './types.js'; + +const codeType = VerificationCodeType.Generic; + +export default function verificationCodeRoutes( + ...[router, { libraries }]: RouterInitArgs +) { + const { + passcodes: { createPasscode, sendPasscode, verifyPasscode }, + } = libraries; + + router.post( + '/verification-codes', + koaGuard({ + body: requestVerificationCodePayloadGuard, + }), + async (ctx, next) => { + const code = await createPasscode(undefined, codeType, ctx.guard.body); + await sendPasscode(code); + + ctx.status = 204; + + return next(); + } + ); + + router.post( + '/verification-codes/verify', + koaGuard({ + body: verifyVerificationCodePayloadGuard, + }), + async (ctx, next) => { + const { verificationCode, ...identifier } = ctx.guard.body; + await verifyPasscode(undefined, codeType, verificationCode, identifier); + + ctx.status = 204; + + return next(); + } + ); +} diff --git a/packages/schemas/src/types/index.ts b/packages/schemas/src/types/index.ts index b5330f7d9..e2fe584cd 100644 --- a/packages/schemas/src/types/index.ts +++ b/packages/schemas/src/types/index.ts @@ -8,3 +8,4 @@ export * from './search.js'; export * from './resource.js'; export * from './scope.js'; export * from './role.js'; +export * from './verification-code.js'; diff --git a/packages/schemas/src/types/interactions.ts b/packages/schemas/src/types/interactions.ts index 94cd5b3e4..8ae379f70 100644 --- a/packages/schemas/src/types/interactions.ts +++ b/packages/schemas/src/types/interactions.ts @@ -2,6 +2,14 @@ import { emailRegEx, phoneRegEx, usernameRegEx, passwordRegEx } from '@logto/cor import { z } from 'zod'; import { arbitraryObjectGuard } from '../foundations/index.js'; +import type { + EmailVerificationCodePayload, + PhoneVerificationCodePayload, +} from './verification-code.js'; +import { + emailVerificationCodePayloadGuard, + phoneVerificationCodePayloadGuard, +} from './verification-code.js'; /** * Detailed Identifier Methods guard @@ -25,18 +33,6 @@ export const phonePasswordPayloadGuard = z.object({ }); export type PhonePasswordPayload = z.infer; -export const emailVerificationCodePayloadGuard = z.object({ - email: z.string().regex(emailRegEx), - verificationCode: z.string().min(1), -}); -export type EmailVerificationCodePayload = z.infer; - -export const phoneVerificationCodePayloadGuard = z.object({ - phone: z.string().regex(phoneRegEx), - verificationCode: z.string().min(1), -}); -export type PhoneVerificationCodePayload = z.infer; - export const socialConnectorPayloadGuard = z.object({ connectorId: z.string(), connectorData: arbitraryObjectGuard, diff --git a/packages/schemas/src/types/verification-code.ts b/packages/schemas/src/types/verification-code.ts new file mode 100644 index 000000000..d6fb00545 --- /dev/null +++ b/packages/schemas/src/types/verification-code.ts @@ -0,0 +1,36 @@ +import { emailRegEx, phoneRegEx } from '@logto/core-kit'; +import { z } from 'zod'; + +const emailIdentifierGuard = z.string().regex(emailRegEx); +const phoneIdentifierGuard = z.string().regex(phoneRegEx); +const codeGuard = z.string().min(1); + +// Used when requesting Logto to send a verification code to email or phone +export const requestVerificationCodePayloadGuard = z.union([ + z.object({ email: emailIdentifierGuard }), + z.object({ phone: phoneIdentifierGuard }), +]); + +export type RequestVerificationCodePayload = z.infer; + +export const emailVerificationCodePayloadGuard = z.object({ + email: emailIdentifierGuard, + verificationCode: codeGuard, +}); +export type EmailVerificationCodePayload = z.infer; + +export const phoneVerificationCodePayloadGuard = z.object({ + phone: phoneIdentifierGuard, + verificationCode: codeGuard, +}); +export type PhoneVerificationCodePayload = z.infer; + +// Used when requesting Logto to verify the validity of a verification code +export const verifyVerificationCodePayloadGuard = z.union([ + emailVerificationCodePayloadGuard, + phoneVerificationCodePayloadGuard, +]); + +export type VerifyVerificationCodePayload = + | EmailVerificationCodePayload + | PhoneVerificationCodePayload;