Skip to content

feat(core): enhanced user lookup by phone number #7382

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
May 14, 2025
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/nice-houses-sneeze.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
"@logto/core": minor
---

enhenced user lookup by phone with phone number normalization.

In some countries, local phone numbers are often entered with a leading '0'. However, in the context of international format this leading '0' should be stripped. E.g. +61 (0)2 1234 5678 should be normalized to +61 2 1234 5678.

In the previous implementation, Logto did not normalize the user's phone number during the user sign-up process. Both 61021345678 and 61212345678 were considered as valid phone numbers, and we do not normalize them before storing them in the database. This could lead to confusion when users try to sign-in with their phone numbers, as they may not remember the exact format they used during sign-up. Users may also end up with different accounts for the same phone number, depending on how they entered it during sign-up.

To address this issue, especially for legacy users, we have added a new enhenced user lookup by phone with either format (with or without leading '0') to the user sign-in process. This means that users can now sign-in with either format of their phone number, and Logto will try to match it with the one stored in the database, even if they might have different formats. This will help to reduce confusion and improve the user experience when logging in with phone numbers.

For example:

- If a user signs up with the phone number +61 2 1234 5678, they can now sign-in with either +61 2 1234 5678 or +61 02 1234 5678.
- The same applies to the phone number +61 02 1234 5678, which can be used to sign-in with either +61 2 1234 5678 or +61 02 1234 5678.

For users who might have created two different counts with the same phone number but different formats. The look up process will alway return the one with exact match. This means that if a user has two accounts with the same phone number but different formats, they will still be able to sign-in with either format, but they will only be able to access the account that matches the format they used during sign-up.

For example:

- If a user has two accounts with the phone numbers +61 2 1234 5678 and +61 02 1234 5678. They will need to sign-in each account using the exact format they used during sign-up.

