From e4b007da38b551e6e5caecddb26724d4d2ebfb96 Mon Sep 17 00:00:00 2001 From: simeng-li Date: Thu, 1 Dec 2022 09:48:24 +0800 Subject: [PATCH] refactor(core): add profile verification (#2551) --- .../src/routes/interaction/types/guard.ts | 22 +- .../src/routes/interaction/types/index.ts | 4 +- .../src/routes/interaction/utils/index.ts | 10 +- .../routes/interaction/utils/interaction.ts | 2 +- .../identifier-verification.test.ts | 10 +- .../verifications/identifier-verification.ts | 4 +- ...profile-verification-profile-exist.test.ts | 113 +++++++++ ...le-verification-profile-registered.test.ts | 197 ++++++++++++++++ ...-verification-protected-identifier.test.ts | 199 ++++++++++++++++ .../verifications/profile-verification.ts | 219 ++++++++++++++++++ 10 files changed, 764 insertions(+), 16 deletions(-) create mode 100644 packages/core/src/routes/interaction/verifications/profile-verification-profile-exist.test.ts create mode 100644 packages/core/src/routes/interaction/verifications/profile-verification-profile-registered.test.ts create mode 100644 packages/core/src/routes/interaction/verifications/profile-verification-protected-identifier.test.ts create mode 100644 packages/core/src/routes/interaction/verifications/profile-verification.ts diff --git a/packages/core/src/routes/interaction/types/guard.ts b/packages/core/src/routes/interaction/types/guard.ts index e1f2351f7..43d8cf5fc 100644 --- a/packages/core/src/routes/interaction/types/guard.ts +++ b/packages/core/src/routes/interaction/types/guard.ts @@ -7,8 +7,10 @@ import type { PhonePasscodePayload, } from '@logto/schemas'; import { + usernamePasswordPayloadGuard, emailPasscodePayloadGuard, phonePasscodePayloadGuard, + socialConnectorPayloadGuard, eventGuard, profileGuard, identifierGuard, @@ -20,7 +22,7 @@ import { z } from 'zod'; const forgotPasswordInteractionPayloadGuard = z.object({ event: z.literal(Event.ForgotPassword), identifier: z.union([emailPasscodePayloadGuard, phonePasscodePayloadGuard]).optional(), - profile: profileGuard.pick({ password: true }).optional(), + profile: z.object({ password: z.string() }).optional(), }); const registerInteractionPayloadGuard = z.object({ @@ -51,7 +53,7 @@ export type PasswordIdentifierPayload = export type PasscodeIdentifierPayload = EmailPasscodePayload | PhonePasscodePayload; -// Passcode Send Route Guard +// Passcode Send Route Payload Guard export const sendPasscodePayloadGuard = z.union([ z.object({ event: eventGuard, @@ -62,14 +64,24 @@ export const sendPasscodePayloadGuard = z.union([ phone: z.string().regex(phoneRegEx), }), ]); - export type SendPasscodePayload = z.infer; -// Social Authorization Uri Route Guard +// Social Authorization Uri Route Payload Guard export const getSocialAuthorizationUrlPayloadGuard = z.object({ connectorId: z.string(), state: z.string(), redirectUri: z.string().refine((url) => validateRedirectUrl(url, 'web')), }); - export type SocialAuthorizationUrlPayload = z.infer; + +// Register Profile Guard +const emailProfileGuard = emailPasscodePayloadGuard.pick({ email: true }); +const phoneProfileGuard = phonePasscodePayloadGuard.pick({ phone: true }); +const socialProfileGuard = socialConnectorPayloadGuard.pick({ connectorId: true }); + +export const registerProfileSafeGuard = z.union([ + usernamePasswordPayloadGuard, + emailProfileGuard, + phoneProfileGuard, + socialProfileGuard, +]); diff --git a/packages/core/src/routes/interaction/types/index.ts b/packages/core/src/routes/interaction/types/index.ts index c0a51eb3a..5087937bb 100644 --- a/packages/core/src/routes/interaction/types/index.ts +++ b/packages/core/src/routes/interaction/types/index.ts @@ -13,9 +13,9 @@ export type Identifier = export type AccountIdIdentifier = { key: 'accountId'; value: string }; -export type VerifiedEmailIdentifier = { key: 'verifiedEmail'; value: string }; +export type VerifiedEmailIdentifier = { key: 'emailVerified'; value: string }; -export type VerifiedPhoneIdentifier = { key: 'verifiedPhone'; value: string }; +export type VerifiedPhoneIdentifier = { key: 'phoneVerified'; value: string }; export type SocialIdentifier = { key: 'social'; connectorId: string; value: UseInfo }; diff --git a/packages/core/src/routes/interaction/utils/index.ts b/packages/core/src/routes/interaction/utils/index.ts index d7012b85a..a12d4a188 100644 --- a/packages/core/src/routes/interaction/utils/index.ts +++ b/packages/core/src/routes/interaction/utils/index.ts @@ -1,4 +1,4 @@ -import type { Profile, SocialConnectorPayload } from '@logto/schemas'; +import type { Profile, SocialConnectorPayload, User } from '@logto/schemas'; import type { PasscodeIdentifierPayload, @@ -32,3 +32,11 @@ export const isProfileIdentifier = ( return profile?.connectorId === identifier.connectorId; }; + +// Social identities can take place the role of password +export const isUserPasswordSet = ({ + passwordEncrypted, + identities, +}: Pick): boolean => { + return Boolean(passwordEncrypted) || Object.keys(identities).length > 0; +}; diff --git a/packages/core/src/routes/interaction/utils/interaction.ts b/packages/core/src/routes/interaction/utils/interaction.ts index eef648ec3..0e65117f5 100644 --- a/packages/core/src/routes/interaction/utils/interaction.ts +++ b/packages/core/src/routes/interaction/utils/interaction.ts @@ -9,5 +9,5 @@ export const assignIdentifierVerificationResult = async ( ctx: Context, provider: Provider ) => { - await provider.interactionResult(ctx.req, ctx.res, payload); + await provider.interactionResult(ctx.req, ctx.res, payload, { mergeWithLastSubmission: true }); }; diff --git a/packages/core/src/routes/interaction/verifications/identifier-verification.test.ts b/packages/core/src/routes/interaction/verifications/identifier-verification.test.ts index af06ab781..db4e8cfc0 100644 --- a/packages/core/src/routes/interaction/verifications/identifier-verification.test.ts +++ b/packages/core/src/routes/interaction/verifications/identifier-verification.test.ts @@ -150,7 +150,7 @@ describe('identifier verification', () => { expect(result).toEqual([ { key: 'accountId', value: 'foo' }, - { key: 'verifiedEmail', value: 'email' }, + { key: 'emailVerified', value: 'email' }, ]); }); @@ -178,7 +178,7 @@ describe('identifier verification', () => { expect(assignIdentifierVerificationResult).toBeCalledWith( { event: Event.SignIn, - identifiers: [{ key: 'verifiedEmail', value: 'email' }], + identifiers: [{ key: 'emailVerified', value: 'email' }], }, ctx, provider @@ -231,7 +231,7 @@ describe('identifier verification', () => { ); expect(findUserByIdentifierMock).not.toBeCalled(); - expect(result).toEqual([{ key: 'verifiedEmail', value: 'email' }]); + expect(result).toEqual([{ key: 'emailVerified', value: 'email' }]); }); it('phone passcode with no profile', async () => { @@ -256,7 +256,7 @@ describe('identifier verification', () => { expect(result).toEqual([ { key: 'accountId', value: 'foo' }, - { key: 'verifiedPhone', value: 'phone' }, + { key: 'phoneVerified', value: 'phone' }, ]); }); @@ -282,6 +282,6 @@ describe('identifier verification', () => { ); expect(findUserByIdentifierMock).not.toBeCalled(); - expect(result).toEqual([{ key: 'verifiedPhone', value: 'phone' }]); + expect(result).toEqual([{ key: 'phoneVerified', value: 'phone' }]); }); }); diff --git a/packages/core/src/routes/interaction/verifications/identifier-verification.ts b/packages/core/src/routes/interaction/verifications/identifier-verification.ts index c79b5bd4b..f4f7d1660 100644 --- a/packages/core/src/routes/interaction/verifications/identifier-verification.ts +++ b/packages/core/src/routes/interaction/verifications/identifier-verification.ts @@ -42,8 +42,8 @@ const passcodeIdentifierVerification = async ( const verifiedPasscodeIdentifier: Identifier = 'email' in identifier - ? { key: 'verifiedEmail', value: identifier.email } - : { key: 'verifiedPhone', value: identifier.phone }; + ? { key: 'emailVerified', value: identifier.email } + : { key: 'phoneVerified', value: identifier.phone }; // Return the verified identity directly if it is for new profile identity verification if (isProfileIdentifier(identifier, profile)) { diff --git a/packages/core/src/routes/interaction/verifications/profile-verification-profile-exist.test.ts b/packages/core/src/routes/interaction/verifications/profile-verification-profile-exist.test.ts new file mode 100644 index 000000000..50ab19782 --- /dev/null +++ b/packages/core/src/routes/interaction/verifications/profile-verification-profile-exist.test.ts @@ -0,0 +1,113 @@ +import { Event } from '@logto/schemas'; + +import RequestError from '#src/errors/RequestError/index.js'; +import { findUserById } from '#src/queries/user.js'; +import { createContextWithRouteParameters } from '#src/utils/test-utils.js'; + +import type { Identifier, InteractionContext } from '../types/index.js'; +import profileVerification from './profile-verification.js'; + +jest.mock('#src/queries/user.js', () => ({ + findUserById: jest.fn().mockResolvedValue({ id: 'foo' }), + hasUserWithEmail: jest.fn().mockResolvedValue(false), + hasUserWithPhone: jest.fn().mockResolvedValue(false), + hasUserWithIdentity: jest.fn().mockResolvedValue(false), +})); + +jest.mock('../utils/index.js', () => ({ + isUserPasswordSet: jest.fn().mockResolvedValueOnce(true), +})); + +describe('Existed profile should throw', () => { + const baseCtx = createContextWithRouteParameters(); + const identifiers: Identifier[] = [ + { key: 'accountId', value: 'foo' }, + { key: 'emailVerified', value: 'email' }, + { key: 'phoneVerified', value: 'phone' }, + { key: 'social', connectorId: 'connectorId', value: { id: 'foo' } }, + ]; + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('username exist', async () => { + (findUserById as jest.Mock).mockResolvedValueOnce({ id: 'foo', username: 'foo' }); + + const ctx: InteractionContext = { + ...baseCtx, + interactionPayload: { + event: Event.SignIn, + profile: { + username: 'username', + }, + }, + }; + + await expect(profileVerification(ctx, identifiers)).rejects.toMatchError( + new RequestError({ + code: 'user.username_exists', + }) + ); + }); + + it('email exist', async () => { + (findUserById as jest.Mock).mockResolvedValueOnce({ id: 'foo', primaryEmail: 'email' }); + + const ctx: InteractionContext = { + ...baseCtx, + interactionPayload: { + event: Event.SignIn, + profile: { + email: 'email', + }, + }, + }; + + await expect(profileVerification(ctx, identifiers)).rejects.toMatchError( + new RequestError({ + code: 'user.email_exists', + }) + ); + }); + + it('phone exist', async () => { + (findUserById as jest.Mock).mockResolvedValueOnce({ id: 'foo', primaryPhone: 'phone' }); + + const ctx: InteractionContext = { + ...baseCtx, + interactionPayload: { + event: Event.SignIn, + profile: { + phone: 'phone', + }, + }, + }; + + await expect(profileVerification(ctx, identifiers)).rejects.toMatchError( + new RequestError({ + code: 'user.sms_exists', + }) + ); + }); + + it('password exist', async () => { + (findUserById as jest.Mock).mockResolvedValueOnce({ id: 'foo' }); + + const ctx: InteractionContext = { + ...baseCtx, + interactionPayload: { + event: Event.SignIn, + profile: { + password: 'password', + }, + }, + }; + + await expect(profileVerification(ctx, identifiers)).rejects.toMatchError( + new RequestError({ + code: 'user.password_exists', + }) + ); + }); +}); diff --git a/packages/core/src/routes/interaction/verifications/profile-verification-profile-registered.test.ts b/packages/core/src/routes/interaction/verifications/profile-verification-profile-registered.test.ts new file mode 100644 index 000000000..9c98279a9 --- /dev/null +++ b/packages/core/src/routes/interaction/verifications/profile-verification-profile-registered.test.ts @@ -0,0 +1,197 @@ +import { Event } from '@logto/schemas'; + +import RequestError from '#src/errors/RequestError/index.js'; +import { + hasUser, + hasUserWithEmail, + hasUserWithPhone, + hasUserWithIdentity, +} from '#src/queries/user.js'; +import { createContextWithRouteParameters } from '#src/utils/test-utils.js'; + +import type { Identifier, InteractionContext } from '../types/index.js'; +import profileVerification from './profile-verification.js'; + +jest.mock('#src/queries/user.js', () => ({ + hasUser: jest.fn().mockResolvedValue(false), + findUserById: jest.fn().mockResolvedValue({ id: 'foo' }), + hasUserWithEmail: jest.fn().mockResolvedValue(false), + hasUserWithPhone: jest.fn().mockResolvedValue(false), + hasUserWithIdentity: jest.fn().mockResolvedValue(false), +})); + +jest.mock('#src/connectors/index.js', () => ({ + getLogtoConnectorById: jest.fn().mockResolvedValue({ + metadata: { target: 'logto' }, + }), +})); + +const baseCtx = createContextWithRouteParameters(); +const identifiers: Identifier[] = [ + { key: 'accountId', value: 'foo' }, + { key: 'emailVerified', value: 'email@logto.io' }, + { key: 'phoneVerified', value: '123456' }, + { key: 'social', connectorId: 'connectorId', value: { id: 'foo' } }, +]; + +describe('register payload guard', () => { + it('username only should throw', async () => { + const ctx: InteractionContext = { + ...baseCtx, + interactionPayload: { + event: Event.Register, + profile: { + username: 'username', + }, + }, + }; + + await expect(profileVerification(ctx, identifiers)).rejects.toThrow(); + }); + + it('password only should throw', async () => { + const ctx: InteractionContext = { + ...baseCtx, + interactionPayload: { + event: Event.Register, + profile: { + password: 'password', + }, + }, + }; + + await expect(profileVerification(ctx, identifiers)).rejects.toThrow(); + }); + + it('username password is valid', async () => { + const ctx: InteractionContext = { + ...baseCtx, + interactionPayload: { + event: Event.Register, + profile: { + username: 'username', + password: 'password', + }, + }, + }; + + await expect(profileVerification(ctx, identifiers)).resolves.not.toThrow(); + }); + + it('username with a given email is valid', async () => { + const ctx: InteractionContext = { + ...baseCtx, + interactionPayload: { + event: Event.Register, + profile: { + username: 'username', + email: 'email@logto.io', + }, + }, + }; + + await expect(profileVerification(ctx, identifiers)).resolves.not.toThrow(); + }); + + it('password with a given email is valid', async () => { + const ctx: InteractionContext = { + ...baseCtx, + interactionPayload: { + event: Event.Register, + profile: { + password: 'password', + email: 'email@logto.io', + }, + }, + }; + + await expect(profileVerification(ctx, identifiers)).resolves.not.toThrow(); + }); +}); + +describe('profile registered validation', () => { + it('username is registered', async () => { + (hasUser as jest.Mock).mockResolvedValueOnce(true); + + const ctx: InteractionContext = { + ...baseCtx, + interactionPayload: { + event: Event.Register, + profile: { + username: 'username', + password: 'password', + }, + }, + }; + + await expect(profileVerification(ctx, identifiers)).rejects.toMatchError( + new RequestError({ + code: 'user.username_exists_register', + status: 422, + }) + ); + }); + + it('email is registered', async () => { + (hasUserWithEmail as jest.Mock).mockResolvedValueOnce(true); + + const ctx: InteractionContext = { + ...baseCtx, + interactionPayload: { + event: Event.Register, + profile: { + email: 'email@logto.io', + }, + }, + }; + + await expect(profileVerification(ctx, identifiers)).rejects.toMatchError( + new RequestError({ + code: 'user.email_exists_register', + status: 422, + }) + ); + }); + + it('phone is registered', async () => { + (hasUserWithPhone as jest.Mock).mockResolvedValueOnce(true); + + const ctx: InteractionContext = { + ...baseCtx, + interactionPayload: { + event: Event.Register, + profile: { + phone: '123456', + }, + }, + }; + + await expect(profileVerification(ctx, identifiers)).rejects.toMatchError( + new RequestError({ + code: 'user.phone_exists_register', + status: 422, + }) + ); + }); + + it('connector identity exist', async () => { + (hasUserWithIdentity as jest.Mock).mockResolvedValueOnce(true); + + const ctx: InteractionContext = { + ...baseCtx, + interactionPayload: { + event: Event.Register, + profile: { + connectorId: 'connectorId', + }, + }, + }; + + await expect(profileVerification(ctx, identifiers)).rejects.toMatchError( + new RequestError({ + code: 'user.identity_exists', + status: 422, + }) + ); + }); +}); diff --git a/packages/core/src/routes/interaction/verifications/profile-verification-protected-identifier.test.ts b/packages/core/src/routes/interaction/verifications/profile-verification-protected-identifier.test.ts new file mode 100644 index 000000000..2a76b2770 --- /dev/null +++ b/packages/core/src/routes/interaction/verifications/profile-verification-protected-identifier.test.ts @@ -0,0 +1,199 @@ +import { Event } from '@logto/schemas'; + +import RequestError from '#src/errors/RequestError/index.js'; +import { createContextWithRouteParameters } from '#src/utils/test-utils.js'; + +import type { Identifier, InteractionContext } from '../types/index.js'; +import profileVerification from './profile-verification.js'; + +jest.mock('#src/queries/user.js', () => ({ + findUserById: jest.fn().mockResolvedValue({ id: 'foo' }), + hasUserWithEmail: jest.fn().mockResolvedValue(false), + hasUserWithPhone: jest.fn().mockResolvedValue(false), + hasUserWithIdentity: jest.fn().mockResolvedValue(false), +})); + +jest.mock('#src/connectors/index.js', () => ({ + getLogtoConnectorById: jest.fn().mockResolvedValue({ + metadata: { target: 'logto' }, + }), +})); + +describe('profile protected identifier verification', () => { + const baseCtx = createContextWithRouteParameters(); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('email, phone and social identifier must be verified', () => { + it('email without a verified identifier should throw', async () => { + const ctx: InteractionContext = { + ...baseCtx, + interactionPayload: { + event: Event.SignIn, + profile: { + email: 'email', + }, + }, + }; + + const identifiers: Identifier[] = [{ key: 'accountId', value: 'foo' }]; + + await expect(profileVerification(ctx, identifiers)).rejects.toMatchError( + new RequestError({ code: 'session.verification_session_not_found', status: 404 }) + ); + }); + + it('email with unmatched identifier should throw', async () => { + const ctx: InteractionContext = { + ...baseCtx, + interactionPayload: { + event: Event.SignIn, + profile: { + email: 'email', + }, + }, + }; + + const identifiers: Identifier[] = [ + { key: 'accountId', value: 'foo' }, + { key: 'emailVerified', value: 'phone' }, + ]; + await expect(profileVerification(ctx, identifiers)).rejects.toMatchError( + new RequestError({ code: 'session.verification_session_not_found', status: 404 }) + ); + }); + + it('email with proper identifier should not throw', async () => { + const ctx: InteractionContext = { + ...baseCtx, + interactionPayload: { + event: Event.SignIn, + profile: { + email: 'email', + }, + }, + }; + + const identifiers: Identifier[] = [ + { key: 'accountId', value: 'foo' }, + { key: 'emailVerified', value: 'email' }, + ]; + await expect(profileVerification(ctx, identifiers)).resolves.not.toThrow(); + }); + + it('phone without a verified identifier should throw', async () => { + const ctx: InteractionContext = { + ...baseCtx, + interactionPayload: { + event: Event.SignIn, + profile: { + phone: 'phone', + }, + }, + }; + + const identifiers: Identifier[] = [{ key: 'accountId', value: 'foo' }]; + + await expect(profileVerification(ctx, identifiers)).rejects.toMatchError( + new RequestError({ code: 'session.verification_session_not_found', status: 404 }) + ); + }); + + it('phone with unmatched identifier should throw', async () => { + const ctx: InteractionContext = { + ...baseCtx, + interactionPayload: { + event: Event.SignIn, + profile: { + phone: 'phone', + }, + }, + }; + + const identifiers: Identifier[] = [ + { key: 'accountId', value: 'foo' }, + { key: 'phoneVerified', value: 'email' }, + ]; + await expect(profileVerification(ctx, identifiers)).rejects.toMatchError( + new RequestError({ code: 'session.verification_session_not_found', status: 404 }) + ); + }); + + it('phone with proper identifier should not throw', async () => { + const ctx: InteractionContext = { + ...baseCtx, + interactionPayload: { + event: Event.SignIn, + profile: { + phone: 'phone', + }, + }, + }; + + const identifiers: Identifier[] = [ + { key: 'accountId', value: 'foo' }, + { key: 'phoneVerified', value: 'phone' }, + ]; + await expect(profileVerification(ctx, identifiers)).resolves.not.toThrow(); + }); + + it('connectorId without a verified identifier should throw', async () => { + const ctx: InteractionContext = { + ...baseCtx, + interactionPayload: { + event: Event.SignIn, + profile: { + connectorId: 'connectorId', + }, + }, + }; + + const identifiers: Identifier[] = [{ key: 'accountId', value: 'foo' }]; + + await expect(profileVerification(ctx, identifiers)).rejects.toMatchError( + new RequestError({ code: 'session.connector_session_not_found', status: 404 }) + ); + }); + + it('connectorId with unmatched identifier should throw', async () => { + const ctx: InteractionContext = { + ...baseCtx, + interactionPayload: { + event: Event.SignIn, + profile: { + connectorId: 'logto', + }, + }, + }; + + const identifiers: Identifier[] = [ + { key: 'accountId', value: 'foo' }, + { key: 'social', connectorId: 'connectorId', value: { id: 'foo' } }, + ]; + await expect(profileVerification(ctx, identifiers)).rejects.toMatchError( + new RequestError({ code: 'session.connector_session_not_found', status: 404 }) + ); + }); + + it('connectorId with proper identifier should not throw', async () => { + const ctx: InteractionContext = { + ...baseCtx, + interactionPayload: { + event: Event.SignIn, + profile: { + connectorId: 'logto', + }, + }, + }; + + const identifiers: Identifier[] = [ + { key: 'accountId', value: 'foo' }, + { key: 'social', connectorId: 'logto', value: { id: 'foo' } }, + ]; + + await expect(profileVerification(ctx, identifiers)).resolves.not.toThrow(); + }); + }); +}); diff --git a/packages/core/src/routes/interaction/verifications/profile-verification.ts b/packages/core/src/routes/interaction/verifications/profile-verification.ts new file mode 100644 index 000000000..06c078f7d --- /dev/null +++ b/packages/core/src/routes/interaction/verifications/profile-verification.ts @@ -0,0 +1,219 @@ +import type { Profile, User } from '@logto/schemas'; +import { Event } from '@logto/schemas'; +import { argon2Verify } from 'hash-wasm'; + +import { getLogtoConnectorById } from '#src/connectors/index.js'; +import RequestError from '#src/errors/RequestError/index.js'; +import { + findUserById, + hasUser, + hasUserWithEmail, + hasUserWithPhone, + hasUserWithIdentity, +} from '#src/queries/user.js'; +import assertThat from '#src/utils/assert-that.js'; + +import { registerProfileSafeGuard } from '../types/guard.js'; +import type { + InteractionContext, + Identifier, + AccountIdIdentifier, + SocialIdentifier, +} from '../types/index.js'; +import { isUserPasswordSet } from '../utils/index.js'; + +const findUserByIdentifier = async (identifiers: Identifier[]) => { + const accountIdentifier = identifiers.find( + (identifier): identifier is AccountIdIdentifier => identifier.key === 'accountId' + ); + + assertThat( + accountIdentifier, + new RequestError({ + code: 'session.unauthorized', + status: 401, + }) + ); + + return findUserById(accountIdentifier.value); +}; + +const verifyProtectedIdentifiers = ( + { email, phone, connectorId }: Profile, + identifiers: Identifier[] +) => { + if (email) { + assertThat( + identifiers.some(({ key, value }) => key === 'emailVerified' && value === email), + new RequestError({ + code: 'session.verification_session_not_found', + status: 404, + }) + ); + } + + if (phone) { + assertThat( + identifiers.some(({ key, value }) => key === 'phoneVerified' && value === phone), + new RequestError({ + code: 'session.verification_session_not_found', + status: 404, + }) + ); + } + + if (connectorId) { + assertThat( + identifiers.some( + (identifier) => identifier.key === 'social' && identifier.connectorId === connectorId + ), + new RequestError({ + code: 'session.connector_session_not_found', + status: 404, + }) + ); + } +}; + +const profileRegisteredValidation = async ( + { username, email, phone, connectorId }: Profile, + identifiers: Identifier[] +) => { + if (username) { + assertThat( + !(await hasUser(username)), + new RequestError({ + code: 'user.username_exists_register', + status: 422, + }) + ); + } + + if (email) { + assertThat( + !(await hasUserWithEmail(email)), + new RequestError({ + code: 'user.email_exists_register', + status: 422, + }) + ); + } + + if (phone) { + assertThat( + !(await hasUserWithPhone(phone)), + new RequestError({ + code: 'user.phone_exists_register', + status: 422, + }) + ); + } + + if (connectorId) { + const { + metadata: { target }, + } = await getLogtoConnectorById(connectorId); + + const socialIdentifier = identifiers.find( + (identifier): identifier is SocialIdentifier => identifier.key === 'social' + ); + + // Social identifier session should be verified by verifyProtectedIdentifiers + if (!socialIdentifier) { + return; + } + + assertThat( + !(await hasUserWithIdentity(target, socialIdentifier.value.id)), + new RequestError({ + code: 'user.identity_exists', + status: 422, + }) + ); + } +}; + +const profileExistValidation = async ( + { username, email, phone, password }: Profile, + user: User +) => { + if (username) { + assertThat( + !user.username, + new RequestError({ + code: 'user.username_exists', + }) + ); + } + + if (email) { + assertThat( + !user.primaryEmail, + new RequestError({ + code: 'user.email_exists', + }) + ); + } + + if (phone) { + assertThat( + !user.primaryPhone, + new RequestError({ + code: 'user.sms_exists', + }) + ); + } + + if (password) { + assertThat( + !isUserPasswordSet(user), + new RequestError({ + code: 'user.password_exists', + }) + ); + } +}; + +export default async function profileVerification( + ctx: InteractionContext, + identifiers: Identifier[] +) { + const { profile, event } = ctx.interactionPayload; + + if (!profile) { + return; + } + + verifyProtectedIdentifiers(profile, identifiers); + + if (event === Event.SignIn) { + // Find existing account + const user = await findUserByIdentifier(identifiers); + + await profileExistValidation(profile, user); + await profileRegisteredValidation(profile, identifiers); + + return; + } + + if (event === Event.Register) { + // Verify the profile includes sufficient identifiers to register a new account + try { + registerProfileSafeGuard.parse(profile); + } catch (error: unknown) { + throw new RequestError({ code: 'guard.invalid_input' }, error); + } + + await profileRegisteredValidation(profile, identifiers); + + return; + } + + // ForgotPassword + const { password } = profile; + const { passwordEncrypted: oldPasswordEncrypted } = await findUserByIdentifier(identifiers); + assertThat( + !oldPasswordEncrypted || !(await argon2Verify({ password, hash: oldPasswordEncrypted })), + new RequestError({ code: 'user.same_password', status: 422 }) + ); +}