feat(phone-number): add phone number verification requirement before sign-in (#1984)

* feat(phone-number): add phone number verification requirement before sign in

* docs: add doc
This commit is contained in:
Bereket Engida
2025-04-11 12:41:00 +03:00
committed by GitHub
parent 99ffacc251
commit e24a60d717
4 changed files with 88 additions and 10 deletions

View File

@@ -204,6 +204,7 @@ export const auth = betterAuth({
- `getTempEmail`: A function that generates a temporary email for the user. It takes the phone number as an argument and returns the temporary email.
- `getTempName`: A function that generates a temporary name for the user. It takes the phone number as an argument and returns the temporary name.
- `requireVerification`: When enabled, users cannot sign in with their phone number until it has been verified. If an unverified user attempts to sign in, the server will respond with a 401 error (PHONE_NUMBER_NOT_VERIFIED) and automatically trigger an OTP send to start the verification process.
## Schema

View File

@@ -13,6 +13,7 @@ import { getDate } from "../../utils/date";
import { setSessionCookie } from "../../cookies";
import { BASE_ERROR_CODES } from "../../error/codes";
import type { User } from "../../types";
import { ERROR_CODES } from "./phone-number-error";
export interface UserWithPhoneNumber extends User {
phoneNumber: string;
@@ -62,6 +63,12 @@ export interface PhoneNumberOptions {
* by default any string is accepted
*/
phoneNumberValidator?: (phoneNumber: string) => boolean | Promise<boolean>;
/**
* Require a phone number verification before signing in
*
* @default false
*/
requireVerification?: boolean;
/**
* Callback when phone number is verified
*/
@@ -122,15 +129,6 @@ export const phoneNumber = (options?: PhoneNumberOptions) => {
createdAt: "createdAt",
};
const ERROR_CODES = {
INVALID_PHONE_NUMBER: "Invalid phone number",
PHONE_NUMBER_EXIST: "Phone number already exist",
INVALID_PHONE_NUMBER_OR_PASSWORD: "Invalid phone number or password",
UNEXPECTED_ERROR: "Unexpected error",
OTP_NOT_FOUND: "OTP not found",
OTP_EXPIRED: "OTP expired",
INVALID_OTP: "Invalid OTP",
} as const;
return {
id: "phone-number",
endpoints: {
@@ -209,6 +207,23 @@ export const phoneNumber = (options?: PhoneNumberOptions) => {
message: ERROR_CODES.INVALID_PHONE_NUMBER_OR_PASSWORD,
});
}
if (opts.requireVerification) {
if (!user.phoneNumberVerified) {
const otp = generateOTP(opts.otpLength);
await ctx.context.internalAdapter.createVerificationValue({
value: otp,
identifier: phoneNumber,
expiresAt: getDate(opts.expiresIn, "sec"),
});
await opts.sendOTP?.({
phoneNumber,
code: otp,
});
throw new APIError("UNAUTHORIZED", {
message: ERROR_CODES.PHONE_NUMBER_NOT_VERIFIED,
});
}
}
const accounts =
await ctx.context.internalAdapter.findAccountByUserId(user.id);
const credentialAccount = accounts.find(

View File

@@ -0,0 +1,10 @@
export const ERROR_CODES = {
INVALID_PHONE_NUMBER: "Invalid phone number",
PHONE_NUMBER_EXIST: "Phone number already exist",
INVALID_PHONE_NUMBER_OR_PASSWORD: "Invalid phone number or password",
UNEXPECTED_ERROR: "Unexpected error",
OTP_NOT_FOUND: "OTP not found",
OTP_EXPIRED: "OTP expired",
INVALID_OTP: "Invalid OTP",
PHONE_NUMBER_NOT_VERIFIED: "Phone number not verified",
} as const;

View File

@@ -370,3 +370,55 @@ describe("reset password flow attempts", async (it) => {
expect(res.data?.status).toBe(true);
});
});
describe("phone number verification requirement", async () => {
let otp = "";
const { customFetchImpl } = await getTestInstance({
plugins: [
phoneNumber({
async sendOTP({ code }) {
otp = code;
},
requireVerification: true,
signUpOnVerification: {
getTempEmail(phoneNumber) {
return `temp-${phoneNumber}`;
},
},
}),
],
user: {
changeEmail: {
enabled: true,
},
},
});
const client = createAuthClient({
baseURL: "http://localhost:3000",
plugins: [phoneNumberClient()],
fetchOptions: {
customFetchImpl,
},
});
const testPhoneNumber = "+251911121314";
const testPassword = "password123";
const testEmail = "test2@test.com";
it("should not allow sign in with unverified phone number and trigger OTP send", async () => {
await client.signUp.email({
email: testEmail,
password: testPassword,
name: "test",
phoneNumber: testPhoneNumber,
});
const signInRes = await client.signIn.phoneNumber({
phoneNumber: testPhoneNumber,
password: testPassword,
});
expect(signInRes.error?.status).toBe(401);
expect(signInRes.error?.code).toMatch("PHONE_NUMBER_NOT_VERIFIED");
expect(otp).toHaveLength(6);
});
});