Skip to content

Commit

Permalink
Add phone number normalization plugin
Browse files Browse the repository at this point in the history
  • Loading branch information
GeKorm committed Nov 28, 2024
1 parent f09514f commit 45ee04a
Show file tree
Hide file tree
Showing 10 changed files with 578 additions and 29 deletions.
5 changes: 5 additions & 0 deletions .changeset/brave-feet-boil.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'better-auth-harmony': minor
---

Add phone number normalization plugin
80 changes: 74 additions & 6 deletions packages/plugins/README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
[//]: # 'FIXME: Use a single picture of the pair (one dark, one light)'

<div align="center">
<picture>
<source
Expand All @@ -26,12 +24,26 @@

</div>

A [better-auth](https://github.com/better-auth/better-auth) plugin for email normalization and
additional validation, blocking over 55,000 temporary email domains.
A [better-auth](https://github.com/better-auth/better-auth) plugin for email & phone normalization
and additional validation, blocking over 55,000 temporary email domains.

**Normalization:** `[email protected]` -> `[email protected]`
**Email normalization:** `[email protected]` -> `[email protected]`
**Phone normalization:** `+1 (555) 123-1234` -> `+15551231234`
**Validation:** `[email protected]` -> Blocked

<!-- TOC -->

- [Email](#email)
- [Getting Started](#getting-started)
- [Options](#options)
- [Schema](#schema)
- [Phone number](#phone-number)
- [Getting Started](#getting-started-1)
- [Options](#options-1)
<!-- TOC -->

# Email

## Getting Started

### 1. Install the plugin
Expand All @@ -44,7 +56,6 @@ npm i better-auth-harmony

```typescript
// auth.ts

import { betterAuth } from 'better-auth';
import { emailHarmony } from 'better-auth-harmony';

Expand Down Expand Up @@ -90,3 +101,60 @@ The `emailHarmony` plugin requires an additional field in the user table:

The `normalizedEmail` field being unique prevents users from signing up with throwaway variations of
the same email address.

---

# Phone number

<!-- eslint-disable markdown/no-missing-label-refs -- https://github.com/eslint/markdown/issues/294 -->
<!-- prettier-ignore -->
> [!NOTE]
> Unlike `emailHarmony`, phone number normalization intercepts and modifies the user's
`phoneNumber`, permitting only normalized numbers in the backend.

<!-- eslint-enable markdown/no-missing-label-refs -- https://github.com/eslint/markdown/issues/294 -->

## Getting Started

### 1. Install the plugin

```shell
npm i better-auth-harmony
```

#### 2. Add the plugin to your auth config

```typescript
// auth.ts
import { betterAuth } from 'better-auth';
import { phoneNumber } from 'better-auth/plugins';
import { phoneHarmony } from 'better-auth-harmony';

export const auth = betterAuth({
// ... other config options
plugins: [phoneNumber(), phoneHarmony()]
});
```

See the better-auth
[`phoneNumber` plugin documentation](https://www.better-auth.com/docs/plugins/phone-number) for
information on configuring the `phoneNumber()`, including **validation**.

## Options

- `defaultCountry` - Default [country](https://www.npmjs.com/package/libphonenumber-js#country-code)
for numbers written in non-international form (without a `+` sign).
- `defaultCallingCode` - Default calling code for numbers written in non-international form (without
a `+` sign). Useful for parsing non-geographic codes such as
[`+800` numbers](https://en.wikipedia.org/wiki/Toll-free_telephone_number).
- `extract` (default=**true**) - Defines the
["strictness"](https://www.npmjs.com/package/libphonenumber-js#strictness) of parsing a phone
number. By default, it will attempt to extract the phone number from any input string, such as
`"My phone number is (213) 373-4253"`.
- `acceptRawInputOnError` (default=**false**) - If the normalizer throws, for example because it is
unable to parse the phone number, use the original input. For example, the phone number `"+12"`
will be saved as-is to the database.
- `normalizer` - Custom function to normalize phone number. Default uses
[`parsePhoneNumberWithError`](https://www.npmjs.com/package/libphonenumber-js#user-content-parse-phone-number)
from `libphonenumber-js/max`. Can be used to infer the country through the Request object, for
example using IP address geolocation.
1 change: 1 addition & 0 deletions packages/plugins/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { default as emailHarmony } from './src/email';
export { default as phoneHarmony } from './src/phone';
23 changes: 18 additions & 5 deletions packages/plugins/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@
"authentication",
"email",
"domains",
"disposable"
"disposable",
"phone number",
"mobile"
],
"license": "MIT",
"homepage": "https://github.com/gekorm/better-auth-harmony",
Expand All @@ -39,6 +41,16 @@
"default": "./dist/index.cjs"
}
},
"./phone": {
"import": {
"types": "./dist/phone.d.ts",
"default": "./dist/phone.js"
},
"require": {
"types": "./dist/phone.d.cts",
"default": "./dist/phone.cjs"
}
},
"./email": {
"import": {
"types": "./dist/email.d.ts",
Expand All @@ -59,23 +71,24 @@
"@repo/eslint-config": "*",
"@repo/tsconfig": "*",
"@types/eslint": "^9.6.1",
"@types/node": "^22.10.0",
"@types/node": "^22.10.1",
"@types/react-dom": "18.3.1",
"@types/validator": "^13.12.2",
"@vitest/coverage-v8": "^2.1.5",
"@vitest/coverage-v8": "^2.1.6",
"better-auth": "1.0.4",
"eslint": "^9.15.0",
"rimraf": "^6.0.1",
"tsup": "^8.3.5",
"typescript": "5.7.2",
"vitest": "^2.1.5"
"vitest": "^2.1.6"
},
"peerDependencies": {
"better-auth": "^1.0.3"
},
"dependencies": {
"better-call": "^0.3.2",
"mailchecker": "^6.0.12",
"libphonenumber-js": "^1.11.15",
"mailchecker": "^6.0.13",
"validator": "^13.12.0"
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { afterAll, describe, expect } from 'vitest';
import { afterAll, describe, expect, it } from 'vitest';
// eslint-disable-next-line import/no-relative-packages -- couldn't find a better way to include it
import { getTestInstance } from '../../../better-auth/packages/better-auth/src/test-utils/test-instance';
import emailHarmony, { type UserWithNormalizedEmail } from './email';
import { getTestInstance } from '../../../../better-auth/packages/better-auth/src/test-utils/test-instance';
import emailHarmony, { type UserWithNormalizedEmail } from '.';

interface SQLiteDB {
close: () => Promise<void>;
Expand All @@ -22,7 +22,7 @@ describe('email harmony', async () => {
await (auth.options.database as unknown as SQLiteDB).close();
});

describe('signup', (it) => {
describe('signup', () => {
it('should normalize email', async () => {
const rawEmail = '[email protected]';
await client.signUp.email({
Expand Down Expand Up @@ -81,7 +81,7 @@ describe('email harmony', async () => {
});
});

describe('login', (it) => {
describe('login', () => {
it('should work with normalized email form', async () => {
const email = '[email protected]';
await client.signUp.email({
Expand All @@ -94,7 +94,7 @@ describe('email harmony', async () => {
password: 'new-password'
});
expect(data?.user.email).toBe(email);
});
}, 15_000);

it('should return error on incorrect email type', async () => {
const { data, error } = await client.signIn.email({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ export interface UserWithNormalizedEmail extends User {

export interface EmailHarmonyOptions {
/**
* Allow logging in with any version of the unnormalized email address. For example a user who signed up with the
* email `[email protected]` may also log in with `[email protected]`. Makes 1 extra database query for
* every login attempt.
* Allow logging in with any version of the unnormalized email address. For example a user who
* signed up with the email `[email protected]` may also log in with `[email protected]`.
* Makes 1 extra database query for every login attempt.
* @default false
*/
allowNormalizedSignin?: boolean;
Expand Down
128 changes: 128 additions & 0 deletions packages/plugins/src/phone/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { type BetterAuthPlugin, createAuthMiddleware } from 'better-auth/plugins';
import { APIError } from 'better-call';
import {
type CountryCode,
type E164Number,
ParseError,
parsePhoneNumberWithError
} from 'libphonenumber-js/max';
import type { HookEndpointContext } from 'better-auth';

interface NormalizationOptions {
/**
* Default [country](https://www.npmjs.com/package/libphonenumber-js#country-code)
* for parsing numbers written in non-international form (without a `+` sign). Will be ignored
* when parsing numbers written in international form
* (with a `+` sign).
*/
defaultCountry?: CountryCode;
/**
* Default calling code for parsing numbers written in
* non-international form (without a `+` sign). Will be ignored when parsing numbers written in
* international form (with a `+` sign). It could be specified when parsing phone numbers
* belonging to ["non-geographic numbering
* plans"](https://www.npmjs.com/package/libphonenumber-js#non-geographic) which by nature don't
* have a country code, making the `defaultCountry` option unusable.
*/
defaultCallingCode?: string;
/**
* Defines the
* ["strictness"](https://www.npmjs.com/package/libphonenumber-js#strictness) of parsing a phone
* number. By default, the extract flag is `true` meaning that it will attempt to extract the
* phone number from an input string like `"My phone number is (213) 373-4253 and my hair is
* blue"`. This could be thought of as
* "less strict" parsing. To make it "more strict", one could pass `extract: false` flag, in which
* case the function will attempt to parse the input string as if the whole string was a phone
* number. Applied to the example above, it would return `undefined` because the entire string is
* not a phone number, but for input string `"(213) 373-4253"` it would return a parsed
* `PhoneNumber`.
* @default true
*/
extract?: boolean;
}

/**
* @see https://www.npmjs.com/package/libphonenumber-js#api
* @returns The phone number in E.164 format. Example: `"+12133734253"`. Returns `undefined` if no
* phone number could be parsed: for example, when the string contains no phone number, or the
* phone number starts with a non-existent country calling code, etc.
*/
export type NormalizePhoneNumber = (
phone: string,
request?: HookEndpointContext['request']
) => Promise<E164Number> | E164Number;

export interface PhoneHarmonyOptions extends NormalizationOptions {
/**
* If the normalizer throws, for example because it is unable to parse the phone number, use the
* original input. For example, the phone number `"+12"` will be saved as-is to the database.
* @default false
*/
acceptRawInputOnError?: boolean;
/**
* Function to normalize phone number. Default uses `parsePhoneNumberWithError` from
* `libphonenumber-js/max`.
* Can be used to infer the country through the Request object, for example using IP address
* geolocation.
* @see https://www.npmjs.com/package/libphonenumber-js#user-content-parse-phone-number
*/
normalizer?: NormalizePhoneNumber;
}

const phonePaths = ['/sign-in/phone-number', '/phone-number/send-otp', '/phone-number/verify'];

const phoneHarmony = ({
defaultCountry,
defaultCallingCode,
extract = true,
acceptRawInputOnError = false,
normalizer
}: PhoneHarmonyOptions = {}) =>
({
id: 'harmony-phone-number',
hooks: {
before: [
{
matcher: (context) => phonePaths.some((pathname) => context.path.startsWith(pathname)),
handler: createAuthMiddleware(async (ctx) => {
// Replace context number with the value of `normalizedPhone`
const { phoneNumber } = ctx.body;

if (typeof phoneNumber !== 'string') return;

let normalize = normalizer;
if (!normalize) {
normalize = (text: string) =>
parsePhoneNumberWithError(text, { defaultCountry, defaultCallingCode, extract })
.number;
}

let normalizedPhone = phoneNumber;

try {
normalizedPhone = await normalize(phoneNumber, ctx.request);
} catch (error) {
if (!acceptRawInputOnError && error instanceof ParseError) {
throw new APIError('BAD_REQUEST', { message: error.message });
} else if (!acceptRawInputOnError) {
throw error;
} else {
normalizedPhone = phoneNumber; // fall back to the raw input
}
}

return {
context: {
body: {
...ctx.body,
phoneNumber: normalizedPhone
}
}
};
})
}
]
}
}) satisfies BetterAuthPlugin;

export default phoneHarmony;
Loading

0 comments on commit 45ee04a

Please sign in to comment.