diff --git a/packages/core/src/routes/experience/classes/experience-interaction.ts b/packages/core/src/routes/experience/classes/experience-interaction.ts index f537f5a4712a..111d1e352b6e 100644 --- a/packages/core/src/routes/experience/classes/experience-interaction.ts +++ b/packages/core/src/routes/experience/classes/experience-interaction.ts @@ -9,7 +9,7 @@ import assertThat from '#src/utils/assert-that.js'; import type { Interaction } from '../types.js'; -import { validateSieVerificationMethod } from './utils.js'; +import { SignInExperienceSettings } from './sign-in-experience-settings.js'; import { buildVerificationRecord, verificationRecordDataGuard, @@ -46,6 +46,7 @@ export default class ExperienceInteraction { private userId?: string; /** The user provided profile data in the current interaction that needs to be stored to database. */ private readonly profile?: Record; // TODO: Fix the type + private readonly signInExperienceSettings: SignInExperienceSettings; /** * Create a new `ExperienceInteraction` instance. @@ -60,12 +61,15 @@ export default class ExperienceInteraction { ) { const { libraries, queries } = tenant; + this.signInExperienceSettings = new SignInExperienceSettings(libraries, queries); + if (!interactionDetails) { return; } const result = interactionStorageGuard.safeParse(interactionDetails.result ?? {}); + // `interactionDetails.result` is not a valid experience interaction storage assertThat( result.success, new RequestError({ code: 'session.interaction_not_found', status: 404 }) @@ -101,13 +105,13 @@ export default class ExperienceInteraction { * Identify the user using the verification record. * * - Check if the verification record exists. - * - Check if the verification record is valid for the current interaction event. + * - Verify the verification record with `SignInExperienceSettings`. * - Create a new user using the verification record if the current interaction event is `Register`. * - Identify the user using the verification record if the current interaction event is `SignIn` or `ForgotPassword`. * - Set the user id to the current interaction. * - * @throws RequestError with 404 if the verification record is not found * @throws RequestError with 404 if the interaction event is not set + * @throws RequestError with 404 if the verification record is not found * @throws RequestError with 400 if the verification record is not valid for the current interaction event * @throws RequestError with 401 if the user is suspended * @throws RequestError with 409 if the current session has already identified a different user @@ -116,47 +120,26 @@ export default class ExperienceInteraction { const verificationRecord = this.getVerificationRecordById(verificationId); assertThat( - verificationRecord && this.interactionEvent, + this.interactionEvent, + new RequestError({ code: 'session.interaction_not_found', status: 404 }) + ); + + assertThat( + verificationRecord, new RequestError({ code: 'session.verification_session_not_found', status: 404 }) ); - // Existing user identification flow - validateSieVerificationMethod(this.interactionEvent, verificationRecord); + await this.signInExperienceSettings.verifyIdentificationMethod( + this.interactionEvent, + verificationRecord + ); - // User creation flow if (this.interactionEvent === InteractionEvent.Register) { - this.createNewUser(verificationRecord); + await this.createNewUser(verificationRecord); return; } - switch (verificationRecord.type) { - case VerificationType.Password: - case VerificationType.VerificationCode: - case VerificationType.Social: - case VerificationType.EnterpriseSso: { - // TODO: social sign-in with verified email - - const { id, isSuspended } = await verificationRecord.identifyUser(); - - assertThat(!isSuspended, new RequestError({ code: 'user.suspended', status: 401 })); - - // Throws an 409 error if the current session has already identified a different user - if (this.userId) { - assertThat( - this.userId === id, - new RequestError({ code: 'session.identity_conflict', status: 409 }) - ); - return; - } - - this.userId = id; - break; - } - default: { - // Unsupported verification type for identification, such as MFA verification. - throw new RequestError({ code: 'session.verification_failed', status: 400 }); - } - } + await this.identifyExistingUser(verificationRecord); } /** @@ -223,7 +206,45 @@ export default class ExperienceInteraction { return [...this.verificationRecords.values()]; } - private createNewUser(verificationRecord: VerificationRecord) { - // TODO: create new user for the Register event + private async identifyExistingUser(verificationRecord: VerificationRecord) { + switch (verificationRecord.type) { + case VerificationType.Password: + case VerificationType.VerificationCode: + case VerificationType.Social: + case VerificationType.EnterpriseSso: { + // TODO: social sign-in with verified email + const { id, isSuspended } = await verificationRecord.identifyUser(); + + assertThat(!isSuspended, new RequestError({ code: 'user.suspended', status: 401 })); + + // Throws an 409 error if the current session has already identified a different user + if (this.userId) { + assertThat( + this.userId === id, + new RequestError({ code: 'session.identity_conflict', status: 409 }) + ); + return; + } + + this.userId = id; + break; + } + default: { + // Unsupported verification type for identification, such as MFA verification. + throw new RequestError({ code: 'session.verification_failed', status: 400 }); + } + } + } + + private async createNewUser(verificationRecord: VerificationRecord) { + switch (verificationRecord.type) { + case VerificationType.VerificationCode: { + break; + } + default: { + // Unsupported verification type for user creation, such as MFA verification. + throw new RequestError({ code: 'session.verification_failed', status: 400 }); + } + } } } diff --git a/packages/core/src/routes/experience/classes/sign-in-experience-settings.ts b/packages/core/src/routes/experience/classes/sign-in-experience-settings.ts new file mode 100644 index 000000000000..6a7e6d163b60 --- /dev/null +++ b/packages/core/src/routes/experience/classes/sign-in-experience-settings.ts @@ -0,0 +1,219 @@ +import { + InteractionEvent, + type SignInExperience, + SignInMode, + VerificationType, +} from '@logto/schemas'; + +import RequestError from '#src/errors/RequestError/index.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 { type VerificationRecord } from './verifications/index.js'; + +const forbiddenMethodError = () => + new RequestError({ code: 'user.sign_in_method_not_enabled', status: 422 }); + +const getEmailIdentifierFromVerificationRecord = (verificationRecord: VerificationRecord) => { + switch (verificationRecord.type) { + case VerificationType.Password: + case VerificationType.VerificationCode: { + const { + identifier: { type, value }, + } = verificationRecord; + + return type === 'email' ? value : undefined; + } + case VerificationType.Social: { + const { socialUserInfo } = verificationRecord; + return socialUserInfo?.email; + } + default: { + break; + } + } +}; + +export class SignInExperienceSettings { + private signInExperienceDataCache?: SignInExperience; + + constructor( + private readonly libraries: Libraries, + private readonly queries: Queries + ) {} + + async verifyIdentificationMethod( + event: InteractionEvent, + verificationRecord: VerificationRecord + ) { + await this.guardInteractionEvent(event); + + switch (event) { + case InteractionEvent.SignIn: { + await this.guardSignInVerificationMethod(verificationRecord); + break; + } + case InteractionEvent.Register: { + await this.guardRegisterVerificationMethod(verificationRecord); + break; + } + case InteractionEvent.ForgotPassword: { + this.guardForgotPasswordVerificationMethod(verificationRecord); + break; + } + } + + await this.guardSsoOnlyEmailIdentifier(verificationRecord); + } + + private async getSignInExperienceData() { + this.signInExperienceDataCache ||= + await this.queries.signInExperiences.findDefaultSignInExperience(); + + return this.signInExperienceDataCache; + } + + /** + * Guard the verification records contains email identifier with SSO enabled + * + * @remarks + * Email identifier with SSO enabled domain will be blocked. + * Can only verify/identify via SSO verification record. + * + * - VerificationCode with email identifier + * - Social userinfo with email + **/ + private async guardSsoOnlyEmailIdentifier(verificationRecord: VerificationRecord) { + const emailIdentifier = getEmailIdentifierFromVerificationRecord(verificationRecord); + + if (!emailIdentifier) { + return; + } + + const domain = emailIdentifier.split('@')[1]; + const { singleSignOnEnabled } = await this.getSignInExperienceData(); + + if (!singleSignOnEnabled || !domain) { + return; + } + + const { getAvailableSsoConnectors } = this.libraries.ssoConnectors; + const availableSsoConnectors = await getAvailableSsoConnectors(); + + const domainEnabledConnectors = availableSsoConnectors.filter(({ domains }) => + domains.includes(domain) + ); + + assertThat( + domainEnabledConnectors.length === 0, + new RequestError( + { + code: 'session.sso_enabled', + status: 422, + }, + { + ssoConnectors: domainEnabledConnectors, + } + ) + ); + } + + private async guardInteractionEvent(event: InteractionEvent) { + const { signInMode } = await this.getSignInExperienceData(); + + switch (event) { + case InteractionEvent.SignIn: { + assertThat( + signInMode !== SignInMode.Register, + new RequestError({ code: 'auth.forbidden', status: 403 }) + ); + break; + } + case InteractionEvent.Register: { + assertThat( + signInMode !== SignInMode.SignIn, + new RequestError({ code: 'auth.forbidden', status: 403 }) + ); + break; + } + case InteractionEvent.ForgotPassword: { + break; + } + } + } + + private async guardSignInVerificationMethod(verificationRecord: VerificationRecord) { + const { + signIn: { methods: signInMethods }, + singleSignOnEnabled, + } = await this.getSignInExperienceData(); + + switch (verificationRecord.type) { + case VerificationType.Password: + case VerificationType.VerificationCode: { + const { + identifier: { type }, + } = verificationRecord; + + assertThat( + signInMethods.some(({ identifier: method, password, verificationCode }) => { + return ( + method === type && + (verificationRecord.type === VerificationType.Password ? password : verificationCode) + ); + }), + forbiddenMethodError() + ); + break; + } + + case VerificationType.Social: { + // No need to verify social verification method + break; + } + case VerificationType.EnterpriseSso: { + assertThat(singleSignOnEnabled, forbiddenMethodError()); + break; + } + default: { + throw forbiddenMethodError(); + } + } + } + + private async guardRegisterVerificationMethod(verificationRecord: VerificationRecord) { + const { signUp, singleSignOnEnabled } = await this.getSignInExperienceData(); + + switch (verificationRecord.type) { + // TODO: username password registration + case VerificationType.VerificationCode: { + const { + identifier: { type }, + } = verificationRecord; + + assertThat(signUp.identifiers.includes(type) && signUp.verify, forbiddenMethodError()); + break; + } + case VerificationType.Social: { + // No need to verify social verification method + break; + } + case VerificationType.EnterpriseSso: { + assertThat(singleSignOnEnabled, forbiddenMethodError()); + break; + } + default: { + throw forbiddenMethodError(); + } + } + } + + /** Forgot password only supports verification code type verification record */ + private guardForgotPasswordVerificationMethod(verificationRecord: VerificationRecord) { + assertThat( + verificationRecord.type === VerificationType.VerificationCode, + forbiddenMethodError() + ); + } +} diff --git a/packages/core/src/routes/experience/classes/utils.ts b/packages/core/src/routes/experience/classes/utils.ts index dfaa48847563..575908b45fa3 100644 --- a/packages/core/src/routes/experience/classes/utils.ts +++ b/packages/core/src/routes/experience/classes/utils.ts @@ -1,62 +1,20 @@ -import { - InteractionEvent, - InteractionIdentifierType, - VerificationType, - type InteractionIdentifier, -} from '@logto/schemas'; +import { SignInIdentifier, type InteractionIdentifier } from '@logto/schemas'; -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 VerificationRecord } from './verifications/index.js'; export const findUserByIdentifier = async ( userQuery: Queries['users'], { type, value }: InteractionIdentifier ) => { switch (type) { - case InteractionIdentifierType.Username: { + case SignInIdentifier.Username: { return userQuery.findUserByUsername(value); } - case InteractionIdentifierType.Email: { + case SignInIdentifier.Email: { return userQuery.findUserByEmail(value); } - case InteractionIdentifierType.Phone: { + case SignInIdentifier.Phone: { return userQuery.findUserByPhone(value); } } }; - -/** - * Check if the verification record is valid for the current interaction event. - * - * This function will compare the verification record for the current interaction event with Logto's SIE settings - * - * @throws RequestError with 400 if the verification record is not valid for the current interaction event - */ -export const validateSieVerificationMethod = ( - interactionEvent: InteractionEvent, - verificationRecord: VerificationRecord -) => { - switch (interactionEvent) { - case InteractionEvent.SignIn: { - // TODO: sign-in methods validation - break; - } - case InteractionEvent.Register: { - // TODO: sign-up methods validation - break; - } - case InteractionEvent.ForgotPassword: { - // Forgot password only supports verification code type verification record - // The verification record's interaction event must be ForgotPassword - assertThat( - verificationRecord.type === VerificationType.VerificationCode && - verificationRecord.interactionEvent === InteractionEvent.ForgotPassword, - new RequestError({ code: 'session.verification_session_not_found', status: 400 }) - ); - break; - } - } -}; diff --git a/packages/core/src/routes/experience/classes/verifications/code-verification.ts b/packages/core/src/routes/experience/classes/verifications/code-verification.ts index ccc8351af923..e87aceed9e1e 100644 --- a/packages/core/src/routes/experience/classes/verifications/code-verification.ts +++ b/packages/core/src/routes/experience/classes/verifications/code-verification.ts @@ -70,9 +70,8 @@ const getPasscodeIdentifierPayload = ( export class CodeVerification implements VerificationRecord { /** * Factory method to create a new CodeVerification record using the given identifier. - * The sendVerificationCode method will be automatically triggered. */ - static async create( + static create( libraries: Libraries, queries: Queries, identifier: VerificationCodeIdentifier, @@ -86,8 +85,6 @@ export class CodeVerification implements VerificationRecord( /** * @api {post} /experience Create a new interaction + * + * @remarks + * Do not append `koaExperienceInteraction` middleware in this API since we are going to create a new interaction. */ - router.put( + anonymousRouter.use(koaAuditLog(queries)).put( experienceRoutes.prefix, koaGuard({ body: z.object({ @@ -108,16 +111,18 @@ export default function experienceApiRoutes( experienceRoutes.identification, koaGuard({ body: identificationApiPayloadGuard, - status: [204, 400, 401, 404], + status: [204, 400, 401, 404, 409], }), async (ctx, next) => { const { verificationId } = ctx.guard.body; + const { experienceInteraction } = ctx; - await ctx.experienceInteraction.identifyUser(verificationId); + await experienceInteraction.identifyUser(verificationId); - await ctx.experienceInteraction.save(); + await experienceInteraction.save(); - ctx.status = 204; + // Return 201 if a new user is created + ctx.status = experienceInteraction.interactionEvent === InteractionEvent.Register ? 201 : 204; return next(); } diff --git a/packages/core/src/routes/experience/verification-routes/verification-code.ts b/packages/core/src/routes/experience/verification-routes/verification-code.ts index 0ee92b68a818..1e99894b51c3 100644 --- a/packages/core/src/routes/experience/verification-routes/verification-code.ts +++ b/packages/core/src/routes/experience/verification-routes/verification-code.ts @@ -36,13 +36,15 @@ export default function verificationCodeRoutes( async (ctx, next) => { const { identifier, interactionEvent } = ctx.guard.body; - const codeVerification = await CodeVerification.create( + const codeVerification = CodeVerification.create( libraries, queries, identifier, interactionEvent ); + await codeVerification.sendVerificationCode(); + ctx.experienceInteraction.setVerificationRecord(codeVerification); await ctx.experienceInteraction.save(); diff --git a/packages/schemas/src/types/interactions.ts b/packages/schemas/src/types/interactions.ts index 877974224726..82b77965b5ba 100644 --- a/packages/schemas/src/types/interactions.ts +++ b/packages/schemas/src/types/interactions.ts @@ -1,7 +1,12 @@ import { emailRegEx, phoneRegEx, usernameRegEx } from '@logto/core-kit'; import { z } from 'zod'; -import { MfaFactor, jsonObjectGuard, webAuthnTransportGuard } from '../foundations/index.js'; +import { + MfaFactor, + SignInIdentifier, + jsonObjectGuard, + webAuthnTransportGuard, +} from '../foundations/index.js'; import { type ToZodObject } from '../utils/zod.js'; import type { @@ -24,31 +29,26 @@ export enum InteractionEvent { } // ====== Experience API payload guards and type definitions start ====== -export enum InteractionIdentifierType { - Username = 'username', - Email = 'email', - Phone = 'phone', -} /** Identifiers that can be used to uniquely identify a user. */ export type InteractionIdentifier = { - type: InteractionIdentifierType; + type: SignInIdentifier; value: string; }; export const interactionIdentifierGuard = z.object({ - type: z.nativeEnum(InteractionIdentifierType), + type: z.nativeEnum(SignInIdentifier), value: z.string(), }) satisfies ToZodObject; /** Currently only email and phone are supported for verification code validation. */ export type VerificationCodeIdentifier = { - type: InteractionIdentifierType.Email | InteractionIdentifierType.Phone; + type: SignInIdentifier.Email | SignInIdentifier.Phone; value: string; }; export const verificationCodeIdentifierGuard = z.object({ - type: z.enum([InteractionIdentifierType.Email, InteractionIdentifierType.Phone]), + type: z.enum([SignInIdentifier.Email, SignInIdentifier.Phone]), value: z.string(), }) satisfies ToZodObject;