mirror of
https://github.com/logto-io/logto.git
synced 2025-03-24 22:41:28 -05:00
feat(core): implement verification code verification API (#6001)
* feat(core,schemas): implement the verification code flow implement the verification code flow * chore(core): fix rebase issue fix rebase issue
This commit is contained in:
parent
aec2cf4f5b
commit
d85cd323ae
11 changed files with 685 additions and 8 deletions
|
@ -0,0 +1,195 @@
|
|||
import { TemplateType } from '@logto/connector-kit';
|
||||
import {
|
||||
InteractionEvent,
|
||||
VerificationType,
|
||||
verificationCodeIdentifierGuard,
|
||||
type VerificationCodeIdentifier,
|
||||
} from '@logto/schemas';
|
||||
import { type ToZodObject } from '@logto/schemas/lib/utils/zod.js';
|
||||
import { generateStandardId } from '@logto/shared';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type createPasscodeLibrary } from '#src/libraries/passcode.js';
|
||||
import type Libraries from '#src/tenants/Libraries.js';
|
||||
import type Queries from '#src/tenants/Queries.js';
|
||||
import assertThat from '#src/utils/assert-that.js';
|
||||
|
||||
import { findUserByIdentifier } from '../../utils.js';
|
||||
|
||||
import { type VerificationRecord } from './verification-record.js';
|
||||
|
||||
const eventToTemplateTypeMap: Record<InteractionEvent, TemplateType> = {
|
||||
SignIn: TemplateType.SignIn,
|
||||
Register: TemplateType.Register,
|
||||
ForgotPassword: TemplateType.ForgotPassword,
|
||||
};
|
||||
|
||||
/**
|
||||
* To make the typescript type checking work. A valid TemplateType is required.
|
||||
* This is a work around to map the latest interaction event type to old TemplateType.
|
||||
*
|
||||
* @remark This is a temporary solution until the connector-kit is updated to use the latest interaction event types.
|
||||
**/
|
||||
const getTemplateTypeByEvent = (event: InteractionEvent): TemplateType =>
|
||||
eventToTemplateTypeMap[event];
|
||||
|
||||
/** The JSON data type for the CodeVerification record */
|
||||
export type CodeVerificationRecordData = {
|
||||
id: string;
|
||||
type: VerificationType.VerificationCode;
|
||||
identifier: VerificationCodeIdentifier;
|
||||
interactionEvent: InteractionEvent;
|
||||
userId?: string;
|
||||
verified: boolean;
|
||||
};
|
||||
|
||||
export const codeVerificationRecordDataGuard = z.object({
|
||||
id: z.string(),
|
||||
type: z.literal(VerificationType.VerificationCode),
|
||||
identifier: verificationCodeIdentifierGuard,
|
||||
interactionEvent: z.nativeEnum(InteractionEvent),
|
||||
userId: z.string().optional(),
|
||||
verified: z.boolean(),
|
||||
}) satisfies ToZodObject<CodeVerificationRecordData>;
|
||||
|
||||
/** This util method convert the interaction identifier to passcode library payload format */
|
||||
const getPasscodeIdentifierPayload = (
|
||||
identifier: VerificationCodeIdentifier
|
||||
): Parameters<ReturnType<typeof createPasscodeLibrary>['createPasscode']>[2] =>
|
||||
identifier.type === 'email' ? { email: identifier.value } : { phone: identifier.value };
|
||||
|
||||
/**
|
||||
* CodeVerification is a verification type that verifies a given identifier by sending a verification code
|
||||
* to the user's email or phone.
|
||||
*
|
||||
* @remark The verification code is sent to the user's email or phone and the user is required to enter the code to verify.
|
||||
* If the identifier is for a existing user, the userId will be set after the verification.
|
||||
*
|
||||
* To avoid the redundant naming, the `CodeVerification` is used instead of `VerificationCodeVerification`.
|
||||
*/
|
||||
export class CodeVerification implements VerificationRecord<VerificationType.VerificationCode> {
|
||||
/**
|
||||
* Factory method to create a new CodeVerification record using the given identifier.
|
||||
* The sendVerificationCode method will be automatically triggered.
|
||||
*/
|
||||
static async create(
|
||||
libraries: Libraries,
|
||||
queries: Queries,
|
||||
identifier: VerificationCodeIdentifier,
|
||||
interactionEvent: InteractionEvent
|
||||
) {
|
||||
const record = new CodeVerification(libraries, queries, {
|
||||
id: generateStandardId(),
|
||||
type: VerificationType.VerificationCode,
|
||||
identifier,
|
||||
interactionEvent,
|
||||
verified: false,
|
||||
});
|
||||
|
||||
await record.sendVerificationCode();
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
public readonly id: string;
|
||||
public readonly type = VerificationType.VerificationCode;
|
||||
public readonly identifier: VerificationCodeIdentifier;
|
||||
|
||||
/**
|
||||
* The interaction event that triggered the verification.
|
||||
* This will be used to determine the template type for the verification code.
|
||||
*
|
||||
* @remark
|
||||
* `InteractionEvent.ForgotPassword` triggered verification results can not used as a verification record for other events.
|
||||
*/
|
||||
private readonly interactionEvent: InteractionEvent;
|
||||
/** The userId will be set after the verification if the identifier matches any existing user's record */
|
||||
private userId?: string;
|
||||
private verified: boolean;
|
||||
|
||||
constructor(
|
||||
private readonly libraries: Libraries,
|
||||
private readonly queries: Queries,
|
||||
data: CodeVerificationRecordData
|
||||
) {
|
||||
const { id, identifier, userId, verified, interactionEvent } = data;
|
||||
|
||||
this.id = id;
|
||||
this.identifier = identifier;
|
||||
this.interactionEvent = interactionEvent;
|
||||
this.userId = userId;
|
||||
this.verified = verified;
|
||||
}
|
||||
|
||||
/** Returns true if the identifier has been verified by a given code */
|
||||
get isVerified() {
|
||||
return this.verified;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the userId if it is set
|
||||
* @deprecated this will be removed in the upcoming PR
|
||||
*/
|
||||
get verifiedUserId() {
|
||||
return this.userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify the `identifier` with the given code
|
||||
*
|
||||
* @remark The identifier and code will be verified in the passcode library.
|
||||
* No need to verify the identifier before calling this method.
|
||||
*
|
||||
* - `isVerified` will be set to true if the code is verified successfully.
|
||||
* - `verifiedUserId` will be set if the `identifier` matches any existing user's record.
|
||||
*/
|
||||
async verify(identifier: VerificationCodeIdentifier, code?: string) {
|
||||
// Throw code not found error if the code is not provided
|
||||
assertThat(code, 'verification_code.not_found');
|
||||
|
||||
const { verifyPasscode } = this.libraries.passcodes;
|
||||
|
||||
await verifyPasscode(
|
||||
this.id,
|
||||
getTemplateTypeByEvent(this.interactionEvent),
|
||||
code,
|
||||
getPasscodeIdentifierPayload(identifier)
|
||||
);
|
||||
|
||||
this.verified = true;
|
||||
|
||||
// Try to lookup the user by the identifier
|
||||
const user = await findUserByIdentifier(this.queries.users, this.identifier);
|
||||
this.userId = user?.id;
|
||||
}
|
||||
|
||||
toJson(): CodeVerificationRecordData {
|
||||
return {
|
||||
id: this.id,
|
||||
type: this.type,
|
||||
identifier: this.identifier,
|
||||
interactionEvent: this.interactionEvent,
|
||||
userId: this.userId,
|
||||
verified: this.verified,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the verification code to the current `identifier`
|
||||
*
|
||||
* @remark Instead of session jti,
|
||||
* the verification id is used as `interaction_jti` to uniquely identify the passcode record in DB
|
||||
* for the current interaction.
|
||||
*/
|
||||
private async sendVerificationCode() {
|
||||
const { createPasscode, sendPasscode } = this.libraries.passcodes;
|
||||
|
||||
const verificationCode = await createPasscode(
|
||||
this.id,
|
||||
getTemplateTypeByEvent(this.interactionEvent),
|
||||
getPasscodeIdentifierPayload(this.identifier)
|
||||
);
|
||||
|
||||
await sendPasscode(verificationCode);
|
||||
}
|
||||
}
|
|
@ -4,32 +4,48 @@ import { z } from 'zod';
|
|||
import type Libraries from '#src/tenants/Libraries.js';
|
||||
import type Queries from '#src/tenants/Queries.js';
|
||||
|
||||
import {
|
||||
CodeVerification,
|
||||
codeVerificationRecordDataGuard,
|
||||
type CodeVerificationRecordData,
|
||||
} from './code-verification.js';
|
||||
import {
|
||||
PasswordVerification,
|
||||
passwordVerificationRecordDataGuard,
|
||||
type PasswordVerificationRecordData,
|
||||
} from './password-verification.js';
|
||||
import { type VerificationRecord } from './verification-record.js';
|
||||
|
||||
export { type VerificationRecord } from './verification-record.js';
|
||||
type VerificationRecordData = PasswordVerificationRecordData | CodeVerificationRecordData;
|
||||
|
||||
type VerificationRecordData = PasswordVerificationRecordData;
|
||||
/**
|
||||
* Union type for all verification record types
|
||||
*
|
||||
* @remark This is a discriminated union type.
|
||||
* The VerificationRecord generic class can not narrow down the type of a verification record instance by its type property.
|
||||
* This union type is used to narrow down the type of the verification record.
|
||||
* Used in the ExperienceInteraction class to store and manage all the verification records. With a given verification type, we can narrow down the type of the verification record.
|
||||
*/
|
||||
export type VerificationRecord = PasswordVerification | CodeVerification;
|
||||
|
||||
export const verificationRecordDataGuard = z.discriminatedUnion('type', [
|
||||
passwordVerificationRecordDataGuard,
|
||||
codeVerificationRecordDataGuard,
|
||||
]);
|
||||
|
||||
/**
|
||||
* The factory method to build a new `VerificationRecord` instance based on the provided `VerificationRecordData`.
|
||||
*/
|
||||
export const buildVerificationRecord = <T extends VerificationRecordData>(
|
||||
export const buildVerificationRecord = (
|
||||
libraries: Libraries,
|
||||
queries: Queries,
|
||||
data: T
|
||||
): VerificationRecord<T['type']> => {
|
||||
data: VerificationRecordData
|
||||
) => {
|
||||
switch (data.type) {
|
||||
case VerificationType.Password: {
|
||||
return new PasswordVerification(libraries, queries, data);
|
||||
}
|
||||
case VerificationType.VerificationCode: {
|
||||
return new CodeVerification(libraries, queries, data);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
@ -24,6 +24,7 @@ import koaExperienceInteraction, {
|
|||
type WithExperienceInteractionContext,
|
||||
} from './middleware/koa-experience-interaction.js';
|
||||
import passwordVerificationRoutes from './verification-routes/password-verification.js';
|
||||
import verificationCodeRoutes from './verification-routes/verification-code.js';
|
||||
|
||||
type RouterContext<T> = T extends Router<unknown, infer Context> ? Context : never;
|
||||
|
||||
|
@ -75,5 +76,7 @@ export default function experienceApiRoutes<T extends AnonymousRouter>(
|
|||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
passwordVerificationRoutes(router, tenant);
|
||||
verificationCodeRoutes(router, tenant);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,96 @@
|
|||
import {
|
||||
InteractionEvent,
|
||||
VerificationType,
|
||||
verificationCodeIdentifierGuard,
|
||||
} from '@logto/schemas';
|
||||
import type Router from 'koa-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import { type WithLogContext } from '#src/middleware/koa-audit-log.js';
|
||||
import koaGuard from '#src/middleware/koa-guard.js';
|
||||
import type TenantContext from '#src/tenants/TenantContext.js';
|
||||
import assertThat from '#src/utils/assert-that.js';
|
||||
|
||||
import { CodeVerification } from '../classes/verifications/code-verification.js';
|
||||
import { experienceRoutes } from '../const.js';
|
||||
import { type WithExperienceInteractionContext } from '../middleware/koa-experience-interaction.js';
|
||||
|
||||
export default function verificationCodeRoutes<T extends WithLogContext>(
|
||||
router: Router<unknown, WithExperienceInteractionContext<T>>,
|
||||
{ libraries, queries }: TenantContext
|
||||
) {
|
||||
router.post(
|
||||
`${experienceRoutes.verification}/verification-code`,
|
||||
koaGuard({
|
||||
body: z.object({
|
||||
identifier: verificationCodeIdentifierGuard,
|
||||
interactionEvent: z.nativeEnum(InteractionEvent),
|
||||
}),
|
||||
response: z.object({
|
||||
verificationId: z.string(),
|
||||
}),
|
||||
// 501: connector not found
|
||||
status: [200, 400, 404, 501],
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const { identifier, interactionEvent } = ctx.guard.body;
|
||||
|
||||
const codeVerification = await CodeVerification.create(
|
||||
libraries,
|
||||
queries,
|
||||
identifier,
|
||||
interactionEvent
|
||||
);
|
||||
|
||||
ctx.experienceInteraction.setVerificationRecord(codeVerification);
|
||||
|
||||
await ctx.experienceInteraction.save();
|
||||
|
||||
ctx.body = {
|
||||
verificationId: codeVerification.id,
|
||||
};
|
||||
|
||||
await next();
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
`${experienceRoutes.verification}/verification-code/verify`,
|
||||
koaGuard({
|
||||
body: z.object({
|
||||
identifier: verificationCodeIdentifierGuard,
|
||||
verificationId: z.string(),
|
||||
code: z.string(),
|
||||
}),
|
||||
response: z.object({
|
||||
verificationId: z.string(),
|
||||
}),
|
||||
// 501: connector not found
|
||||
status: [200, 400, 404, 501],
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const { verificationId, code, identifier } = ctx.guard.body;
|
||||
|
||||
const codeVerificationRecord =
|
||||
ctx.experienceInteraction.getVerificationRecordById(verificationId);
|
||||
|
||||
assertThat(
|
||||
codeVerificationRecord &&
|
||||
// Make the Verification type checker happy
|
||||
codeVerificationRecord.type === VerificationType.VerificationCode,
|
||||
new RequestError({ code: 'session.verification_session_not_found', status: 404 })
|
||||
);
|
||||
|
||||
await codeVerificationRecord.verify(identifier, code);
|
||||
|
||||
await ctx.experienceInteraction.save();
|
||||
|
||||
ctx.body = {
|
||||
verificationId: codeVerificationRecord.id,
|
||||
};
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
}
|
|
@ -1,4 +1,9 @@
|
|||
import { type IdentificationApiPayload, type PasswordVerificationPayload } from '@logto/schemas';
|
||||
import {
|
||||
type IdentificationApiPayload,
|
||||
type InteractionEvent,
|
||||
type PasswordVerificationPayload,
|
||||
type VerificationCodeIdentifier,
|
||||
} from '@logto/schemas';
|
||||
|
||||
import MockClient from '#src/client/index.js';
|
||||
|
||||
|
@ -42,4 +47,29 @@ export class ExperienceClient extends MockClient {
|
|||
})
|
||||
.json<{ verificationId: string }>();
|
||||
}
|
||||
|
||||
public async sendVerificationCode(payload: {
|
||||
identifier: VerificationCodeIdentifier;
|
||||
interactionEvent: InteractionEvent;
|
||||
}) {
|
||||
return api
|
||||
.post(`${experienceRoutes.verification}/verification-code`, {
|
||||
headers: { cookie: this.interactionCookie },
|
||||
json: payload,
|
||||
})
|
||||
.json<{ verificationId: string }>();
|
||||
}
|
||||
|
||||
public async verifyVerificationCode(payload: {
|
||||
identifier: VerificationCodeIdentifier;
|
||||
verificationId: string;
|
||||
code: string;
|
||||
}) {
|
||||
return api
|
||||
.post(`${experienceRoutes.verification}/verification-code/verify`, {
|
||||
headers: { cookie: this.interactionCookie },
|
||||
json: payload,
|
||||
})
|
||||
.json<{ verificationId: string }>();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,10 +2,19 @@
|
|||
* @fileoverview This file contains the successful interaction flow helper functions that use the experience APIs.
|
||||
*/
|
||||
|
||||
import { InteractionEvent, type InteractionIdentifier } from '@logto/schemas';
|
||||
import {
|
||||
InteractionEvent,
|
||||
type InteractionIdentifier,
|
||||
type VerificationCodeIdentifier,
|
||||
} from '@logto/schemas';
|
||||
|
||||
import { initExperienceClient, logoutClient, processSession } from '../client.js';
|
||||
|
||||
import {
|
||||
successfullySendVerificationCode,
|
||||
successfullyVerifyVerificationCode,
|
||||
} from './verification-code.js';
|
||||
|
||||
export const signInWithPassword = async ({
|
||||
identifier,
|
||||
password,
|
||||
|
@ -30,3 +39,28 @@ export const signInWithPassword = async ({
|
|||
await processSession(client, redirectTo);
|
||||
await logoutClient(client);
|
||||
};
|
||||
|
||||
export const signInWithVerificationCode = async (identifier: VerificationCodeIdentifier) => {
|
||||
const client = await initExperienceClient();
|
||||
|
||||
const { verificationId, code } = await successfullySendVerificationCode(client, {
|
||||
identifier,
|
||||
interactionEvent: InteractionEvent.SignIn,
|
||||
});
|
||||
|
||||
const verifiedVerificationId = await successfullyVerifyVerificationCode(client, {
|
||||
identifier,
|
||||
verificationId,
|
||||
code,
|
||||
});
|
||||
|
||||
await client.identifyUser({
|
||||
interactionEvent: InteractionEvent.SignIn,
|
||||
verificationId: verifiedVerificationId,
|
||||
});
|
||||
|
||||
const { redirectTo } = await client.submitInteraction();
|
||||
|
||||
await processSession(client, redirectTo);
|
||||
await logoutClient(client);
|
||||
};
|
||||
|
|
|
@ -0,0 +1,42 @@
|
|||
import { type InteractionEvent, type VerificationCodeIdentifier } from '@logto/schemas';
|
||||
|
||||
import { type ExperienceClient } from '#src/client/experience/index.js';
|
||||
|
||||
import { readConnectorMessage } from '../index.js';
|
||||
|
||||
export const successfullySendVerificationCode = async (
|
||||
client: ExperienceClient,
|
||||
payload: {
|
||||
identifier: VerificationCodeIdentifier;
|
||||
interactionEvent: InteractionEvent;
|
||||
}
|
||||
) => {
|
||||
const { type } = payload.identifier;
|
||||
const { verificationId } = await client.sendVerificationCode(payload);
|
||||
const { code, phone, address } = await readConnectorMessage(type === 'email' ? 'Email' : 'Sms');
|
||||
|
||||
expect(verificationId).toBeTruthy();
|
||||
expect(code).toBeTruthy();
|
||||
|
||||
expect(payload.identifier.type === 'email' ? address : phone).toBe(payload.identifier.value);
|
||||
|
||||
return {
|
||||
verificationId,
|
||||
code,
|
||||
};
|
||||
};
|
||||
|
||||
export const successfullyVerifyVerificationCode = async (
|
||||
client: ExperienceClient,
|
||||
payload: {
|
||||
identifier: VerificationCodeIdentifier;
|
||||
verificationId: string;
|
||||
code: string;
|
||||
}
|
||||
) => {
|
||||
const { verificationId } = await client.verifyVerificationCode(payload);
|
||||
|
||||
expect(verificationId).toBeTruthy();
|
||||
|
||||
return verificationId;
|
||||
};
|
|
@ -0,0 +1,42 @@
|
|||
import { InteractionIdentifierType } from '@logto/schemas';
|
||||
|
||||
import { deleteUser } from '#src/api/admin-user.js';
|
||||
import { setEmailConnector, setSmsConnector } from '#src/helpers/connector.js';
|
||||
import { signInWithVerificationCode } from '#src/helpers/experience/index.js';
|
||||
import { enableAllVerificationCodeSignInMethods } from '#src/helpers/sign-in-experience.js';
|
||||
import { generateNewUser } from '#src/helpers/user.js';
|
||||
import { devFeatureTest } from '#src/utils.js';
|
||||
|
||||
const verificationIdentifierType: readonly [
|
||||
InteractionIdentifierType.Email,
|
||||
InteractionIdentifierType.Phone,
|
||||
] = Object.freeze([InteractionIdentifierType.Email, InteractionIdentifierType.Phone]);
|
||||
|
||||
const identifiersTypeToUserProfile = Object.freeze({
|
||||
email: 'primaryEmail',
|
||||
phone: 'primaryPhone',
|
||||
});
|
||||
|
||||
devFeatureTest.describe('Sign-in with verification code happy path', () => {
|
||||
beforeAll(async () => {
|
||||
await Promise.all([setEmailConnector(), setSmsConnector()]);
|
||||
await enableAllVerificationCodeSignInMethods();
|
||||
});
|
||||
|
||||
it.each(verificationIdentifierType)(
|
||||
'Should sign-in with verification code using %p',
|
||||
async (identifier) => {
|
||||
const { userProfile, user } = await generateNewUser({
|
||||
[identifiersTypeToUserProfile[identifier]]: true,
|
||||
password: true,
|
||||
});
|
||||
|
||||
await signInWithVerificationCode({
|
||||
type: identifier,
|
||||
value: userProfile[identifiersTypeToUserProfile[identifier]]!,
|
||||
});
|
||||
|
||||
await deleteUser(user.id);
|
||||
}
|
||||
);
|
||||
});
|
|
@ -0,0 +1,208 @@
|
|||
import { ConnectorType } from '@logto/connector-kit';
|
||||
import {
|
||||
InteractionEvent,
|
||||
InteractionIdentifierType,
|
||||
type VerificationCodeIdentifier,
|
||||
} from '@logto/schemas';
|
||||
|
||||
import { initExperienceClient } from '#src/helpers/client.js';
|
||||
import {
|
||||
clearConnectorsByTypes,
|
||||
setEmailConnector,
|
||||
setSmsConnector,
|
||||
} from '#src/helpers/connector.js';
|
||||
import {
|
||||
successfullySendVerificationCode,
|
||||
successfullyVerifyVerificationCode,
|
||||
} from '#src/helpers/experience/verification-code.js';
|
||||
import { expectRejects } from '#src/helpers/index.js';
|
||||
import { devFeatureTest } from '#src/utils.js';
|
||||
|
||||
devFeatureTest.describe('Verification code verification APIs', () => {
|
||||
beforeAll(async () => {
|
||||
await clearConnectorsByTypes([ConnectorType.Email, ConnectorType.Sms]);
|
||||
});
|
||||
|
||||
const identifiers: VerificationCodeIdentifier[] = [
|
||||
{
|
||||
type: InteractionIdentifierType.Email,
|
||||
value: 'foo@logto.io',
|
||||
},
|
||||
{
|
||||
type: InteractionIdentifierType.Phone,
|
||||
value: '+1234567890',
|
||||
},
|
||||
];
|
||||
|
||||
describe.each(identifiers)('Verification code verification APIs for %p', ({ type, value }) => {
|
||||
it(`should throw an 501 error if the ${type} connector is not set`, async () => {
|
||||
const client = await initExperienceClient();
|
||||
|
||||
await expectRejects(
|
||||
client.sendVerificationCode({
|
||||
interactionEvent: InteractionEvent.SignIn,
|
||||
identifier: {
|
||||
type,
|
||||
value,
|
||||
},
|
||||
}),
|
||||
{
|
||||
code: 'connector.not_found',
|
||||
status: 501,
|
||||
}
|
||||
);
|
||||
|
||||
await (type === 'email' ? setEmailConnector() : setSmsConnector());
|
||||
});
|
||||
|
||||
it(`should send a verification code to the ${type} successfully`, async () => {
|
||||
const client = await initExperienceClient();
|
||||
|
||||
await successfullySendVerificationCode(client, {
|
||||
interactionEvent: InteractionEvent.SignIn,
|
||||
identifier: {
|
||||
type,
|
||||
value,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw a 404 error if the verificationId is invalid', async () => {
|
||||
const client = await initExperienceClient();
|
||||
|
||||
const { code } = await successfullySendVerificationCode(client, {
|
||||
interactionEvent: InteractionEvent.SignIn,
|
||||
identifier: {
|
||||
type,
|
||||
value,
|
||||
},
|
||||
});
|
||||
|
||||
await expectRejects(
|
||||
client.verifyVerificationCode({
|
||||
code,
|
||||
identifier: {
|
||||
type,
|
||||
value,
|
||||
},
|
||||
verificationId: 'invalid_verification_id',
|
||||
}),
|
||||
{
|
||||
code: 'session.verification_session_not_found',
|
||||
status: 404,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw a 404 error if the verification record is overwrote by a concurrent verification request', async () => {
|
||||
const client = await initExperienceClient();
|
||||
|
||||
const { verificationId, code } = await successfullySendVerificationCode(client, {
|
||||
interactionEvent: InteractionEvent.SignIn,
|
||||
identifier: {
|
||||
type,
|
||||
value,
|
||||
},
|
||||
});
|
||||
|
||||
// Resend and recreate the verification record
|
||||
await successfullySendVerificationCode(client, {
|
||||
interactionEvent: InteractionEvent.SignIn,
|
||||
identifier: {
|
||||
type,
|
||||
value,
|
||||
},
|
||||
});
|
||||
|
||||
await expectRejects(
|
||||
client.verifyVerificationCode({
|
||||
code,
|
||||
identifier: {
|
||||
type,
|
||||
value,
|
||||
},
|
||||
verificationId,
|
||||
}),
|
||||
{
|
||||
code: 'session.verification_session_not_found',
|
||||
status: 404,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw a 400 error if the identifier is different', async () => {
|
||||
const client = await initExperienceClient();
|
||||
|
||||
const { code, verificationId } = await successfullySendVerificationCode(client, {
|
||||
interactionEvent: InteractionEvent.SignIn,
|
||||
identifier: {
|
||||
type,
|
||||
value,
|
||||
},
|
||||
});
|
||||
|
||||
await expectRejects(
|
||||
client.verifyVerificationCode({
|
||||
code,
|
||||
identifier: {
|
||||
type,
|
||||
value: 'invalid_identifier',
|
||||
},
|
||||
verificationId,
|
||||
}),
|
||||
{
|
||||
code: `verification_code.${type}_mismatch`,
|
||||
status: 400,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw a 400 error if the code is mismatched', async () => {
|
||||
const client = await initExperienceClient();
|
||||
|
||||
const { verificationId } = await successfullySendVerificationCode(client, {
|
||||
interactionEvent: InteractionEvent.SignIn,
|
||||
identifier: {
|
||||
type,
|
||||
value,
|
||||
},
|
||||
});
|
||||
|
||||
await expectRejects(
|
||||
client.verifyVerificationCode({
|
||||
code: 'invalid_code',
|
||||
identifier: {
|
||||
type,
|
||||
value,
|
||||
},
|
||||
verificationId,
|
||||
}),
|
||||
{
|
||||
code: 'verification_code.code_mismatch',
|
||||
status: 400,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('should verify the verification code successfully', async () => {
|
||||
const client = await initExperienceClient();
|
||||
|
||||
const { code, verificationId } = await successfullySendVerificationCode(client, {
|
||||
interactionEvent: InteractionEvent.SignIn,
|
||||
identifier: {
|
||||
type,
|
||||
value,
|
||||
},
|
||||
});
|
||||
|
||||
await successfullyVerifyVerificationCode(client, {
|
||||
code,
|
||||
identifier: {
|
||||
type,
|
||||
value,
|
||||
},
|
||||
verificationId,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -41,6 +41,17 @@ export const interactionIdentifierGuard = z.object({
|
|||
value: z.string(),
|
||||
}) satisfies ToZodObject<InteractionIdentifier>;
|
||||
|
||||
/** Currently only email and phone are supported for verification code validation. */
|
||||
export type VerificationCodeIdentifier = {
|
||||
type: InteractionIdentifierType.Email | InteractionIdentifierType.Phone;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export const verificationCodeIdentifierGuard = z.object({
|
||||
type: z.enum([InteractionIdentifierType.Email, InteractionIdentifierType.Phone]),
|
||||
value: z.string(),
|
||||
}) satisfies ToZodObject<VerificationCodeIdentifier>;
|
||||
|
||||
/** Logto supported interaction verification types. */
|
||||
export enum VerificationType {
|
||||
Password = 'Password',
|
||||
|
|
Loading…
Add table
Reference in a new issue