mirror of
https://github.com/logto-io/logto.git
synced 2025-04-07 23:01:25 -05:00
feat(core): parse mandatory profile (#7133)
* feat(core): parse mandatory profile parse mandatory profile * refactor(core): reorder mandatory profile validation reorder mandatory profile validation
This commit is contained in:
parent
eb802f4c4b
commit
d352b6716c
4 changed files with 364 additions and 45 deletions
|
@ -1,20 +1,23 @@
|
|||
/* eslint-disable max-lines */
|
||||
import { TemplateType } from '@logto/connector-kit';
|
||||
import {
|
||||
InteractionEvent,
|
||||
MissingProfile,
|
||||
type SignInExperience,
|
||||
SignInIdentifier,
|
||||
SignInMode,
|
||||
VerificationType,
|
||||
} from '@logto/schemas';
|
||||
import Sinon, { type SinonStub } from 'sinon';
|
||||
|
||||
import { mockSignInExperience } from '#src/__mocks__/sign-in-experience.js';
|
||||
import { EnvSet } from '#src/env-set/index.js';
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import { MockTenant } from '#src/test-utils/tenant.js';
|
||||
|
||||
import { createNewCodeVerificationRecord } from '../verifications/code-verification.js';
|
||||
import { EnterpriseSsoVerification } from '../verifications/enterprise-sso-verification.js';
|
||||
import { type VerificationRecord } from '../verifications/index.js';
|
||||
import { NewPasswordIdentityVerification } from '../verifications/new-password-identity-verification.js';
|
||||
import { PasswordVerification } from '../verifications/password-verification.js';
|
||||
import { SocialVerification } from '../verifications/social-verification.js';
|
||||
|
||||
|
@ -33,24 +36,6 @@ const ssoConnectors = {
|
|||
|
||||
const mockTenant = new MockTenant(undefined, { signInExperiences }, undefined, { ssoConnectors });
|
||||
|
||||
const newPasswordIdentityVerificationRecord = NewPasswordIdentityVerification.create(
|
||||
mockTenant.libraries,
|
||||
mockTenant.queries,
|
||||
{
|
||||
type: SignInIdentifier.Username,
|
||||
value: 'username',
|
||||
}
|
||||
);
|
||||
|
||||
const emailNewPasswordIdentityVerificationRecord = NewPasswordIdentityVerification.create(
|
||||
mockTenant.libraries,
|
||||
mockTenant.queries,
|
||||
{
|
||||
type: SignInIdentifier.Email,
|
||||
value: `foo@${emailDomain}`,
|
||||
}
|
||||
);
|
||||
|
||||
const passwordVerificationRecords = Object.fromEntries(
|
||||
Object.values(SignInIdentifier).map((identifier) => [
|
||||
identifier,
|
||||
|
@ -397,4 +382,137 @@ describe('SignInExperienceValidator', () => {
|
|||
).rejects.toMatchError(expectError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMandatoryUserProfileBySignUpMethods', () => {
|
||||
const testCases: Array<[SignInExperience['signUp'], Set<MissingProfile>]> = [
|
||||
[
|
||||
{
|
||||
identifiers: [SignInIdentifier.Username],
|
||||
password: true,
|
||||
verify: false,
|
||||
},
|
||||
new Set([MissingProfile.password, MissingProfile.username]),
|
||||
],
|
||||
[
|
||||
{
|
||||
identifiers: [SignInIdentifier.Email],
|
||||
password: false,
|
||||
verify: true,
|
||||
},
|
||||
new Set([MissingProfile.email]),
|
||||
],
|
||||
[
|
||||
{
|
||||
identifiers: [SignInIdentifier.Phone, SignInIdentifier.Email],
|
||||
password: true,
|
||||
verify: false,
|
||||
},
|
||||
new Set([MissingProfile.password, MissingProfile.emailOrPhone]),
|
||||
],
|
||||
];
|
||||
|
||||
it.each(testCases)('signUp: %p', async (signUp, expected) => {
|
||||
signInExperiences.findDefaultSignInExperience.mockResolvedValueOnce({
|
||||
...mockSignInExperience,
|
||||
signUp,
|
||||
});
|
||||
|
||||
const signInExperienceValidator = new SignInExperienceValidator(
|
||||
mockTenant.libraries,
|
||||
mockTenant.queries
|
||||
);
|
||||
|
||||
const result = await signInExperienceValidator.getMandatoryUserProfileBySignUpMethods();
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should early return with unsupported signUp identifiers', async () => {
|
||||
signInExperiences.findDefaultSignInExperience.mockResolvedValueOnce({
|
||||
...mockSignInExperience,
|
||||
signUp: {
|
||||
identifiers: [SignInIdentifier.Email, SignInIdentifier.Username],
|
||||
password: true,
|
||||
verify: false,
|
||||
},
|
||||
});
|
||||
const signInExperienceValidator = new SignInExperienceValidator(
|
||||
mockTenant.libraries,
|
||||
mockTenant.queries
|
||||
);
|
||||
|
||||
const result = await signInExperienceValidator.getMandatoryUserProfileBySignUpMethods();
|
||||
expect(result).toEqual(new Set([MissingProfile.username, MissingProfile.password]));
|
||||
});
|
||||
|
||||
describe('getMandatoryUserProfileBySignUpMethods with secondary identifiers provided', () => {
|
||||
// eslint-disable-next-line @silverhand/fp/no-let
|
||||
let stub: SinonStub;
|
||||
|
||||
beforeAll(() => {
|
||||
// eslint-disable-next-line @silverhand/fp/no-mutation
|
||||
stub = Sinon.stub(EnvSet, 'values').value({
|
||||
...EnvSet.values,
|
||||
isDevFeatureEnabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
stub.restore();
|
||||
});
|
||||
|
||||
const testCases: Array<[SignInExperience['signUp'], Set<MissingProfile>]> = [
|
||||
[
|
||||
{
|
||||
identifiers: [SignInIdentifier.Username],
|
||||
password: true,
|
||||
verify: false,
|
||||
secondaryIdentifiers: [
|
||||
{ identifier: SignInIdentifier.Email },
|
||||
{ identifier: SignInIdentifier.Phone },
|
||||
],
|
||||
},
|
||||
new Set([
|
||||
MissingProfile.username,
|
||||
MissingProfile.password,
|
||||
MissingProfile.email,
|
||||
MissingProfile.phone,
|
||||
]),
|
||||
],
|
||||
[
|
||||
{
|
||||
identifiers: [SignInIdentifier.Email],
|
||||
password: false,
|
||||
verify: true,
|
||||
secondaryIdentifiers: [{ identifier: SignInIdentifier.Username }],
|
||||
},
|
||||
new Set([MissingProfile.email, MissingProfile.username]),
|
||||
],
|
||||
[
|
||||
{
|
||||
identifiers: [SignInIdentifier.Phone, SignInIdentifier.Email],
|
||||
password: true,
|
||||
verify: true,
|
||||
secondaryIdentifiers: [{ identifier: SignInIdentifier.Username }],
|
||||
},
|
||||
new Set([MissingProfile.password, MissingProfile.emailOrPhone, MissingProfile.username]),
|
||||
],
|
||||
];
|
||||
|
||||
it.each(testCases)('signUp: %p', async (signUp, expected) => {
|
||||
signInExperiences.findDefaultSignInExperience.mockResolvedValueOnce({
|
||||
...mockSignInExperience,
|
||||
signUp,
|
||||
});
|
||||
|
||||
const signInExperienceValidator = new SignInExperienceValidator(
|
||||
mockTenant.libraries,
|
||||
mockTenant.queries
|
||||
);
|
||||
|
||||
const result = await signInExperienceValidator.getMandatoryUserProfileBySignUpMethods();
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
/* eslint-enable max-lines */
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import {
|
||||
AlternativeSignUpIdentifier,
|
||||
InteractionEvent,
|
||||
MissingProfile,
|
||||
type SignInExperience,
|
||||
|
@ -7,6 +8,7 @@ import {
|
|||
VerificationType,
|
||||
} from '@logto/schemas';
|
||||
|
||||
import { EnvSet } from '#src/env-set/index.js';
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import type Libraries from '#src/tenants/Libraries.js';
|
||||
import type Queries from '#src/tenants/Queries.js';
|
||||
|
@ -35,6 +37,36 @@ const getEmailIdentifierFromVerificationRecord = (verificationRecord: Verificati
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @remarks
|
||||
* In our legacy `signUp.identifiers` field design, a list of {@link SignInIdentifier} is accepted.
|
||||
*
|
||||
* `signUp.identifiers` represents the primary identifier for the user to sign up.
|
||||
* If more than one identifier is provided, the user can choose one of them to sign up.
|
||||
* The supported case suppose to be `['email', 'phone']`. In this case, the user can sign up with either email or phone.
|
||||
* However, the current implementation does not provide a safe guard for invalid cases like `['email', 'username']`.
|
||||
* Use this function to safely parse the mandatory primary identifier. Always early return if the primary identifier is found.
|
||||
*/
|
||||
const parseMandatoryPrimaryIdentifier = (
|
||||
identifiers: SignInIdentifier[]
|
||||
): MissingProfile | undefined => {
|
||||
const identifiersSet = new Set(identifiers);
|
||||
|
||||
if (identifiersSet.has(SignInIdentifier.Username)) {
|
||||
return MissingProfile.username;
|
||||
}
|
||||
|
||||
if (identifiersSet.has(SignInIdentifier.Email)) {
|
||||
return identifiersSet.has(SignInIdentifier.Phone)
|
||||
? MissingProfile.emailOrPhone
|
||||
: MissingProfile.email;
|
||||
}
|
||||
|
||||
if (identifiersSet.has(SignInIdentifier.Phone)) {
|
||||
return MissingProfile.phone;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* SignInExperienceValidator class provides all the sign-in experience settings validation logic.
|
||||
*
|
||||
|
@ -130,34 +162,47 @@ export class SignInExperienceValidator {
|
|||
|
||||
public async getMandatoryUserProfileBySignUpMethods(): Promise<Set<MissingProfile>> {
|
||||
const {
|
||||
signUp: { identifiers, password },
|
||||
signUp: { identifiers, password, secondaryIdentifiers = [] },
|
||||
} = await this.getSignInExperienceData();
|
||||
|
||||
const mandatoryUserProfile = new Set<MissingProfile>();
|
||||
|
||||
// Check for mandatory primary identifier
|
||||
const mandatoryPrimaryIdentifier = parseMandatoryPrimaryIdentifier(identifiers);
|
||||
if (mandatoryPrimaryIdentifier) {
|
||||
mandatoryUserProfile.add(mandatoryPrimaryIdentifier);
|
||||
}
|
||||
|
||||
// TODO: Remove this dev feature check
|
||||
// Check for mandatory secondary identifiers
|
||||
if (EnvSet.values.isDevFeaturesEnabled) {
|
||||
for (const { identifier } of secondaryIdentifiers) {
|
||||
switch (identifier) {
|
||||
case SignInIdentifier.Email: {
|
||||
mandatoryUserProfile.add(MissingProfile.email);
|
||||
continue;
|
||||
}
|
||||
case SignInIdentifier.Phone: {
|
||||
mandatoryUserProfile.add(MissingProfile.phone);
|
||||
continue;
|
||||
}
|
||||
case SignInIdentifier.Username: {
|
||||
mandatoryUserProfile.add(MissingProfile.username);
|
||||
continue;
|
||||
}
|
||||
case AlternativeSignUpIdentifier.EmailOrPhone: {
|
||||
mandatoryUserProfile.add(MissingProfile.emailOrPhone);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for mandatory password
|
||||
if (password) {
|
||||
mandatoryUserProfile.add(MissingProfile.password);
|
||||
}
|
||||
|
||||
if (identifiers.includes(SignInIdentifier.Username)) {
|
||||
mandatoryUserProfile.add(MissingProfile.username);
|
||||
}
|
||||
|
||||
if (
|
||||
identifiers.includes(SignInIdentifier.Email) &&
|
||||
identifiers.includes(SignInIdentifier.Phone)
|
||||
) {
|
||||
mandatoryUserProfile.add(MissingProfile.emailOrPhone);
|
||||
return mandatoryUserProfile;
|
||||
}
|
||||
|
||||
if (identifiers.includes(SignInIdentifier.Email)) {
|
||||
mandatoryUserProfile.add(MissingProfile.email);
|
||||
}
|
||||
|
||||
if (identifiers.includes(SignInIdentifier.Phone)) {
|
||||
mandatoryUserProfile.add(MissingProfile.phone);
|
||||
}
|
||||
|
||||
return mandatoryUserProfile;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,15 +1,17 @@
|
|||
import { InteractionEvent, SignInIdentifier } from '@logto/schemas';
|
||||
import { AlternativeSignUpIdentifier, InteractionEvent, SignInIdentifier } from '@logto/schemas';
|
||||
|
||||
import { deleteUser } from '#src/api/admin-user.js';
|
||||
import { updateSignInExperience } from '#src/api/sign-in-experience.js';
|
||||
import { initExperienceClient, logoutClient, processSession } from '#src/helpers/client.js';
|
||||
import { setEmailConnector, setSmsConnector } from '#src/helpers/connector.js';
|
||||
import {
|
||||
fulfillUserEmail,
|
||||
registerNewUserUsernamePassword,
|
||||
signInWithPassword,
|
||||
} from '#src/helpers/experience/index.js';
|
||||
import { expectRejects } from '#src/helpers/index.js';
|
||||
import { generateNewUserProfile, UserApiTest } from '#src/helpers/user.js';
|
||||
import { generateUsername } from '#src/utils.js';
|
||||
import { devFeatureTest, generateUsername } from '#src/utils.js';
|
||||
|
||||
describe('register new user with username and password', () => {
|
||||
const userApi = new UserApiTest();
|
||||
|
@ -83,3 +85,73 @@ describe('register new user with username and password', () => {
|
|||
await deleteUser(userId);
|
||||
});
|
||||
});
|
||||
|
||||
devFeatureTest.describe(
|
||||
'register new user with username and password with secondary identifiers',
|
||||
() => {
|
||||
beforeAll(async () => {
|
||||
await Promise.all([setEmailConnector(), setSmsConnector()]);
|
||||
});
|
||||
|
||||
it.each([SignInIdentifier.Email, AlternativeSignUpIdentifier.EmailOrPhone])(
|
||||
'set %s as secondary identifier',
|
||||
async (secondaryIdentifier) => {
|
||||
await updateSignInExperience({
|
||||
signUp: {
|
||||
identifiers: [SignInIdentifier.Username],
|
||||
password: true,
|
||||
verify: false,
|
||||
secondaryIdentifiers: [
|
||||
{
|
||||
identifier: secondaryIdentifier,
|
||||
verify: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
passwordPolicy: {},
|
||||
});
|
||||
|
||||
const { username, password, primaryEmail } = generateNewUserProfile({
|
||||
username: true,
|
||||
password: true,
|
||||
primaryEmail: true,
|
||||
});
|
||||
|
||||
const client = await initExperienceClient(InteractionEvent.Register);
|
||||
|
||||
await client.updateProfile({ type: SignInIdentifier.Username, value: username });
|
||||
await client.updateProfile({ type: 'password', value: password });
|
||||
|
||||
await expectRejects(client.identifyUser(), {
|
||||
status: 422,
|
||||
code: 'user.missing_profile',
|
||||
});
|
||||
|
||||
await fulfillUserEmail(client, primaryEmail);
|
||||
|
||||
await client.identifyUser();
|
||||
const { redirectTo } = await client.submitInteraction();
|
||||
const userId = await processSession(client, redirectTo);
|
||||
await logoutClient(client);
|
||||
|
||||
await signInWithPassword({
|
||||
identifier: {
|
||||
type: SignInIdentifier.Username,
|
||||
value: username,
|
||||
},
|
||||
password,
|
||||
});
|
||||
|
||||
await signInWithPassword({
|
||||
identifier: {
|
||||
type: SignInIdentifier.Email,
|
||||
value: primaryEmail,
|
||||
},
|
||||
password,
|
||||
});
|
||||
|
||||
await deleteUser(userId);
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
|
@ -5,17 +5,21 @@ import {
|
|||
} from '@logto/schemas';
|
||||
|
||||
import { deleteUser } from '#src/api/admin-user.js';
|
||||
import { updateSignInExperience } from '#src/api/sign-in-experience.js';
|
||||
import { initExperienceClient, logoutClient, processSession } from '#src/helpers/client.js';
|
||||
import { setEmailConnector, setSmsConnector } from '#src/helpers/connector.js';
|
||||
import { registerNewUserWithVerificationCode } from '#src/helpers/experience/index.js';
|
||||
import {
|
||||
registerNewUserWithVerificationCode,
|
||||
signInWithPassword,
|
||||
} from '#src/helpers/experience/index.js';
|
||||
import {
|
||||
successfullySendVerificationCode,
|
||||
successfullyVerifyVerificationCode,
|
||||
} from '#src/helpers/experience/verification-code.js';
|
||||
import { expectRejects } from '#src/helpers/index.js';
|
||||
import { enableAllVerificationCodeSignInMethods } from '#src/helpers/sign-in-experience.js';
|
||||
import { generateNewUser } from '#src/helpers/user.js';
|
||||
import { generateEmail, generatePhone } from '#src/utils.js';
|
||||
import { generateNewUser, generateNewUserProfile } from '#src/helpers/user.js';
|
||||
import { devFeatureTest, generateEmail, generatePhone } from '#src/utils.js';
|
||||
|
||||
const verificationIdentifierType: readonly [SignInIdentifier.Email, SignInIdentifier.Phone] =
|
||||
Object.freeze([SignInIdentifier.Email, SignInIdentifier.Phone]);
|
||||
|
@ -189,3 +193,83 @@ describe('Register interaction with verification code happy path', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
devFeatureTest.describe('username as secondary identifier', () => {
|
||||
beforeAll(async () => {
|
||||
await updateSignInExperience({
|
||||
signUp: {
|
||||
identifiers: [SignInIdentifier.Email, SignInIdentifier.Phone],
|
||||
password: true,
|
||||
verify: true,
|
||||
secondaryIdentifiers: [
|
||||
{
|
||||
identifier: SignInIdentifier.Username,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each(verificationIdentifierType)(
|
||||
'Should register with verification code using %p and fulfill the password and username successfully',
|
||||
async (identifierType) => {
|
||||
const identifier = {
|
||||
type: identifierType,
|
||||
value: identifierType === SignInIdentifier.Email ? generateEmail() : generatePhone(),
|
||||
};
|
||||
|
||||
const client = await initExperienceClient(InteractionEvent.Register);
|
||||
const { verificationId, code } = await successfullySendVerificationCode(client, {
|
||||
identifier,
|
||||
interactionEvent: InteractionEvent.Register,
|
||||
});
|
||||
await successfullyVerifyVerificationCode(client, {
|
||||
identifier,
|
||||
verificationId,
|
||||
code,
|
||||
});
|
||||
await expectRejects(client.identifyUser({ verificationId }), {
|
||||
code: 'user.missing_profile',
|
||||
status: 422,
|
||||
});
|
||||
|
||||
const { username, password } = generateNewUserProfile({
|
||||
username: true,
|
||||
password: true,
|
||||
});
|
||||
|
||||
await client.updateProfile({ type: 'password', value: password });
|
||||
|
||||
await expectRejects(client.identifyUser({ verificationId }), {
|
||||
code: 'user.missing_profile',
|
||||
status: 422,
|
||||
});
|
||||
|
||||
await client.updateProfile({ type: SignInIdentifier.Username, value: username });
|
||||
|
||||
await client.identifyUser();
|
||||
|
||||
const { redirectTo } = await client.submitInteraction();
|
||||
|
||||
const userId = await processSession(client, redirectTo);
|
||||
await logoutClient(client);
|
||||
|
||||
// Should able to sign-in with username password
|
||||
await signInWithPassword({
|
||||
identifier: {
|
||||
type: SignInIdentifier.Username,
|
||||
value: username,
|
||||
},
|
||||
password,
|
||||
});
|
||||
|
||||
// Should able to sign-in with email password
|
||||
await signInWithPassword({
|
||||
identifier,
|
||||
password,
|
||||
});
|
||||
|
||||
void deleteUser(userId);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
|
Loading…
Add table
Reference in a new issue