mirror of
https://github.com/logto-io/logto.git
synced 2025-03-31 22:51:25 -05:00
refactor(core): refactor interaction verification flow (#2579)
This commit is contained in:
parent
9545baa8e0
commit
ed62c106d9
30 changed files with 1717 additions and 470 deletions
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
|
@ -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<T extends AnonymousRouter>(
|
|||
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 }),
|
||||
|
|
|
@ -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<Context> = {
|
||||
...baseCtx,
|
||||
request: {
|
||||
...baseCtx.request,
|
||||
body: {
|
||||
event,
|
||||
},
|
||||
it.each([Event.SignIn, Event.ForgotPassword])('%p should parse successfully', async (event) => {
|
||||
const ctx: WithGuardedIdentifierPayloadContext<Context> = {
|
||||
...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<Context> = {
|
||||
...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', () => {
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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<typeof sendPasscodePayloadGuard>;
|
|||
|
||||
export type SocialAuthorizationUrlPayload = z.infer<typeof getSocialAuthorizationUrlPayloadGuard>;
|
||||
|
||||
// Identifier Types
|
||||
// Interaction Types
|
||||
export type AccountIdIdentifier = z.infer<typeof accountIdIdentifierGuard>;
|
||||
|
||||
export type VerifiedEmailIdentifier = z.infer<typeof verifiedEmailIdentifierGuard>;
|
||||
|
@ -48,6 +55,59 @@ export type SocialIdentifier = z.infer<typeof socialIdentifierGuard>;
|
|||
|
||||
export type Identifier = z.infer<typeof identifierGuard>;
|
||||
|
||||
export type AnonymousInteractionResult = z.infer<typeof anonymousInteractionResultGuard>;
|
||||
|
||||
export type RegisterSafeProfile = z.infer<typeof registerProfileSafeGuard>;
|
||||
|
||||
export type ForgotPasswordProfile = z.infer<typeof forgotPasswordProfileGuard>;
|
||||
|
||||
export type VerifiedRegisterInteractionResult = z.infer<
|
||||
typeof verifiedRegisterInteractionResultGuard
|
||||
>;
|
||||
|
||||
export type VerifiedSignInInteractionResult = z.infer<typeof verifiedSignInteractionResultGuard>;
|
||||
|
||||
export type VerifiedForgotPasswordInteractionResult = z.infer<
|
||||
typeof verifiedForgotPasswordInteractionResultGuard
|
||||
>;
|
||||
|
||||
export type RegisterInteractionResult = Omit<AnonymousInteractionResult, 'event'> & {
|
||||
event: Event.Register;
|
||||
};
|
||||
|
||||
export type SignInInteractionResult = Omit<AnonymousInteractionResult, 'event'> & {
|
||||
event: Event.SignIn;
|
||||
};
|
||||
|
||||
export type ForgotPasswordInteractionResult = Omit<AnonymousInteractionResult, 'event'> & {
|
||||
event: Event.ForgotPassword;
|
||||
};
|
||||
|
||||
export type PreAccountVerifiedInteractionResult =
|
||||
| SignInInteractionResult
|
||||
| ForgotPasswordInteractionResult;
|
||||
|
||||
export type PayloadVerifiedInteractionResult =
|
||||
| RegisterInteractionResult
|
||||
| PreAccountVerifiedInteractionResult;
|
||||
|
||||
export type AccountVerifiedInteractionResult =
|
||||
| (Omit<SignInInteractionResult, 'accountId'> & {
|
||||
accountId: string;
|
||||
})
|
||||
| (Omit<ForgotPasswordInteractionResult, 'accountId'> & {
|
||||
accountId: string;
|
||||
});
|
||||
|
||||
export type IdentifierVerifiedInteractionResult =
|
||||
| RegisterInteractionResult
|
||||
| AccountVerifiedInteractionResult;
|
||||
|
||||
export type VerifiedInteractionResult =
|
||||
| VerifiedRegisterInteractionResult
|
||||
| VerifiedSignInInteractionResult
|
||||
| VerifiedForgotPasswordInteractionResult;
|
||||
|
||||
export type InteractionContext = WithGuardedIdentifierPayloadContext<IRouterParamContext & Context>;
|
||||
|
||||
export type UserIdentity =
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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]);
|
||||
});
|
||||
});
|
|
@ -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<AnonymousInteractionResult, 'event'> & { 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;
|
||||
};
|
||||
|
|
|
@ -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 }],
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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<AccountIdIdentifier> => {
|
||||
// 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<VerifiedEmailIdentifier | VerifiedPhoneIdentifier> => {
|
||||
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<SocialIdentifier> => {
|
||||
const userInfo = await verifySocialIdentity(identifier, ctx.log);
|
||||
|
||||
return { key: 'social', connectorId: identifier.connectorId, userInfo };
|
||||
};
|
||||
|
||||
const verifySocialIdentityInInteractionRecord = async (
|
||||
{ connectorId, identityType }: SocialIdentityPayload,
|
||||
interactionRecord?: AnonymousInteractionResult
|
||||
): Promise<VerifiedEmailIdentifier | VerifiedPhoneIdentifier> => {
|
||||
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<Identifier | undefined> => {
|
||||
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<PayloadVerifiedInteractionResult> {
|
||||
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;
|
||||
}
|
|
@ -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<Identifier[]> => {
|
||||
// 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<Identifier[]> => {
|
||||
const { identifier, event, profile } = payload;
|
||||
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
|
||||
provider: Provider,
|
||||
interactionRecord?: AnonymousInteractionResult
|
||||
): Promise<IdentifierVerifiedInteractionResult> {
|
||||
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<Identifier[]> => {
|
||||
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<Identifier[]> {
|
||||
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);
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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<Context>,
|
||||
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(
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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<Profile | undefined> {
|
||||
const { profile, event } = ctx.interactionPayload;
|
||||
provider: Provider,
|
||||
interaction: IdentifierVerifiedInteractionResult
|
||||
): Promise<VerifiedInteractionResult> {
|
||||
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;
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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<AccountVerifiedInteractionResult> {
|
||||
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;
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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}}',
|
||||
|
|
|
@ -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}}",
|
||||
|
|
|
@ -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}}',
|
||||
|
|
|
@ -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}}',
|
||||
|
|
|
@ -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}}',
|
||||
|
|
|
@ -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}}',
|
||||
|
|
|
@ -41,6 +41,12 @@ export const socialConnectorPayloadGuard = z.object({
|
|||
});
|
||||
export type SocialConnectorPayload = z.infer<typeof socialConnectorPayloadGuard>;
|
||||
|
||||
export const socialIdentityPayloadGuard = z.object({
|
||||
connectorId: z.string(),
|
||||
identityType: z.union([z.literal('phone'), z.literal('email')]),
|
||||
});
|
||||
export type SocialIdentityPayload = z.infer<typeof socialIdentityPayloadGuard>;
|
||||
|
||||
/**
|
||||
* 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(),
|
||||
|
|
Loading…
Add table
Reference in a new issue