feat: allow signin with phone number and password (#531)

This commit is contained in:
Bereket Engida
2024-11-18 13:27:43 +03:00
committed by GitHub
parent 0c6b914a5a
commit 36860bbfda
5 changed files with 143 additions and 3 deletions

View File

@@ -111,6 +111,18 @@ export const auth = betterAuth({
})
```
### SignIn with Phone number
In addition to signing in a user using send-verify flow, you can also use phone number as an identifier and sign in a user using phone number and password.
```ts
await authClient.signIn.phoneNumber({
phoneNumber: "+123456789",
password: "password",
remeberMe: true //optional defaults to true
})
```
### Update Phone Number
Updating phone number uses the same process as verifying a phone number. The user will receive an OTP code to verify the new phone number.

View File

@@ -62,6 +62,7 @@ exports[`init > should match config 1`] = `
"deleteVerificationByIdentifier": [Function],
"deleteVerificationValue": [Function],
"findAccount": [Function],
"findAccountByUserId": [Function],
"findAccounts": [Function],
"findSession": [Function],
"findSessions": [Function],

View File

@@ -622,6 +622,18 @@ export const createInternalAdapter = (
});
return account;
},
findAccountByUserId: async (userId: string) => {
const account = await adapter.findMany<Account>({
model: "account",
where: [
{
field: "userId",
value: userId,
},
],
});
return account;
},
updateAccount: async (accountId: string, data: Partial<Account>) => {
const account = await updateWithHooks<Account>(
data,

View File

@@ -48,7 +48,7 @@ export const phoneNumber = (options?: {
*
* by default any string is accepted
*/
phoneNumberValidator?: (phoneNumber: string) => boolean;
phoneNumberValidator?: (phoneNumber: string) => boolean | Promise<boolean>;
/**
* Callback when phone number is verified
*/
@@ -93,16 +93,111 @@ export const phoneNumber = (options?: {
schema?: InferOptionSchema<typeof schema>;
}) => {
const opts = {
expiresIn: options?.expiresIn || 300,
otpLength: options?.otpLength || 6,
...options,
phoneNumber: "phoneNumber",
phoneNumberVerified: "phoneNumberVerified",
code: "code",
createdAt: "createdAt",
expiresIn: options?.expiresIn || 300,
otpLength: options?.otpLength || 6,
};
return {
id: "phone-number",
endpoints: {
signInPhoneNumber: createAuthEndpoint(
"/sign-in/phone-number",
{
method: "POST",
body: z.object({
phoneNumber: z.string(),
password: z.string(),
rememberMe: z.boolean().optional(),
}),
},
async (ctx) => {
const { password, phoneNumber } = ctx.body;
if (opts.phoneNumberValidator) {
const isValidNumber = await opts.phoneNumberValidator(
ctx.body.phoneNumber,
);
if (!isValidNumber) {
throw new APIError("BAD_REQUEST", {
message: "Invalid phone number!",
});
}
}
const user = await ctx.context.adapter.findOne<UserWithPhoneNumber>({
model: "user",
where: [
{
field: "phoneNumber",
value: phoneNumber,
},
],
});
if (!user) {
throw new APIError("UNAUTHORIZED", {
message: "Invalid phone number or password",
});
}
const accounts =
await ctx.context.internalAdapter.findAccountByUserId(user.id);
const credentialAccount = accounts.find(
(a) => a.providerId === "credential",
);
if (!credentialAccount) {
ctx.context.logger.error("Credential account not found", {
phoneNumber,
});
throw new APIError("UNAUTHORIZED", {
message: "Invalid password or password",
});
}
const currentPassword = credentialAccount?.password;
if (!currentPassword) {
ctx.context.logger.error("Password not found", { phoneNumber });
throw new APIError("UNAUTHORIZED", {
message: "Unexpected error",
});
}
const validPassword = await ctx.context.password.verify(
currentPassword,
password,
);
if (!validPassword) {
ctx.context.logger.error("Invalid password");
throw new APIError("UNAUTHORIZED", {
message: "Invalid email or password",
});
}
const session = await ctx.context.internalAdapter.createSession(
user.id,
ctx.headers,
ctx.body.rememberMe === false,
);
if (!session) {
ctx.context.logger.error("Failed to create session");
throw new APIError("UNAUTHORIZED", {
message: "Failed to create session",
});
}
await setSessionCookie(
ctx,
{
session,
user: user,
},
ctx.body.rememberMe === false,
);
return ctx.json({
user: user,
session,
});
},
),
sendPhoneNumberOTP: createAuthEndpoint(
"/phone-number/send-otp",
{
@@ -118,6 +213,18 @@ export const phoneNumber = (options?: {
message: "sendOTP not implemented",
});
}
if (opts.phoneNumberValidator) {
const isValidNumber = await opts.phoneNumberValidator(
ctx.body.phoneNumber,
);
if (!isValidNumber) {
throw new APIError("BAD_REQUEST", {
message: "Invalid phone number!",
});
}
}
const code = generateOTP(opts.otpLength);
await ctx.context.internalAdapter.createVerificationValue({
value: code,

View File

@@ -186,6 +186,14 @@ describe("phone auth flow", async () => {
expect(changedEmailRes.data?.user.email).toBe(newEmail);
});
it("should sign in with phone number and password", async () => {
const res = await client.signIn.phoneNumber({
phoneNumber: "+251911121314",
password: "password",
});
expect(res.data?.session).toBeDefined();
});
it("should sign in with new email", async () => {
const res = await client.signIn.email({
email: newEmail,