0
Fork 0
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:
simeng-li 2025-03-24 11:25:28 +08:00 committed by GitHub
parent eb802f4c4b
commit d352b6716c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 364 additions and 45 deletions

View file

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

View file

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

View file

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

View file

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