Skip to content

Commit

Permalink
refactor(core,schemas): refactor using the latest experience api stru…
Browse files Browse the repository at this point in the history
…cture

refactor using the latest experience api structure
  • Loading branch information
simeng-li committed Jun 24, 2024
1 parent 9a93118 commit 4c46231
Show file tree
Hide file tree
Showing 10 changed files with 564 additions and 5 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
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 Verification } from './verification.js';

/**
* 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 eventToTemplateTypeMap: Record<InteractionEvent, TemplateType> = {
SignIn: TemplateType.SignIn,
Register: TemplateType.Register,
ForgotPassword: TemplateType.ForgotPassword,
};
const getTemplateTypeByEvent = (event: InteractionEvent): TemplateType =>
eventToTemplateTypeMap[event];

export type CodeVerificationRecordData = {
id: string;
type: VerificationType.VerificationCode;
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.
*/
interactionEvent: InteractionEvent;
/** The userId of the user that has been verified. Only available after the verification of existing identifier */
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 Verification {
/**
* Factory method to create a new CodeVerification record using the given identifier.
* The sendVerificationCode method will be automatically triggered on the creation of the record.
*/
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;
}

readonly type = VerificationType.VerificationCode;
public readonly identifier: VerificationCodeIdentifier;
public readonly id: string;
private readonly interactionEvent: InteractionEvent;
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 */
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 session.
*/
private async sendVerificationCode() {
const { createPasscode, sendPasscode } = this.libraries.passcodes;

const verificationCode = await createPasscode(
this.id,
getTemplateTypeByEvent(this.interactionEvent),
getPasscodeIdentifierPayload(this.identifier)
);

await sendPasscode(verificationCode);
}
}
13 changes: 11 additions & 2 deletions packages/core/src/routes/experience/classes/verifications/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ 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,
Expand All @@ -12,13 +17,14 @@ import {

export { PasswordVerification } from './password-verification.js';

type VerificationRecordData = PasswordVerificationRecordData;
type VerificationRecordData = PasswordVerificationRecordData | CodeVerificationRecordData;

export const verificationRecordDataGuard = z.discriminatedUnion('type', [
passwordVerificationRecordDataGuard,
codeVerificationRecordDataGuard,
]);

export type VerificationRecord = PasswordVerification;
export type VerificationRecord = PasswordVerification | CodeVerification;

export const buildVerificationRecord = (
libraries: Libraries,
Expand All @@ -29,5 +35,8 @@ export const buildVerificationRecord = (
case VerificationType.Password: {
return new PasswordVerification(libraries, queries, data);
}
case VerificationType.VerificationCode: {
return new CodeVerification(libraries, queries, data);
}
}
};
1 change: 1 addition & 0 deletions packages/core/src/routes/experience/const.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export const experienceApiRoutesPrefix = '/experience';
export const experienceVerificationApiRoutesPrefix = '/experience/verification';
5 changes: 3 additions & 2 deletions packages/core/src/routes/experience/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { experienceApiRoutesPrefix } from './const.js';
import koaInteractionSession, {
type WithInteractionSessionContext,
} from './middleware/koa-interaction-session.js';
import verificationCodeRoutes from './verification-routes/verification-code.js';

type RouterContext<T> = T extends Router<unknown, infer Context> ? Context : never;

Expand Down Expand Up @@ -54,8 +55,6 @@ export default function experienceApiRoutes<T extends AnonymousRouter>(
await passwordVerification.verify(password);
ctx.interactionSession.appendVerificationRecord(passwordVerification);

ctx.interactionSession.identifyUser(passwordVerification.id);

await ctx.interactionSession.save();

ctx.status = 204;
Expand All @@ -75,4 +74,6 @@ export default function experienceApiRoutes<T extends AnonymousRouter>(
return next();
}
);

verificationCodeRoutes(router, tenant);
}
Original file line number Diff line number Diff line change
@@ -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 { experienceVerificationApiRoutesPrefix } from '../const.js';
import { type WithInteractionSessionContext } from '../middleware/koa-interaction-session.js';

export default function verificationCodeRoutes<T extends WithLogContext>(
router: Router<unknown, WithInteractionSessionContext<T>>,
{ libraries, queries }: TenantContext
) {
router.post(
`${experienceVerificationApiRoutesPrefix}/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.interactionSession.appendVerificationRecord(codeVerification);

await ctx.interactionSession.save();

ctx.body = {
verificationId: codeVerification.id,
};

await next();
}
);

router.post(
`${experienceVerificationApiRoutesPrefix}/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.interactionSession.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.interactionSession.save();

ctx.body = {
verificationId,
};

return next();
}
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { type InteractionEvent, type VerificationCodeIdentifier } from '@logto/schemas';

import api from '../api.js';

import { experienceVerificationApiRoutesPrefix } from './const.js';

export const sendVerificationCode = async (
cookie: string,
payload: {
identifier: VerificationCodeIdentifier;
interactionEvent: InteractionEvent;
}
) =>
api
.post(`${experienceVerificationApiRoutesPrefix}/verification-code`, {
headers: { cookie },
json: payload,
})
.json<{ verificationId: string }>();

export const verifyVerificationCode = async (
cookie: string,
payload: {
identifier: VerificationCodeIdentifier;
verificationId: string;
code: string;
}
) =>
api
.post(`${experienceVerificationApiRoutesPrefix}/verification-code/verify`, {
headers: { cookie },
json: payload,
})
.json<{ verificationId: string }>();
Loading

0 comments on commit 4c46231

Please sign in to comment.