0
Fork 0
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:
simeng-li 2022-12-07 18:35:14 +08:00 committed by GitHub
parent 9545baa8e0
commit ed62c106d9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 1717 additions and 470 deletions

View file

@ -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();
});
});

View file

@ -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;
}

View file

@ -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 }),

View file

@ -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', () => {

View file

@ -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,
});

View file

@ -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 =

View file

@ -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;

View file

@ -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]);
});
});

View file

@ -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;
};

View file

@ -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 }],
});
});
});

View file

@ -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;
}

View file

@ -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);
}

View file

@ -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';

View file

@ -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();
});
});

View file

@ -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(

View file

@ -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();
});
});

View file

@ -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();
});
});

View file

@ -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();
});
});

View file

@ -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
);
});
});
});

View file

@ -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;
}

View file

@ -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',
},
});
});
});

View file

@ -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;
}

View file

@ -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

View file

@ -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}}',

View file

@ -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}}",

View file

@ -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}}',

View file

@ -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}}',

View file

@ -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}}',

View file

@ -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}}',

View file

@ -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(),