From 40a8fb617cd79e1a276384f277c8b306f67e9de2 Mon Sep 17 00:00:00 2001 From: simeng-li Date: Fri, 19 Jul 2024 16:52:12 +0800 Subject: [PATCH] refactor(core): extract helpers and provision methods extract helpers and provision methods --- .../classes/experience-interaction.ts | 55 +------- .../src/routes/experience/classes/helpers.ts | 108 +++++++++++++++ .../src/routes/experience/classes/utils.ts | 103 +------------- .../{ => validators}/provision-library.ts | 127 ++++++++++++++---- 4 files changed, 214 insertions(+), 179 deletions(-) create mode 100644 packages/core/src/routes/experience/classes/helpers.ts rename packages/core/src/routes/experience/classes/{ => validators}/provision-library.ts (69%) diff --git a/packages/core/src/routes/experience/classes/experience-interaction.ts b/packages/core/src/routes/experience/classes/experience-interaction.ts index 672d9bfc81ac..9dd36d12f0e3 100644 --- a/packages/core/src/routes/experience/classes/experience-interaction.ts +++ b/packages/core/src/routes/experience/classes/experience-interaction.ts @@ -1,6 +1,5 @@ import { type ToZodObject } from '@logto/connector-kit'; import { InteractionEvent, type User, type VerificationType } from '@logto/schemas'; -import { generateStandardId } from '@logto/shared'; import { conditional } from '@silverhand/essentials'; import { z } from 'zod'; @@ -11,14 +10,14 @@ import assertThat from '#src/utils/assert-that.js'; import { interactionProfileGuard, type Interaction, type InteractionProfile } from '../types.js'; -import { ProvisionLibrary } from './provision-library.js'; import { getNewUserProfileFromVerificationRecord, identifyUserByVerificationRecord, - toUserSocialIdentityData, -} from './utils.js'; +} from './helpers.js'; +import { toUserSocialIdentityData } from './utils.js'; import { MfaValidator } from './validators/mfa-validator.js'; import { ProfileValidator } from './validators/profile-validator.js'; +import { ProvisionLibrary } from './validators/provision-library.js'; import { SignInExperienceValidator } from './validators/sign-in-experience-validator.js'; import { buildVerificationRecord, @@ -274,14 +273,7 @@ export default class ExperienceInteraction { // TODO: missing profile fields validation if (enterpriseSsoIdentity) { - await userSsoIdentitiesQueries.insert({ - id: generateStandardId(), - userId: user.id, - ...enterpriseSsoIdentity, - }); - await this.provisionLibrary.newUserJtiOrganizationProvision(user.id, { - enterpriseSsoIdentity, - }); + await this.provisionLibrary.provisionNewSsoIdentity(user.id, enterpriseSsoIdentity); } const { provider } = this.tenant; @@ -356,47 +348,10 @@ export default class ExperienceInteraction { * @throws {RequestError} with 400 if the verification record is invalid for creating a new user or not verified */ private async createNewUser(verificationRecord: VerificationRecord) { - const { - libraries: { - users: { generateUserId, insertUser }, - }, - queries: { userSsoIdentities: userSsoIdentitiesQueries }, - } = this.tenant; - const newProfile = await getNewUserProfileFromVerificationRecord(verificationRecord); - await this.profileValidator.guardProfileUniquenessAcrossUsers(newProfile); - const { socialIdentity, enterpriseSsoIdentity, ...rest } = newProfile; - - const { isCreatingFirstAdminUser, initialUserRoles, customData } = - await this.provisionLibrary.getUserProvisionContext(newProfile); - - const [user] = await insertUser( - { - id: await generateUserId(), - ...rest, - ...conditional(socialIdentity && { identities: toUserSocialIdentityData(socialIdentity) }), - ...conditional(customData && { customData }), - }, - initialUserRoles - ); - - if (isCreatingFirstAdminUser) { - await this.provisionLibrary.adminUserProvision(user); - } - - if (enterpriseSsoIdentity) { - await userSsoIdentitiesQueries.insert({ - id: generateStandardId(), - userId: user.id, - ...enterpriseSsoIdentity, - }); - } - - await this.provisionLibrary.newUserJtiOrganizationProvision(user.id, newProfile); - - // TODO: new user hooks + const user = await this.provisionLibrary.provisionNewUser(newProfile); this.userId = user.id; } diff --git a/packages/core/src/routes/experience/classes/helpers.ts b/packages/core/src/routes/experience/classes/helpers.ts new file mode 100644 index 000000000000..c208f2be26e0 --- /dev/null +++ b/packages/core/src/routes/experience/classes/helpers.ts @@ -0,0 +1,108 @@ +/** + * @overview This file contains helper functions for the main interaction class. + * + * To help reduce the complexity and code amount of the main interaction class, + * we have moved some of the standalone functions into this file. + */ + +import { VerificationType, type User } from '@logto/schemas'; +import { conditional } from '@silverhand/essentials'; + +import RequestError from '#src/errors/RequestError/index.js'; +import assertThat from '#src/utils/assert-that.js'; + +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. + */ +export const getNewUserProfileFromVerificationRecord = async ( + verificationRecord: VerificationRecord +): Promise => { + switch (verificationRecord.type) { + case VerificationType.NewPasswordIdentity: + case VerificationType.EmailVerificationCode: + case VerificationType.PhoneVerificationCode: { + return verificationRecord.toUserProfile(); + } + case VerificationType.EnterpriseSso: + case VerificationType.Social: { + const identityProfile = await verificationRecord.toUserProfile(); + const syncedProfile = await verificationRecord.toSyncedProfile(true); + return { ...identityProfile, ...syncedProfile }; + } + default: { + // Unsupported verification type for user creation, such as MFA verification. + throw new RequestError({ code: 'session.verification_failed', status: 400 }); + } + } +}; + +/** + * @throws {RequestError} -400 if the verification record type is not supported for user identification. + * @throws {RequestError} -400 if the verification record is not verified. + * @throws {RequestError} -404 if the user is not found. + */ +export const identifyUserByVerificationRecord = async ( + verificationRecord: VerificationRecord, + linkSocialIdentity?: boolean +): Promise<{ + user: User; + /** + * Returns the social/enterprise SSO synced profiled if the verification record is a social/enterprise SSO verification. + * - For new linked identity, the synced profile will includes the new social or enterprise SSO identity. + * - For existing social or enterprise SSO identity, the synced profile will return the synced user profile based on connector settings. + */ + syncedProfile?: Pick< + InteractionProfile, + 'enterpriseSsoIdentity' | 'socialIdentity' | 'avatar' | 'name' + >; +}> => { + // Check verification record can be used to identify a user using the `identifyUser` method. + // E.g. MFA verification record does not have the `identifyUser` method, cannot be used to identify a user. + assertThat( + 'identifyUser' in verificationRecord, + new RequestError({ code: 'session.verification_failed', status: 400 }) + ); + + switch (verificationRecord.type) { + case VerificationType.Password: + case VerificationType.EmailVerificationCode: + case VerificationType.PhoneVerificationCode: { + return { user: await verificationRecord.identifyUser() }; + } + case VerificationType.Social: { + const user = linkSocialIdentity + ? await verificationRecord.identifyRelatedUser() + : await verificationRecord.identifyUser(); + + const syncedProfile = { + ...(await verificationRecord.toSyncedProfile()), + ...conditional(linkSocialIdentity && (await verificationRecord.toUserProfile())), + }; + + return { user, syncedProfile }; + } + case VerificationType.EnterpriseSso: { + try { + const user = await verificationRecord.identifyUser(); + const syncedProfile = await verificationRecord.toSyncedProfile(); + return { user, syncedProfile }; + } catch (error: unknown) { + // 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()), + ...(await verificationRecord.toSyncedProfile()), + }; + return { user, syncedProfile }; + } + throw error; + } + } + } +}; diff --git a/packages/core/src/routes/experience/classes/utils.ts b/packages/core/src/routes/experience/classes/utils.ts index e2949d8ec006..25f98bb2ba36 100644 --- a/packages/core/src/routes/experience/classes/utils.ts +++ b/packages/core/src/routes/experience/classes/utils.ts @@ -5,16 +5,11 @@ import { type User, type VerificationCodeSignInIdentifier, } from '@logto/schemas'; -import { conditional } from '@silverhand/essentials'; -import RequestError from '#src/errors/RequestError/index.js'; import type Queries from '#src/tenants/Queries.js'; -import assertThat from '#src/utils/assert-that.js'; import type { InteractionProfile } from '../types.js'; -import { type VerificationRecord } from './verifications/index.js'; - export const findUserByIdentifier = async ( userQuery: Queries['users'], { type, value }: InteractionIdentifier @@ -32,98 +27,6 @@ export const findUserByIdentifier = async ( } }; -/** - * @throws {RequestError} -400 if the verification record type is not supported for user creation. - * @throws {RequestError} -400 if the verification record is not verified. - */ -export const getNewUserProfileFromVerificationRecord = async ( - verificationRecord: VerificationRecord -): Promise => { - switch (verificationRecord.type) { - case VerificationType.NewPasswordIdentity: - case VerificationType.EmailVerificationCode: - case VerificationType.PhoneVerificationCode: { - return verificationRecord.toUserProfile(); - } - case VerificationType.EnterpriseSso: - case VerificationType.Social: { - const identityProfile = await verificationRecord.toUserProfile(); - const syncedProfile = await verificationRecord.toSyncedProfile(true); - return { ...identityProfile, ...syncedProfile }; - } - default: { - // Unsupported verification type for user creation, such as MFA verification. - throw new RequestError({ code: 'session.verification_failed', status: 400 }); - } - } -}; - -/** - * @throws {RequestError} -400 if the verification record type is not supported for user identification. - * @throws {RequestError} -400 if the verification record is not verified. - * @throws {RequestError} -404 if the user is not found. - */ -export const identifyUserByVerificationRecord = async ( - verificationRecord: VerificationRecord, - linkSocialIdentity?: boolean -): Promise<{ - user: User; - /** - * Returns the social/enterprise SSO synced profiled if the verification record is a social/enterprise SSO verification. - * - For new linked identity, the synced profile will includes the new social or enterprise SSO identity. - * - For existing social or enterprise SSO identity, the synced profile will return the synced user profile based on connector settings. - */ - syncedProfile?: Pick< - InteractionProfile, - 'enterpriseSsoIdentity' | 'socialIdentity' | 'avatar' | 'name' - >; -}> => { - // Check verification record can be used to identify a user using the `identifyUser` method. - // E.g. MFA verification record does not have the `identifyUser` method, cannot be used to identify a user. - assertThat( - 'identifyUser' in verificationRecord, - new RequestError({ code: 'session.verification_failed', status: 400 }) - ); - - switch (verificationRecord.type) { - case VerificationType.Password: - case VerificationType.EmailVerificationCode: - case VerificationType.PhoneVerificationCode: { - return { user: await verificationRecord.identifyUser() }; - } - case VerificationType.Social: { - const user = linkSocialIdentity - ? await verificationRecord.identifyRelatedUser() - : await verificationRecord.identifyUser(); - - const syncedProfile = { - ...(await verificationRecord.toSyncedProfile()), - ...conditional(linkSocialIdentity && (await verificationRecord.toUserProfile())), - }; - - return { user, syncedProfile }; - } - case VerificationType.EnterpriseSso: { - try { - const user = await verificationRecord.identifyUser(); - const syncedProfile = await verificationRecord.toSyncedProfile(); - return { user, syncedProfile }; - } catch (error: unknown) { - // 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()), - ...(await verificationRecord.toSyncedProfile()), - }; - return { user, syncedProfile }; - } - throw error; - } - } - } -}; - /** * Convert the interaction profile `socialIdentity` to `User['identities']` data format */ @@ -140,9 +43,9 @@ export const toUserSocialIdentityData = ( }; }; -export function interactionIdentifierToUserProfile( +export const interactionIdentifierToUserProfile = ( identifier: InteractionIdentifier -): { username: string } | { primaryEmail: string } | { primaryPhone: string } { +): { username: string } | { primaryEmail: string } | { primaryPhone: string } => { const { type, value } = identifier; switch (type) { case SignInIdentifier.Username: { @@ -155,7 +58,7 @@ export function interactionIdentifierToUserProfile( return { primaryPhone: value }; } } -} +}; export const codeVerificationIdentifierRecordTypeMap = Object.freeze({ [SignInIdentifier.Email]: VerificationType.EmailVerificationCode, diff --git a/packages/core/src/routes/experience/classes/provision-library.ts b/packages/core/src/routes/experience/classes/validators/provision-library.ts similarity index 69% rename from packages/core/src/routes/experience/classes/provision-library.ts rename to packages/core/src/routes/experience/classes/validators/provision-library.ts index 3a978bc56089..8d9e502acf13 100644 --- a/packages/core/src/routes/experience/classes/provision-library.ts +++ b/packages/core/src/routes/experience/classes/validators/provision-library.ts @@ -13,14 +13,16 @@ import { type User, type UserOnboardingData, } from '@logto/schemas'; -import { conditionalArray } from '@silverhand/essentials'; +import { generateStandardId } from '@logto/shared'; +import { conditional, conditionalArray } from '@silverhand/essentials'; import { EnvSet } from '#src/env-set/index.js'; import { type WithLogContext } from '#src/middleware/koa-audit-log.js'; import type TenantContext from '#src/tenants/TenantContext.js'; import { getTenantId } from '#src/utils/tenant.js'; -import { type InteractionProfile } from '../types.js'; +import { type InteractionProfile } from '../../types.js'; +import { toUserSocialIdentityData } from '../utils.js'; type OrganizationProvisionPayload = | { @@ -38,11 +40,77 @@ export class ProvisionLibrary { private readonly ctx: WithLogContext ) {} + /** + * Insert a new user into the Logto database using the provided profile. + * + * - provision the organization for the new user based on the profile + * - OSS only, new user provisioning + */ + async provisionNewUser(profile: InteractionProfile) { + const { + libraries: { + users: { generateUserId, insertUser }, + }, + queries: { userSsoIdentities: userSsoIdentitiesQueries }, + } = this.tenantContext; + + const { socialIdentity, enterpriseSsoIdentity, ...rest } = profile; + + const { isCreatingFirstAdminUser, initialUserRoles, customData } = + await this.getUserProvisionContext(profile); + + const [user] = await insertUser( + { + id: await generateUserId(), + ...rest, + ...conditional(socialIdentity && { identities: toUserSocialIdentityData(socialIdentity) }), + ...conditional(customData && { customData }), + }, + initialUserRoles + ); + + if (enterpriseSsoIdentity) { + await userSsoIdentitiesQueries.insert({ + id: generateStandardId(), + userId: user.id, + ...enterpriseSsoIdentity, + }); + } + + if (isCreatingFirstAdminUser) { + await this.provisionForFirstAdminUser(user); + } + + await this.provisionNewUserJitOrganizations(user.id, profile); + + // TODO: New user created hooks + // TODO: log + + return user; + } + + async provisionNewSsoIdentity( + userId: string, + enterpriseSsoIdentity: Required['enterpriseSsoIdentity'] + ) { + const { + queries: { userSsoIdentities: userSsoIdentitiesQueries }, + } = this.tenantContext; + + await userSsoIdentitiesQueries.insert({ + id: generateStandardId(), + userId, + ...enterpriseSsoIdentity, + }); + + await this.provisionNewUserJitOrganizations(userId, { enterpriseSsoIdentity }); + } + /** * This method is used to get the provision context for a new user registration. * It will return the provision context based on the current tenant and the request context. */ - async getUserProvisionContext(profile: InteractionProfile): Promise<{ + private async getUserProvisionContext(profile: InteractionProfile): Promise<{ /** Admin user provisioning flag */ isCreatingFirstAdminUser: boolean; /** Initial user roles for admin tenant users */ @@ -121,14 +189,39 @@ export class ProvisionLibrary { }; } + /** + * Provision the organization for a new user + * + * - If the user has an enterprise SSO identity, provision the organization based on the SSO connector + * - Otherwise, provision the organization based on the primary email + */ + private async provisionNewUserJitOrganizations( + userId: string, + { primaryEmail, enterpriseSsoIdentity }: InteractionProfile + ) { + if (enterpriseSsoIdentity) { + return this.provisionJitOrganization({ + userId, + ssoConnectorId: enterpriseSsoIdentity.ssoConnectorId, + }); + } + if (primaryEmail) { + return this.provisionJitOrganization({ + userId, + email: primaryEmail, + }); + } + } + /** * First admin user provision * * - For OSS, update the default sign-in experience to "sign-in only" once the first admin has been created. * - Add the user to the default organization and assign the admin role. */ - async adminUserProvision({ id }: User) { + private async provisionForFirstAdminUser({ id }: User) { const { isCloud } = EnvSet.values; + const { queries: { signInExperiences, organizations }, } = this.tenantContext; @@ -148,31 +241,7 @@ export class ProvisionLibrary { }); } - /** - * Provision the organization for a new user - * - * - If the user has an enterprise SSO identity, provision the organization based on the SSO connector - * - Otherwise, provision the organization based on the primary email - */ - async newUserJtiOrganizationProvision( - userId: string, - { primaryEmail, enterpriseSsoIdentity }: InteractionProfile - ) { - if (enterpriseSsoIdentity) { - return this.jitOrganizationProvision({ - userId, - ssoConnectorId: enterpriseSsoIdentity.ssoConnectorId, - }); - } - if (primaryEmail) { - return this.jitOrganizationProvision({ - userId, - email: primaryEmail, - }); - } - } - - private async jitOrganizationProvision(payload: OrganizationProvisionPayload) { + private async provisionJitOrganization(payload: OrganizationProvisionPayload) { const { libraries: { users: usersLibraries }, } = this.tenantContext;