related github issue [#7371](https://github.com/logto-io/logto/issues/7371).
4 changes: 2 additions & 2 deletions packages/core/src/libraries/social.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ const getUserInfoFromInteractionResult = async (
};

export const createSocialLibrary = (queries: Queries, connectorLibrary: ConnectorLibrary) => {
const { findUserByEmail, findUserByPhone } = queries.users;
const { findUserByEmail, findUserByNormalizedPhone } = queries.users;
const { getLogtoConnectorById } = connectorLibrary;

const getConnector = async (connectorId: string): Promise<LogtoConnector> => {
Expand Down Expand Up @@ -85,7 +85,7 @@ export const createSocialLibrary = (queries: Queries, connectorLibrary: Connecto
info: SocialUserInfo
): Promise<Nullable<[{ type: 'email' | 'phone'; value: string }, User]>> => {
if (info.phone) {
const user = await findUserByPhone(info.phone);
const user = await findUserByNormalizedPhone(info.phone);

if (user) {
return [{ type: 'phone', value: info.phone }, user];
Expand Down
72 changes: 71 additions & 1 deletion packages/core/src/queries/user.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { User, CreateUser } from '@logto/schemas';
import { Users } from '@logto/schemas';
import { conditionalArray, pick } from '@silverhand/essentials';
import { PhoneNumberParser } from '@logto/shared';
import { conditionalArray, type Nullable, pick } from '@silverhand/essentials';
import type { CommonQueryMethods } from '@silverhand/slonik';
import { sql } from '@silverhand/slonik';

Expand Down Expand Up @@ -85,6 +86,71 @@ export const createUserQueries = (pool: CommonQueryMethods) => {
where ${fields.primaryPhone}=${phone}
`);

/**
* Find user by phone with normalized match.
*
* @remarks
* In some countries, local phone numbers are often entered with a leading '0'.
* However, in the international format that includes the country code, this leading '0' should be removed.
* The previous implementation did not handle this correctly, causing the combination of country code + 0 + local number
* to be treated as different from country code + local number in the Logto system.
* Both formats should be considered the same phone number.
*
* To address this, this function will:
*
* 1. Normalize the input phone number by separating it into a standard country code and a local number without the leading '0'.
* 2. Query the user by the phone number both with and without the leading '0'.
* If one match is found, return that account. If multiple matches exist (e.g., a user registered two accounts with the same phone number, one with a leading '0' and one without), return the exact match.
* 3. If the phone number cannot be normalized, attempt to find it with an exact match.
*
* @example
* - DB: 61 0412 345 678
* - input: 61 412 345 678
* - return : user with 61 0412 345 678
*
* @example
* - DB: 61 412 345 678
* - input: 61 0412 345 678
* - return : user with 61 412 345 678
*
* @example
* - DB: 61 0412 345 678, 61 412 345 678
* - input: 61 0412 345 678
* - return : user with 61 0412 345 678
*/
const findUserByNormalizedPhone = async (phone: string): Promise<Nullable<User>> => {
const phoneNumberParser = new PhoneNumberParser(phone);

const { internationalNumber, internationalNumberWithLeadingZero, isValid } = phoneNumberParser;

// If the phone number is not a valid international phone number, return the user with the exact match.
if (!isValid || !internationalNumber || !internationalNumberWithLeadingZero) {
return findUserByPhone(phone);
}

const users = await pool.any<User>(sql`
select ${sql.join(Object.values(fields), sql`,`)}
from ${table}
where ${fields.primaryPhone}=${internationalNumber}
or ${fields.primaryPhone}=${internationalNumberWithLeadingZero}
order by ${fields.createdAt} desc
`);

if (users.length === 0) {
return null;
}

// If only one user is found, return that user.
if (users.length === 1) {
return users[0] ?? null;
}

// Incase user has created two different accounts with the same phone number, one with leading '0' and one without.
// If more than one user is found, return the user with the exact match.
// Otherwise, return the first found user, which should be the be the latest one.
return users.find((user) => user.primaryPhone === phone) ?? users[0] ?? null;
};

const findUserById = async (id: string): Promise<User> =>
pool.one<User>(sql`
select ${sql.join(Object.values(fields), sql`,`)}
Expand Down Expand Up @@ -128,6 +194,9 @@ export const createUserQueries = (pool: CommonQueryMethods) => {
${conditionalSql(excludeUserId, (id) => sql`and ${fields.id}<>${id}`)}
`);

/**
* Find user by phone with exact match.
*/
const hasUserWithPhone = async (phone: string, excludeUserId?: string) =>
pool.exists(sql`
select ${fields.primaryPhone}
Expand Down Expand Up @@ -265,6 +334,7 @@ export const createUserQueries = (pool: CommonQueryMethods) => {
findUserByUsername,
findUserByEmail,
findUserByPhone,
findUserByNormalizedPhone,
findUserById,
findUserByIdentity,
hasUser,
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/routes/experience/classes/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export const findUserByIdentifier = async (
return userQuery.findUserByEmail(value);
}
case SignInIdentifier.Phone: {
return userQuery.findUserByPhone(value);
return userQuery.findUserByNormalizedPhone(value);
}
case AdditionalIdentifier.UserId: {
return userQuery.findUserById(value);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ import {
enableAllPasswordSignInMethods,
enableAllVerificationCodeSignInMethods,
} from '#src/helpers/sign-in-experience.js';
import { generateNewUser } from '#src/helpers/user.js';
import { devFeatureTest, generateEmail } from '#src/utils.js';
import { generateNewUser, UserApiTest } from '#src/helpers/user.js';
import { devFeatureTest, generateEmail, generatePassword } from '#src/utils.js';

const identifiersTypeToUserProfile = Object.freeze({
username: 'username',
Expand All @@ -27,7 +27,7 @@ const identifiersTypeToUserProfile = Object.freeze({
userId: '',
});

describe('sign-in with password verification happy path', () => {
describe.skip('sign-in with password verification happy path', () => {
beforeAll(async () => {
await enableAllPasswordSignInMethods();
await Promise.all([setEmailConnector(), setSmsConnector()]);
Expand Down Expand Up @@ -148,3 +148,97 @@ describe('sign-in with password verification happy path', () => {
await deleteUser(user.id);
});
});

describe('phone number sanitisation sign-in test +61 412 345 678', () => {
const testPhoneNumber = '61412345678';
const testPhoneNumberWithLeadingZero = '610412345678';
const password = generatePassword();
const userApi = new UserApiTest();

beforeAll(async () => {
await enableAllPasswordSignInMethods();
await updateSignInExperience({
sentinelPolicy: {
maxAttempts: 100,
},
});
});

afterEach(async () => {
await userApi.cleanUp();
});

type TestCase = {
registerPhone: string;
signInPhone: string;
};
const testCases: TestCase[] = [
{
registerPhone: testPhoneNumber,
signInPhone: testPhoneNumber,
},
{
registerPhone: testPhoneNumberWithLeadingZero,
signInPhone: testPhoneNumberWithLeadingZero,
},
{
registerPhone: testPhoneNumber,
signInPhone: testPhoneNumberWithLeadingZero,
},
{
registerPhone: testPhoneNumberWithLeadingZero,
signInPhone: testPhoneNumber,
},
];

it.each(testCases)(
'should register with $registerPhone and sign-in with $signInPhone successfully',
async ({ registerPhone, signInPhone }) => {
const user = await userApi.create({
primaryPhone: registerPhone,
password,
});

console.log(user.primaryPhone);

await signInWithPassword({
identifier: {
type: SignInIdentifier.Phone,
value: signInPhone,
},
password,
});
}
);

it('should sign-in with extact phone number if multiple account is found', async () => {
const passwordA = generatePassword();
const passwordB = generatePassword();

await userApi.create({
primaryPhone: testPhoneNumber,
password: passwordA,
});

await userApi.create({
primaryPhone: testPhoneNumberWithLeadingZero,
password: passwordB,
});

await signInWithPassword({
identifier: {
type: SignInIdentifier.Phone,
value: testPhoneNumber,
},
password: passwordA,
});

await signInWithPassword({
identifier: {
type: SignInIdentifier.Phone,
value: testPhoneNumberWithLeadingZero,
},
password: passwordB,
});
});
});
100 changes: 100 additions & 0 deletions packages/shared/src/utils/phone.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { describe, it, expect } from 'vitest';

import { parsePhoneNumber, PhoneNumberParser } from './phone.js';

describe('parsePhoneNumber', () => {
it('parsePhoneNumber should return if the phone number is valid', () => {
const phoneNumber = '12025550123';
expect(parsePhoneNumber(phoneNumber, true)).toEqual('12025550123');
});

it('parsePhoneNumber should strip the leading +', () => {
const phoneNumber = '+12025550123';
expect(parsePhoneNumber(phoneNumber)).toEqual('12025550123');
});

it('parsePhoneNumber should srtip the leading 0', () => {
const phoneNumber = '610412345678';
expect(parsePhoneNumber(phoneNumber)).toEqual('61412345678');
});

it('parsePhoneNumber should strip non-digit characters', () => {
const phoneNumber = '+61 (0) 412 345 678';
expect(parsePhoneNumber(phoneNumber)).toEqual('61412345678');
});

it('should return the original phone number if it is invalid', () => {
const phoneNumber = '0123';
expect(parsePhoneNumber(phoneNumber)).toEqual(phoneNumber);
});

it('should throw an error if the phone number is invalid and throwError is true', () => {
const phoneNumber = '0123';
expect(() => parsePhoneNumber(phoneNumber, true)).toThrowError();
});
});

describe('PhoneNumberParser', () => {
type TestCase = {
phone: string;
isValid: boolean;
countryCode?: string;
nationalNumber?: string;
internationalNumber?: string;
internationalNumberWithLeadingZero?: string;
};
const testCases: TestCase[] = [
{
phone: '12025550123',
isValid: true,
countryCode: '1',
nationalNumber: '2025550123',
internationalNumber: '12025550123',
internationalNumberWithLeadingZero: '102025550123',
},
{
phone: '+61 (0) 412 345 678',
isValid: true,
countryCode: '61',
nationalNumber: '412345678',
internationalNumber: '61412345678',
internationalNumberWithLeadingZero: '610412345678',
},
{
phone: '61 412 345 678',
isValid: true,
countryCode: '61',
nationalNumber: '412345678',
internationalNumber: '61412345678',
internationalNumberWithLeadingZero: '610412345678',
},
{
phone: '456',
isValid: false,
countryCode: undefined,
nationalNumber: undefined,
internationalNumber: undefined,
internationalNumberWithLeadingZero: undefined,
},
];

it.each(testCases)(
'parsePhoneNumber should return $phone if the phone number is valid',
({
phone,
isValid,
countryCode,
nationalNumber,
internationalNumber,
internationalNumberWithLeadingZero,
}) => {
const parser = new PhoneNumberParser(phone);
expect(parser.raw).toEqual(phone);
expect(parser.isValid).toEqual(isValid);
expect(parser.countryCode).toEqual(countryCode);
expect(parser.nationalNumber).toEqual(nationalNumber);
expect(parser.internationalNumber).toEqual(internationalNumber);
expect(parser.internationalNumberWithLeadingZero).toEqual(internationalNumberWithLeadingZero);
}
);
});
Loading
Loading