From 5bae495cc9f6a095c5d0c338dcd5dbea2c8d7166 Mon Sep 17 00:00:00 2001 From: simeng-li Date: Mon, 15 Jul 2024 09:53:50 +0800 Subject: [PATCH] feat(core,schemas): implement the sie settings guard (#6215) * feat(core,schemas): implement the sie settings guard implement the sie settings guard * fix(test): fix integration test fix integration test * test(core): add sie guard ut add sie guard ut * chore(core): add some comment add some comment * refactor(core): rename the sign-in-experience-settings class rename the sign-in-experience-settings class --- .../classes/experience-interaction.ts | 122 +++-- .../src/routes/experience/classes/utils.ts | 50 +- .../sign-in-experience-validator.test.ts | 500 ++++++++++++++++++ .../sign-in-experience-validator.ts | 230 ++++++++ .../verifications/code-verification.ts | 43 +- packages/core/src/routes/experience/index.ts | 19 +- .../backup-code-verification.ts | 2 +- .../verification-routes/totp-verification.ts | 4 +- .../verification-routes/verification-code.ts | 4 +- .../src/helpers/experience/index.ts | 4 +- .../api/experience-api/interaction.test.ts | 4 +- .../sign-in-interaction/password.test.ts | 4 +- .../verification-code.test.ts | 8 +- .../backup-code-verification.test.ts | 2 +- .../password-verification.test.ts | 4 +- .../verifications/social-verification.test.ts | 4 +- .../verifications/totp-verification.test.ts | 6 +- .../verifications/verification-code.test.ts | 6 +- .../phrases/src/locales/en/errors/session.ts | 1 - packages/schemas/src/types/interactions.ts | 20 +- 20 files changed, 882 insertions(+), 155 deletions(-) create mode 100644 packages/core/src/routes/experience/classes/validators/sign-in-experience-validator.test.ts create mode 100644 packages/core/src/routes/experience/classes/validators/sign-in-experience-validator.ts diff --git a/packages/core/src/routes/experience/classes/experience-interaction.ts b/packages/core/src/routes/experience/classes/experience-interaction.ts index ec37d2590..9be963602 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 { SignInExperienceValidator } from './validators/sign-in-experience-validator.js'; import { buildVerificationRecord, verificationRecordDataGuard, @@ -38,6 +38,8 @@ const interactionStorageGuard = z.object({ * @see {@link https://github.com/logto-io/rfcs | Logto RFCs} for more information about RFC 0004. */ export default class ExperienceInteraction { + public readonly signInExperienceValidator: SignInExperienceValidator; + /** The user verification record list for the current interaction. */ private readonly verificationRecords = new Map(); /** The userId of the user for the current interaction. Only available once the user is identified. */ @@ -60,12 +62,15 @@ export default class ExperienceInteraction { ) { const { libraries, queries } = tenant; + this.signInExperienceValidator = new SignInExperienceValidator(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 }) @@ -91,9 +96,26 @@ export default class ExperienceInteraction { return this.#interactionEvent; } - /** Set the interaction event for the current interaction */ - public setInteractionEvent(interactionEvent: InteractionEvent) { - // TODO: conflict event check (e.g. reset password session can't be used for sign in) + /** + * Set the interaction event for the current interaction + * + * @throws RequestError with 403 if the interaction event is not allowed by the `SignInExperienceValidator` + * @throws RequestError with 400 if the interaction event is `ForgotPassword` and the current interaction event is not `ForgotPassword` + * @throws RequestError with 400 if the interaction event is not `ForgotPassword` and the current interaction event is `ForgotPassword` + */ + public async setInteractionEvent(interactionEvent: InteractionEvent) { + await this.signInExperienceValidator.guardInteractionEvent(interactionEvent); + + // `ForgotPassword` interaction event can not interchanged with other events + if (this.interactionEvent) { + assertThat( + interactionEvent === InteractionEvent.ForgotPassword + ? this.interactionEvent === InteractionEvent.ForgotPassword + : this.interactionEvent !== InteractionEvent.ForgotPassword, + new RequestError({ code: 'session.not_supported_for_forgot_password', status: 400 }) + ); + } + this.#interactionEvent = interactionEvent; } @@ -101,13 +123,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 `SignInExperienceValidator`. * - 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 +138,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.signInExperienceValidator.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 +224,46 @@ 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) { + // TODO: To be implemented + 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/utils.ts b/packages/core/src/routes/experience/classes/utils.ts index dfaa48847..575908b45 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/validators/sign-in-experience-validator.test.ts b/packages/core/src/routes/experience/classes/validators/sign-in-experience-validator.test.ts new file mode 100644 index 000000000..f7f49f296 --- /dev/null +++ b/packages/core/src/routes/experience/classes/validators/sign-in-experience-validator.test.ts @@ -0,0 +1,500 @@ +/* eslint-disable max-lines */ +import { + InteractionEvent, + type SignInExperience, + SignInIdentifier, + SignInMode, + VerificationType, +} from '@logto/schemas'; + +import { mockSignInExperience } from '#src/__mocks__/sign-in-experience.js'; +import RequestError from '#src/errors/RequestError/index.js'; +import { MockTenant } from '#src/test-utils/tenant.js'; + +import { CodeVerification } from '../verifications/code-verification.js'; +import { EnterpriseSsoVerification } from '../verifications/enterprise-sso-verification.js'; +import { type VerificationRecord } from '../verifications/index.js'; +import { PasswordVerification } from '../verifications/password-verification.js'; +import { SocialVerification } from '../verifications/social-verification.js'; + +import { SignInExperienceValidator } from './sign-in-experience-validator.js'; + +const { jest } = import.meta; + +const emailDomain = 'logto.io'; + +const signInExperiences = { + findDefaultSignInExperience: jest.fn().mockResolvedValue(mockSignInExperience), +}; +const ssoConnectors = { + getAvailableSsoConnectors: jest.fn().mockResolvedValue([]), +}; + +const mockTenant = new MockTenant(undefined, { signInExperiences }, undefined, { ssoConnectors }); + +const passwordVerificationRecords = Object.fromEntries( + Object.values(SignInIdentifier).map((identifier) => [ + identifier, + PasswordVerification.create(mockTenant.libraries, mockTenant.queries, { + type: identifier, + value: identifier === SignInIdentifier.Email ? `foo@${emailDomain}` : 'value', + }), + ]) +) as Record; + +const verificationCodeVerificationRecords = Object.freeze({ + [SignInIdentifier.Email]: CodeVerification.create( + mockTenant.libraries, + mockTenant.queries, + { + type: SignInIdentifier.Email, + value: `foo@${emailDomain}`, + }, + InteractionEvent.SignIn + ), + [SignInIdentifier.Phone]: CodeVerification.create( + mockTenant.libraries, + mockTenant.queries, + { + type: SignInIdentifier.Phone, + value: 'value', + }, + InteractionEvent.SignIn + ), +}); + +const enterpriseSsoVerificationRecords = EnterpriseSsoVerification.create( + mockTenant.libraries, + mockTenant.queries, + 'mock_connector_id' +); + +const socialVerificationRecord = new SocialVerification(mockTenant.libraries, mockTenant.queries, { + id: 'social_verification_id', + type: VerificationType.Social, + connectorId: 'mock_connector_id', + socialUserInfo: { + id: 'user_id', + email: `foo@${emailDomain}`, + }, +}); + +describe('SignInExperienceValidator', () => { + describe('guardInteractionEvent', () => { + it('SignInMode.Register', async () => { + const signInExperience = { + signInMode: SignInMode.Register, + }; + signInExperiences.findDefaultSignInExperience.mockResolvedValueOnce(signInExperience); + + const signInExperienceSettings = new SignInExperienceValidator( + mockTenant.libraries, + mockTenant.queries + ); + + await expect( + signInExperienceSettings.guardInteractionEvent(InteractionEvent.SignIn) + ).rejects.toMatchError(new RequestError({ code: 'auth.forbidden', status: 403 })); + + await expect( + signInExperienceSettings.guardInteractionEvent(InteractionEvent.Register) + ).resolves.not.toThrow(); + + await expect( + signInExperienceSettings.guardInteractionEvent(InteractionEvent.ForgotPassword) + ).resolves.not.toThrow(); + }); + + it('SignInMode.SignIn', async () => { + const signInExperience = { + signInMode: SignInMode.SignIn, + }; + signInExperiences.findDefaultSignInExperience.mockResolvedValueOnce(signInExperience); + + const signInExperienceSettings = new SignInExperienceValidator( + mockTenant.libraries, + mockTenant.queries + ); + await expect( + signInExperienceSettings.guardInteractionEvent(InteractionEvent.Register) + ).rejects.toMatchError(new RequestError({ code: 'auth.forbidden', status: 403 })); + + await expect( + signInExperienceSettings.guardInteractionEvent(InteractionEvent.SignIn) + ).resolves.not.toThrow(); + + await expect( + signInExperienceSettings.guardInteractionEvent(InteractionEvent.ForgotPassword) + ).resolves.not.toThrow(); + }); + + it('SignInMode.SignInAndRegister', async () => { + const signInExperience = { + signInMode: SignInMode.SignInAndRegister, + }; + signInExperiences.findDefaultSignInExperience.mockResolvedValueOnce(signInExperience); + + const signInExperienceSettings = new SignInExperienceValidator( + mockTenant.libraries, + mockTenant.queries + ); + + await expect( + signInExperienceSettings.guardInteractionEvent(InteractionEvent.Register) + ).resolves.not.toThrow(); + await expect( + signInExperienceSettings.guardInteractionEvent(InteractionEvent.SignIn) + ).resolves.not.toThrow(); + await expect( + signInExperienceSettings.guardInteractionEvent(InteractionEvent.ForgotPassword) + ).resolves.not.toThrow(); + }); + }); + + describe('verifyIdentificationMethod (SignIn)', () => { + const signInVerificationTestCases: Record< + string, + { + signInExperience: SignInExperience; + cases: Array<{ verificationRecord: VerificationRecord; accepted: boolean }>; + } + > = Object.freeze({ + 'password enabled for all identifiers': { + signInExperience: mockSignInExperience, + cases: [ + { + verificationRecord: passwordVerificationRecords[SignInIdentifier.Username], + accepted: true, + }, + { + verificationRecord: passwordVerificationRecords[SignInIdentifier.Email], + accepted: true, + }, + { + verificationRecord: passwordVerificationRecords[SignInIdentifier.Phone], + accepted: true, + }, + ], + }, + 'password disabled for email and phone': { + signInExperience: { + ...mockSignInExperience, + signIn: { + methods: mockSignInExperience.signIn.methods.map((method) => + method.identifier === SignInIdentifier.Username + ? method + : { ...method, password: false } + ), + }, + }, + cases: [ + { + verificationRecord: passwordVerificationRecords[SignInIdentifier.Username], + accepted: true, + }, + { + verificationRecord: passwordVerificationRecords[SignInIdentifier.Email], + accepted: false, + }, + { + verificationRecord: passwordVerificationRecords[SignInIdentifier.Phone], + accepted: false, + }, + ], + }, + 'verification code enabled for email and phone': { + signInExperience: mockSignInExperience, + cases: [ + { + verificationRecord: verificationCodeVerificationRecords[SignInIdentifier.Email], + accepted: true, + }, + { + verificationRecord: verificationCodeVerificationRecords[SignInIdentifier.Phone], + accepted: true, + }, + ], + }, + 'verification code disabled for email and phone': { + signInExperience: { + ...mockSignInExperience, + signIn: { + methods: mockSignInExperience.signIn.methods.map((method) => + method.identifier === SignInIdentifier.Username + ? method + : { ...method, verificationCode: false } + ), + }, + }, + cases: [ + { + verificationRecord: verificationCodeVerificationRecords[SignInIdentifier.Email], + accepted: false, + }, + { + verificationRecord: verificationCodeVerificationRecords[SignInIdentifier.Phone], + accepted: false, + }, + ], + }, + 'no sign-in methods is enabled': { + signInExperience: { + ...mockSignInExperience, + signIn: { + methods: [], + }, + }, + cases: [ + { + verificationRecord: passwordVerificationRecords[SignInIdentifier.Username], + accepted: false, + }, + { + verificationRecord: verificationCodeVerificationRecords[SignInIdentifier.Email], + accepted: false, + }, + { + verificationRecord: verificationCodeVerificationRecords[SignInIdentifier.Phone], + accepted: false, + }, + ], + }, + 'single sign-on enabled': { + signInExperience: { + ...mockSignInExperience, + singleSignOnEnabled: true, + }, + cases: [ + { + verificationRecord: enterpriseSsoVerificationRecords, + accepted: true, + }, + ], + }, + 'single sign-on disabled': { + signInExperience: { + ...mockSignInExperience, + singleSignOnEnabled: false, + }, + cases: [ + { + verificationRecord: enterpriseSsoVerificationRecords, + accepted: false, + }, + ], + }, + }); + + describe.each(Object.keys(signInVerificationTestCases))(`%s`, (testCase) => { + const { signInExperience, cases } = signInVerificationTestCases[testCase]!; + + it.each(cases)('guard verification record %p', async ({ verificationRecord, accepted }) => { + signInExperiences.findDefaultSignInExperience.mockResolvedValueOnce(signInExperience); + + const signInExperienceSettings = new SignInExperienceValidator( + mockTenant.libraries, + mockTenant.queries + ); + + await (accepted + ? expect( + signInExperienceSettings.verifyIdentificationMethod( + InteractionEvent.SignIn, + verificationRecord + ) + ).resolves.not.toThrow() + : expect( + signInExperienceSettings.verifyIdentificationMethod( + InteractionEvent.SignIn, + verificationRecord + ) + ).rejects.toMatchError( + new RequestError({ code: 'user.sign_in_method_not_enabled', status: 422 }) + )); + }); + }); + }); + + describe('verifyIdentificationMethod (Register)', () => { + const registerVerificationTestCases: Record< + string, + { + signInExperience: SignInExperience; + cases: Array<{ verificationRecord: VerificationRecord; accepted: boolean }>; + } + > = Object.freeze({ + 'only username is enabled for sign-up': { + signInExperience: mockSignInExperience, + cases: [ + // TODO: username password registration + { + verificationRecord: verificationCodeVerificationRecords[SignInIdentifier.Email], + accepted: false, + }, + { + verificationRecord: verificationCodeVerificationRecords[SignInIdentifier.Phone], + accepted: false, + }, + ], + }, + 'email is enabled for sign-up': { + signInExperience: { + ...mockSignInExperience, + signUp: { + identifiers: [SignInIdentifier.Email], + password: true, + verify: true, + }, + }, + cases: [ + { + verificationRecord: verificationCodeVerificationRecords[SignInIdentifier.Email], + accepted: true, + }, + { + verificationRecord: verificationCodeVerificationRecords[SignInIdentifier.Phone], + accepted: false, + }, + ], + }, + 'email and phone are enabled for sign-up': { + signInExperience: { + ...mockSignInExperience, + signUp: { + identifiers: [SignInIdentifier.Email, SignInIdentifier.Phone], + password: true, + verify: true, + }, + }, + cases: [ + { + verificationRecord: verificationCodeVerificationRecords[SignInIdentifier.Email], + accepted: true, + }, + { + verificationRecord: verificationCodeVerificationRecords[SignInIdentifier.Phone], + accepted: true, + }, + ], + }, + 'enterprise sso enabled': { + signInExperience: { + ...mockSignInExperience, + singleSignOnEnabled: true, + }, + cases: [ + { + verificationRecord: enterpriseSsoVerificationRecords, + accepted: true, + }, + ], + }, + 'enterprise sso disabled': { + signInExperience: { + ...mockSignInExperience, + singleSignOnEnabled: false, + }, + cases: [ + { + verificationRecord: enterpriseSsoVerificationRecords, + accepted: false, + }, + ], + }, + }); + + describe.each(Object.keys(registerVerificationTestCases))(`%s`, (testCase) => { + const { signInExperience, cases } = registerVerificationTestCases[testCase]!; + + it.each(cases)('guard verification record %p', async ({ verificationRecord, accepted }) => { + signInExperiences.findDefaultSignInExperience.mockResolvedValueOnce(signInExperience); + + const signInExperienceSettings = new SignInExperienceValidator( + mockTenant.libraries, + mockTenant.queries + ); + + await (accepted + ? expect( + signInExperienceSettings.verifyIdentificationMethod( + InteractionEvent.Register, + verificationRecord + ) + ).resolves.not.toThrow() + : expect( + signInExperienceSettings.verifyIdentificationMethod( + InteractionEvent.Register, + verificationRecord + ) + ).rejects.toMatchError( + new RequestError({ code: 'user.sign_up_method_not_enabled', status: 422 }) + )); + }); + }); + }); + + describe('guardSsoOnlyEmailIdentifier: identifier with SSO enabled domain should throw', () => { + const mockSsoConnector = { + domains: [emailDomain], + }; + + const expectError = new RequestError( + { + code: 'session.sso_enabled', + status: 422, + }, + { + ssoConnectors: [mockSsoConnector], + } + ); + + it('email password verification record', async () => { + ssoConnectors.getAvailableSsoConnectors.mockResolvedValueOnce([mockSsoConnector]); + + const signInExperienceSettings = new SignInExperienceValidator( + mockTenant.libraries, + mockTenant.queries + ); + + await expect( + signInExperienceSettings.verifyIdentificationMethod( + InteractionEvent.SignIn, + passwordVerificationRecords[SignInIdentifier.Email] + ) + ).rejects.toMatchError(expectError); + }); + + it('email verification code verification record', async () => { + ssoConnectors.getAvailableSsoConnectors.mockResolvedValueOnce([mockSsoConnector]); + + const signInExperienceSettings = new SignInExperienceValidator( + mockTenant.libraries, + mockTenant.queries + ); + + await expect( + signInExperienceSettings.verifyIdentificationMethod( + InteractionEvent.SignIn, + verificationCodeVerificationRecords[SignInIdentifier.Email] + ) + ).rejects.toMatchError(expectError); + }); + + it('social verification record', async () => { + ssoConnectors.getAvailableSsoConnectors.mockResolvedValueOnce([mockSsoConnector]); + + const signInExperienceSettings = new SignInExperienceValidator( + mockTenant.libraries, + mockTenant.queries + ); + + await expect( + signInExperienceSettings.verifyIdentificationMethod( + InteractionEvent.SignIn, + socialVerificationRecord + ) + ).rejects.toMatchError(expectError); + }); + }); +}); +/* eslint-enable max-lines */ diff --git a/packages/core/src/routes/experience/classes/validators/sign-in-experience-validator.ts b/packages/core/src/routes/experience/classes/validators/sign-in-experience-validator.ts new file mode 100644 index 000000000..26f095f3a --- /dev/null +++ b/packages/core/src/routes/experience/classes/validators/sign-in-experience-validator.ts @@ -0,0 +1,230 @@ +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 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; + } + } +}; + +/** + * SignInExperienceValidator class provides all the sign-in experience settings validation logic. + * + * - Guard the interaction event based on the sign-in experience settings + * - Guard the identification method based on the sign-in experience settings + * - Guard the email identifier with SSO enabled domains + */ +export class SignInExperienceValidator { + private signInExperienceDataCache?: SignInExperience; + + constructor( + private readonly libraries: Libraries, + private readonly queries: Queries + ) {} + + public 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; + } + } + } + + async verifyIdentificationMethod( + event: InteractionEvent, + verificationRecord: VerificationRecord + ) { + 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 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) + ); + }), + new RequestError({ code: 'user.sign_in_method_not_enabled', status: 422 }) + ); + break; + } + + case VerificationType.Social: { + // No need to verify social verification method + break; + } + case VerificationType.EnterpriseSso: { + assertThat( + singleSignOnEnabled, + new RequestError({ code: 'user.sign_in_method_not_enabled', status: 422 }) + ); + break; + } + default: { + throw new RequestError({ code: 'user.sign_in_method_not_enabled', status: 422 }); + } + } + } + + 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, + new RequestError({ code: 'user.sign_up_method_not_enabled', status: 422 }) + ); + break; + } + case VerificationType.Social: { + // No need to verify social verification method + break; + } + case VerificationType.EnterpriseSso: { + assertThat( + singleSignOnEnabled, + new RequestError({ code: 'user.sign_up_method_not_enabled', status: 422 }) + ); + break; + } + default: { + throw new RequestError({ code: 'user.sign_up_method_not_enabled', status: 422 }); + } + } + } + + /** Forgot password only supports verification code type verification record */ + private guardForgotPasswordVerificationMethod(verificationRecord: VerificationRecord) { + assertThat( + verificationRecord.type === VerificationType.VerificationCode, + new RequestError({ code: 'session.not_supported_for_forgot_password', status: 422 }) + ); + } +} 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 ccc8351af..e87aceed9 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( body: z.object({ interactionEvent: z.nativeEnum(InteractionEvent), }), - status: [204], + status: [204, 403], }), async (ctx, next) => { const { interactionEvent } = ctx.guard.body; @@ -61,7 +61,8 @@ export default function experienceApiRoutes( createLog(`Interaction.${interactionEvent}.Update`); const experienceInteraction = new ExperienceInteraction(ctx, tenant); - experienceInteraction.setInteractionEvent(interactionEvent); + + await experienceInteraction.setInteractionEvent(interactionEvent); await experienceInteraction.save(); @@ -78,7 +79,7 @@ export default function experienceApiRoutes( body: z.object({ interactionEvent: z.nativeEnum(InteractionEvent), }), - status: [204], + status: [204, 403], }), async (ctx, next) => { const { interactionEvent } = ctx.guard.body; @@ -88,7 +89,7 @@ export default function experienceApiRoutes( `Interaction.${experienceInteraction.interactionEvent ?? interactionEvent}.Update` ); - experienceInteraction.setInteractionEvent(interactionEvent); + await experienceInteraction.setInteractionEvent(interactionEvent); eventLog.append({ interactionEvent, @@ -106,16 +107,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/backup-code-verification.ts b/packages/core/src/routes/experience/verification-routes/backup-code-verification.ts index 282197c1e..b38b72cdb 100644 --- a/packages/core/src/routes/experience/verification-routes/backup-code-verification.ts +++ b/packages/core/src/routes/experience/verification-routes/backup-code-verification.ts @@ -30,7 +30,7 @@ export default function backupCodeVerificationRoutes( const { experienceInteraction } = ctx; const { code } = ctx.guard.body; - assertThat(experienceInteraction.identifiedUserId, 'session.not_identified'); + assertThat(experienceInteraction.identifiedUserId, 'session.identifier_not_found'); // TODO: Check if the MFA is enabled diff --git a/packages/core/src/routes/experience/verification-routes/totp-verification.ts b/packages/core/src/routes/experience/verification-routes/totp-verification.ts index d564a7bca..ef6a4925b 100644 --- a/packages/core/src/routes/experience/verification-routes/totp-verification.ts +++ b/packages/core/src/routes/experience/verification-routes/totp-verification.ts @@ -31,7 +31,7 @@ export default function totpVerificationRoutes( async (ctx, next) => { const { experienceInteraction } = ctx; - assertThat(experienceInteraction.identifiedUserId, 'session.not_identified'); + assertThat(experienceInteraction.identifiedUserId, 'session.identifier_not_found'); // TODO: Check if the MFA is enabled // TODO: Check if the interaction is fully verified @@ -71,7 +71,7 @@ export default function totpVerificationRoutes( const { experienceInteraction } = ctx; const { verificationId, code } = ctx.guard.body; - assertThat(experienceInteraction.identifiedUserId, 'session.not_identified'); + assertThat(experienceInteraction.identifiedUserId, 'session.identifier_not_found'); // Verify new generated secret if (verificationId) { 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 0ee92b68a..1e99894b5 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/integration-tests/src/helpers/experience/index.ts b/packages/integration-tests/src/helpers/experience/index.ts index af3ebe3e6..95082dda0 100644 --- a/packages/integration-tests/src/helpers/experience/index.ts +++ b/packages/integration-tests/src/helpers/experience/index.ts @@ -4,7 +4,7 @@ import { InteractionEvent, - InteractionIdentifierType, + SignInIdentifier, type InteractionIdentifier, type VerificationCodeIdentifier, } from '@logto/schemas'; @@ -86,7 +86,7 @@ export const identifyUserWithUsernamePassword = async ( const { verificationId } = await client.verifyPassword({ identifier: { - type: InteractionIdentifierType.Username, + type: SignInIdentifier.Username, value: username, }, password, diff --git a/packages/integration-tests/src/tests/api/experience-api/interaction.test.ts b/packages/integration-tests/src/tests/api/experience-api/interaction.test.ts index 37be24186..bdf0315dd 100644 --- a/packages/integration-tests/src/tests/api/experience-api/interaction.test.ts +++ b/packages/integration-tests/src/tests/api/experience-api/interaction.test.ts @@ -1,4 +1,4 @@ -import { InteractionEvent, InteractionIdentifierType } from '@logto/schemas'; +import { InteractionEvent, SignInIdentifier } from '@logto/schemas'; import { initExperienceClient } from '#src/helpers/client.js'; import { expectRejects } from '#src/helpers/index.js'; @@ -19,7 +19,7 @@ devFeatureTest.describe('PUT /experience API', () => { const client = await initExperienceClient(); await client.initInteraction({ interactionEvent: InteractionEvent.SignIn }); const { verificationId } = await client.verifyPassword({ - identifier: { type: InteractionIdentifierType.Username, value: username }, + identifier: { type: SignInIdentifier.Username, value: username }, password, }); diff --git a/packages/integration-tests/src/tests/api/experience-api/sign-in-interaction/password.test.ts b/packages/integration-tests/src/tests/api/experience-api/sign-in-interaction/password.test.ts index 0663ca7ff..fcf9a6093 100644 --- a/packages/integration-tests/src/tests/api/experience-api/sign-in-interaction/password.test.ts +++ b/packages/integration-tests/src/tests/api/experience-api/sign-in-interaction/password.test.ts @@ -1,4 +1,4 @@ -import { InteractionIdentifierType } from '@logto/schemas'; +import { SignInIdentifier } from '@logto/schemas'; import { deleteUser } from '#src/api/admin-user.js'; import { signInWithPassword } from '#src/helpers/experience/index.js'; @@ -17,7 +17,7 @@ devFeatureTest.describe('sign-in with password verification happy path', () => { await enableAllPasswordSignInMethods(); }); - it.each(Object.values(InteractionIdentifierType))( + it.each(Object.values(SignInIdentifier))( 'should sign-in with password using %p', async (identifier) => { const { userProfile, user } = await generateNewUser({ diff --git a/packages/integration-tests/src/tests/api/experience-api/sign-in-interaction/verification-code.test.ts b/packages/integration-tests/src/tests/api/experience-api/sign-in-interaction/verification-code.test.ts index d18eb8e5b..4deb166f1 100644 --- a/packages/integration-tests/src/tests/api/experience-api/sign-in-interaction/verification-code.test.ts +++ b/packages/integration-tests/src/tests/api/experience-api/sign-in-interaction/verification-code.test.ts @@ -1,4 +1,4 @@ -import { InteractionIdentifierType } from '@logto/schemas'; +import { SignInIdentifier } from '@logto/schemas'; import { deleteUser } from '#src/api/admin-user.js'; import { setEmailConnector, setSmsConnector } from '#src/helpers/connector.js'; @@ -7,10 +7,8 @@ import { enableAllVerificationCodeSignInMethods } from '#src/helpers/sign-in-exp import { generateNewUser } from '#src/helpers/user.js'; import { devFeatureTest } from '#src/utils.js'; -const verificationIdentifierType: readonly [ - InteractionIdentifierType.Email, - InteractionIdentifierType.Phone, -] = Object.freeze([InteractionIdentifierType.Email, InteractionIdentifierType.Phone]); +const verificationIdentifierType: readonly [SignInIdentifier.Email, SignInIdentifier.Phone] = + Object.freeze([SignInIdentifier.Email, SignInIdentifier.Phone]); const identifiersTypeToUserProfile = Object.freeze({ email: 'primaryEmail', diff --git a/packages/integration-tests/src/tests/api/experience-api/verifications/backup-code-verification.test.ts b/packages/integration-tests/src/tests/api/experience-api/verifications/backup-code-verification.test.ts index d5148b7e5..206c285fd 100644 --- a/packages/integration-tests/src/tests/api/experience-api/verifications/backup-code-verification.test.ts +++ b/packages/integration-tests/src/tests/api/experience-api/verifications/backup-code-verification.test.ts @@ -25,7 +25,7 @@ devFeatureTest.describe('backup code verification APIs', () => { const client = await initExperienceClient(); await expectRejects(client.verifyBackupCode({ code: '1234' }), { - code: 'session.not_identified', + code: 'session.identifier_not_found', status: 400, }); }); diff --git a/packages/integration-tests/src/tests/api/experience-api/verifications/password-verification.test.ts b/packages/integration-tests/src/tests/api/experience-api/verifications/password-verification.test.ts index 372a80a0f..f2c8c9cba 100644 --- a/packages/integration-tests/src/tests/api/experience-api/verifications/password-verification.test.ts +++ b/packages/integration-tests/src/tests/api/experience-api/verifications/password-verification.test.ts @@ -1,4 +1,4 @@ -import { InteractionIdentifierType } from '@logto/schemas'; +import { SignInIdentifier } from '@logto/schemas'; import { deleteUser } from '#src/api/admin-user.js'; import { initExperienceClient } from '#src/helpers/client.js'; @@ -12,7 +12,7 @@ const identifiersTypeToUserProfile = Object.freeze({ }); devFeatureTest.describe('password verifications', () => { - it.each(Object.values(InteractionIdentifierType))( + it.each(Object.values(SignInIdentifier))( 'should verify with password successfully using %p', async (identifier) => { const { userProfile, user } = await generateNewUser({ diff --git a/packages/integration-tests/src/tests/api/experience-api/verifications/social-verification.test.ts b/packages/integration-tests/src/tests/api/experience-api/verifications/social-verification.test.ts index 6962924e3..6350cdcd3 100644 --- a/packages/integration-tests/src/tests/api/experience-api/verifications/social-verification.test.ts +++ b/packages/integration-tests/src/tests/api/experience-api/verifications/social-verification.test.ts @@ -1,5 +1,5 @@ import { ConnectorType } from '@logto/connector-kit'; -import { InteractionEvent, InteractionIdentifierType } from '@logto/schemas'; +import { InteractionEvent, SignInIdentifier } from '@logto/schemas'; import { mockEmailConnectorId, mockSocialConnectorId } from '#src/__mocks__/connectors-mock.js'; import { initExperienceClient } from '#src/helpers/client.js'; @@ -134,7 +134,7 @@ devFeatureTest.describe('social verification', () => { const { verificationId } = await client.sendVerificationCode({ identifier: { - type: InteractionIdentifierType.Email, + type: SignInIdentifier.Email, value: 'foo', }, interactionEvent: InteractionEvent.SignIn, diff --git a/packages/integration-tests/src/tests/api/experience-api/verifications/totp-verification.test.ts b/packages/integration-tests/src/tests/api/experience-api/verifications/totp-verification.test.ts index 7e77e99ea..c1fa2a76b 100644 --- a/packages/integration-tests/src/tests/api/experience-api/verifications/totp-verification.test.ts +++ b/packages/integration-tests/src/tests/api/experience-api/verifications/totp-verification.test.ts @@ -31,7 +31,7 @@ devFeatureTest.describe('TOTP verification APIs', () => { const client = await initExperienceClient(); await expectRejects(client.createTotpSecret(), { - code: 'session.not_identified', + code: 'session.identifier_not_found', status: 400, }); }); @@ -50,7 +50,7 @@ devFeatureTest.describe('TOTP verification APIs', () => { const client = await initExperienceClient(); await expectRejects(client.verifyTotp({ code: '1234' }), { - code: 'session.not_identified', + code: 'session.identifier_not_found', status: 400, }); }); @@ -107,7 +107,7 @@ devFeatureTest.describe('TOTP verification APIs', () => { const client = await initExperienceClient(); await expectRejects(client.verifyTotp({ code: '1234' }), { - code: 'session.not_identified', + code: 'session.identifier_not_found', status: 400, }); }); diff --git a/packages/integration-tests/src/tests/api/experience-api/verifications/verification-code.test.ts b/packages/integration-tests/src/tests/api/experience-api/verifications/verification-code.test.ts index 169d82f81..66dafe72b 100644 --- a/packages/integration-tests/src/tests/api/experience-api/verifications/verification-code.test.ts +++ b/packages/integration-tests/src/tests/api/experience-api/verifications/verification-code.test.ts @@ -1,7 +1,7 @@ import { ConnectorType } from '@logto/connector-kit'; import { InteractionEvent, - InteractionIdentifierType, + SignInIdentifier, type VerificationCodeIdentifier, } from '@logto/schemas'; @@ -25,11 +25,11 @@ devFeatureTest.describe('Verification code verification APIs', () => { const identifiers: VerificationCodeIdentifier[] = [ { - type: InteractionIdentifierType.Email, + type: SignInIdentifier.Email, value: 'foo@logto.io', }, { - type: InteractionIdentifierType.Phone, + type: SignInIdentifier.Phone, value: '+1234567890', }, ]; diff --git a/packages/phrases/src/locales/en/errors/session.ts b/packages/phrases/src/locales/en/errors/session.ts index 34c58ac1f..ab6e00e11 100644 --- a/packages/phrases/src/locales/en/errors/session.ts +++ b/packages/phrases/src/locales/en/errors/session.ts @@ -23,7 +23,6 @@ const session = { interaction_not_found: 'Interaction session not found. Please go back and start the session again.', not_supported_for_forgot_password: 'This operation is not supported for forgot password.', - not_identified: 'User not identified. Please sign in first.', identity_conflict: 'Identity mismatch detected. Please initiate a new session to proceed with a different identity.', mfa: { diff --git a/packages/schemas/src/types/interactions.ts b/packages/schemas/src/types/interactions.ts index 877974224..82b77965b 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;