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:
parent
a5467e265b
commit
10ea2307ad
18 changed files with 393 additions and 78 deletions
5
.changeset-staged/unlucky-months-clap.md
Normal file
5
.changeset-staged/unlucky-months-clap.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@logto/core": minor
|
||||
---
|
||||
|
||||
Add support to send and verify verification code in management APIs
|
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
|
@ -27,16 +27,17 @@
|
|||
"Alipay",
|
||||
"CIAM",
|
||||
"codecov",
|
||||
"hasura",
|
||||
"Logto",
|
||||
"oidc",
|
||||
"passcode",
|
||||
"passcodes",
|
||||
"Passwordless",
|
||||
"pnpm",
|
||||
"silverhand",
|
||||
"slonik",
|
||||
"stylelint",
|
||||
"topbar",
|
||||
"hasura",
|
||||
"withtyped"
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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<Passcode> => {
|
||||
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(
|
||||
|
|
|
@ -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<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) {
|
||||
throw new RequestError('verification_code.not_found');
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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<Passcode>(sql`
|
||||
select ${sql.join(Object.values(fields), sql`, `)}
|
||||
from ${table}
|
||||
where ${fields.interactionJti}=${jti} and ${fields.type}=${type} and ${
|
||||
fields.consumed
|
||||
} = false
|
||||
`);
|
||||
pool.maybeOne<Passcode>(buildSqlForFindByJtiAndType(jti, type));
|
||||
|
||||
const findUnconsumedPasscodesByJtiAndType = async (jti: string, type: VerificationCodeType) =>
|
||||
pool.any<Passcode>(sql`
|
||||
select ${sql.join(Object.values(fields), sql`, `)}
|
||||
from ${table}
|
||||
where ${fields.interactionJti}=${jti} and ${fields.type}=${type} and ${
|
||||
fields.consumed
|
||||
} = false
|
||||
`);
|
||||
pool.any<Passcode>(buildSqlForFindByJtiAndType(jti, type));
|
||||
|
||||
const findUnconsumedPasscodeByIdentifierAndType = async (
|
||||
properties: FindByIdentifierAndTypeProperties
|
||||
) => pool.maybeOne<Passcode>(buildSqlForFindByIdentifierAndType(properties));
|
||||
|
||||
const findUnconsumedPasscodesByIdentifierAndType = async (
|
||||
properties: FindByIdentifierAndTypeProperties
|
||||
) => pool.any<Passcode>(buildSqlForFindByIdentifierAndType(properties));
|
||||
|
||||
const insertPasscode = buildInsertIntoWithPool(pool)<CreatePasscode, Passcode>(Passcodes, {
|
||||
returning: true,
|
||||
|
@ -74,6 +99,8 @@ export const createPasscodeQueries = (pool: CommonQueryMethods) => {
|
|||
return {
|
||||
findUnconsumedPasscodeByJtiAndType,
|
||||
findUnconsumedPasscodesByJtiAndType,
|
||||
findUnconsumedPasscodeByIdentifierAndType,
|
||||
findUnconsumedPasscodesByIdentifierAndType,
|
||||
insertPasscode,
|
||||
consumePasscode,
|
||||
increasePasscodeTryCount,
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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<T extends AnonymousRouter>(
|
|||
router.post(
|
||||
`${interactionPrefix}/${verificationPath}/verification-code`,
|
||||
koaGuard({
|
||||
body: sendVerificationCodePayloadGuard,
|
||||
body: requestVerificationCodePayloadGuard,
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const { interactionDetails, guard, createLog } = ctx;
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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<typeof sendVerificationCodePayloadGuard>;
|
||||
|
||||
export type SocialAuthorizationUrlPayload = z.infer<typeof socialAuthorizationUrlPayloadGuard>;
|
||||
|
||||
/* Interaction Types */
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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<VerifiedEmailIdentifier | VerifiedPhoneIdentifier> => {
|
||||
|
|
94
packages/core/src/routes/verification-code.test.ts
Normal file
94
packages/core/src/routes/verification-code.test.ts
Normal 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);
|
||||
});
|
||||
});
|
49
packages/core/src/routes/verification-code.ts
Normal file
49
packages/core/src/routes/verification-code.ts
Normal 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();
|
||||
}
|
||||
);
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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<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({
|
||||
connectorId: z.string(),
|
||||
connectorData: arbitraryObjectGuard,
|
||||
|
|
36
packages/schemas/src/types/verification-code.ts
Normal file
36
packages/schemas/src/types/verification-code.ts
Normal 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;
|
Loading…
Reference in a new issue