diff --git a/packages/core/src/routes/interaction/actions/submit-interaction.test.ts b/packages/core/src/routes/interaction/actions/submit-interaction.test.ts new file mode 100644 index 000000000..168e4196c --- /dev/null +++ b/packages/core/src/routes/interaction/actions/submit-interaction.test.ts @@ -0,0 +1,161 @@ +import { Event } from '@logto/schemas'; +import { Provider } from 'oidc-provider'; + +import { getLogtoConnectorById } from '#src/connectors/index.js'; +import { assignInteractionResults } from '#src/lib/session.js'; +import { encryptUserPassword, generateUserId, insertUser } from '#src/lib/user.js'; +import { updateUserById } from '#src/queries/user.js'; +import { createContextWithRouteParameters } from '#src/utils/test-utils.js'; + +import type { + Identifier, + VerifiedRegisterInteractionResult, + InteractionContext, + VerifiedSignInInteractionResult, + VerifiedForgotPasswordInteractionResult, +} from '../types/index.js'; +import submitInteraction from './submit-interaction.js'; + +jest.mock('#src/connectors/index.js', () => ({ + getLogtoConnectorById: jest + .fn() + .mockResolvedValue({ metadata: { target: 'logto' }, dbEntry: { syncProfile: true } }), +})); + +jest.mock('#src/lib/session.js', () => ({ + assignInteractionResults: jest.fn(), +})); + +jest.mock('#src/lib/user.js', () => ({ + encryptUserPassword: jest.fn().mockResolvedValue({ + passwordEncrypted: 'passwordEncrypted', + passwordEncryptionMethod: 'plain', + }), + generateUserId: jest.fn().mockResolvedValue('uid'), + insertUser: jest.fn(), +})); + +jest.mock('#src/queries/user.js', () => ({ + findUserById: jest + .fn() + .mockResolvedValue({ identities: { google: { userId: 'googleId', details: {} } } }), + updateUserById: jest.fn(), +})); + +jest.mock('oidc-provider', () => ({ + Provider: jest.fn(() => ({ + interactionDetails: jest.fn(async () => ({ params: {}, jti: 'jti' })), + })), +})); + +const now = Date.now(); + +jest.useFakeTimers().setSystemTime(now); + +describe('submit action', () => { + const provider = new Provider(''); + const log = jest.fn(); + const ctx: InteractionContext = { + ...createContextWithRouteParameters(), + log, + interactionPayload: { event: Event.SignIn }, + }; + const profile = { + username: 'username', + password: 'password', + phone: '123456', + email: 'email@logto.io', + connectorId: 'logto', + }; + + const userInfo = { id: 'foo', name: 'foo_social', avatar: 'avatar' }; + const identifiers: Identifier[] = [ + { + key: 'social', + connectorId: 'logto', + userInfo, + }, + ]; + + const upsertProfile = { + username: 'username', + primaryPhone: '123456', + primaryEmail: 'email@logto.io', + passwordEncrypted: 'passwordEncrypted', + passwordEncryptionMethod: 'plain', + identities: { + logto: { userId: userInfo.id, details: userInfo }, + }, + name: userInfo.name, + avatar: userInfo.avatar, + lastSignInAt: now, + }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('register', async () => { + const interaction: VerifiedRegisterInteractionResult = { + event: Event.Register, + profile, + identifiers, + }; + + await submitInteraction(interaction, ctx, provider); + + expect(generateUserId).toBeCalled(); + expect(encryptUserPassword).toBeCalledWith('password'); + expect(getLogtoConnectorById).toBeCalledWith('logto'); + + expect(insertUser).toBeCalledWith({ + id: 'uid', + ...upsertProfile, + }); + expect(assignInteractionResults).toBeCalledWith(ctx, provider, { login: { accountId: 'uid' } }); + }); + + it('sign-in', async () => { + (getLogtoConnectorById as jest.Mock).mockResolvedValueOnce({ + metadata: { target: 'logto' }, + dbEntry: { syncProfile: false }, + }); + const interaction: VerifiedSignInInteractionResult = { + event: Event.SignIn, + accountId: 'foo', + profile: { connectorId: 'logto', password: 'password' }, + identifiers, + }; + + await submitInteraction(interaction, ctx, provider); + + expect(encryptUserPassword).toBeCalledWith('password'); + expect(getLogtoConnectorById).toBeCalledWith('logto'); + + expect(updateUserById).toBeCalledWith('foo', { + passwordEncrypted: 'passwordEncrypted', + passwordEncryptionMethod: 'plain', + identities: { + logto: { userId: userInfo.id, details: userInfo }, + google: { userId: 'googleId', details: {} }, + }, + lastSignInAt: now, + }); + expect(assignInteractionResults).toBeCalledWith(ctx, provider, { login: { accountId: 'foo' } }); + }); + + it('reset password', async () => { + const interaction: VerifiedForgotPasswordInteractionResult = { + event: Event.ForgotPassword, + accountId: 'foo', + profile: { password: 'password' }, + }; + await submitInteraction(interaction, ctx, provider); + expect(encryptUserPassword).toBeCalledWith('password'); + expect(updateUserById).toBeCalledWith('foo', { + passwordEncrypted: 'passwordEncrypted', + passwordEncryptionMethod: 'plain', + }); + expect(assignInteractionResults).not.toBeCalled(); + }); +}); diff --git a/packages/core/src/routes/interaction/actions/submit-interaction.ts b/packages/core/src/routes/interaction/actions/submit-interaction.ts new file mode 100644 index 000000000..41b75e480 --- /dev/null +++ b/packages/core/src/routes/interaction/actions/submit-interaction.ts @@ -0,0 +1,128 @@ +import type { User } from '@logto/schemas'; +import { Event } from '@logto/schemas'; +import { conditional } from '@silverhand/essentials'; +import type { Provider } from 'oidc-provider'; + +import { getLogtoConnectorById } from '#src/connectors/index.js'; +import { assignInteractionResults } from '#src/lib/session.js'; +import { encryptUserPassword, generateUserId, insertUser } from '#src/lib/user.js'; +import { findUserById, updateUserById } from '#src/queries/user.js'; + +import type { + InteractionContext, + Identifier, + VerifiedInteractionResult, + SocialIdentifier, + VerifiedSignInInteractionResult, + VerifiedRegisterInteractionResult, +} from '../types/index.js'; + +const getSocialUpdateProfile = async ({ + user, + connectorId, + identifiers, +}: { + user?: User; + connectorId: string; + identifiers?: Identifier[]; +}) => { + // TODO: @simeng refactor me. This step should be verified by the previous profile verification cycle Already. + // Should pickup the verified social user info result automatically + const socialIdentifier = identifiers?.find( + (identifier): identifier is SocialIdentifier => + identifier.key === 'social' && identifier.connectorId === connectorId + ); + + if (!socialIdentifier) { + return; + } + + const { + metadata: { target }, + dbEntry: { syncProfile }, + } = await getLogtoConnectorById(connectorId); + + const { userInfo } = socialIdentifier; + const { name, avatar, id } = userInfo; + + const profileUpdate = conditional( + (syncProfile || !user) && { + ...conditional(name && { name }), + ...conditional(avatar && { avatar }), + } + ); + + return { + identities: { ...user?.identities, [target]: { userId: id, details: userInfo } }, + ...profileUpdate, + }; +}; + +const parseUserProfile = async ( + { profile, identifiers }: VerifiedSignInInteractionResult | VerifiedRegisterInteractionResult, + user?: User +) => { + if (!profile) { + return; + } + + const { phone, username, email, connectorId, password } = profile; + + const [passwordProfile, socialProfile] = await Promise.all([ + conditional(password && (await encryptUserPassword(password))), + conditional(connectorId && (await getSocialUpdateProfile({ connectorId, identifiers, user }))), + ]); + + return { + ...conditional(phone && { primaryPhone: phone }), + ...conditional(username && { username }), + ...conditional(email && { primaryEmail: email }), + ...passwordProfile, + ...socialProfile, + lastSignInAt: Date.now(), + }; +}; + +export default async function submitInteraction( + interaction: VerifiedInteractionResult, + ctx: InteractionContext, + provider: Provider +) { + const { event, profile } = interaction; + + if (event === Event.Register) { + const id = await generateUserId(); + const upsertProfile = await parseUserProfile(interaction); + + await insertUser({ + id, + ...upsertProfile, + }); + + await assignInteractionResults(ctx, provider, { login: { accountId: id } }); + + return; + } + + const { accountId } = interaction; + + if (event === Event.SignIn) { + const user = await findUserById(accountId); + const upsertProfile = await parseUserProfile(interaction, user); + + if (upsertProfile) { + await updateUserById(accountId, upsertProfile); + } + + await assignInteractionResults(ctx, provider, { login: { accountId } }); + + return; + } + + // Forgot Password + const { passwordEncrypted, passwordEncryptionMethod } = await encryptUserPassword( + profile.password + ); + await updateUserById(accountId, { passwordEncrypted, passwordEncryptionMethod }); + ctx.status = 204; +} diff --git a/packages/core/src/routes/interaction/index.ts b/packages/core/src/routes/interaction/index.ts index 12cde4415..f02c788c9 100644 --- a/packages/core/src/routes/interaction/index.ts +++ b/packages/core/src/routes/interaction/index.ts @@ -1,18 +1,24 @@ +import type { LogtoErrorCode } from '@logto/phrases'; import { Event } from '@logto/schemas'; import type { Provider } from 'oidc-provider'; +import RequestError from '#src/errors/RequestError/index.js'; +import { assignInteractionResults } from '#src/lib/session.js'; import koaGuard from '#src/middleware/koa-guard.js'; +import assertThat from '#src/utils/assert-that.js'; import type { AnonymousRouter } from '../types.js'; +import submitInteraction from './actions/submit-interaction.js'; import koaInteractionBodyGuard from './middleware/koa-interaction-body-guard.js'; import koaSessionSignInExperienceGuard from './middleware/koa-session-sign-in-experience-guard.js'; import { sendPasscodePayloadGuard, getSocialAuthorizationUrlPayloadGuard } from './types/guard.js'; +import { getInteractionStorage } from './utils/interaction.js'; import { sendPasscodeToIdentifier } from './utils/passcode-validation.js'; import { createSocialAuthorizationUrl } from './utils/social-verification.js'; import { - identifierVerification, - profileVerification, - mandatoryUserProfileValidation, + verifyIdentifier, + verifyProfile, + validateMandatoryUserProfile, } from './verifications/index.js'; export const identifierPrefix = '/identifier'; @@ -27,27 +33,67 @@ export default function interactionRoutes( koaInteractionBodyGuard(), koaSessionSignInExperienceGuard(provider), async (ctx, next) => { + const { event } = ctx.interactionPayload; + // Check interaction session await provider.interactionDetails(ctx.req, ctx.res); - const verifiedIdentifiers = await identifierVerification(ctx, provider); + const identifierVerifiedInteraction = await verifyIdentifier(ctx, provider); - const profile = await profileVerification(ctx, verifiedIdentifiers); - - const { event } = ctx.interactionPayload; + const interaction = await verifyProfile(ctx, provider, identifierVerifiedInteraction); if (event !== Event.ForgotPassword) { - await mandatoryUserProfileValidation(ctx, verifiedIdentifiers, profile); + await validateMandatoryUserProfile(ctx, interaction); } - // TODO: SignIn Register & ResetPassword final step - - ctx.status = 200; + await submitInteraction(interaction, ctx, provider); return next(); } ); + router.patch( + identifierPrefix, + koaInteractionBodyGuard(), + koaSessionSignInExperienceGuard(provider), + async (ctx, next) => { + const { event } = ctx.interactionPayload; + const interactionStorage = await getInteractionStorage(ctx, provider); + + // Forgot Password specific event interaction can't be shared with other types of interactions + assertThat( + event === Event.ForgotPassword + ? interactionStorage.event === Event.ForgotPassword + : interactionStorage.event !== Event.ForgotPassword, + new RequestError({ code: 'session.verification_session_not_found' }) + ); + + const identifierVerifiedInteraction = await verifyIdentifier( + ctx, + provider, + interactionStorage + ); + + const interaction = await verifyProfile(ctx, provider, identifierVerifiedInteraction); + + if (event !== Event.ForgotPassword) { + await validateMandatoryUserProfile(ctx, interaction); + } + + await submitInteraction(interaction, ctx, provider); + + return next(); + } + ); + + router.delete(identifierPrefix, async (ctx, next) => { + await provider.interactionDetails(ctx.req, ctx.res); + const error: LogtoErrorCode = 'oidc.aborted'; + await assignInteractionResults(ctx, provider, { error }); + + return next(); + }); + router.post( `${verificationPrefix}/social/authorization-uri`, koaGuard({ body: getSocialAuthorizationUrlPayloadGuard }), diff --git a/packages/core/src/routes/interaction/middleware/koa-interaction-body-guard.test.ts b/packages/core/src/routes/interaction/middleware/koa-interaction-body-guard.test.ts index a4b2c9f7f..2fe0bc0d9 100644 --- a/packages/core/src/routes/interaction/middleware/koa-interaction-body-guard.test.ts +++ b/packages/core/src/routes/interaction/middleware/koa-interaction-body-guard.test.ts @@ -36,24 +36,38 @@ describe('koaInteractionBodyGuard', () => { await expect(koaInteractionBodyGuard()(ctx, next)).rejects.toThrow(); }); - it.each([Event.SignIn, Event.Register, Event.ForgotPassword])( - '%p should parse successfully', - async (event) => { - const ctx: WithGuardedIdentifierPayloadContext = { - ...baseCtx, - request: { - ...baseCtx.request, - body: { - event, - }, + it.each([Event.SignIn, Event.ForgotPassword])('%p should parse successfully', async (event) => { + const ctx: WithGuardedIdentifierPayloadContext = { + ...baseCtx, + request: { + ...baseCtx.request, + body: { + event, }, - interactionPayload: { event: Event.SignIn }, - }; + }, + interactionPayload: { event: Event.SignIn }, + }; - await expect(koaInteractionBodyGuard()(ctx, next)).resolves.not.toThrow(); - expect(ctx.interactionPayload.event).toEqual(event); - } - ); + await expect(koaInteractionBodyGuard()(ctx, next)).resolves.not.toThrow(); + expect(ctx.interactionPayload.event).toEqual(event); + }); + + it('register should parse successfully', async () => { + const ctx: WithGuardedIdentifierPayloadContext = { + ...baseCtx, + request: { + ...baseCtx.request, + body: { + event: Event.Register, + profile: { username: 'username', password: 'password' }, + }, + }, + interactionPayload: { event: Event.SignIn }, + }; + + await expect(koaInteractionBodyGuard()(ctx, next)).resolves.not.toThrow(); + expect(ctx.interactionPayload.event).toEqual(Event.Register); + }); }); describe('identifier', () => { diff --git a/packages/core/src/routes/interaction/types/guard.ts b/packages/core/src/routes/interaction/types/guard.ts index b2174343c..e0559885d 100644 --- a/packages/core/src/routes/interaction/types/guard.ts +++ b/packages/core/src/routes/interaction/types/guard.ts @@ -23,7 +23,7 @@ const forgotPasswordInteractionPayloadGuard = z.object({ const registerInteractionPayloadGuard = z.object({ event: z.literal(Event.Register), identifier: z.union([emailPasscodePayloadGuard, phonePasscodePayloadGuard]).optional(), - profile: profileGuard.optional(), + profile: profileGuard, }); const signInInteractionPayloadGuard = z.object({ @@ -56,16 +56,17 @@ export const getSocialAuthorizationUrlPayloadGuard = z.object({ state: z.string(), redirectUri: z.string().refine((url) => validateRedirectUrl(url, 'web')), }); + // 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, + usernamePasswordPayloadGuard.merge(profileGuard.omit({ username: true, password: true })), + emailProfileGuard.merge(profileGuard.omit({ email: true })), + phoneProfileGuard.merge(profileGuard.omit({ phone: true })), + socialProfileGuard.merge(profileGuard.omit({ connectorId: true })), ]); // Identifier Guard @@ -87,7 +88,7 @@ export const verifiedPhoneIdentifierGuard = z.object({ export const socialIdentifierGuard = z.object({ key: z.literal('social'), connectorId: z.string(), - value: socialUserInfoGuard, + userInfo: socialUserInfoGuard, }); export const identifierGuard = z.discriminatedUnion('key', [ @@ -97,8 +98,32 @@ export const identifierGuard = z.discriminatedUnion('key', [ socialIdentifierGuard, ]); -export const customInteractionResultGuard = z.object({ - event: eventGuard.optional(), +export const anonymousInteractionResultGuard = z.object({ + event: eventGuard, + profile: profileGuard.optional(), + accountId: z.string().optional(), + identifiers: z.array(identifierGuard).optional(), +}); + +export const verifiedRegisterInteractionResultGuard = z.object({ + event: z.literal(Event.Register), + profile: registerProfileSafeGuard, + identifiers: z.array(identifierGuard).optional(), +}); + +export const verifiedSignInteractionResultGuard = z.object({ + event: z.literal(Event.SignIn), + accountId: z.string(), profile: profileGuard.optional(), identifiers: z.array(identifierGuard).optional(), }); + +export const forgotPasswordProfileGuard = z.object({ + password: z.string(), +}); + +export const verifiedForgotPasswordInteractionResultGuard = z.object({ + event: z.literal(Event.ForgotPassword), + accountId: z.string(), + profile: forgotPasswordProfileGuard, +}); diff --git a/packages/core/src/routes/interaction/types/index.ts b/packages/core/src/routes/interaction/types/index.ts index 32de7811a..8ff0f49e8 100644 --- a/packages/core/src/routes/interaction/types/index.ts +++ b/packages/core/src/routes/interaction/types/index.ts @@ -4,6 +4,7 @@ import type { EmailPasscodePayload, PhonePasswordPayload, PhonePasscodePayload, + Event, } from '@logto/schemas'; import type { Context } from 'koa'; import type { IRouterParamContext } from 'koa-router'; @@ -21,6 +22,12 @@ import type { verifiedPhoneIdentifierGuard, socialIdentifierGuard, identifierGuard, + anonymousInteractionResultGuard, + verifiedRegisterInteractionResultGuard, + verifiedSignInteractionResultGuard, + verifiedForgotPasswordInteractionResultGuard, + registerProfileSafeGuard, + forgotPasswordProfileGuard, } from './guard.js'; // Payload Types @@ -37,7 +44,7 @@ export type SendPasscodePayload = z.infer; export type SocialAuthorizationUrlPayload = z.infer; -// Identifier Types +// Interaction Types export type AccountIdIdentifier = z.infer; export type VerifiedEmailIdentifier = z.infer; @@ -48,6 +55,59 @@ export type SocialIdentifier = z.infer; export type Identifier = z.infer; +export type AnonymousInteractionResult = z.infer; + +export type RegisterSafeProfile = z.infer; + +export type ForgotPasswordProfile = z.infer; + +export type VerifiedRegisterInteractionResult = z.infer< + typeof verifiedRegisterInteractionResultGuard +>; + +export type VerifiedSignInInteractionResult = z.infer; + +export type VerifiedForgotPasswordInteractionResult = z.infer< + typeof verifiedForgotPasswordInteractionResultGuard +>; + +export type RegisterInteractionResult = Omit & { + event: Event.Register; +}; + +export type SignInInteractionResult = Omit & { + event: Event.SignIn; +}; + +export type ForgotPasswordInteractionResult = Omit & { + event: Event.ForgotPassword; +}; + +export type PreAccountVerifiedInteractionResult = + | SignInInteractionResult + | ForgotPasswordInteractionResult; + +export type PayloadVerifiedInteractionResult = + | RegisterInteractionResult + | PreAccountVerifiedInteractionResult; + +export type AccountVerifiedInteractionResult = + | (Omit & { + accountId: string; + }) + | (Omit & { + accountId: string; + }); + +export type IdentifierVerifiedInteractionResult = + | RegisterInteractionResult + | AccountVerifiedInteractionResult; + +export type VerifiedInteractionResult = + | VerifiedRegisterInteractionResult + | VerifiedSignInInteractionResult + | VerifiedForgotPasswordInteractionResult; + export type InteractionContext = WithGuardedIdentifierPayloadContext; export type UserIdentity = diff --git a/packages/core/src/routes/interaction/utils/index.ts b/packages/core/src/routes/interaction/utils/index.ts index 4d529015e..5e4a285a8 100644 --- a/packages/core/src/routes/interaction/utils/index.ts +++ b/packages/core/src/routes/interaction/utils/index.ts @@ -1,6 +1,10 @@ import type { Profile, SocialConnectorPayload, User, IdentifierPayload } from '@logto/schemas'; -import type { PasscodeIdentifierPayload, PasswordIdentifierPayload } from '../types/index.js'; +import type { + PasscodeIdentifierPayload, + PasswordIdentifierPayload, + Identifier, +} from '../types/index.js'; export const isPasswordIdentifier = ( identifier: IdentifierPayload @@ -12,18 +16,20 @@ export const isPasscodeIdentifier = ( export const isSocialIdentifier = ( identifier: IdentifierPayload -): identifier is SocialConnectorPayload => 'connectorId' in identifier; +): identifier is SocialConnectorPayload => + 'connectorId' in identifier && 'connectorData' in identifier; -export const isProfileIdentifier = ( - identifier: PasscodeIdentifierPayload | SocialConnectorPayload, - profile?: Profile -) => { - if ('email' in identifier) { - return profile?.email === identifier.email; +export const isProfileIdentifier = (identifier: Identifier, profile?: Profile) => { + if (identifier.key === 'accountId') { + return false; } - if ('phone' in identifier) { - return profile?.phone === identifier.phone; + if (identifier.key === 'emailVerified') { + return profile?.email === identifier.value; + } + + if (identifier.key === 'phoneVerified') { + return profile?.phone === identifier.value; } return profile?.connectorId === identifier.connectorId; diff --git a/packages/core/src/routes/interaction/utils/interaction.test.ts b/packages/core/src/routes/interaction/utils/interaction.test.ts new file mode 100644 index 000000000..274f97d97 --- /dev/null +++ b/packages/core/src/routes/interaction/utils/interaction.test.ts @@ -0,0 +1,45 @@ +import type { Identifier } from '../types/index.js'; +import { mergeIdentifiers } from './interaction.js'; + +describe('interaction utils', () => { + const usernameIdentifier: Identifier = { key: 'accountId', value: 'foo' }; + const emailIdentifier: Identifier = { key: 'emailVerified', value: 'foo@logto.io' }; + const phoneIdentifier: Identifier = { key: 'phoneVerified', value: '12346' }; + + it('mergeIdentifiers', () => { + expect(mergeIdentifiers({})).toEqual(undefined); + expect(mergeIdentifiers({ oldIdentifiers: [usernameIdentifier] })).toEqual([ + usernameIdentifier, + ]); + expect(mergeIdentifiers({ newIdentifiers: [usernameIdentifier] })).toEqual([ + usernameIdentifier, + ]); + expect( + mergeIdentifiers({ + oldIdentifiers: [usernameIdentifier], + newIdentifiers: [usernameIdentifier], + }) + ).toEqual([usernameIdentifier]); + + expect( + mergeIdentifiers({ + oldIdentifiers: [emailIdentifier], + newIdentifiers: [usernameIdentifier], + }) + ).toEqual([emailIdentifier, usernameIdentifier]); + + expect( + mergeIdentifiers({ + oldIdentifiers: [emailIdentifier, phoneIdentifier], + newIdentifiers: [phoneIdentifier, usernameIdentifier], + }) + ).toEqual([emailIdentifier, phoneIdentifier, usernameIdentifier]); + + expect( + mergeIdentifiers({ + oldIdentifiers: [emailIdentifier, phoneIdentifier], + newIdentifiers: [usernameIdentifier], + }) + ).toEqual([emailIdentifier, phoneIdentifier, usernameIdentifier]); + }); +}); diff --git a/packages/core/src/routes/interaction/utils/interaction.ts b/packages/core/src/routes/interaction/utils/interaction.ts index 0e65117f5..49a169113 100644 --- a/packages/core/src/routes/interaction/utils/interaction.ts +++ b/packages/core/src/routes/interaction/utils/interaction.ts @@ -2,12 +2,69 @@ import type { Event } from '@logto/schemas'; import type { Context } from 'koa'; import type { Provider } from 'oidc-provider'; -import type { Identifier } from '../types/index.js'; +import RequestError from '#src/errors/RequestError/index.js'; +import assertThat from '#src/utils/assert-that.js'; -export const assignIdentifierVerificationResult = async ( - payload: { event: Event; identifiers: Identifier[] }, +import { anonymousInteractionResultGuard } from '../types/guard.js'; +import type { + Identifier, + AnonymousInteractionResult, + AccountVerifiedInteractionResult, +} from '../types/index.js'; + +// Unique identifier type is required +export const mergeIdentifiers = (pairs: { + newIdentifiers?: Identifier[]; + oldIdentifiers?: Identifier[]; +}) => { + const { newIdentifiers, oldIdentifiers } = pairs; + + if (!newIdentifiers) { + return oldIdentifiers; + } + + if (!oldIdentifiers) { + return newIdentifiers; + } + + const leftOvers = oldIdentifiers.filter((oldIdentifier) => { + return !newIdentifiers.some((newIdentifier) => newIdentifier.key === oldIdentifier.key); + }); + + return [...leftOvers, ...newIdentifiers]; +}; + +export const isAccountVerifiedInteractionResult = ( + interaction: AnonymousInteractionResult +): interaction is AccountVerifiedInteractionResult => Boolean(interaction.accountId); + +export const storeInteractionResult = async ( + interaction: Omit & { event?: Event }, ctx: Context, provider: Provider ) => { - await provider.interactionResult(ctx.req, ctx.res, payload, { mergeWithLastSubmission: true }); + // The "mergeWithLastSubmission" will only merge current request's interaction results, + // manually merge with previous interaction results + // refer to: https://github.com/panva/node-oidc-provider/blob/c243bf6b6663c41ff3e75c09b95fb978eba87381/lib/actions/authorization/interactions.js#L106 + + const { result } = await provider.interactionDetails(ctx.req, ctx.res); + + await provider.interactionResult( + ctx.req, + ctx.res, + { ...result, ...interaction }, + { mergeWithLastSubmission: true } + ); +}; + +export const getInteractionStorage = async (ctx: Context, provider: Provider) => { + const { result } = await provider.interactionDetails(ctx.req, ctx.res); + const parseResult = anonymousInteractionResultGuard.safeParse(result); + + assertThat( + parseResult.success, + new RequestError({ code: 'session.verification_session_not_found' }) + ); + + return parseResult.data; }; diff --git a/packages/core/src/routes/interaction/verifications/identifier-verification.test.ts b/packages/core/src/routes/interaction/verifications/identifier-payload-verification.test.ts similarity index 52% rename from packages/core/src/routes/interaction/verifications/identifier-verification.test.ts rename to packages/core/src/routes/interaction/verifications/identifier-payload-verification.test.ts index db4e8cfc0..c515f4f4e 100644 --- a/packages/core/src/routes/interaction/verifications/identifier-verification.test.ts +++ b/packages/core/src/routes/interaction/verifications/identifier-payload-verification.test.ts @@ -1,13 +1,15 @@ import { Event } from '@logto/schemas'; import { Provider } from 'oidc-provider'; +import RequestError from '#src/errors/RequestError/index.js'; import { verifyUserPassword } from '#src/lib/user.js'; import { createContextWithRouteParameters } from '#src/utils/test-utils.js'; +import type { AnonymousInteractionResult, VerifiedPhoneIdentifier } from '../types/index.js'; import findUserByIdentifier from '../utils/find-user-by-identifier.js'; -import { assignIdentifierVerificationResult } from '../utils/interaction.js'; import { verifyIdentifierByPasscode } from '../utils/passcode-validation.js'; -import identifierVerification from './identifier-verification.js'; +import { verifySocialIdentity } from '../utils/social-verification.js'; +import identifierPayloadVerification from './identifier-payload-verification.js'; jest.mock('#src/lib/user.js', () => ({ verifyUserPassword: jest.fn(), @@ -16,7 +18,8 @@ jest.mock('#src/lib/user.js', () => ({ jest.mock('../utils/find-user-by-identifier.js', () => jest.fn()); jest.mock('../utils/interaction.js', () => ({ - assignIdentifierVerificationResult: jest.fn(), + ...jest.requireActual('../utils/interaction.js'), + storeInteractionResult: jest.fn(), })); jest.mock('../utils/passcode-validation.js', () => ({ @@ -29,6 +32,10 @@ jest.mock('oidc-provider', () => ({ })), })); +jest.mock('../utils/social-verification.js', () => ({ + verifySocialIdentity: jest.fn().mockResolvedValue({ id: 'foo' }), +})); + const log = jest.fn(); describe('identifier verification', () => { @@ -43,6 +50,7 @@ describe('identifier verification', () => { it('username password user not found', async () => { findUserByIdentifierMock.mockResolvedValueOnce(null); + const identifier = { username: 'username', password: 'password', @@ -56,7 +64,7 @@ describe('identifier verification', () => { }), }; - await expect(identifierVerification(ctx, new Provider(''))).rejects.toThrow(); + await expect(identifierPayloadVerification(ctx, new Provider(''))).rejects.toThrow(); expect(findUserByIdentifier).toBeCalledWith({ username: 'username' }); expect(verifyUserPassword).toBeCalledWith(null, 'password'); }); @@ -77,7 +85,10 @@ describe('identifier verification', () => { }), }; - await expect(identifierVerification(ctx, new Provider(''))).rejects.toThrow(); + await expect(identifierPayloadVerification(ctx, new Provider(''))).rejects.toMatchError( + new RequestError({ code: 'user.suspended', status: 401 }) + ); + expect(findUserByIdentifier).toBeCalledWith({ username: 'username' }); expect(verifyUserPassword).toBeCalledWith({ id: 'foo' }, 'password'); }); @@ -99,10 +110,13 @@ describe('identifier verification', () => { }), }; - const result = await identifierVerification(ctx, new Provider('')); + const result = await identifierPayloadVerification(ctx, new Provider('')); expect(findUserByIdentifier).toBeCalledWith({ email: 'email' }); expect(verifyUserPassword).toBeCalledWith({ id: 'foo' }, 'password'); - expect(result).toEqual([{ key: 'accountId', value: 'foo' }]); + expect(result).toEqual({ + event: Event.SignIn, + identifiers: [{ key: 'accountId', value: 'foo' }], + }); }); it('phone password', async () => { @@ -122,14 +136,16 @@ describe('identifier verification', () => { }), }; - const result = await identifierVerification(ctx, new Provider('')); + const result = await identifierPayloadVerification(ctx, new Provider('')); expect(findUserByIdentifier).toBeCalledWith({ phone: 'phone' }); expect(verifyUserPassword).toBeCalledWith({ id: 'foo' }, 'password'); - expect(result).toEqual([{ key: 'accountId', value: 'foo' }]); + expect(result).toEqual({ + event: Event.SignIn, + identifiers: [{ key: 'accountId', value: 'foo' }], + }); }); - it('email passcode with no profile', async () => { - findUserByIdentifierMock.mockResolvedValueOnce({ id: 'foo' }); + it('email passcode', async () => { const identifier = { email: 'email', passcode: 'passcode' }; const ctx = { @@ -140,102 +156,20 @@ describe('identifier verification', () => { }), }; - const result = await identifierVerification(ctx, new Provider('')); + const result = await identifierPayloadVerification(ctx, new Provider('')); expect(verifyIdentifierByPasscodeMock).toBeCalledWith( { ...identifier, event: Event.SignIn }, 'jti', log ); - expect(findUserByIdentifier).toBeCalledWith(identifier); - expect(result).toEqual([ - { key: 'accountId', value: 'foo' }, - { key: 'emailVerified', value: 'email' }, - ]); + expect(result).toEqual({ + event: Event.SignIn, + identifiers: [{ key: 'emailVerified', value: identifier.email }], + }); }); - it('email passcode with no profile and no user should throw and assign interaction', async () => { - findUserByIdentifierMock.mockResolvedValueOnce(null); - const identifier = { email: 'email', passcode: 'passcode' }; - - const ctx = { - ...baseCtx, - interactionPayload: Object.freeze({ - event: Event.SignIn, - identifier, - }), - }; - - const provider = new Provider(''); - - await expect(identifierVerification(ctx, provider)).rejects.toThrow(); - expect(verifyIdentifierByPasscodeMock).toBeCalledWith( - { ...identifier, event: Event.SignIn }, - 'jti', - log - ); - expect(findUserByIdentifier).toBeCalledWith(identifier); - expect(assignIdentifierVerificationResult).toBeCalledWith( - { - event: Event.SignIn, - identifiers: [{ key: 'emailVerified', value: 'email' }], - }, - ctx, - provider - ); - }); - - it('forgot password email passcode with no profile and no user should throw', async () => { - findUserByIdentifierMock.mockResolvedValueOnce(null); - const identifier = { email: 'email', passcode: 'passcode' }; - - const ctx = { - ...baseCtx, - interactionPayload: Object.freeze({ - event: Event.ForgotPassword, - identifier, - }), - }; - - const provider = new Provider(''); - - await expect(identifierVerification(ctx, provider)).rejects.toThrow(); - expect(verifyIdentifierByPasscodeMock).toBeCalledWith( - { ...identifier, event: Event.ForgotPassword }, - 'jti', - log - ); - expect(findUserByIdentifier).toBeCalledWith(identifier); - expect(assignIdentifierVerificationResult).not.toBeCalled(); - }); - - it('email passcode with profile', async () => { - const identifier = { email: 'email', passcode: 'passcode' }; - - const ctx = { - ...baseCtx, - interactionPayload: Object.freeze({ - event: Event.SignIn, - identifier, - profile: { - email: 'email', - }, - }), - }; - - const result = await identifierVerification(ctx, new Provider('')); - expect(verifyIdentifierByPasscodeMock).toBeCalledWith( - { ...identifier, event: Event.SignIn }, - 'jti', - log - ); - expect(findUserByIdentifierMock).not.toBeCalled(); - - expect(result).toEqual([{ key: 'emailVerified', value: 'email' }]); - }); - - it('phone passcode with no profile', async () => { - findUserByIdentifierMock.mockResolvedValueOnce({ id: 'foo' }); + it('phone passcode', async () => { const identifier = { phone: 'phone', passcode: 'passcode' }; const ctx = { @@ -246,42 +180,159 @@ describe('identifier verification', () => { }), }; - const result = await identifierVerification(ctx, new Provider('')); + const result = await identifierPayloadVerification(ctx, new Provider('')); expect(verifyIdentifierByPasscodeMock).toBeCalledWith( { ...identifier, event: Event.SignIn }, 'jti', log ); - expect(findUserByIdentifier).toBeCalledWith(identifier); - expect(result).toEqual([ - { key: 'accountId', value: 'foo' }, - { key: 'phoneVerified', value: 'phone' }, - ]); + expect(result).toEqual({ + event: Event.SignIn, + identifiers: [{ key: 'phoneVerified', value: identifier.phone }], + }); }); - it('phone passcode with profile', async () => { - const identifier = { phone: 'phone', passcode: 'passcode' }; + it('social', async () => { + const identifier = { connectorId: 'logto', connectorData: {} }; const ctx = { ...baseCtx, interactionPayload: Object.freeze({ event: Event.SignIn, identifier, - profile: { - phone: 'phone', - }, }), }; - const result = await identifierVerification(ctx, new Provider('')); + const result = await identifierPayloadVerification(ctx, new Provider('')); + + expect(verifySocialIdentity).toBeCalledWith(identifier, log); + expect(findUserByIdentifierMock).not.toBeCalled(); + + expect(result).toEqual({ + event: Event.SignIn, + identifiers: [ + { key: 'social', connectorId: identifier.connectorId, userInfo: { id: 'foo' } }, + ], + }); + }); + + it('verified social email', async () => { + const interactionRecord: AnonymousInteractionResult = { + event: Event.SignIn, + identifiers: [ + { + key: 'social', + connectorId: 'logto', + userInfo: { + id: 'foo', + email: 'email@logto.io', + }, + }, + ], + }; + + const identifierPayload = { connectorId: 'logto', identityType: 'email' }; + + const ctx = { + ...baseCtx, + interactionPayload: Object.freeze({ + event: Event.SignIn, + identifier: identifierPayload, + }), + }; + + const result = await identifierPayloadVerification(ctx, new Provider(''), interactionRecord); + expect(result).toEqual({ + event: Event.SignIn, + identifiers: [ + { + key: 'social', + connectorId: 'logto', + userInfo: { + id: 'foo', + email: 'email@logto.io', + }, + }, + { + key: 'emailVerified', + value: 'email@logto.io', + }, + ], + }); + }); + + it('verified social email should throw if social session not found', async () => { + const identifierPayload = { connectorId: 'logto', identityType: 'email' }; + + const ctx = { + ...baseCtx, + interactionPayload: Object.freeze({ + event: Event.SignIn, + identifier: identifierPayload, + }), + }; + + await expect(identifierPayloadVerification(ctx, new Provider(''))).rejects.toMatchError( + new RequestError('session.connector_session_not_found') + ); + }); + + it('verified social email should throw if social identity not found', async () => { + const interactionRecord: AnonymousInteractionResult = { + event: Event.SignIn, + identifiers: [ + { + key: 'social', + connectorId: 'logto', + userInfo: { + id: 'foo', + }, + }, + ], + }; + + const identifierPayload = { connectorId: 'logto', identityType: 'email' }; + + const ctx = { + ...baseCtx, + interactionPayload: Object.freeze({ + event: Event.SignIn, + identifier: identifierPayload, + }), + }; + + await expect( + identifierPayloadVerification(ctx, new Provider(''), interactionRecord) + ).rejects.toMatchError(new RequestError('session.connector_session_not_found')); + }); + + it('should merge identifier if exist', async () => { + const identifier = { email: 'email', passcode: 'passcode' }; + const oldIdentifier: VerifiedPhoneIdentifier = { key: 'phoneVerified', value: '123456' }; + + const ctx = { + ...baseCtx, + interactionPayload: Object.freeze({ + event: Event.SignIn, + identifier, + }), + }; + + const result = await identifierPayloadVerification(ctx, new Provider(''), { + event: Event.Register, + identifiers: [oldIdentifier], + }); + expect(verifyIdentifierByPasscodeMock).toBeCalledWith( { ...identifier, event: Event.SignIn }, 'jti', log ); - expect(findUserByIdentifierMock).not.toBeCalled(); - expect(result).toEqual([{ key: 'phoneVerified', value: 'phone' }]); + expect(result).toEqual({ + event: Event.SignIn, + identifiers: [oldIdentifier, { key: 'emailVerified', value: identifier.email }], + }); }); }); diff --git a/packages/core/src/routes/interaction/verifications/identifier-payload-verification.ts b/packages/core/src/routes/interaction/verifications/identifier-payload-verification.ts new file mode 100644 index 000000000..3424c4232 --- /dev/null +++ b/packages/core/src/routes/interaction/verifications/identifier-payload-verification.ts @@ -0,0 +1,131 @@ +import type { Event, SocialConnectorPayload, SocialIdentityPayload } from '@logto/schemas'; +import type { Provider } from 'oidc-provider'; + +import RequestError from '#src/errors/RequestError/index.js'; +import { verifyUserPassword } from '#src/lib/user.js'; +import assertThat from '#src/utils/assert-that.js'; + +import type { + PasswordIdentifierPayload, + PasscodeIdentifierPayload, + InteractionContext, + SocialIdentifier, + VerifiedEmailIdentifier, + VerifiedPhoneIdentifier, + AnonymousInteractionResult, + PayloadVerifiedInteractionResult, + Identifier, + AccountIdIdentifier, +} from '../types/index.js'; +import findUserByIdentifier from '../utils/find-user-by-identifier.js'; +import { isPasscodeIdentifier, isPasswordIdentifier, isSocialIdentifier } from '../utils/index.js'; +import { mergeIdentifiers, storeInteractionResult } from '../utils/interaction.js'; +import { verifyIdentifierByPasscode } from '../utils/passcode-validation.js'; +import { verifySocialIdentity } from '../utils/social-verification.js'; + +const verifyPasswordIdentifier = async ( + identifier: PasswordIdentifierPayload +): Promise => { + // TODO: Log + const { password, ...identity } = identifier; + const user = await findUserByIdentifier(identity); + const verifiedUser = await verifyUserPassword(user, password); + + const { isSuspended, id } = verifiedUser; + + assertThat(!isSuspended, new RequestError({ code: 'user.suspended', status: 401 })); + + return { key: 'accountId', value: id }; +}; + +const verifyPasscodeIdentifier = async ( + event: Event, + identifier: PasscodeIdentifierPayload, + ctx: InteractionContext, + provider: Provider +): Promise => { + const { jti } = await provider.interactionDetails(ctx.req, ctx.res); + + await verifyIdentifierByPasscode({ ...identifier, event }, jti, ctx.log); + + return 'email' in identifier + ? { key: 'emailVerified', value: identifier.email } + : { key: 'phoneVerified', value: identifier.phone }; +}; + +const verifySocialIdentifier = async ( + identifier: SocialConnectorPayload, + ctx: InteractionContext +): Promise => { + const userInfo = await verifySocialIdentity(identifier, ctx.log); + + return { key: 'social', connectorId: identifier.connectorId, userInfo }; +}; + +const verifySocialIdentityInInteractionRecord = async ( + { connectorId, identityType }: SocialIdentityPayload, + interactionRecord?: AnonymousInteractionResult +): Promise => { + const socialIdentifierRecord = interactionRecord?.identifiers?.find( + (entity): entity is SocialIdentifier => + entity.key === 'social' && entity.connectorId === connectorId + ); + + const verifiedSocialIdentity = socialIdentifierRecord?.userInfo[identityType]; + + assertThat(verifiedSocialIdentity, new RequestError('session.connector_session_not_found')); + + return { + key: identityType === 'email' ? 'emailVerified' : 'phoneVerified', + value: verifiedSocialIdentity, + }; +}; + +const verifyIdentifierPayload = async ( + ctx: InteractionContext, + provider: Provider, + interactionRecord?: AnonymousInteractionResult +): Promise => { + const { identifier, event } = ctx.interactionPayload; + + if (!identifier) { + return; + } + + if (isPasswordIdentifier(identifier)) { + return verifyPasswordIdentifier(identifier); + } + + if (isPasscodeIdentifier(identifier)) { + return verifyPasscodeIdentifier(event, identifier, ctx, provider); + } + + if (isSocialIdentifier(identifier)) { + return verifySocialIdentifier(identifier, ctx); + } + + return verifySocialIdentityInInteractionRecord(identifier, interactionRecord); +}; + +export default async function identifierPayloadVerification( + ctx: InteractionContext, + provider: Provider, + interactionRecord?: AnonymousInteractionResult +): Promise { + const { event } = ctx.interactionPayload; + + const identifier = await verifyIdentifierPayload(ctx, provider, interactionRecord); + + const interaction: PayloadVerifiedInteractionResult = { + ...interactionRecord, + event, + identifiers: mergeIdentifiers({ + oldIdentifiers: interactionRecord?.identifiers, + newIdentifiers: identifier && [identifier], + }), + }; + + await storeInteractionResult(interaction, ctx, provider); + + return interaction; +} diff --git a/packages/core/src/routes/interaction/verifications/identifier-verification.ts b/packages/core/src/routes/interaction/verifications/identifier-verification.ts index c3931f6d7..d3075e54b 100644 --- a/packages/core/src/routes/interaction/verifications/identifier-verification.ts +++ b/packages/core/src/routes/interaction/verifications/identifier-verification.ts @@ -1,145 +1,24 @@ -import type { Profile, SocialConnectorPayload } from '@logto/schemas'; import { Event } from '@logto/schemas'; import type { Provider } from 'oidc-provider'; -import RequestError from '#src/errors/RequestError/index.js'; -import { findSocialRelatedUser } from '#src/lib/social.js'; -import { verifyUserPassword } from '#src/lib/user.js'; -import assertThat from '#src/utils/assert-that.js'; -import { maskUserInfo } from '#src/utils/format.js'; - import type { - PasswordIdentifierPayload, - PasscodeIdentifierPayload, InteractionContext, - Identifier, - SocialIdentifier, + AnonymousInteractionResult, + IdentifierVerifiedInteractionResult, } from '../types/index.js'; -import findUserByIdentifier from '../utils/find-user-by-identifier.js'; -import { isPasscodeIdentifier, isPasswordIdentifier, isProfileIdentifier } from '../utils/index.js'; -import { assignIdentifierVerificationResult } from '../utils/interaction.js'; -import { verifyIdentifierByPasscode } from '../utils/passcode-validation.js'; -import { verifySocialIdentity } from '../utils/social-verification.js'; +import identifierPayloadVerification from './identifier-payload-verification.js'; +import userAccountVerification from './user-identity-verification.js'; -const passwordIdentifierVerification = async ( - identifier: PasswordIdentifierPayload -): Promise => { - // TODO: Log - const { password, ...identity } = identifier; - const user = await findUserByIdentifier(identity); - const verifiedUser = await verifyUserPassword(user, password); - - const { isSuspended, id } = verifiedUser; - assertThat(!isSuspended, new RequestError({ code: 'user.suspended', status: 401 })); - - return [{ key: 'accountId', value: id }]; -}; - -const passcodeIdentifierVerification = async ( - payload: { event: Event; identifier: PasscodeIdentifierPayload; profile?: Profile }, +export default async function verifyIdentifier( ctx: InteractionContext, - provider: Provider -): Promise => { - const { identifier, event, profile } = payload; - const { jti } = await provider.interactionDetails(ctx.req, ctx.res); + provider: Provider, + interactionRecord?: AnonymousInteractionResult +): Promise { + const verifiedInteraction = await identifierPayloadVerification(ctx, provider, interactionRecord); - await verifyIdentifierByPasscode({ ...identifier, event }, jti, ctx.log); - - const verifiedPasscodeIdentifier: Identifier = - 'email' in identifier - ? { 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)) { - return [verifiedPasscodeIdentifier]; + if (verifiedInteraction.event === Event.Register) { + return verifiedInteraction; } - const user = await findUserByIdentifier(identifier); - - if (!user) { - // Throw verification exception and assign verified identifiers to the interaction - if (event !== Event.ForgotPassword) { - await assignIdentifierVerificationResult( - { event, identifiers: [verifiedPasscodeIdentifier] }, - ctx, - provider - ); - } - - throw new RequestError({ code: 'user.user_not_exist', status: 404 }); - } - - const { id, isSuspended } = user; - assertThat(!isSuspended, new RequestError({ code: 'user.suspended', status: 401 })); - - return [{ key: 'accountId', value: id }, verifiedPasscodeIdentifier]; -}; - -const socialIdentifierVerification = async ( - payload: { event: Event; identifier: SocialConnectorPayload; profile?: Profile }, - ctx: InteractionContext, - provider: Provider -): Promise => { - const { event, identifier, profile } = payload; - const userInfo = await verifySocialIdentity(identifier, ctx.log); - - const { connectorId } = identifier; - const socialIdentifier: SocialIdentifier = { key: 'social', connectorId, value: userInfo }; - - // Return the verified identity directly if it is for new profile identity verification - if (isProfileIdentifier(identifier, profile)) { - return [socialIdentifier]; - } - - const user = await findUserByIdentifier({ connectorId, userInfo }); - - if (!user) { - // Throw verification exception and assign verified identifiers to the interaction - await assignIdentifierVerificationResult( - { event, identifiers: [socialIdentifier] }, - ctx, - provider - ); - - const relatedInfo = await findSocialRelatedUser(userInfo); - - throw new RequestError( - { - code: 'user.identity_not_exists', - status: 422, - }, - relatedInfo && { relatedUser: maskUserInfo(relatedInfo[0]) } - ); - } - - const { id, isSuspended } = user; - assertThat(!isSuspended, new RequestError({ code: 'user.suspended', status: 401 })); - - return [ - { key: 'accountId', value: id }, - { key: 'social', connectorId, value: userInfo }, - ]; -}; - -export default async function identifierVerification( - ctx: InteractionContext, - provider: Provider -): Promise { - const { identifier, event, profile } = ctx.interactionPayload; - - if (!identifier) { - return []; - } - - if (isPasswordIdentifier(identifier)) { - return passwordIdentifierVerification(identifier); - } - - if (isPasscodeIdentifier(identifier)) { - return passcodeIdentifierVerification({ identifier, event, profile }, ctx, provider); - } - - // Social Identifier - return socialIdentifierVerification({ event, identifier, profile }, ctx, provider); + return userAccountVerification(verifiedInteraction, ctx, provider); } diff --git a/packages/core/src/routes/interaction/verifications/index.ts b/packages/core/src/routes/interaction/verifications/index.ts index a8208a419..d5af13cb2 100644 --- a/packages/core/src/routes/interaction/verifications/index.ts +++ b/packages/core/src/routes/interaction/verifications/index.ts @@ -1,3 +1,3 @@ -export { default as identifierVerification } from './identifier-verification.js'; -export { default as profileVerification } from './profile-verification.js'; -export { default as mandatoryUserProfileValidation } from './mandatory-user-profile-validation.js'; +export { default as verifyProfile } from './profile-verification.js'; +export { default as validateMandatoryUserProfile } from './mandatory-user-profile-validation.js'; +export { default as verifyIdentifier } from './identifier-verification.js'; diff --git a/packages/core/src/routes/interaction/verifications/mandatory-user-profile-validation.test.ts b/packages/core/src/routes/interaction/verifications/mandatory-user-profile-validation.test.ts index 8d80de410..9e0eca6f0 100644 --- a/packages/core/src/routes/interaction/verifications/mandatory-user-profile-validation.test.ts +++ b/packages/core/src/routes/interaction/verifications/mandatory-user-profile-validation.test.ts @@ -1,13 +1,19 @@ -import { MissingProfile, SignInIdentifier } from '@logto/schemas'; +import { Event, MissingProfile, SignInIdentifier } from '@logto/schemas'; import { mockSignInExperience } from '#src/__mocks__/sign-in-experience.js'; 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 } from '../types/index.js'; +import type { IdentifierVerifiedInteractionResult } from '../types/index.js'; import { isUserPasswordSet } from '../utils/index.js'; -import mandatoryUserProfileValidation from './mandatory-user-profile-validation.js'; +import validateMandatoryUserProfile from './mandatory-user-profile-validation.js'; + +jest.mock('oidc-provider', () => ({ + Provider: jest.fn(() => ({ + interactionDetails: jest.fn(async () => ({ params: {}, jti: 'jti' })), + })), +})); jest.mock('#src/queries/user.js', () => ({ findUserById: jest.fn(), @@ -17,9 +23,12 @@ jest.mock('../utils/index.js', () => ({ isUserPasswordSet: jest.fn(), })); -describe('mandatoryUserProfileValidation', () => { +describe('validateMandatoryUserProfile', () => { const baseCtx = createContextWithRouteParameters(); - const identifiers: Identifier[] = [{ key: 'accountId', value: 'foo' }]; + const interaction: IdentifierVerifiedInteractionResult = { + event: Event.SignIn, + accountId: 'foo', + }; it('username and password missing but required', async () => { const ctx = { @@ -27,9 +36,7 @@ describe('mandatoryUserProfileValidation', () => { signInExperience: mockSignInExperience, }; - await expect( - mandatoryUserProfileValidation(ctx, identifiers, { email: 'email' }) - ).rejects.toMatchError( + await expect(validateMandatoryUserProfile(ctx, interaction)).rejects.toMatchError( new RequestError( { code: 'user.missing_profile', status: 422 }, { missingProfile: [MissingProfile.password, MissingProfile.username] } @@ -37,9 +44,12 @@ describe('mandatoryUserProfileValidation', () => { ); await expect( - mandatoryUserProfileValidation(ctx, identifiers, { - username: 'username', - password: 'password', + validateMandatoryUserProfile(ctx, { + ...interaction, + profile: { + username: 'username', + password: 'password', + }, }) ).resolves.not.toThrow(); }); @@ -55,7 +65,7 @@ describe('mandatoryUserProfileValidation', () => { signInExperience: mockSignInExperience, }; - await expect(mandatoryUserProfileValidation(ctx, identifiers, {})).resolves.not.toThrow(); + await expect(validateMandatoryUserProfile(ctx, interaction)).resolves.not.toThrow(); }); it('email missing but required', async () => { @@ -67,9 +77,7 @@ describe('mandatoryUserProfileValidation', () => { }, }; - await expect( - mandatoryUserProfileValidation(ctx, identifiers, { username: 'username' }) - ).rejects.toMatchError( + await expect(validateMandatoryUserProfile(ctx, interaction)).rejects.toMatchError( new RequestError( { code: 'user.missing_profile', status: 422 }, { missingProfile: [MissingProfile.email] } @@ -90,9 +98,7 @@ describe('mandatoryUserProfileValidation', () => { }, }; - await expect( - mandatoryUserProfileValidation(ctx, identifiers, { username: 'username' }) - ).resolves.not.toThrow(); + await expect(validateMandatoryUserProfile(ctx, interaction)).resolves.not.toThrow(); }); it('phone missing but required', async () => { @@ -104,9 +110,7 @@ describe('mandatoryUserProfileValidation', () => { }, }; - await expect( - mandatoryUserProfileValidation(ctx, identifiers, { username: 'username' }) - ).rejects.toMatchError( + await expect(validateMandatoryUserProfile(ctx, interaction)).rejects.toMatchError( new RequestError( { code: 'user.missing_profile', status: 422 }, { missingProfile: [MissingProfile.phone] } @@ -127,9 +131,7 @@ describe('mandatoryUserProfileValidation', () => { }, }; - await expect( - mandatoryUserProfileValidation(ctx, identifiers, { username: 'username' }) - ).resolves.not.toThrow(); + await expect(validateMandatoryUserProfile(ctx, interaction)).resolves.not.toThrow(); }); it('email or Phone required', async () => { @@ -145,9 +147,7 @@ describe('mandatoryUserProfileValidation', () => { }, }; - await expect( - mandatoryUserProfileValidation(ctx, identifiers, { username: 'username' }) - ).rejects.toMatchError( + await expect(validateMandatoryUserProfile(ctx, interaction)).rejects.toMatchError( new RequestError( { code: 'user.missing_profile', status: 422 }, { missingProfile: [MissingProfile.emailOrPhone] } @@ -155,11 +155,11 @@ describe('mandatoryUserProfileValidation', () => { ); await expect( - mandatoryUserProfileValidation(ctx, identifiers, { email: 'email' }) + validateMandatoryUserProfile(ctx, { ...interaction, profile: { email: 'email' } }) ).resolves.not.toThrow(); await expect( - mandatoryUserProfileValidation(ctx, identifiers, { phone: 'phone' }) + validateMandatoryUserProfile(ctx, { ...interaction, profile: { phone: '123456' } }) ).resolves.not.toThrow(); }); }); diff --git a/packages/core/src/routes/interaction/verifications/mandatory-user-profile-validation.ts b/packages/core/src/routes/interaction/verifications/mandatory-user-profile-validation.ts index 551d9042f..6b5b2caaf 100644 --- a/packages/core/src/routes/interaction/verifications/mandatory-user-profile-validation.ts +++ b/packages/core/src/routes/interaction/verifications/mandatory-user-profile-validation.ts @@ -1,5 +1,5 @@ import type { Profile, SignInExperience, User } from '@logto/schemas'; -import { MissingProfile, SignInIdentifier } from '@logto/schemas'; +import { Event, MissingProfile, SignInIdentifier } from '@logto/schemas'; import type { Nullable } from '@silverhand/essentials'; import type { Context } from 'koa'; @@ -8,21 +8,9 @@ import { findUserById } from '#src/queries/user.js'; import assertThat from '#src/utils/assert-that.js'; import type { WithSignInExperienceContext } from '../middleware/koa-session-sign-in-experience-guard.js'; -import type { Identifier, AccountIdIdentifier } from '../types/index.js'; +import type { IdentifierVerifiedInteractionResult } from '../types/index.js'; import { isUserPasswordSet } from '../utils/index.js'; -const findUserByIdentifiers = async (identifiers: Identifier[]) => { - const accountIdentifier = identifiers.find( - (identifier): identifier is AccountIdIdentifier => identifier.key === 'accountId' - ); - - if (!accountIdentifier) { - return null; - } - - return findUserById(accountIdentifier.value); -}; - // eslint-disable-next-line complexity const getMissingProfileBySignUpIdentifiers = ({ signUp, @@ -81,15 +69,16 @@ const getMissingProfileBySignUpIdentifiers = ({ return missingProfile; }; -export default async function mandatoryUserProfileValidation( +export default async function validateMandatoryUserProfile( ctx: WithSignInExperienceContext, - identifiers: Identifier[], - profile?: Profile + interaction: IdentifierVerifiedInteractionResult ) { const { signInExperience: { signUp }, } = ctx; - const user = await findUserByIdentifiers(identifiers); + const { event, accountId, profile } = interaction; + + const user = event === Event.Register ? null : await findUserById(accountId); const missingProfileSet = getMissingProfileBySignUpIdentifiers({ signUp, user, profile }); assertThat( diff --git a/packages/core/src/routes/interaction/verifications/profile-verification-forgot-password.test.ts b/packages/core/src/routes/interaction/verifications/profile-verification-forgot-password.test.ts new file mode 100644 index 000000000..2e6659315 --- /dev/null +++ b/packages/core/src/routes/interaction/verifications/profile-verification-forgot-password.test.ts @@ -0,0 +1,96 @@ +import { Event } from '@logto/schemas'; +import { argon2Verify } from 'hash-wasm'; +import { Provider } from 'oidc-provider'; + +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 { InteractionContext } from '../types/index.js'; +import { storeInteractionResult } from '../utils/interaction.js'; +import verifyProfile from './profile-verification.js'; + +jest.mock('../utils/interaction.js', () => ({ + storeInteractionResult: jest.fn(), +})); + +jest.mock('oidc-provider', () => ({ + Provider: jest.fn(() => ({ + interactionDetails: jest.fn(async () => ({ params: {}, jti: 'jti' })), + })), +})); + +jest.mock('#src/queries/user.js', () => ({ + findUserById: jest.fn().mockResolvedValue({ id: 'foo', passwordEncrypted: 'passwordHash' }), +})); + +jest.mock('hash-wasm', () => ({ + argon2Verify: jest.fn(), +})); + +describe('forgot password interaction profile verification', () => { + const provider = new Provider(''); + const baseCtx = createContextWithRouteParameters(); + + const interaction = { + event: Event.ForgotPassword, + accountId: 'foo', + }; + + it('missing profile', async () => { + const ctx: InteractionContext = { + ...baseCtx, + interactionPayload: { + event: Event.ForgotPassword, + }, + }; + + await expect(verifyProfile(ctx, provider, interaction)).rejects.toMatchError( + new RequestError({ + code: 'user.require_new_password', + status: 422, + }) + ); + expect(storeInteractionResult).not.toBeCalled(); + }); + + it('same password', async () => { + (argon2Verify as jest.Mock).mockResolvedValueOnce(true); + const ctx: InteractionContext = { + ...baseCtx, + interactionPayload: { + event: Event.ForgotPassword, + profile: { + password: 'password', + }, + }, + }; + + await expect(verifyProfile(ctx, provider, interaction)).rejects.toMatchError( + new RequestError({ + code: 'user.same_password', + status: 422, + }) + ); + expect(findUserById).toBeCalledWith(interaction.accountId); + expect(argon2Verify).toBeCalledWith({ password: 'password', hash: 'passwordHash' }); + expect(storeInteractionResult).not.toBeCalled(); + }); + + it('proper set password', async () => { + const ctx: InteractionContext = { + ...baseCtx, + interactionPayload: { + event: Event.ForgotPassword, + profile: { + password: 'password', + }, + }, + }; + + await expect(verifyProfile(ctx, provider, interaction)).resolves.not.toThrow(); + expect(findUserById).toBeCalledWith(interaction.accountId); + expect(argon2Verify).toBeCalledWith({ password: 'password', hash: 'passwordHash' }); + expect(storeInteractionResult).toBeCalled(); + }); +}); 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 index 50ab19782..12d8d6939 100644 --- 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 @@ -1,11 +1,23 @@ import { Event } from '@logto/schemas'; +import { Provider } from 'oidc-provider'; 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'; +import type { + Identifier, + IdentifierVerifiedInteractionResult, + InteractionContext, +} from '../types/index.js'; +import { storeInteractionResult } from '../utils/interaction.js'; +import verifyProfile from './profile-verification.js'; + +jest.mock('oidc-provider', () => ({ + Provider: jest.fn(() => ({ + interactionDetails: jest.fn(async () => ({ params: {}, jti: 'jti' })), + })), +})); jest.mock('#src/queries/user.js', () => ({ findUserById: jest.fn().mockResolvedValue({ id: 'foo' }), @@ -14,18 +26,28 @@ jest.mock('#src/queries/user.js', () => ({ hasUserWithIdentity: jest.fn().mockResolvedValue(false), })); +jest.mock('../utils/interaction.js', () => ({ + storeInteractionResult: jest.fn(), +})); + jest.mock('../utils/index.js', () => ({ isUserPasswordSet: jest.fn().mockResolvedValueOnce(true), })); describe('Existed profile should throw', () => { + const provider = new Provider(''); 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' } }, + { key: 'social', connectorId: 'connectorId', userInfo: { id: 'foo' } }, ]; + const interaction: IdentifierVerifiedInteractionResult = { + event: Event.SignIn, + accountId: 'foo', + identifiers, + }; afterEach(() => { jest.clearAllMocks(); @@ -44,11 +66,12 @@ describe('Existed profile should throw', () => { }, }; - await expect(profileVerification(ctx, identifiers)).rejects.toMatchError( + await expect(verifyProfile(ctx, provider, interaction)).rejects.toMatchError( new RequestError({ code: 'user.username_exists', }) ); + expect(storeInteractionResult).not.toBeCalled(); }); it('email exist', async () => { @@ -64,11 +87,12 @@ describe('Existed profile should throw', () => { }, }; - await expect(profileVerification(ctx, identifiers)).rejects.toMatchError( + await expect(verifyProfile(ctx, provider, interaction)).rejects.toMatchError( new RequestError({ code: 'user.email_exists', }) ); + expect(storeInteractionResult).not.toBeCalled(); }); it('phone exist', async () => { @@ -84,11 +108,12 @@ describe('Existed profile should throw', () => { }, }; - await expect(profileVerification(ctx, identifiers)).rejects.toMatchError( + await expect(verifyProfile(ctx, provider, interaction)).rejects.toMatchError( new RequestError({ code: 'user.sms_exists', }) ); + expect(storeInteractionResult).not.toBeCalled(); }); it('password exist', async () => { @@ -104,10 +129,11 @@ describe('Existed profile should throw', () => { }, }; - await expect(profileVerification(ctx, identifiers)).rejects.toMatchError( + await expect(verifyProfile(ctx, provider, interaction)).rejects.toMatchError( new RequestError({ code: 'user.password_exists', }) ); + expect(storeInteractionResult).not.toBeCalled(); }); }); 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 index 9c98279a9..17b18fe18 100644 --- 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 @@ -1,4 +1,5 @@ import { Event } from '@logto/schemas'; +import { Provider } from 'oidc-provider'; import RequestError from '#src/errors/RequestError/index.js'; import { @@ -9,8 +10,23 @@ import { } 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'; +import type { + Identifier, + InteractionContext, + IdentifierVerifiedInteractionResult, +} from '../types/index.js'; +import { storeInteractionResult } from '../utils/interaction.js'; +import verifyProfile from './profile-verification.js'; + +jest.mock('oidc-provider', () => ({ + Provider: jest.fn(() => ({ + interactionDetails: jest.fn(async () => ({ params: {}, jti: 'jti' })), + })), +})); + +jest.mock('../utils/interaction.js', () => ({ + storeInteractionResult: jest.fn(), +})); jest.mock('#src/queries/user.js', () => ({ hasUser: jest.fn().mockResolvedValue(false), @@ -31,10 +47,20 @@ const identifiers: Identifier[] = [ { key: 'accountId', value: 'foo' }, { key: 'emailVerified', value: 'email@logto.io' }, { key: 'phoneVerified', value: '123456' }, - { key: 'social', connectorId: 'connectorId', value: { id: 'foo' } }, + { key: 'social', connectorId: 'connectorId', userInfo: { id: 'foo' } }, ]; +const provider = new Provider(''); + +const interaction: IdentifierVerifiedInteractionResult = { + event: Event.Register, + identifiers, +}; describe('register payload guard', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + it('username only should throw', async () => { const ctx: InteractionContext = { ...baseCtx, @@ -46,7 +72,8 @@ describe('register payload guard', () => { }, }; - await expect(profileVerification(ctx, identifiers)).rejects.toThrow(); + await expect(verifyProfile(ctx, provider, interaction)).rejects.toThrow(); + expect(storeInteractionResult).not.toBeCalled(); }); it('password only should throw', async () => { @@ -60,7 +87,8 @@ describe('register payload guard', () => { }, }; - await expect(profileVerification(ctx, identifiers)).rejects.toThrow(); + await expect(verifyProfile(ctx, provider, interaction)).rejects.toThrow(); + expect(storeInteractionResult).not.toBeCalled(); }); it('username password is valid', async () => { @@ -75,7 +103,8 @@ describe('register payload guard', () => { }, }; - await expect(profileVerification(ctx, identifiers)).resolves.not.toThrow(); + const result = await verifyProfile(ctx, provider, interaction); + expect(result).toEqual({ ...interaction, profile: ctx.interactionPayload.profile }); }); it('username with a given email is valid', async () => { @@ -90,7 +119,7 @@ describe('register payload guard', () => { }, }; - await expect(profileVerification(ctx, identifiers)).resolves.not.toThrow(); + await expect(verifyProfile(ctx, provider, interaction)).resolves.not.toThrow(); }); it('password with a given email is valid', async () => { @@ -105,7 +134,7 @@ describe('register payload guard', () => { }, }; - await expect(profileVerification(ctx, identifiers)).resolves.not.toThrow(); + await expect(verifyProfile(ctx, provider, interaction)).resolves.not.toThrow(); }); }); @@ -124,12 +153,13 @@ describe('profile registered validation', () => { }, }; - await expect(profileVerification(ctx, identifiers)).rejects.toMatchError( + await expect(verifyProfile(ctx, provider, interaction)).rejects.toMatchError( new RequestError({ code: 'user.username_exists_register', status: 422, }) ); + expect(storeInteractionResult).not.toBeCalled(); }); it('email is registered', async () => { @@ -145,12 +175,13 @@ describe('profile registered validation', () => { }, }; - await expect(profileVerification(ctx, identifiers)).rejects.toMatchError( + await expect(verifyProfile(ctx, provider, interaction)).rejects.toMatchError( new RequestError({ code: 'user.email_exists_register', status: 422, }) ); + expect(storeInteractionResult).not.toBeCalled(); }); it('phone is registered', async () => { @@ -166,12 +197,13 @@ describe('profile registered validation', () => { }, }; - await expect(profileVerification(ctx, identifiers)).rejects.toMatchError( + await expect(verifyProfile(ctx, provider, interaction)).rejects.toMatchError( new RequestError({ code: 'user.phone_exists_register', status: 422, }) ); + expect(storeInteractionResult).not.toBeCalled(); }); it('connector identity exist', async () => { @@ -187,11 +219,12 @@ describe('profile registered validation', () => { }, }; - await expect(profileVerification(ctx, identifiers)).rejects.toMatchError( + await expect(verifyProfile(ctx, provider, interaction)).rejects.toMatchError( new RequestError({ code: 'user.identity_exists', status: 422, }) ); + expect(storeInteractionResult).not.toBeCalled(); }); }); 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 index 2a76b2770..626cac78b 100644 --- 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 @@ -1,10 +1,22 @@ import { Event } from '@logto/schemas'; +import { Provider } from 'oidc-provider'; 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'; +import { storeInteractionResult } from '../utils/interaction.js'; +import verifyProfile from './profile-verification.js'; + +jest.mock('oidc-provider', () => ({ + Provider: jest.fn(() => ({ + interactionDetails: jest.fn(async () => ({ params: {}, jti: 'jti' })), + })), +})); + +jest.mock('../utils/interaction.js', () => ({ + storeInteractionResult: jest.fn(), +})); jest.mock('#src/queries/user.js', () => ({ findUserById: jest.fn().mockResolvedValue({ id: 'foo' }), @@ -21,6 +33,8 @@ jest.mock('#src/connectors/index.js', () => ({ describe('profile protected identifier verification', () => { const baseCtx = createContextWithRouteParameters(); + const interaction = { event: Event.SignIn, accountId: 'foo' }; + const provider = new Provider(''); afterEach(() => { jest.clearAllMocks(); @@ -38,11 +52,10 @@ describe('profile protected identifier verification', () => { }, }; - const identifiers: Identifier[] = [{ key: 'accountId', value: 'foo' }]; - - await expect(profileVerification(ctx, identifiers)).rejects.toMatchError( + await expect(verifyProfile(ctx, provider, interaction)).rejects.toMatchError( new RequestError({ code: 'session.verification_session_not_found', status: 404 }) ); + expect(storeInteractionResult).not.toBeCalled(); }); it('email with unmatched identifier should throw', async () => { @@ -56,13 +69,13 @@ describe('profile protected identifier verification', () => { }, }; - const identifiers: Identifier[] = [ - { key: 'accountId', value: 'foo' }, - { key: 'emailVerified', value: 'phone' }, - ]; - await expect(profileVerification(ctx, identifiers)).rejects.toMatchError( + const identifiers: Identifier[] = [{ key: 'emailVerified', value: 'phone' }]; + await expect( + verifyProfile(ctx, provider, { ...interaction, identifiers }) + ).rejects.toMatchError( new RequestError({ code: 'session.verification_session_not_found', status: 404 }) ); + expect(storeInteractionResult).not.toBeCalled(); }); it('email with proper identifier should not throw', async () => { @@ -76,11 +89,19 @@ describe('profile protected identifier verification', () => { }, }; - const identifiers: Identifier[] = [ - { key: 'accountId', value: 'foo' }, - { key: 'emailVerified', value: 'email' }, - ]; - await expect(profileVerification(ctx, identifiers)).resolves.not.toThrow(); + const identifiers: Identifier[] = [{ key: 'emailVerified', value: 'email' }]; + await expect( + verifyProfile(ctx, provider, { ...interaction, identifiers }) + ).resolves.not.toThrow(); + expect(storeInteractionResult).toBeCalledWith( + { + ...interaction, + identifiers, + profile: ctx.interactionPayload.profile, + }, + ctx, + provider + ); }); it('phone without a verified identifier should throw', async () => { @@ -94,11 +115,10 @@ describe('profile protected identifier verification', () => { }, }; - const identifiers: Identifier[] = [{ key: 'accountId', value: 'foo' }]; - - await expect(profileVerification(ctx, identifiers)).rejects.toMatchError( + await expect(verifyProfile(ctx, provider, interaction)).rejects.toMatchError( new RequestError({ code: 'session.verification_session_not_found', status: 404 }) ); + expect(storeInteractionResult).not.toBeCalled(); }); it('phone with unmatched identifier should throw', async () => { @@ -112,13 +132,13 @@ describe('profile protected identifier verification', () => { }, }; - const identifiers: Identifier[] = [ - { key: 'accountId', value: 'foo' }, - { key: 'phoneVerified', value: 'email' }, - ]; - await expect(profileVerification(ctx, identifiers)).rejects.toMatchError( + const identifiers: Identifier[] = [{ key: 'phoneVerified', value: 'email' }]; + await expect( + verifyProfile(ctx, provider, { ...interaction, identifiers }) + ).rejects.toMatchError( new RequestError({ code: 'session.verification_session_not_found', status: 404 }) ); + expect(storeInteractionResult).not.toBeCalled(); }); it('phone with proper identifier should not throw', async () => { @@ -132,11 +152,19 @@ describe('profile protected identifier verification', () => { }, }; - const identifiers: Identifier[] = [ - { key: 'accountId', value: 'foo' }, - { key: 'phoneVerified', value: 'phone' }, - ]; - await expect(profileVerification(ctx, identifiers)).resolves.not.toThrow(); + const identifiers: Identifier[] = [{ key: 'phoneVerified', value: 'phone' }]; + await expect( + verifyProfile(ctx, provider, { ...interaction, identifiers }) + ).resolves.not.toThrow(); + expect(storeInteractionResult).toBeCalledWith( + { + ...interaction, + identifiers, + profile: ctx.interactionPayload.profile, + }, + ctx, + provider + ); }); it('connectorId without a verified identifier should throw', async () => { @@ -150,11 +178,14 @@ describe('profile protected identifier verification', () => { }, }; - const identifiers: Identifier[] = [{ key: 'accountId', value: 'foo' }]; + const identifiers: Identifier[] = [{ key: 'emailVerified', value: 'foo@logto.io' }]; - await expect(profileVerification(ctx, identifiers)).rejects.toMatchError( + await expect( + verifyProfile(ctx, provider, { ...interaction, identifiers }) + ).rejects.toMatchError( new RequestError({ code: 'session.connector_session_not_found', status: 404 }) ); + expect(storeInteractionResult).not.toBeCalled(); }); it('connectorId with unmatched identifier should throw', async () => { @@ -169,12 +200,14 @@ describe('profile protected identifier verification', () => { }; const identifiers: Identifier[] = [ - { key: 'accountId', value: 'foo' }, - { key: 'social', connectorId: 'connectorId', value: { id: 'foo' } }, + { key: 'social', connectorId: 'connectorId', userInfo: { id: 'foo' } }, ]; - await expect(profileVerification(ctx, identifiers)).rejects.toMatchError( + await expect( + verifyProfile(ctx, provider, { ...interaction, identifiers }) + ).rejects.toMatchError( new RequestError({ code: 'session.connector_session_not_found', status: 404 }) ); + expect(storeInteractionResult).not.toBeCalled(); }); it('connectorId with proper identifier should not throw', async () => { @@ -190,10 +223,21 @@ describe('profile protected identifier verification', () => { const identifiers: Identifier[] = [ { key: 'accountId', value: 'foo' }, - { key: 'social', connectorId: 'logto', value: { id: 'foo' } }, + { key: 'social', connectorId: 'logto', userInfo: { id: 'foo' } }, ]; - await expect(profileVerification(ctx, identifiers)).resolves.not.toThrow(); + await expect( + verifyProfile(ctx, provider, { ...interaction, identifiers }) + ).resolves.not.toThrow(); + expect(storeInteractionResult).toBeCalledWith( + { + ...interaction, + identifiers, + profile: ctx.interactionPayload.profile, + }, + ctx, + provider + ); }); }); }); diff --git a/packages/core/src/routes/interaction/verifications/profile-verification.ts b/packages/core/src/routes/interaction/verifications/profile-verification.ts index f90484bfb..00f00f959 100644 --- a/packages/core/src/routes/interaction/verifications/profile-verification.ts +++ b/packages/core/src/routes/interaction/verifications/profile-verification.ts @@ -1,6 +1,7 @@ import type { Profile, User } from '@logto/schemas'; import { Event } from '@logto/schemas'; import { argon2Verify } from 'hash-wasm'; +import type { Provider } from 'oidc-provider'; import { getLogtoConnectorById } from '#src/connectors/index.js'; import RequestError from '#src/errors/RequestError/index.js'; @@ -13,38 +14,30 @@ import { } from '#src/queries/user.js'; import assertThat from '#src/utils/assert-that.js'; -import { registerProfileSafeGuard } from '../types/guard.js'; +import { registerProfileSafeGuard, forgotPasswordProfileGuard } from '../types/guard.js'; import type { InteractionContext, Identifier, - AccountIdIdentifier, SocialIdentifier, + IdentifierVerifiedInteractionResult, + VerifiedInteractionResult, + VerifiedRegisterInteractionResult, + RegisterSafeProfile, + VerifiedSignInInteractionResult, + VerifiedForgotPasswordInteractionResult, } from '../types/index.js'; import { isUserPasswordSet } from '../utils/index.js'; +import { storeInteractionResult } from '../utils/interaction.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 = ( +const verifyProfileIdentifiers = ( { email, phone, connectorId }: Profile, - identifiers: Identifier[] + identifiers: Identifier[] = [] ) => { if (email) { assertThat( - identifiers.some(({ key, value }) => key === 'emailVerified' && value === email), + identifiers.some( + (identifier) => identifier.key === 'emailVerified' && identifier.value === email + ), new RequestError({ code: 'session.verification_session_not_found', status: 404, @@ -54,7 +47,9 @@ const verifyProtectedIdentifiers = ( if (phone) { assertThat( - identifiers.some(({ key, value }) => key === 'phoneVerified' && value === phone), + identifiers.some( + (identifier) => identifier.key === 'phoneVerified' && identifier.value === phone + ), new RequestError({ code: 'session.verification_session_not_found', status: 404, @@ -75,9 +70,9 @@ const verifyProtectedIdentifiers = ( } }; -const profileRegisteredValidation = async ( +const verifyProfileNotRegistered = async ( { username, email, phone, connectorId }: Profile, - identifiers: Identifier[] + identifiers: Identifier[] = [] ) => { if (username) { assertThat( @@ -118,13 +113,13 @@ const profileRegisteredValidation = async ( (identifier): identifier is SocialIdentifier => identifier.key === 'social' ); - // Social identifier session should be verified by verifyProtectedIdentifiers + // Social identifier session should be verified by verifyProfileIdentifiers if (!socialIdentifier) { return; } assertThat( - !(await hasUserWithIdentity(target, socialIdentifier.value.id)), + !(await hasUserWithIdentity(target, socialIdentifier.userInfo.id)), new RequestError({ code: 'user.identity_exists', status: 422, @@ -133,10 +128,7 @@ const profileRegisteredValidation = async ( } }; -const profileExistValidation = async ( - { username, email, phone, password }: Profile, - user: User -) => { +const verifyProfileNotExist = async ({ username, email, phone, password }: Profile, user: User) => { if (username) { assertThat( !user.username, @@ -174,48 +166,66 @@ const profileExistValidation = async ( } }; -export default async function profileVerification( +const isValidRegisterProfile = (profile: Profile): profile is RegisterSafeProfile => + registerProfileSafeGuard.safeParse(profile).success; + +export default async function verifyProfile( ctx: InteractionContext, - identifiers: Identifier[] -): Promise { - const { profile, event } = ctx.interactionPayload; + provider: Provider, + interaction: IdentifierVerifiedInteractionResult +): Promise { + const profile = { ...interaction.profile, ...ctx.interactionPayload.profile }; - 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 profile; - } + const { event, identifiers, accountId } = interaction; 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); - } + assertThat(isValidRegisterProfile(profile), new RequestError({ code: 'guard.invalid_input' })); - await profileRegisteredValidation(profile, identifiers); + verifyProfileIdentifiers(profile, identifiers); + await verifyProfileNotRegistered(profile, identifiers); - return profile; + const interactionWithProfile: VerifiedRegisterInteractionResult = { ...interaction, profile }; + await storeInteractionResult(interactionWithProfile, ctx, provider); + + return interactionWithProfile; } - // ForgotPassword - const { password } = profile; - const { passwordEncrypted: oldPasswordEncrypted } = await findUserByIdentifier(identifiers); + if (event === Event.SignIn) { + verifyProfileIdentifiers(profile, identifiers); + // Find existing account + const user = await findUserById(accountId); + await verifyProfileNotExist(profile, user); + await verifyProfileNotRegistered(profile, identifiers); + + const interactionWithProfile: VerifiedSignInInteractionResult = { ...interaction, profile }; + await storeInteractionResult(interactionWithProfile, ctx, provider); + + return interactionWithProfile; + } + + // Forgot Password + const passwordProfileResult = forgotPasswordProfileGuard.safeParse(profile); assertThat( - !oldPasswordEncrypted || !(await argon2Verify({ password, hash: oldPasswordEncrypted })), + passwordProfileResult.success, + new RequestError({ code: 'user.require_new_password', status: 422 }) + ); + + const passwordProfile = passwordProfileResult.data; + + const { passwordEncrypted: oldPasswordEncrypted } = await findUserById(accountId); + + assertThat( + !oldPasswordEncrypted || + !(await argon2Verify({ password: passwordProfile.password, hash: oldPasswordEncrypted })), new RequestError({ code: 'user.same_password', status: 422 }) ); - return profile; + const interactionWithProfile: VerifiedForgotPasswordInteractionResult = { + ...interaction, + profile: passwordProfile, + }; + await storeInteractionResult(interactionWithProfile, ctx, provider); + + return interactionWithProfile; } diff --git a/packages/core/src/routes/interaction/verifications/user-identity-verification.test.ts b/packages/core/src/routes/interaction/verifications/user-identity-verification.test.ts new file mode 100644 index 000000000..5a44a4a61 --- /dev/null +++ b/packages/core/src/routes/interaction/verifications/user-identity-verification.test.ts @@ -0,0 +1,287 @@ +import { Event } from '@logto/schemas'; +import { Provider } from 'oidc-provider'; + +import RequestError from '#src/errors/RequestError/index.js'; +import { createContextWithRouteParameters } from '#src/utils/test-utils.js'; + +import type { InteractionContext, PayloadVerifiedInteractionResult } from '../types/index.js'; +import findUserByIdentifier from '../utils/find-user-by-identifier.js'; +import { storeInteractionResult } from '../utils/interaction.js'; +import userAccountVerification from './user-identity-verification.js'; + +jest.mock('oidc-provider', () => ({ + Provider: jest.fn(() => ({ + interactionDetails: jest.fn(async () => ({ params: {}, jti: 'jti' })), + })), +})); + +jest.mock('../utils/find-user-by-identifier.js', () => jest.fn()); +jest.mock('#src/lib/social.js', () => ({ + findSocialRelatedUser: jest.fn().mockResolvedValue(null), +})); + +jest.mock('../utils/interaction.js', () => ({ + ...jest.requireActual('../utils/interaction.js'), + storeInteractionResult: jest.fn(), +})); + +describe('userAccountVerification', () => { + const findUserByIdentifierMock = findUserByIdentifier as jest.Mock; + + const ctx: InteractionContext = { + ...createContextWithRouteParameters(), + interactionPayload: { + event: Event.SignIn, + }, + }; + const provider = new Provider(''); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('empty identifiers with accountId', async () => { + const interaction: PayloadVerifiedInteractionResult = { + event: Event.SignIn, + accountId: 'foo', + }; + + const result = await userAccountVerification(interaction, ctx, provider); + + expect(storeInteractionResult).not.toBeCalled(); + expect(result).toEqual(result); + }); + + it('empty identifiers withOut accountId should throw', async () => { + const interaction: PayloadVerifiedInteractionResult = { + event: Event.SignIn, + }; + + await expect(userAccountVerification(interaction, ctx, provider)).rejects.toMatchError( + new RequestError({ code: 'session.unauthorized', status: 401 }) + ); + expect(storeInteractionResult).not.toBeCalled(); + }); + + it('verify accountId identifier', async () => { + const interaction: PayloadVerifiedInteractionResult = { + event: Event.SignIn, + identifiers: [{ key: 'accountId', value: 'foo' }], + }; + + const result = await userAccountVerification(interaction, ctx, provider); + + expect(storeInteractionResult).toBeCalledWith(result, ctx, provider); + expect(result).toEqual({ event: Event.SignIn, accountId: 'foo', identifiers: [] }); + }); + + it('verify emailVerified identifier', async () => { + findUserByIdentifierMock.mockResolvedValueOnce({ id: 'foo' }); + + const interaction: PayloadVerifiedInteractionResult = { + event: Event.SignIn, + identifiers: [{ key: 'emailVerified', value: 'email' }], + }; + + const result = await userAccountVerification(interaction, ctx, provider); + expect(findUserByIdentifierMock).toBeCalledWith({ email: 'email' }); + expect(storeInteractionResult).toBeCalledWith(result, ctx, provider); + + expect(result).toEqual({ event: Event.SignIn, accountId: 'foo', identifiers: [] }); + }); + + it('verify phoneVerified identifier', async () => { + findUserByIdentifierMock.mockResolvedValueOnce({ id: 'foo' }); + + const interaction: PayloadVerifiedInteractionResult = { + event: Event.SignIn, + identifiers: [{ key: 'phoneVerified', value: '123456' }], + }; + + const result = await userAccountVerification(interaction, ctx, provider); + expect(findUserByIdentifierMock).toBeCalledWith({ phone: '123456' }); + expect(storeInteractionResult).toBeCalledWith(result, ctx, provider); + + expect(result).toEqual({ event: Event.SignIn, accountId: 'foo', identifiers: [] }); + }); + + it('verify social identifier', async () => { + findUserByIdentifierMock.mockResolvedValueOnce({ id: 'foo' }); + + const interaction: PayloadVerifiedInteractionResult = { + event: Event.SignIn, + identifiers: [{ key: 'social', connectorId: 'connectorId', userInfo: { id: 'foo' } }], + }; + + const result = await userAccountVerification(interaction, ctx, provider); + expect(findUserByIdentifierMock).toBeCalledWith({ + connectorId: 'connectorId', + userInfo: { id: 'foo' }, + }); + expect(storeInteractionResult).toBeCalledWith(result, ctx, provider); + + expect(result).toEqual({ event: Event.SignIn, accountId: 'foo', identifiers: [] }); + }); + + it('verify social identifier user identity not exist', async () => { + findUserByIdentifierMock.mockResolvedValueOnce(null); + + const interaction: PayloadVerifiedInteractionResult = { + event: Event.SignIn, + identifiers: [{ key: 'social', connectorId: 'connectorId', userInfo: { id: 'foo' } }], + }; + + await expect(userAccountVerification(interaction, ctx, provider)).rejects.toMatchError( + new RequestError( + { + code: 'user.identity_not_exists', + status: 422, + }, + null + ) + ); + + expect(findUserByIdentifierMock).toBeCalledWith({ + connectorId: 'connectorId', + userInfo: { id: 'foo' }, + }); + + expect(storeInteractionResult).not.toBeCalled(); + }); + + it('verify accountId and emailVerified identifier with same user', async () => { + findUserByIdentifierMock.mockResolvedValueOnce({ id: 'foo' }); + + const interaction: PayloadVerifiedInteractionResult = { + event: Event.SignIn, + identifiers: [ + { key: 'accountId', value: 'foo' }, + { key: 'emailVerified', value: 'email' }, + ], + }; + + const result = await userAccountVerification(interaction, ctx, provider); + expect(findUserByIdentifierMock).toBeCalledWith({ email: 'email' }); + expect(storeInteractionResult).toBeCalledWith(result, ctx, provider); + expect(result).toEqual({ event: Event.SignIn, accountId: 'foo', identifiers: [] }); + }); + + it('verify accountId and emailVerified identifier with email user not exist', async () => { + findUserByIdentifierMock.mockResolvedValueOnce(null); + + const interaction: PayloadVerifiedInteractionResult = { + event: Event.SignIn, + identifiers: [ + { key: 'accountId', value: 'foo' }, + { key: 'emailVerified', value: 'email' }, + ], + }; + + await expect(userAccountVerification(interaction, ctx, provider)).rejects.toMatchError( + new RequestError({ code: 'user.user_not_exist', status: 404 }, { identifier: 'email' }) + ); + + expect(findUserByIdentifierMock).toBeCalledWith({ email: 'email' }); + expect(storeInteractionResult).not.toBeCalled(); + }); + + it('verify phoneVerified and emailVerified identifier with email user suspend', async () => { + findUserByIdentifierMock + .mockResolvedValueOnce({ id: 'foo' }) + .mockResolvedValueOnce({ id: 'foo2', isSuspended: true }); + + const interaction: PayloadVerifiedInteractionResult = { + event: Event.SignIn, + identifiers: [ + { key: 'emailVerified', value: 'email' }, + { key: 'phoneVerified', value: '123456' }, + ], + }; + + await expect(userAccountVerification(interaction, ctx, provider)).rejects.toMatchError( + new RequestError({ code: 'user.suspended', status: 401 }) + ); + + expect(findUserByIdentifierMock).toHaveBeenNthCalledWith(1, { email: 'email' }); + expect(findUserByIdentifierMock).toHaveBeenNthCalledWith(2, { phone: '123456' }); + expect(storeInteractionResult).not.toBeCalled(); + }); + + it('verify phoneVerified and emailVerified identifier returns inconsistent id', async () => { + findUserByIdentifierMock + .mockResolvedValueOnce({ id: 'foo' }) + .mockResolvedValueOnce({ id: 'foo2' }); + + const interaction: PayloadVerifiedInteractionResult = { + event: Event.SignIn, + identifiers: [ + { key: 'emailVerified', value: 'email' }, + { key: 'phoneVerified', value: '123456' }, + ], + }; + + await expect(userAccountVerification(interaction, ctx, provider)).rejects.toMatchError( + new RequestError('session.verification_failed') + ); + expect(findUserByIdentifierMock).toHaveBeenNthCalledWith(1, { email: 'email' }); + expect(findUserByIdentifierMock).toHaveBeenNthCalledWith(2, { phone: '123456' }); + expect(storeInteractionResult).not.toBeCalled(); + }); + + it('verify emailVerified identifier returns inconsistent id with existing accountId', async () => { + findUserByIdentifierMock.mockResolvedValueOnce({ id: 'foo' }); + + const interaction: PayloadVerifiedInteractionResult = { + event: Event.SignIn, + accountId: 'foo2', + identifiers: [{ key: 'emailVerified', value: 'email' }], + }; + + await expect(userAccountVerification(interaction, ctx, provider)).rejects.toMatchError( + new RequestError('session.verification_failed') + ); + expect(findUserByIdentifierMock).toBeCalledWith({ email: 'email' }); + expect(storeInteractionResult).not.toBeCalled(); + }); + + it('profile use identifier should remain', async () => { + findUserByIdentifierMock.mockResolvedValueOnce({ id: 'foo' }); + + const interaction: PayloadVerifiedInteractionResult = { + event: Event.SignIn, + identifiers: [ + { key: 'social', connectorId: 'connectorId', userInfo: { id: 'foo' } }, + { key: 'emailVerified', value: 'email' }, + { key: 'phoneVerified', value: '123456' }, + ], + profile: { + phone: '123456', + }, + }; + + const ctxWithSocialProfile: InteractionContext = { + ...ctx, + interactionPayload: { + event: Event.SignIn, + profile: { + connectorId: 'connectorId', + }, + }, + }; + + const result = await userAccountVerification(interaction, ctxWithSocialProfile, provider); + expect(findUserByIdentifierMock).toBeCalledWith({ email: 'email' }); + expect(storeInteractionResult).toBeCalledWith(result, ctxWithSocialProfile, provider); + expect(result).toEqual({ + event: Event.SignIn, + accountId: 'foo', + identifiers: [ + { key: 'social', connectorId: 'connectorId', userInfo: { id: 'foo' } }, + { key: 'phoneVerified', value: '123456' }, + ], + profile: { + phone: '123456', + }, + }); + }); +}); diff --git a/packages/core/src/routes/interaction/verifications/user-identity-verification.ts b/packages/core/src/routes/interaction/verifications/user-identity-verification.ts new file mode 100644 index 000000000..7824e338c --- /dev/null +++ b/packages/core/src/routes/interaction/verifications/user-identity-verification.ts @@ -0,0 +1,131 @@ +import { deduplicate } from '@silverhand/essentials'; +import type { Provider } from 'oidc-provider'; + +import RequestError from '#src/errors/RequestError/index.js'; +import { findSocialRelatedUser } from '#src/lib/social.js'; +import assertThat from '#src/utils/assert-that.js'; +import { maskUserInfo } from '#src/utils/format.js'; + +import type { + SocialIdentifier, + VerifiedEmailIdentifier, + VerifiedPhoneIdentifier, + PreAccountVerifiedInteractionResult, + AccountVerifiedInteractionResult, + Identifier, + InteractionContext, +} from '../types/index.js'; +import findUserByIdentifier from '../utils/find-user-by-identifier.js'; +import { isProfileIdentifier } from '../utils/index.js'; +import { + storeInteractionResult, + isAccountVerifiedInteractionResult, +} from '../utils/interaction.js'; + +const identifyUserByVerifiedEmailOrPhone = async ( + identifier: VerifiedEmailIdentifier | VerifiedPhoneIdentifier +) => { + const user = await findUserByIdentifier( + identifier.key === 'emailVerified' ? { email: identifier.value } : { phone: identifier.value } + ); + + assertThat( + user, + new RequestError({ code: 'user.user_not_exist', status: 404 }, { identifier: identifier.value }) + ); + + const { id, isSuspended } = user; + + assertThat(!isSuspended, new RequestError({ code: 'user.suspended', status: 401 })); + + return id; +}; + +const identifyUserBySocialIdentifier = async (identifier: SocialIdentifier) => { + const { connectorId, userInfo } = identifier; + + const user = await findUserByIdentifier({ connectorId, userInfo }); + + if (!user) { + const relatedInfo = await findSocialRelatedUser(userInfo); + + throw new RequestError( + { + code: 'user.identity_not_exists', + status: 422, + }, + relatedInfo && { relatedUser: maskUserInfo(relatedInfo[0]) } + ); + } + + const { id, isSuspended } = user; + + assertThat(!isSuspended, new RequestError({ code: 'user.suspended', status: 401 })); + + return id; +}; + +const identifyUser = async (identifier: Identifier) => { + if (identifier.key === 'social') { + return identifyUserBySocialIdentifier(identifier); + } + + if (identifier.key === 'accountId') { + return identifier.value; + } + + return identifyUserByVerifiedEmailOrPhone(identifier); +}; + +export default async function userAccountVerification( + interaction: PreAccountVerifiedInteractionResult, + ctx: InteractionContext, + provider: Provider +): Promise { + const { identifiers = [], accountId } = interaction; + // Need to merge the profile in payload + const profile = { ...interaction.profile, ...ctx.interactionPayload.profile }; + + // Filter all non-profile identifiers + const userIdentifiers = identifiers.filter( + (identifier) => !isProfileIdentifier(identifier, profile) + ); + + if (isAccountVerifiedInteractionResult(interaction) && userIdentifiers.length === 0) { + return interaction; + } + + assertThat( + userIdentifiers.length > 0, + new RequestError({ + code: 'session.unauthorized', + status: 401, + }) + ); + + // Verify All non-profile identifiers + const accountIds = await Promise.all( + userIdentifiers.map(async (identifier) => identifyUser(identifier)) + ); + + const deduplicateAccountIds = deduplicate(accountIds); + + // Inconsistent identities + assertThat( + deduplicateAccountIds.length === 1 && + deduplicateAccountIds[0] && + (!accountId || accountId === deduplicateAccountIds[0]), + new RequestError('session.verification_failed') + ); + + // Assign verification result and filter out account verified identifiers + const verifiedInteraction: AccountVerifiedInteractionResult = { + ...interaction, + identifiers: identifiers.filter((identifier) => isProfileIdentifier(identifier, profile)), + accountId: deduplicateAccountIds[0], + }; + + await storeInteractionResult(verifiedInteraction, ctx, provider); + + return verifiedInteraction; +} diff --git a/packages/phrases/src/locales/de/errors.ts b/packages/phrases/src/locales/de/errors.ts index 8de26e02d..feb5d0df1 100644 --- a/packages/phrases/src/locales/de/errors.ts +++ b/packages/phrases/src/locales/de/errors.ts @@ -47,6 +47,7 @@ const errors = { sign_in_method_not_enabled: 'This sign in method is not enabled.', // UNTRANSLATED same_password: 'Das neue Passwort muss sich vom alten unterscheiden.', require_password: 'You need to set a password before signing-in.', // UNTRANSLATED + require_new_password: 'You need to set a new password', // UNTRANSLATED password_exists: 'Your password has been set.', // UNTRANSLATED require_username: 'You need to set a username before signing-in.', // UNTRANSLATED username_exists: 'This username is already in use.', // UNTRANSLATED @@ -56,7 +57,7 @@ const errors = { sms_exists: 'This phone number is associated with an existing account.', // UNTRANSLATED require_email_or_sms: 'You need to add an email address or phone number before signing-in.', // UNTRANSLATED suspended: 'This account is suspended.', // UNTRANSLATED - user_not_exist: 'User with {{ identity }} has not been registered yet', // UNTRANSLATED, + user_not_exist: 'User with {{ identifier }} has not been registered yet', // UNTRANSLATED, missing_profile: 'You need to provide additional info before signing-in.', // UNTRANSLATED }, password: { @@ -78,6 +79,8 @@ const errors = { unauthorized: 'Bitte melde dich erst an.', unsupported_prompt_name: 'Nicht unterstützter prompt Name.', forgot_password_not_enabled: 'Forgot password is not enabled.', + verification_failed: + 'Die Verifizierung war nicht erfolgreich. Starte die Verifizierung neu und versuche es erneut.', }, connector: { // UNTRANSLATED diff --git a/packages/phrases/src/locales/en/errors.ts b/packages/phrases/src/locales/en/errors.ts index f073d9a1b..9deb4f327 100644 --- a/packages/phrases/src/locales/en/errors.ts +++ b/packages/phrases/src/locales/en/errors.ts @@ -47,6 +47,7 @@ const errors = { sign_in_method_not_enabled: 'This sign in method is not enabled.', same_password: 'New password cannot be the same as your old password.', require_password: 'You need to set a password before signing-in.', + require_new_password: 'You need to set a new password', password_exists: 'Your password has been set.', require_username: 'You need to set a username before signing-in.', username_exists: 'This username is already in use.', @@ -56,7 +57,7 @@ const errors = { sms_exists: 'This phone number is associated with an existing account.', require_email_or_sms: 'You need to add an email address or phone number before signing-in.', suspended: 'This account is suspended.', - user_not_exist: 'User with {{ identity }} has not been registered yet', + user_not_exist: 'User with {{ identifier }} has not been registered yet', missing_profile: 'You need to provide additional info before signing-in.', }, password: { @@ -78,6 +79,8 @@ const errors = { unauthorized: 'Please sign in first.', unsupported_prompt_name: 'Unsupported prompt name.', forgot_password_not_enabled: 'Forgot password is not enabled.', + verification_failed: + 'The verification was not successful. Restart the verification flow and try again.', }, connector: { general: 'An unexpected error occurred in connector.{{errorDescription}}', diff --git a/packages/phrases/src/locales/fr/errors.ts b/packages/phrases/src/locales/fr/errors.ts index 5269dc709..d920eb565 100644 --- a/packages/phrases/src/locales/fr/errors.ts +++ b/packages/phrases/src/locales/fr/errors.ts @@ -48,6 +48,7 @@ const errors = { sign_in_method_not_enabled: 'This sign in method is not enabled.', // UNTRANSLATED same_password: 'New password cannot be the same as your old password.', // UNTRANSLATED require_password: 'You need to set a password before signing-in.', // UNTRANSLATED + require_new_password: 'You need to set a new password', // UNTRANSLATED password_exists: 'Your password has been set.', // UNTRANSLATED require_username: 'You need to set a username before signing-in.', // UNTRANSLATED username_exists: 'This username is already in use.', // UNTRANSLATED @@ -57,7 +58,7 @@ const errors = { sms_exists: 'This phone number is associated with an existing account.', // UNTRANSLATED require_email_or_sms: 'You need to add an email address or phone number before signing-in.', // UNTRANSLATED suspended: 'This account is suspended.', // UNTRANSLATED - user_not_exist: 'User with {{ identity }} has not been registered yet', // UNTRANSLATED, + user_not_exist: 'User with {{ identifier }} has not been registered yet', // UNTRANSLATED, missing_profile: 'You need to provide additional info before signing-in.', // UNTRANSLATED }, password: { @@ -83,6 +84,8 @@ const errors = { unauthorized: "Veuillez vous enregistrer d'abord.", unsupported_prompt_name: "Nom d'invite non supporté.", forgot_password_not_enabled: 'Forgot password is not enabled.', // UNTRANSLATED + verification_failed: + 'The verification was not successful. Restart the verification flow and try again.', // UNTRANSLATED }, connector: { general: "Une erreur inattendue s'est produite dans le connecteur. {{errorDescription}}", diff --git a/packages/phrases/src/locales/ko/errors.ts b/packages/phrases/src/locales/ko/errors.ts index 3b32d1f1c..eac6cc903 100644 --- a/packages/phrases/src/locales/ko/errors.ts +++ b/packages/phrases/src/locales/ko/errors.ts @@ -46,6 +46,7 @@ const errors = { sign_in_method_not_enabled: 'This sign in method is not enabled.', // UNTRANSLATED same_password: 'New password cannot be the same as your old password.', // UNTRANSLATED require_password: 'You need to set a password before signing-in.', // UNTRANSLATED + require_new_password: 'You need to set a new password', // UNTRANSLATED password_exists: 'Your password has been set.', // UNTRANSLATED require_username: 'You need to set a username before signing-in.', // UNTRANSLATED username_exists: 'This username is already in use.', // UNTRANSLATED @@ -55,7 +56,7 @@ const errors = { sms_exists: 'This phone number is associated with an existing account.', // UNTRANSLATED require_email_or_sms: 'You need to add an email address or phone number before signing-in.', // UNTRANSLATED suspended: 'This account is suspended.', // UNTRANSLATED - user_not_exist: 'User with {{ identity }} has not been registered yet', // UNTRANSLATED, + user_not_exist: 'User with {{ identifier }} has not been registered yet', // UNTRANSLATED, missing_profile: 'You need to provide additional info before signing-in.', // UNTRANSLATED }, password: { @@ -77,6 +78,8 @@ const errors = { unauthorized: '로그인을 먼저 해주세요.', unsupported_prompt_name: '지원하지 않는 Prompt 이름이예요.', forgot_password_not_enabled: 'Forgot password is not enabled.', // UNTRANSLATED + verification_failed: + 'The verification was not successful. Restart the verification flow and try again.', // UNTRANSLATED }, connector: { general: '연동 중에 알 수 없는 오류가 발생했어요. {{errorDescription}}', diff --git a/packages/phrases/src/locales/pt-pt/errors.ts b/packages/phrases/src/locales/pt-pt/errors.ts index c7d42da50..33cd39326 100644 --- a/packages/phrases/src/locales/pt-pt/errors.ts +++ b/packages/phrases/src/locales/pt-pt/errors.ts @@ -46,6 +46,7 @@ const errors = { sign_in_method_not_enabled: 'This sign in method is not enabled.', // UNTRANSLATED same_password: 'New password cannot be the same as your old password.', // UNTRANSLATED require_password: 'You need to set a password before signing-in.', // UNTRANSLATED + require_new_password: 'You need to set a new password', // UNTRANSLATED password_exists: 'Your password has been set.', // UNTRANSLATED require_username: 'You need to set a username before signing-in.', // UNTRANSLATED username_exists: 'This username is already in use.', // UNTRANSLATED @@ -55,7 +56,7 @@ const errors = { sms_exists: 'This phone number is associated with an existing account.', // UNTRANSLATED require_email_or_sms: 'You need to add an email address or phone number before signing-in.', // UNTRANSLATED suspended: 'This account is suspended.', // UNTRANSLATED - user_not_exist: 'User with {{ identity }} has not been registered yet', // UNTRANSLATED, + user_not_exist: 'User with {{ identifier }} has not been registered yet', // UNTRANSLATED, missing_profile: 'You need to provide additional info before signing-in.', // UNTRANSLATED }, password: { @@ -79,6 +80,8 @@ const errors = { unauthorized: 'Faça login primeiro.', unsupported_prompt_name: 'Nome de prompt não suportado.', forgot_password_not_enabled: 'Forgot password is not enabled.', // UNTRANSLATED + verification_failed: + 'The verification was not successful. Restart the verification flow and try again.', // UNTRANSLATED }, connector: { general: 'Ocorreu um erro inesperado no conector.{{errorDescription}}', diff --git a/packages/phrases/src/locales/tr-tr/errors.ts b/packages/phrases/src/locales/tr-tr/errors.ts index 982b5065b..31acc078a 100644 --- a/packages/phrases/src/locales/tr-tr/errors.ts +++ b/packages/phrases/src/locales/tr-tr/errors.ts @@ -47,6 +47,7 @@ const errors = { sign_in_method_not_enabled: 'This sign in method is not enabled.', // UNTRANSLATED same_password: 'New password cannot be the same as your old password.', // UNTRANSLATED require_password: 'You need to set a password before signing-in.', // UNTRANSLATED + require_new_password: 'You need to set a new password', // UNTRANSLATED password_exists: 'Your password has been set.', // UNTRANSLATED require_username: 'You need to set a username before signing-in.', // UNTRANSLATED username_exists: 'This username is already in use.', // UNTRANSLATED @@ -56,7 +57,7 @@ const errors = { sms_exists: 'This phone number is associated with an existing account.', // UNTRANSLATED require_email_or_sms: 'You need to add an email address or phone number before signing-in.', // UNTRANSLATED suspended: 'This account is suspended.', // UNTRANSLATED - user_not_exist: 'User with {{ identity }} has not been registered yet', // UNTRANSLATED, + user_not_exist: 'User with {{ identifier }} has not been registered yet', // UNTRANSLATED, missing_profile: 'You need to provide additional info before signing-in.', // UNTRANSLATED }, password: { @@ -79,6 +80,8 @@ const errors = { unauthorized: 'Lütfen önce oturum açın.', unsupported_prompt_name: 'Desteklenmeyen prompt adı.', forgot_password_not_enabled: 'Forgot password is not enabled.', // UNTRANSLATED + verification_failed: + 'The verification was not successful. Restart the verification flow and try again.', // UNTRANSLATED }, connector: { general: 'Bağlayıcıda beklenmeyen bir hata oldu.{{errorDescription}}', diff --git a/packages/phrases/src/locales/zh-cn/errors.ts b/packages/phrases/src/locales/zh-cn/errors.ts index 35b1cb0d6..7a28346b1 100644 --- a/packages/phrases/src/locales/zh-cn/errors.ts +++ b/packages/phrases/src/locales/zh-cn/errors.ts @@ -46,16 +46,17 @@ const errors = { sign_in_method_not_enabled: '登录方式尚未启用', same_password: '为确保你的账户安全,新密码不能与旧密码一致', require_password: '请设置密码', + require_new_password: '请设置新密码', password_exists: '密码已设置过', require_username: '请设置用户名', username_exists: '该用户名已存在', require_email: '请绑定邮箱地址', - email_exists: '该邮箱地址已被其它账户绑定', + email_exists: '该用户已绑定邮箱', require_sms: '请绑定手机号码', - sms_exists: '该手机号码已被其它账户绑定', + sms_exists: '该用户已绑定手机号', require_email_or_sms: '请绑定邮箱地址或手机号码', suspended: '账号已被禁用', - user_not_exist: 'User with {{ identity }} has not been registered yet', // UNTRANSLATED, + user_not_exist: 'User with {{ identifier }} has not been registered yet', // UNTRANSLATED, missing_profile: 'You need to provide additional info before signing-in.', // UNTRANSLATED }, password: { @@ -75,6 +76,7 @@ const errors = { unauthorized: '请先登录', unsupported_prompt_name: '不支持的 prompt name', forgot_password_not_enabled: '忘记密码功能没有开启。', + verification_failed: '验证失败,请重新验证。', }, connector: { general: '连接器发生未知错误{{errorDescription}}', diff --git a/packages/schemas/src/types/interactions.ts b/packages/schemas/src/types/interactions.ts index 5e950a545..082f62c89 100644 --- a/packages/schemas/src/types/interactions.ts +++ b/packages/schemas/src/types/interactions.ts @@ -41,6 +41,12 @@ export const socialConnectorPayloadGuard = z.object({ }); export type SocialConnectorPayload = z.infer; +export const socialIdentityPayloadGuard = z.object({ + connectorId: z.string(), + identityType: z.union([z.literal('phone'), z.literal('email')]), +}); +export type SocialIdentityPayload = z.infer; + /** * Interaction Payload Guard */ @@ -59,6 +65,7 @@ export const identifierPayloadGuard = z.union([ emailPasscodePayloadGuard, phonePasscodePayloadGuard, socialConnectorPayloadGuard, + socialIdentityPayloadGuard, ]); export type IdentifierPayload = @@ -67,7 +74,8 @@ export type IdentifierPayload = | PhonePasswordPayload | EmailPasscodePayload | PhonePasscodePayload - | SocialConnectorPayload; + | SocialConnectorPayload + | SocialIdentityPayload; export const profileGuard = z.object({ username: z.string().regex(usernameRegEx).optional(),