-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add phone number normalization plugin
- Loading branch information
Showing
10 changed files
with
578 additions
and
29 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
'better-auth-harmony': minor | ||
--- | ||
|
||
Add phone number normalization plugin |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
|
@@ -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 --> | ||
|
||
|
||
## Getting Started | ||
|
||
### 1. Install the plugin | ||
|
@@ -44,7 +56,6 @@ npm i better-auth-harmony | |
|
||
```typescript | ||
// auth.ts | ||
|
||
import { betterAuth } from 'better-auth'; | ||
import { emailHarmony } from 'better-auth-harmony'; | ||
|
||
|
@@ -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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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>; | ||
|
@@ -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({ | ||
|
@@ -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({ | ||
|
@@ -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({ | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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; | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
Oops, something went wrong.