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",
|
"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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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');
|
||||||
|
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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(),
|
||||||
|
|
|
@ -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 */
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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> => {
|
||||||
|
|
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 './resource.js';
|
||||||
export * from './scope.js';
|
export * from './scope.js';
|
||||||
export * from './role.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 { 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,
|
||||||
|
|
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