Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor(core): implement verification records map #6289

Merged
merged 8 commits into from
Jul 23, 2024
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 @@
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 @@
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 @@

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

Check warning on line 106 in packages/core/src/routes/experience/classes/experience-interaction.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/experience-interaction.ts#L106

Added line #L106 was not covered by tests
}
}

Expand Down Expand Up @@ -182,13 +184,21 @@
* 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 })
gao-sun marked this conversation as resolved.
Show resolved Hide resolved
);

return record;

Check warning on line 201 in packages/core/src/routes/experience/classes/experience-interaction.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/experience-interaction.ts#L191-L201

Added lines #L191 - L201 were not covered by tests
}

/**
Expand Down Expand Up @@ -254,7 +264,7 @@

const { socialIdentity, enterpriseSsoIdentity, ...rest } = this.#profile ?? {};

// TODO: profile updates validation

Check warning on line 267 in packages/core/src/routes/experience/classes/experience-interaction.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/core/src/routes/experience/classes/experience-interaction.ts#L267

[no-warning-comments] Unexpected 'todo' comment: 'TODO: profile updates validation'.

// Update user profile
await userQueries.updateUserById(user.id, {
Expand All @@ -270,7 +280,7 @@
lastSignInAt: Date.now(),
});

// TODO: missing profile fields validation

Check warning on line 283 in packages/core/src/routes/experience/classes/experience-interaction.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/core/src/routes/experience/classes/experience-interaction.ts#L283

[no-warning-comments] Unexpected 'todo' comment: 'TODO: missing profile fields validation'.

if (enterpriseSsoIdentity) {
await this.provisionLibrary.provisionNewSsoIdentity(user.id, enterpriseSsoIdentity);
Expand Down Expand Up @@ -298,7 +308,7 @@
}

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

/**
Expand Down Expand Up @@ -389,4 +399,8 @@
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 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 @@
}
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),
]);

Check warning on line 39 in packages/core/src/routes/experience/classes/helpers.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/helpers.ts#L33-L39

Added lines #L33 - L39 were not covered by tests
return { ...identityProfile, ...syncedProfile };
}
default: {
Expand Down Expand Up @@ -95,8 +100,9 @@
// 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();

Check warning on line 103 in packages/core/src/routes/experience/classes/helpers.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/helpers.ts#L103

Added line #L103 was not covered by tests
const syncedProfile = {
...(await verificationRecord.toUserProfile()),
...verificationRecord.toUserProfile(),

Check warning on line 105 in packages/core/src/routes/experience/classes/helpers.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/helpers.ts#L105

Added line #L105 was not covered by tests
...(await verificationRecord.toSyncedProfile()),
};
return { user, syncedProfile };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
/**
* 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 Expand Up @@ -83,8 +83,8 @@

await this.provisionNewUserJitOrganizations(user.id, profile);

// TODO: New user created hooks

Check warning on line 86 in packages/core/src/routes/experience/classes/validators/provision-library.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/core/src/routes/experience/classes/validators/provision-library.ts#L86

[no-warning-comments] Unexpected 'todo' comment: 'TODO: New user created hooks'.
// TODO: log

Check warning on line 87 in packages/core/src/routes/experience/classes/validators/provision-library.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/core/src/routes/experience/classes/validators/provision-library.ts#L87

[no-warning-comments] Unexpected 'todo' comment: 'TODO: log'.

return user;
}
Expand Down Expand Up @@ -248,7 +248,7 @@

const provisionedOrganizations = await usersLibraries.provisionOrganizations(payload);

// TODO: trigger hooks event

Check warning on line 251 in packages/core/src/routes/experience/classes/validators/provision-library.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/core/src/routes/experience/classes/validators/provision-library.ts#L251

[no-warning-comments] Unexpected 'todo' comment: 'TODO: trigger hooks event'.

return provisionedOrganizations;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@
new RequestError({ code: 'session.verification_failed', status: 400 })
);

// TODO: sync userInfo and link sso identity

Check warning on line 147 in packages/core/src/routes/experience/classes/verifications/enterprise-sso-verification.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/core/src/routes/experience/classes/verifications/enterprise-sso-verification.ts#L147

[no-warning-comments] Unexpected 'todo' comment: 'TODO: sync userInfo and link sso...'.

const userSsoIdentityResult = await this.findUserSsoIdentityByEnterpriseSsoUserInfo();

Expand Down Expand Up @@ -173,7 +173,7 @@
/**
* 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
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;
}

Check warning on line 25 in packages/core/src/routes/experience/classes/verifications/verification-records-map.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/verifications/verification-records-map.ts#L21-L25

Added lines #L21 - L25 were not covered by tests

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

Check warning on line 29 in packages/core/src/routes/experience/classes/verifications/verification-records-map.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/classes/verifications/verification-records-map.ts#L28-L29

Added lines #L28 - L29 were not covered by tests

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

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

Check warning on line 86 in packages/core/src/routes/experience/verification-routes/enterprise-sso-verification.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/verification-routes/enterprise-sso-verification.ts#L83-L86

Added lines #L83 - L86 were not covered by tests

assertThat(
enterpriseSsoVerificationRecord &&
enterpriseSsoVerificationRecord.type === VerificationType.EnterpriseSso &&
enterpriseSsoVerificationRecord.connectorId === connectorId,
enterpriseSsoVerificationRecord.connectorId === connectorId,

Check warning on line 89 in packages/core/src/routes/experience/verification-routes/enterprise-sso-verification.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/verification-routes/enterprise-sso-verification.ts#L89

Added line #L89 was not covered by tests
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 @@
const { connectorId } = ctx.params;
const { connectorData, verificationId } = ctx.guard.body;

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

Check warning on line 81 in packages/core/src/routes/experience/verification-routes/social-verification.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/verification-routes/social-verification.ts#L78-L81

Added lines #L78 - L81 were not covered by tests

assertThat(
socialVerificationRecord &&
socialVerificationRecord.type === VerificationType.Social &&
socialVerificationRecord.connectorId === connectorId,
socialVerificationRecord.connectorId === connectorId,

Check warning on line 84 in packages/core/src/routes/experience/verification-routes/social-verification.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/verification-routes/social-verification.ts#L84

Added line #L84 was not covered by tests
new RequestError({ code: 'session.verification_session_not_found', status: 404 })
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@

assertThat(experienceInteraction.identifiedUserId, 'session.identifier_not_found');

// TODO: Check if the MFA is enabled

Check warning on line 36 in packages/core/src/routes/experience/verification-routes/totp-verification.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/core/src/routes/experience/verification-routes/totp-verification.ts#L36

[no-warning-comments] Unexpected 'todo' comment: 'TODO: Check if the MFA is enabled'.
// TODO: Check if the interaction is fully verified

Check warning on line 37 in packages/core/src/routes/experience/verification-routes/totp-verification.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/core/src/routes/experience/verification-routes/totp-verification.ts#L37

[no-warning-comments] Unexpected 'todo' comment: 'TODO: Check if the interaction is fully...'.

const totpVerification = TotpVerification.create(
libraries,
Expand Down Expand Up @@ -75,13 +75,13 @@

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

Check warning on line 81 in packages/core/src/routes/experience/verification-routes/totp-verification.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/verification-routes/totp-verification.ts#L78-L81

Added lines #L78 - L81 were not covered by tests

assertThat(
totpVerificationRecord &&
totpVerificationRecord.type === VerificationType.TOTP &&
totpVerificationRecord.userId === experienceInteraction.identifiedUserId,
totpVerificationRecord.userId === experienceInteraction.identifiedUserId,

Check warning on line 84 in packages/core/src/routes/experience/verification-routes/totp-verification.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/verification-routes/totp-verification.ts#L84

Added line #L84 was not covered by tests
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 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 @@
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

Check warning on line 74 in packages/core/src/routes/experience/verification-routes/verification-code.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/experience/verification-routes/verification-code.ts#L72-L74

Added lines #L72 - L74 were not covered by tests
);

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