0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

feat(core): add send and verify APIs for generic type verification code (#2849)

This commit is contained in:
Charles Zhao 2023-01-12 11:44:07 +08:00 committed by GitHub
parent a5467e265b
commit 10ea2307ad
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 393 additions and 78 deletions

View file

@ -0,0 +1,5 @@
---
"@logto/core": minor
---
Add support to send and verify verification code in management APIs

View file

@ -27,16 +27,17 @@
"Alipay", "Alipay",
"CIAM", "CIAM",
"codecov", "codecov",
"hasura",
"Logto", "Logto",
"oidc", "oidc",
"passcode", "passcode",
"passcodes",
"Passwordless", "Passwordless",
"pnpm", "pnpm",
"silverhand", "silverhand",
"slonik", "slonik",
"stylelint", "stylelint",
"topbar", "topbar",
"hasura",
"withtyped" "withtyped"
] ]
} }

View file

@ -19,6 +19,8 @@ const { jest } = import.meta;
const passcodeQueries = { const passcodeQueries = {
findUnconsumedPasscodesByJtiAndType: jest.fn(), findUnconsumedPasscodesByJtiAndType: jest.fn(),
findUnconsumedPasscodeByJtiAndType: jest.fn(), findUnconsumedPasscodeByJtiAndType: jest.fn(),
findUnconsumedPasscodeByIdentifierAndType: jest.fn(),
findUnconsumedPasscodesByIdentifierAndType: jest.fn(),
deletePasscodesByIds: jest.fn(), deletePasscodesByIds: jest.fn(),
insertPasscode: jest.fn(), insertPasscode: jest.fn(),
consumePasscode: jest.fn(), consumePasscode: jest.fn(),
@ -27,6 +29,8 @@ const passcodeQueries = {
const { const {
findUnconsumedPasscodeByJtiAndType, findUnconsumedPasscodeByJtiAndType,
findUnconsumedPasscodesByJtiAndType, findUnconsumedPasscodesByJtiAndType,
findUnconsumedPasscodeByIdentifierAndType,
findUnconsumedPasscodesByIdentifierAndType,
deletePasscodesByIds, deletePasscodesByIds,
increasePasscodeTryCount, increasePasscodeTryCount,
insertPasscode, insertPasscode,
@ -43,6 +47,7 @@ const { createPasscode, sendPasscode, verifyPasscode } = createPasscodeLibrary(
beforeAll(() => { beforeAll(() => {
findUnconsumedPasscodesByJtiAndType.mockResolvedValue([]); findUnconsumedPasscodesByJtiAndType.mockResolvedValue([]);
findUnconsumedPasscodesByIdentifierAndType.mockResolvedValue([]);
insertPasscode.mockImplementation(async (data): Promise<Passcode> => { insertPasscode.mockImplementation(async (data): Promise<Passcode> => {
return { return {
phone: null, phone: null,
@ -60,7 +65,7 @@ afterEach(() => {
}); });
describe('createPasscode', () => { 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 phone = '13000000000';
const passcode = await createPasscode('jti', VerificationCodeType.SignIn, { const passcode = await createPasscode('jti', VerificationCodeType.SignIn, {
phone, phone,
@ -69,7 +74,7 @@ describe('createPasscode', () => {
expect(passcode.phone).toEqual(phone); 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 email = 'jony@example.com';
const passcode = await createPasscode('jti', VerificationCodeType.SignIn, { const passcode = await createPasscode('jti', VerificationCodeType.SignIn, {
email, email,
@ -78,7 +83,25 @@ describe('createPasscode', () => {
expect(passcode.email).toEqual(email); 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 email = 'jony@example.com';
const jti = 'jti'; const jti = 'jti';
findUnconsumedPasscodesByJtiAndType.mockResolvedValue([ findUnconsumedPasscodesByJtiAndType.mockResolvedValue([
@ -99,6 +122,27 @@ describe('createPasscode', () => {
}); });
expect(deletePasscodesByIds).toHaveBeenCalledWith(['id']); 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', () => { describe('sendPasscode', () => {
@ -235,6 +279,19 @@ describe('verifyPasscode', () => {
).rejects.toThrow(new RequestError('verification_code.not_found')); ).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 () => { it('should fail when phone mismatch', async () => {
findUnconsumedPasscodeByJtiAndType.mockResolvedValue(passcode); findUnconsumedPasscodeByJtiAndType.mockResolvedValue(passcode);
await expect( await expect(

View file

@ -28,18 +28,24 @@ export const createPasscodeLibrary = (queries: Queries, connectorLibrary: Connec
deletePasscodesByIds, deletePasscodesByIds,
findUnconsumedPasscodeByJtiAndType, findUnconsumedPasscodeByJtiAndType,
findUnconsumedPasscodesByJtiAndType, findUnconsumedPasscodesByJtiAndType,
findUnconsumedPasscodeByIdentifierAndType,
findUnconsumedPasscodesByIdentifierAndType,
increasePasscodeTryCount, increasePasscodeTryCount,
insertPasscode, insertPasscode,
} = queries.passcodes; } = queries.passcodes;
const { getLogtoConnectors } = connectorLibrary; const { getLogtoConnectors } = connectorLibrary;
const createPasscode = async ( const createPasscode = async (
jti: string, jti: string | undefined,
type: VerificationCodeType, type: VerificationCodeType,
payload: { phone: string } | { email: string } payload: { phone: string } | { email: string }
) => { ) => {
// Disable existing passcodes. // 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) { if (passcodes.length > 0) {
await deletePasscodesByIds(passcodes.map(({ id }) => id)); await deletePasscodesByIds(passcodes.map(({ id }) => id));
@ -97,12 +103,16 @@ export const createPasscodeLibrary = (queries: Queries, connectorLibrary: Connec
}; };
const verifyPasscode = async ( const verifyPasscode = async (
sessionId: string, jti: string | undefined,
type: VerificationCodeType, type: VerificationCodeType,
code: string, code: string,
payload: { phone: string } | { email: string } payload: { phone: string } | { email: string }
): Promise<void> => { ): Promise<void> => {
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) { if (!passcode) {
throw new RequestError('verification_code.not_found'); throw new RequestError('verification_code.not_found');

View file

@ -23,6 +23,8 @@ const { createPasscodeQueries } = await import('./passcode.js');
const { const {
findUnconsumedPasscodeByJtiAndType, findUnconsumedPasscodeByJtiAndType,
findUnconsumedPasscodesByJtiAndType, findUnconsumedPasscodesByJtiAndType,
findUnconsumedPasscodeByIdentifierAndType,
findUnconsumedPasscodesByIdentifierAndType,
insertPasscode, insertPasscode,
deletePasscodeById, deletePasscodeById,
deletePasscodesByIds, deletePasscodesByIds,
@ -71,6 +73,56 @@ describe('passcode query', () => {
await expect(findUnconsumedPasscodesByJtiAndType(jti, type)).resolves.toEqual([mockPasscode]); 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 () => { it('insertPasscode', async () => {
const keys = excludeAutoSetFields(Passcodes.fieldKeys); const keys = excludeAutoSetFields(Passcodes.fieldKeys);

View file

@ -1,7 +1,7 @@
import type { VerificationCodeType } from '@logto/connector-kit'; 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 { Passcodes } from '@logto/schemas';
import { convertToIdentifiers } from '@logto/shared'; import { conditionalSql, convertToIdentifiers } from '@logto/shared';
import type { CommonQueryMethods } from 'slonik'; import type { CommonQueryMethods } from 'slonik';
import { sql } from 'slonik'; import { sql } from 'slonik';
@ -10,24 +10,49 @@ import { DeletionError } from '#src/errors/SlonikError/index.js';
const { table, fields } = convertToIdentifiers(Passcodes); 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) => { export const createPasscodeQueries = (pool: CommonQueryMethods) => {
const findUnconsumedPasscodeByJtiAndType = async (jti: string, type: VerificationCodeType) => const findUnconsumedPasscodeByJtiAndType = async (jti: string, type: VerificationCodeType) =>
pool.maybeOne<Passcode>(sql` pool.maybeOne<Passcode>(buildSqlForFindByJtiAndType(jti, type));
select ${sql.join(Object.values(fields), sql`, `)}
from ${table}
where ${fields.interactionJti}=${jti} and ${fields.type}=${type} and ${
fields.consumed
} = false
`);
const findUnconsumedPasscodesByJtiAndType = async (jti: string, type: VerificationCodeType) => const findUnconsumedPasscodesByJtiAndType = async (jti: string, type: VerificationCodeType) =>
pool.any<Passcode>(sql` pool.any<Passcode>(buildSqlForFindByJtiAndType(jti, type));
select ${sql.join(Object.values(fields), sql`, `)}
from ${table} const findUnconsumedPasscodeByIdentifierAndType = async (
where ${fields.interactionJti}=${jti} and ${fields.type}=${type} and ${ properties: FindByIdentifierAndTypeProperties
fields.consumed ) => pool.maybeOne<Passcode>(buildSqlForFindByIdentifierAndType(properties));
} = false
`); const findUnconsumedPasscodesByIdentifierAndType = async (
properties: FindByIdentifierAndTypeProperties
) => pool.any<Passcode>(buildSqlForFindByIdentifierAndType(properties));
const insertPasscode = buildInsertIntoWithPool(pool)<CreatePasscode, Passcode>(Passcodes, { const insertPasscode = buildInsertIntoWithPool(pool)<CreatePasscode, Passcode>(Passcodes, {
returning: true, returning: true,
@ -74,6 +99,8 @@ export const createPasscodeQueries = (pool: CommonQueryMethods) => {
return { return {
findUnconsumedPasscodeByJtiAndType, findUnconsumedPasscodeByJtiAndType,
findUnconsumedPasscodesByJtiAndType, findUnconsumedPasscodesByJtiAndType,
findUnconsumedPasscodeByIdentifierAndType,
findUnconsumedPasscodesByIdentifierAndType,
insertPasscode, insertPasscode,
consumePasscode, consumePasscode,
increasePasscodeTryCount, increasePasscodeTryCount,

View file

@ -23,6 +23,7 @@ import signInExperiencesRoutes from './sign-in-experience/index.js';
import statusRoutes from './status.js'; import statusRoutes from './status.js';
import swaggerRoutes from './swagger.js'; import swaggerRoutes from './swagger.js';
import type { AnonymousRouter, AuthedRouter } from './types.js'; import type { AnonymousRouter, AuthedRouter } from './types.js';
import verificationCodeRoutes from './verification-code.js';
import wellKnownRoutes from './well-known.js'; import wellKnownRoutes from './well-known.js';
const createRouters = (tenant: TenantContext) => { const createRouters = (tenant: TenantContext) => {
@ -43,6 +44,7 @@ const createRouters = (tenant: TenantContext) => {
dashboardRoutes(managementRouter, tenant); dashboardRoutes(managementRouter, tenant);
customPhraseRoutes(managementRouter, tenant); customPhraseRoutes(managementRouter, tenant);
hookRoutes(managementRouter, tenant); hookRoutes(managementRouter, tenant);
verificationCodeRoutes(managementRouter, tenant);
const anonymousRouter: AnonymousRouter = new Router(); const anonymousRouter: AnonymousRouter = new Router();
phraseRoutes(anonymousRouter, tenant); phraseRoutes(anonymousRouter, tenant);

View file

@ -1,5 +1,11 @@
import type { LogtoErrorCode } from '@logto/phrases'; 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 type Router from 'koa-router';
import { z } from 'zod'; 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 type { WithInteractionDetailsContext } from './middleware/koa-interaction-details.js';
import koaInteractionHooks from './middleware/koa-interaction-hooks.js'; import koaInteractionHooks from './middleware/koa-interaction-hooks.js';
import koaInteractionSie from './middleware/koa-interaction-sie.js'; import koaInteractionSie from './middleware/koa-interaction-sie.js';
import { import { socialAuthorizationUrlPayloadGuard } from './types/guard.js';
sendVerificationCodePayloadGuard,
socialAuthorizationUrlPayloadGuard,
} from './types/guard.js';
import { import {
getInteractionStorage, getInteractionStorage,
storeInteractionResult, storeInteractionResult,
@ -328,7 +331,7 @@ export default function interactionRoutes<T extends AnonymousRouter>(
router.post( router.post(
`${interactionPrefix}/${verificationPath}/verification-code`, `${interactionPrefix}/${verificationPath}/verification-code`,
koaGuard({ koaGuard({
body: sendVerificationCodePayloadGuard, body: requestVerificationCodePayloadGuard,
}), }),
async (ctx, next) => { async (ctx, next) => {
const { interactionDetails, guard, createLog } = ctx; const { interactionDetails, guard, createLog } = ctx;

View file

@ -1,18 +1,8 @@
import { socialUserInfoGuard } from '@logto/connector-kit'; 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 { eventGuard, profileGuard, InteractionEvent } from '@logto/schemas';
import { z } from 'zod'; 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 // Social Authorization Uri Route Payload Guard
export const socialAuthorizationUrlPayloadGuard = z.object({ export const socialAuthorizationUrlPayloadGuard = z.object({
connectorId: z.string(), connectorId: z.string(),

View file

@ -2,15 +2,12 @@ import type { SocialUserInfo } from '@logto/connector-kit';
import type { import type {
UsernamePasswordPayload, UsernamePasswordPayload,
EmailPasswordPayload, EmailPasswordPayload,
EmailVerificationCodePayload,
PhonePasswordPayload, PhonePasswordPayload,
PhoneVerificationCodePayload,
InteractionEvent, InteractionEvent,
} from '@logto/schemas'; } from '@logto/schemas';
import type { z } from 'zod'; import type { z } from 'zod';
import type { import type {
sendVerificationCodePayloadGuard,
socialAuthorizationUrlPayloadGuard, socialAuthorizationUrlPayloadGuard,
accountIdIdentifierGuard, accountIdIdentifierGuard,
verifiedEmailIdentifierGuard, verifiedEmailIdentifierGuard,
@ -30,12 +27,6 @@ export type PasswordIdentifierPayload =
| EmailPasswordPayload | EmailPasswordPayload
| PhonePasswordPayload; | PhonePasswordPayload;
export type VerificationCodeIdentifierPayload =
| EmailVerificationCodePayload
| PhoneVerificationCodePayload;
export type SendVerificationCodePayload = z.infer<typeof sendVerificationCodePayloadGuard>;
export type SocialAuthorizationUrlPayload = z.infer<typeof socialAuthorizationUrlPayloadGuard>; export type SocialAuthorizationUrlPayload = z.infer<typeof socialAuthorizationUrlPayloadGuard>;
/* Interaction Types */ /* Interaction Types */

View file

@ -1,9 +1,11 @@
import type { SocialConnectorPayload, User, IdentifierPayload } from '@logto/schemas';
import type { import type {
VerificationCodeIdentifierPayload, SocialConnectorPayload,
PasswordIdentifierPayload, User,
} from '../types/index.js'; IdentifierPayload,
VerifyVerificationCodePayload,
} from '@logto/schemas';
import type { PasswordIdentifierPayload } from '../types/index.js';
export const isPasswordIdentifier = ( export const isPasswordIdentifier = (
identifier: IdentifierPayload identifier: IdentifierPayload
@ -11,7 +13,7 @@ export const isPasswordIdentifier = (
export const isVerificationCodeIdentifier = ( export const isVerificationCodeIdentifier = (
identifier: IdentifierPayload identifier: IdentifierPayload
): identifier is VerificationCodeIdentifierPayload => 'verificationCode' in identifier; ): identifier is VerifyVerificationCodePayload => 'verificationCode' in identifier;
export const isSocialIdentifier = ( export const isSocialIdentifier = (
identifier: IdentifierPayload identifier: IdentifierPayload

View file

@ -1,14 +1,13 @@
import { VerificationCodeType } from '@logto/connector-kit'; 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 { PasscodeLibrary } from '#src/libraries/passcode.js';
import type { LogContext } from '#src/middleware/koa-audit-log.js'; import type { LogContext } from '#src/middleware/koa-audit-log.js';
import type {
SendVerificationCodePayload,
VerificationCodeIdentifierPayload,
} from '../types/index.js';
/** /**
* Refactor Needed: * Refactor Needed:
* This is a work around to map the latest interaction event type to old VerificationCodeType * 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]; eventToVerificationCodeTypeMap[event];
export const sendVerificationCodeToIdentifier = async ( export const sendVerificationCodeToIdentifier = async (
payload: SendVerificationCodePayload & { event: InteractionEvent }, payload: RequestVerificationCodePayload & { event: InteractionEvent },
jti: string, jti: string,
createLog: LogContext['createLog'], createLog: LogContext['createLog'],
{ createPasscode, sendPasscode }: PasscodeLibrary { createPasscode, sendPasscode }: PasscodeLibrary
@ -41,7 +40,7 @@ export const sendVerificationCodeToIdentifier = async (
}; };
export const verifyIdentifierByVerificationCode = async ( export const verifyIdentifierByVerificationCode = async (
payload: VerificationCodeIdentifierPayload & { event: InteractionEvent }, payload: VerifyVerificationCodePayload & { event: InteractionEvent },
jti: string, jti: string,
createLog: LogContext['createLog'], createLog: LogContext['createLog'],
passcodeLibrary: PasscodeLibrary passcodeLibrary: PasscodeLibrary

View file

@ -3,6 +3,7 @@ import type {
IdentifierPayload, IdentifierPayload,
SocialConnectorPayload, SocialConnectorPayload,
SocialIdentityPayload, SocialIdentityPayload,
VerifyVerificationCodePayload,
} from '@logto/schemas'; } from '@logto/schemas';
import RequestError from '#src/errors/RequestError/index.js'; import RequestError from '#src/errors/RequestError/index.js';
@ -13,7 +14,6 @@ import assertThat from '#src/utils/assert-that.js';
import type { import type {
PasswordIdentifierPayload, PasswordIdentifierPayload,
VerificationCodeIdentifierPayload,
SocialIdentifier, SocialIdentifier,
VerifiedEmailIdentifier, VerifiedEmailIdentifier,
VerifiedPhoneIdentifier, VerifiedPhoneIdentifier,
@ -53,7 +53,7 @@ const verifyPasswordIdentifier = async (
const verifyVerificationCodeIdentifier = async ( const verifyVerificationCodeIdentifier = async (
event: InteractionEvent, event: InteractionEvent,
identifier: VerificationCodeIdentifierPayload, identifier: VerifyVerificationCodePayload,
ctx: WithLogContext, ctx: WithLogContext,
{ provider, libraries }: TenantContext { provider, libraries }: TenantContext
): Promise<VerifiedEmailIdentifier | VerifiedPhoneIdentifier> => { ): Promise<VerifiedEmailIdentifier | VerifiedPhoneIdentifier> => {

View file

@ -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);
});
});

View file

@ -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<T extends AuthedRouter>(
...[router, { libraries }]: RouterInitArgs<T>
) {
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();
}
);
}

View file

@ -8,3 +8,4 @@ export * from './search.js';
export * from './resource.js'; export * from './resource.js';
export * from './scope.js'; export * from './scope.js';
export * from './role.js'; export * from './role.js';
export * from './verification-code.js';

View file

@ -2,6 +2,14 @@ import { emailRegEx, phoneRegEx, usernameRegEx, passwordRegEx } from '@logto/cor
import { z } from 'zod'; import { z } from 'zod';
import { arbitraryObjectGuard } from '../foundations/index.js'; 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 * Detailed Identifier Methods guard
@ -25,18 +33,6 @@ export const phonePasswordPayloadGuard = z.object({
}); });
export type PhonePasswordPayload = z.infer<typeof phonePasswordPayloadGuard>; export type PhonePasswordPayload = z.infer<typeof phonePasswordPayloadGuard>;
export const emailVerificationCodePayloadGuard = z.object({
email: z.string().regex(emailRegEx),
verificationCode: z.string().min(1),
});
export type EmailVerificationCodePayload = z.infer<typeof emailVerificationCodePayloadGuard>;
export const phoneVerificationCodePayloadGuard = z.object({
phone: z.string().regex(phoneRegEx),
verificationCode: z.string().min(1),
});
export type PhoneVerificationCodePayload = z.infer<typeof phoneVerificationCodePayloadGuard>;
export const socialConnectorPayloadGuard = z.object({ export const socialConnectorPayloadGuard = z.object({
connectorId: z.string(), connectorId: z.string(),
connectorData: arbitraryObjectGuard, connectorData: arbitraryObjectGuard,

View file

@ -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<typeof requestVerificationCodePayloadGuard>;
export const emailVerificationCodePayloadGuard = z.object({
email: emailIdentifierGuard,
verificationCode: codeGuard,
});
export type EmailVerificationCodePayload = z.infer<typeof emailVerificationCodePayloadGuard>;
export const phoneVerificationCodePayloadGuard = z.object({
phone: phoneIdentifierGuard,
verificationCode: codeGuard,
});
export type PhoneVerificationCodePayload = z.infer<typeof phoneVerificationCodePayloadGuard>;
// Used when requesting Logto to verify the validity of a verification code
export const verifyVerificationCodePayloadGuard = z.union([
emailVerificationCodePayloadGuard,
phoneVerificationCodePayloadGuard,
]);
export type VerifyVerificationCodePayload =
| EmailVerificationCodePayload
| PhoneVerificationCodePayload;