diff --git a/packages/core/src/libraries/passcode.test.ts b/packages/core/src/libraries/passcode.test.ts index e51bdeccd..b53e90e13 100644 --- a/packages/core/src/libraries/passcode.test.ts +++ b/packages/core/src/libraries/passcode.test.ts @@ -1,15 +1,29 @@ import { ConnectorType, VerificationCodeType } from '@logto/connector-kit'; import { Passcode } from '@logto/schemas'; -import { createMockUtils } from '@logto/shared/esm'; import { any } from 'zod'; import { mockConnector, mockMetadata } from '#src/__mocks__/index.js'; import RequestError from '#src/errors/RequestError/index.js'; +import { MockQueries } from '#src/test-utils/tenant.js'; import { defaultConnectorMethods } from '#src/utils/connectors/consts.js'; -const { jest } = import.meta; -const { mockEsm } = createMockUtils(jest); +import { + createPasscodeLibrary, + passcodeExpiration, + passcodeMaxTryCount, + passcodeLength, +} from './passcode.js'; +const { jest } = import.meta; + +const passcodeQueries = { + findUnconsumedPasscodesByJtiAndType: jest.fn(), + findUnconsumedPasscodeByJtiAndType: jest.fn(), + deletePasscodesByIds: jest.fn(), + insertPasscode: jest.fn(), + consumePasscode: jest.fn(), + increasePasscodeTryCount: jest.fn(), +}; const { findUnconsumedPasscodeByJtiAndType, findUnconsumedPasscodesByJtiAndType, @@ -17,27 +31,15 @@ const { increasePasscodeTryCount, insertPasscode, consumePasscode, -} = mockEsm('#src/queries/passcode.js', () => ({ - findUnconsumedPasscodesByJtiAndType: jest.fn(), - findUnconsumedPasscodeByJtiAndType: jest.fn(), - deletePasscodesByIds: jest.fn(), - insertPasscode: jest.fn(), - consumePasscode: jest.fn(), - increasePasscodeTryCount: jest.fn(), -})); +} = passcodeQueries; -const { getLogtoConnectors } = mockEsm('#src/libraries/connector.js', () => ({ - getLogtoConnectors: jest.fn(), -})); +const getLogtoConnectors = jest.fn(); -const { - createPasscode, - passcodeExpiration, - passcodeMaxTryCount, - passcodeLength, - sendPasscode, - verifyPasscode, -} = await import('./passcode.js'); +const { createPasscode, sendPasscode, verifyPasscode } = createPasscodeLibrary( + new MockQueries({ passcodes: passcodeQueries }), + // @ts-expect-error + { getLogtoConnectors } +); beforeAll(() => { findUnconsumedPasscodesByJtiAndType.mockResolvedValue([]); diff --git a/packages/core/src/libraries/passcode.ts b/packages/core/src/libraries/passcode.ts index 5800a337a..4aa631c14 100644 --- a/packages/core/src/libraries/passcode.ts +++ b/packages/core/src/libraries/passcode.ts @@ -8,15 +8,8 @@ import type { Passcode } from '@logto/schemas'; import { customAlphabet, nanoid } from 'nanoid'; import RequestError from '#src/errors/RequestError/index.js'; -import { getLogtoConnectors } from '#src/libraries/connector.js'; -import { - consumePasscode, - deletePasscodesByIds, - findUnconsumedPasscodeByJtiAndType, - findUnconsumedPasscodesByJtiAndType, - increasePasscodeTryCount, - insertPasscode, -} from '#src/queries/passcode.js'; +import type { ConnectorLibrary } from '#src/libraries/connector.js'; +import type Queries from '#src/tenants/Queries.js'; import assertThat from '#src/utils/assert-that.js'; import { ConnectorType } from '#src/utils/connectors/types.js'; import type { LogtoConnector } from '#src/utils/connectors/types.js'; @@ -24,104 +17,120 @@ import type { LogtoConnector } from '#src/utils/connectors/types.js'; export const passcodeLength = 6; const randomCode = customAlphabet('1234567890', passcodeLength); -export const createPasscode = async ( - jti: string, - type: VerificationCodeType, - payload: { phone: string } | { email: string } -) => { - // Disable existing passcodes. - const passcodes = await findUnconsumedPasscodesByJtiAndType(jti, type); - - if (passcodes.length > 0) { - await deletePasscodesByIds(passcodes.map(({ id }) => id)); - } - - return insertPasscode({ - id: nanoid(), - interactionJti: jti, - type, - code: randomCode(), - ...payload, - }); -}; - -export const sendPasscode = async (passcode: Passcode) => { - const emailOrPhone = passcode.email ?? passcode.phone; - - if (!emailOrPhone) { - throw new RequestError('verification_code.phone_email_empty'); - } - - const expectType = passcode.phone ? ConnectorType.Sms : ConnectorType.Email; - const connectors = await getLogtoConnectors(); - - const connector = connectors.find( - (connector): connector is LogtoConnector => - connector.type === expectType - ); - - assertThat( - connector, - new RequestError({ - code: 'connector.not_found', - type: expectType, - }) - ); - - const { dbEntry, metadata, sendMessage } = connector; - - const messageTypeResult = verificationCodeTypeGuard.safeParse(passcode.type); - - if (!messageTypeResult.success) { - throw new ConnectorError(ConnectorErrorCodes.InvalidConfig); - } - - const response = await sendMessage({ - to: emailOrPhone, - type: messageTypeResult.data, - payload: { - code: passcode.code, - }, - }); - - return { dbEntry, metadata, response }; -}; - export const passcodeExpiration = 10 * 60 * 1000; // 10 minutes. export const passcodeMaxTryCount = 10; -export const verifyPasscode = async ( - sessionId: string, - type: VerificationCodeType, - code: string, - payload: { phone: string } | { email: string } -): Promise => { - const passcode = await findUnconsumedPasscodeByJtiAndType(sessionId, type); +export type PasscodeLibrary = ReturnType; - if (!passcode) { - throw new RequestError('verification_code.not_found'); - } +export const createPasscodeLibrary = (queries: Queries, connectorLibrary: ConnectorLibrary) => { + const { + consumePasscode, + deletePasscodesByIds, + findUnconsumedPasscodeByJtiAndType, + findUnconsumedPasscodesByJtiAndType, + increasePasscodeTryCount, + insertPasscode, + } = queries.passcodes; + const { getLogtoConnectors } = connectorLibrary; - if ('phone' in payload && passcode.phone !== payload.phone) { - throw new RequestError('verification_code.phone_mismatch'); - } + const createPasscode = async ( + jti: string, + type: VerificationCodeType, + payload: { phone: string } | { email: string } + ) => { + // Disable existing passcodes. + const passcodes = await findUnconsumedPasscodesByJtiAndType(jti, type); - if ('email' in payload && passcode.email !== payload.email) { - throw new RequestError('verification_code.email_mismatch'); - } + if (passcodes.length > 0) { + await deletePasscodesByIds(passcodes.map(({ id }) => id)); + } - if (passcode.createdAt + passcodeExpiration < Date.now()) { - throw new RequestError('verification_code.expired'); - } + return insertPasscode({ + id: nanoid(), + interactionJti: jti, + type, + code: randomCode(), + ...payload, + }); + }; - if (passcode.tryCount >= passcodeMaxTryCount) { - throw new RequestError('verification_code.exceed_max_try'); - } + const sendPasscode = async (passcode: Passcode) => { + const emailOrPhone = passcode.email ?? passcode.phone; - if (code !== passcode.code) { - await increasePasscodeTryCount(passcode.id); - throw new RequestError('verification_code.code_mismatch'); - } + if (!emailOrPhone) { + throw new RequestError('verification_code.phone_email_empty'); + } - await consumePasscode(passcode.id); + const expectType = passcode.phone ? ConnectorType.Sms : ConnectorType.Email; + const connectors = await getLogtoConnectors(); + + const connector = connectors.find( + (connector): connector is LogtoConnector => + connector.type === expectType + ); + + assertThat( + connector, + new RequestError({ + code: 'connector.not_found', + type: expectType, + }) + ); + + const { dbEntry, metadata, sendMessage } = connector; + + const messageTypeResult = verificationCodeTypeGuard.safeParse(passcode.type); + + if (!messageTypeResult.success) { + throw new ConnectorError(ConnectorErrorCodes.InvalidConfig); + } + + const response = await sendMessage({ + to: emailOrPhone, + type: messageTypeResult.data, + payload: { + code: passcode.code, + }, + }); + + return { dbEntry, metadata, response }; + }; + + const verifyPasscode = async ( + sessionId: string, + type: VerificationCodeType, + code: string, + payload: { phone: string } | { email: string } + ): Promise => { + const passcode = await findUnconsumedPasscodeByJtiAndType(sessionId, type); + + if (!passcode) { + throw new RequestError('verification_code.not_found'); + } + + if ('phone' in payload && passcode.phone !== payload.phone) { + throw new RequestError('verification_code.phone_mismatch'); + } + + if ('email' in payload && passcode.email !== payload.email) { + throw new RequestError('verification_code.email_mismatch'); + } + + if (passcode.createdAt + passcodeExpiration < Date.now()) { + throw new RequestError('verification_code.expired'); + } + + if (passcode.tryCount >= passcodeMaxTryCount) { + throw new RequestError('verification_code.exceed_max_try'); + } + + if (code !== passcode.code) { + await increasePasscodeTryCount(passcode.id); + throw new RequestError('verification_code.code_mismatch'); + } + + await consumePasscode(passcode.id); + }; + + return { createPasscode, sendPasscode, verifyPasscode }; }; diff --git a/packages/core/src/libraries/phrase.test.ts b/packages/core/src/libraries/phrase.test.ts index 8bed35bd2..ac1cb79df 100644 --- a/packages/core/src/libraries/phrase.test.ts +++ b/packages/core/src/libraries/phrase.test.ts @@ -1,6 +1,5 @@ import resource from '@logto/phrases-ui'; import type { CustomPhrase } from '@logto/schemas'; -import { createMockUtils } from '@logto/shared/esm'; import deepmerge from 'deepmerge'; import { @@ -17,8 +16,6 @@ import { MockQueries } from '#src/test-utils/tenant.js'; const { jest } = import.meta; -const { mockEsm } = createMockUtils(jest); - const englishBuiltInPhrase = resource[enTag]; const customOnlyLanguage = zhHkTag; diff --git a/packages/core/src/routes/interaction/index.test.ts b/packages/core/src/routes/interaction/index.test.ts index 5f73bbc09..a41ef36db 100644 --- a/packages/core/src/routes/interaction/index.test.ts +++ b/packages/core/src/routes/interaction/index.test.ts @@ -104,29 +104,31 @@ describe('interaction routes', () => { client_id: demoAppApplicationId, }; + const tenantContext = new MockTenant( + createMockProvider(jest.fn().mockResolvedValue(baseProviderMock)), + undefined, + { + connectors: { + getLogtoConnectorById: async (connectorId: string) => { + const connector = await getLogtoConnectorByIdHelper(connectorId); + + if (connector.type !== ConnectorType.Social) { + throw new RequestError({ + code: 'entity.not_found', + status: 404, + }); + } + + // @ts-expect-error + return connector as LogtoConnector; + }, + }, + } + ); + const sessionRequest = createRequester({ anonymousRoutes: interactionRoutes, - tenantContext: new MockTenant( - createMockProvider(jest.fn().mockResolvedValue(baseProviderMock)), - undefined, - { - connectors: { - getLogtoConnectorById: async (connectorId: string) => { - const connector = await getLogtoConnectorByIdHelper(connectorId); - - if (connector.type !== ConnectorType.Social) { - throw new RequestError({ - code: 'entity.not_found', - status: 404, - }); - } - - // @ts-expect-error - return connector as LogtoConnector; - }, - }, - } - ), + tenantContext, }); afterEach(() => { @@ -313,7 +315,8 @@ describe('interaction routes', () => { ...body, }, 'jti', - createLog + createLog, + tenantContext.libraries.passcodes ); expect(response.status).toEqual(204); }); diff --git a/packages/core/src/routes/interaction/index.ts b/packages/core/src/routes/interaction/index.ts index 37490743f..9864a24ff 100644 --- a/packages/core/src/routes/interaction/index.ts +++ b/packages/core/src/routes/interaction/index.ts @@ -45,7 +45,7 @@ export type RouterContext = T extends Router ? Contex export default function interactionRoutes( ...[anonymousRouter, tenant]: RouterInitArgs ) { - const { provider, queries } = tenant; + const { provider, queries, libraries } = tenant; const router = // @ts-expect-error for good koa types // eslint-disable-next-line no-restricted-syntax @@ -338,7 +338,8 @@ export default function interactionRoutes( await sendVerificationCodeToIdentifier( { event, ...guard.body }, interactionDetails.jti, - createLog + createLog, + libraries.passcodes ); ctx.status = 204; diff --git a/packages/core/src/routes/interaction/utils/verification-code-validation.test.ts b/packages/core/src/routes/interaction/utils/verification-code-validation.test.ts index c3cbbc8eb..cfdf925e3 100644 --- a/packages/core/src/routes/interaction/utils/verification-code-validation.test.ts +++ b/packages/core/src/routes/interaction/utils/verification-code-validation.test.ts @@ -1,19 +1,15 @@ import { VerificationCodeType } from '@logto/connector-kit'; import { InteractionEvent } from '@logto/schemas'; -import { createMockUtils } from '@logto/shared/esm'; import { createMockLogContext } from '#src/test-utils/koa-audit-log.js'; const { jest } = import.meta; -const { mockEsmWithActual } = createMockUtils(jest); const passcode = { createPasscode: jest.fn(() => ({})), sendPasscode: jest.fn().mockResolvedValue({ dbEntry: { id: 'foo' } }), }; -await mockEsmWithActual('#src/libraries/passcode.js', () => passcode); - const { sendVerificationCodeToIdentifier } = await import('./verification-code-validation.js'); const sendVerificationCodeTestCase = [ @@ -53,7 +49,8 @@ describe('verification-code-validation utils', () => { it.each(sendVerificationCodeTestCase)( 'send verification code successfully', async ({ payload, createVerificationCodeParams }) => { - await sendVerificationCodeToIdentifier(payload, 'jti', log.createLog); + // @ts-expect-error + await sendVerificationCodeToIdentifier(payload, 'jti', log.createLog, passcode); expect(passcode.createPasscode).toBeCalledWith('jti', ...createVerificationCodeParams); expect(passcode.sendPasscode).toBeCalled(); } 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 7ad8a9060..8a53d3591 100644 --- a/packages/core/src/routes/interaction/utils/verification-code-validation.ts +++ b/packages/core/src/routes/interaction/utils/verification-code-validation.ts @@ -1,7 +1,7 @@ import { VerificationCodeType } from '@logto/connector-kit'; import type { InteractionEvent } from '@logto/schemas'; -import { createPasscode, sendPasscode, verifyPasscode } from '#src/libraries/passcode.js'; +import type { PasscodeLibrary } from '#src/libraries/passcode.js'; import type { LogContext } from '#src/middleware/koa-audit-log.js'; import type { @@ -25,7 +25,8 @@ const getVerificationCodeTypeByEvent = (event: InteractionEvent): VerificationCo export const sendVerificationCodeToIdentifier = async ( payload: SendVerificationCodePayload & { event: InteractionEvent }, jti: string, - createLog: LogContext['createLog'] + createLog: LogContext['createLog'], + { createPasscode, sendPasscode }: PasscodeLibrary ) => { const { event, ...identifier } = payload; const messageType = getVerificationCodeTypeByEvent(event); @@ -42,7 +43,8 @@ export const sendVerificationCodeToIdentifier = async ( export const verifyIdentifierByVerificationCode = async ( payload: VerificationCodeIdentifierPayload & { event: InteractionEvent }, jti: string, - createLog: LogContext['createLog'] + createLog: LogContext['createLog'], + passcodeLibrary: PasscodeLibrary ) => { const { event, verificationCode, ...identifier } = payload; const messageType = getVerificationCodeTypeByEvent(event); @@ -50,5 +52,5 @@ export const verifyIdentifierByVerificationCode = async ( const log = createLog(`Interaction.${event}.Identifier.VerificationCode.Submit`); log.append(identifier); - await verifyPasscode(jti, messageType, verificationCode, identifier); + await passcodeLibrary.verifyPasscode(jti, messageType, verificationCode, identifier); }; diff --git a/packages/core/src/routes/interaction/verifications/identifier-payload-verification.test.ts b/packages/core/src/routes/interaction/verifications/identifier-payload-verification.test.ts index af9ccd55c..4d55a0e89 100644 --- a/packages/core/src/routes/interaction/verifications/identifier-payload-verification.test.ts +++ b/packages/core/src/routes/interaction/verifications/identifier-payload-verification.test.ts @@ -128,7 +128,8 @@ describe('identifier verification', () => { expect(verifyIdentifierByVerificationCode).toBeCalledWith( { ...identifier, event: interactionStorage.event }, 'jti', - logContext.createLog + logContext.createLog, + tenant.libraries.passcodes ); expect(result).toEqual({ key: 'emailVerified', value: identifier.email }); @@ -147,7 +148,8 @@ describe('identifier verification', () => { expect(verifyIdentifierByVerificationCode).toBeCalledWith( { ...identifier, event: interactionStorage.event }, 'jti', - logContext.createLog + logContext.createLog, + tenant.libraries.passcodes ); expect(result).toEqual({ key: 'phoneVerified', value: identifier.phone }); 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 9c4316635..7e96342af 100644 --- a/packages/core/src/routes/interaction/verifications/identifier-payload-verification.ts +++ b/packages/core/src/routes/interaction/verifications/identifier-payload-verification.ts @@ -54,11 +54,16 @@ const verifyVerificationCodeIdentifier = async ( event: InteractionEvent, identifier: VerificationCodeIdentifierPayload, ctx: WithLogContext, - { provider }: TenantContext + { provider, libraries }: TenantContext ): Promise => { const { jti } = await provider.interactionDetails(ctx.req, ctx.res); - await verifyIdentifierByVerificationCode({ ...identifier, event }, jti, ctx.createLog); + await verifyIdentifierByVerificationCode( + { ...identifier, event }, + jti, + ctx.createLog, + libraries.passcodes + ); return 'email' in identifier ? { key: 'emailVerified', value: identifier.email } diff --git a/packages/core/src/tenants/Libraries.ts b/packages/core/src/tenants/Libraries.ts index 4bde62de2..545a36769 100644 --- a/packages/core/src/tenants/Libraries.ts +++ b/packages/core/src/tenants/Libraries.ts @@ -1,5 +1,6 @@ import { createConnectorLibrary } from '#src/libraries/connector.js'; import { createHookLibrary } from '#src/libraries/hook.js'; +import { createPasscodeLibrary } from '#src/libraries/passcode.js'; import { createPhraseLibrary } from '#src/libraries/phrase.js'; import { createResourceLibrary } from '#src/libraries/resource.js'; import { createSignInExperienceLibrary } from '#src/libraries/sign-in-experience/index.js'; @@ -17,6 +18,7 @@ export default class Libraries { resources = createResourceLibrary(this.queries); hooks = createHookLibrary(this.queries, this.modelRouters); socials = createSocialLibrary(this.queries, this.connectors); + passcodes = createPasscodeLibrary(this.queries, this.connectors); constructor(private readonly queries: Queries, private readonly modelRouters: ModelRouters) {} }