Skip to content

Commit

Permalink
refactor(core): implement verification records map (#6289)
Browse files Browse the repository at this point in the history
* refactor(core): implement verificaiton records map

implement verification records map

* fix(core): fix invalid verification type error

fix invalid verificaiton type error

* fix(core): update the verification record map

update the verification record map

* fix(core): update some comments

update some comments

* refactor(core): polish promise dependency

polish promise dependency

* fix(core): fix the social/sso syncing profile logic

fix the social/sso syncing profile logic

* refactor(core): optimize the verification records map

optimize the verification records map

* fix(core): fix set method of VerificationRecord map
fix set method of VerificationRecord map
  • Loading branch information
simeng-li authored Jul 23, 2024
1 parent b16de4b commit 52c0dcc
Show file tree
Hide file tree
Showing 10 changed files with 111 additions and 52 deletions.
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { type ToZodObject } from '@logto/connector-kit';
import { InteractionEvent, type User, type VerificationType } from '@logto/schemas';
import { InteractionEvent, type User } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import { z } from 'zod';

Expand All @@ -24,7 +24,9 @@ import {
verificationRecordDataGuard,
type VerificationRecord,
type VerificationRecordData,
type VerificationRecordMap,
} from './verifications/index.js';
import { VerificationRecordsMap } from './verifications/verification-records-map.js';

type InteractionStorage = {
interactionEvent?: InteractionEvent;
Expand Down Expand Up @@ -52,7 +54,7 @@ export default class ExperienceInteraction {
public readonly provisionLibrary: ProvisionLibrary;

/** The user verification record list for the current interaction. */
private readonly verificationRecords = new Map<VerificationType, VerificationRecord>();
private readonly verificationRecords = new VerificationRecordsMap();
/** The userId of the user for the current interaction. Only available once the user is identified. */
private userId?: string;
private userCache?: User;
Expand Down Expand Up @@ -101,7 +103,7 @@ export default class ExperienceInteraction {

for (const record of verificationRecords) {
const instance = buildVerificationRecord(libraries, queries, record);
this.verificationRecords.set(instance.type, instance);
this.verificationRecords.setValue(instance);
}
}

Expand Down Expand Up @@ -182,13 +184,21 @@ export default class ExperienceInteraction {
* If a record with the same type already exists, it will be replaced.
*/
public setVerificationRecord(record: VerificationRecord) {
const { type } = record;

this.verificationRecords.set(type, record);
this.verificationRecords.setValue(record);
}

public getVerificationRecordById(verificationId: string) {
return this.verificationRecordsArray.find((record) => record.id === verificationId);
public getVerificationRecordByTypeAndId<K extends keyof VerificationRecordMap>(
type: K,
verificationId: string
): VerificationRecordMap[K] {
const record = this.verificationRecords.get(type);

assertThat(
record?.id === verificationId,
new RequestError({ code: 'session.verification_session_not_found', status: 404 })
);

return record;
}

/**
Expand Down Expand Up @@ -298,7 +308,7 @@ export default class ExperienceInteraction {
}

private get verificationRecordsArray() {
return [...this.verificationRecords.values()];
return this.verificationRecords.array();
}

/**
Expand Down Expand Up @@ -389,4 +399,8 @@ export default class ExperienceInteraction {
this.userCache = user;
return this.userCache;
}

private getVerificationRecordById(verificationId: string) {
return this.verificationRecordsArray.find((record) => record.id === verificationId);
}
}
16 changes: 11 additions & 5 deletions packages/core/src/routes/experience/classes/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ import type { InteractionProfile } from '../types.js';
import { type VerificationRecord } from './verifications/index.js';

/**
* @throws {RequestError} -400 if the verification record type is not supported for user creation.
* @throws {RequestError} -400 if the verification record is not verified.
* @throws {RequestError} with status 400 if the verification record type is not supported for user creation.
* @throws {RequestError} with status 400 if the verification record is not verified.
*/
export const getNewUserProfileFromVerificationRecord = async (
verificationRecord: VerificationRecord
Expand All @@ -30,8 +30,13 @@ export const getNewUserProfileFromVerificationRecord = async (
}
case VerificationType.EnterpriseSso:
case VerificationType.Social: {
const identityProfile = await verificationRecord.toUserProfile();
const syncedProfile = await verificationRecord.toSyncedProfile(true);
const [identityProfile, syncedProfile] = await Promise.all([
verificationRecord.toUserProfile(),
// Set `isNewUser` to true to specify syncing profile from the
// social/enterprise SSO identity for a new user.
verificationRecord.toSyncedProfile(true),
]);

return { ...identityProfile, ...syncedProfile };
}
default: {
Expand Down Expand Up @@ -95,8 +100,9 @@ export const identifyUserByVerificationRecord = async (
// Auto fallback to identify the related user if the user does not exist for enterprise SSO.
if (error instanceof RequestError && error.code === 'user.identity_not_exist') {
const user = await verificationRecord.identifyRelatedUser();

const syncedProfile = {
...(await verificationRecord.toUserProfile()),
...verificationRecord.toUserProfile(),
...(await verificationRecord.toSyncedProfile()),
};
return { user, syncedProfile };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export class ProvisionLibrary {
/**
* Insert a new user into the Logto database using the provided profile.
*
* - provision the organization for the new user based on the profile
* - Provision the organization for the new user based on the profile
* - OSS only, new user provisioning
*/
async provisionNewUser(profile: InteractionProfile) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ export class EnterpriseSsoVerification
/**
* Returns the use SSO identity as a new user profile.
*/
async toUserProfile(): Promise<Required<Pick<InteractionProfile, 'enterpriseSsoIdentity'>>> {
toUserProfile(): Required<Pick<InteractionProfile, 'enterpriseSsoIdentity'>> {
assertThat(
this.enterpriseSsoUserInfo && this.issuer,
new RequestError({ code: 'session.verification_failed', status: 400 })
Expand Down
35 changes: 23 additions & 12 deletions packages/core/src/routes/experience/classes/verifications/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
totpVerificationRecordDataGuard,
type TotpVerificationRecordData,
} from './totp-verification.js';
import { type VerificationRecord as GenericVerificationRecord } from './verification-record.js';

export type VerificationRecordData =
| PasswordVerificationRecordData
Expand All @@ -52,23 +53,33 @@ export type VerificationRecordData =
| BackupCodeVerificationRecordData
| NewPasswordIdentityVerificationRecordData;

// This is to ensure the keys of the map are the same as the type of the verification record
type VerificationRecordInterfaceMap = {
[K in VerificationType]?: GenericVerificationRecord<K>;
};
type AssertVerificationMap<T extends VerificationRecordInterfaceMap> = T;

export type VerificationRecordMap = AssertVerificationMap<{
[VerificationType.Password]: PasswordVerification;
[VerificationType.EmailVerificationCode]: EmailCodeVerification;
[VerificationType.PhoneVerificationCode]: PhoneCodeVerification;
[VerificationType.Social]: SocialVerification;
[VerificationType.EnterpriseSso]: EnterpriseSsoVerification;
[VerificationType.TOTP]: TotpVerification;
[VerificationType.BackupCode]: BackupCodeVerification;
[VerificationType.NewPasswordIdentity]: NewPasswordIdentityVerification;
}>;

type ValueOf<T> = T[keyof T];
/**
* 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.
* @remarks 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.
* 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
| EmailCodeVerification
| PhoneCodeVerification
| SocialVerification
| EnterpriseSsoVerification
| TotpVerification
| BackupCodeVerification
| NewPasswordIdentityVerification;
export type VerificationRecord = ValueOf<VerificationRecordMap>;

export const verificationRecordDataGuard = z.discriminatedUnion('type', [
passwordVerificationRecordDataGuard,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* @fileoverview
*
* Since `Map` in TS does not support key value type mapping,
* we have to manually define a `setValue` method to ensure correct key will be set
* This class is used to store and manage all the verification records.
*
* Extends the Map class and adds a `setValue` method to ensure the key value type mapping.
*/

import { type VerificationType } from '@logto/schemas';

import { type VerificationRecord, type VerificationRecordMap } from './index.js';

export class VerificationRecordsMap extends Map<VerificationType, VerificationRecord> {
setValue(value: VerificationRecord) {
return super.set(value.type, value);
}

override get<K extends keyof VerificationRecordMap>(
key: K
): VerificationRecordMap[K] | undefined {
// eslint-disable-next-line no-restricted-syntax
return super.get(key) as VerificationRecordMap[K] | undefined;
}

override set(): never {
throw new Error('Use `setValue` method to set the value');
}

array(): VerificationRecord[] {
return [...this.values()];
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -80,12 +80,13 @@ export default function enterpriseSsoVerificationRoutes<T extends WithLogContext
const { connectorData, verificationId } = ctx.guard.body;

const enterpriseSsoVerificationRecord =
ctx.experienceInteraction.getVerificationRecordById(verificationId);
ctx.experienceInteraction.getVerificationRecordByTypeAndId(
VerificationType.EnterpriseSso,
verificationId
);

assertThat(
enterpriseSsoVerificationRecord &&
enterpriseSsoVerificationRecord.type === VerificationType.EnterpriseSso &&
enterpriseSsoVerificationRecord.connectorId === connectorId,
enterpriseSsoVerificationRecord.connectorId === connectorId,
new RequestError({ code: 'session.verification_session_not_found', status: 404 })
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,13 +75,13 @@ export default function socialVerificationRoutes<T extends WithLogContext>(
const { connectorId } = ctx.params;
const { connectorData, verificationId } = ctx.guard.body;

const socialVerificationRecord =
ctx.experienceInteraction.getVerificationRecordById(verificationId);
const socialVerificationRecord = ctx.experienceInteraction.getVerificationRecordByTypeAndId(
VerificationType.Social,
verificationId
);

assertThat(
socialVerificationRecord &&
socialVerificationRecord.type === VerificationType.Social &&
socialVerificationRecord.connectorId === connectorId,
socialVerificationRecord.connectorId === connectorId,
new RequestError({ code: 'session.verification_session_not_found', status: 404 })
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,13 +75,13 @@ export default function totpVerificationRoutes<T extends WithLogContext>(

// Verify new generated secret
if (verificationId) {
const totpVerificationRecord =
experienceInteraction.getVerificationRecordById(verificationId);
const totpVerificationRecord = experienceInteraction.getVerificationRecordByTypeAndId(
VerificationType.TOTP,
verificationId
);

assertThat(
totpVerificationRecord &&
totpVerificationRecord.type === VerificationType.TOTP &&
totpVerificationRecord.userId === experienceInteraction.identifiedUserId,
totpVerificationRecord.userId === experienceInteraction.identifiedUserId,
new RequestError({
code: 'session.verification_session_not_found',
status: 404,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,9 @@ import { InteractionEvent, verificationCodeIdentifierGuard } from '@logto/schema
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 { codeVerificationIdentifierRecordTypeMap } from '../classes/utils.js';
import { createNewCodeVerificationRecord } from '../classes/verifications/code-verification.js';
Expand Down Expand Up @@ -71,14 +69,9 @@ export default function verificationCodeRoutes<T extends WithLogContext>(
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 === codeVerificationIdentifierRecordTypeMap[identifier.type],
new RequestError({ code: 'session.verification_session_not_found', status: 404 })
const codeVerificationRecord = ctx.experienceInteraction.getVerificationRecordByTypeAndId(
codeVerificationIdentifierRecordTypeMap[identifier.type],
verificationId
);

await codeVerificationRecord.verify(identifier, code);
Expand Down

0 comments on commit 52c0dcc

Please sign in to comment.