From 36860bbfda33a31a4d2f1ca9de2786eaee5a5bb2 Mon Sep 17 00:00:00 2001 From: Bereket Engida <86073083+Bekacru@users.noreply.github.com> Date: Mon, 18 Nov 2024 13:27:43 +0300 Subject: [PATCH] feat: allow signin with phone number and password (#531) --- docs/content/docs/plugins/phone-number.mdx | 12 ++ .../src/__snapshots__/init.test.ts.snap | 1 + .../better-auth/src/db/internal-adapter.ts | 12 ++ .../src/plugins/phone-number/index.ts | 113 +++++++++++++++++- .../plugins/phone-number/phone-number.test.ts | 8 ++ 5 files changed, 143 insertions(+), 3 deletions(-) diff --git a/docs/content/docs/plugins/phone-number.mdx b/docs/content/docs/plugins/phone-number.mdx index c4f37d6f6b..3d3974e77a 100644 --- a/docs/content/docs/plugins/phone-number.mdx +++ b/docs/content/docs/plugins/phone-number.mdx @@ -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. diff --git a/packages/better-auth/src/__snapshots__/init.test.ts.snap b/packages/better-auth/src/__snapshots__/init.test.ts.snap index 9e43fa328c..59895003b6 100644 --- a/packages/better-auth/src/__snapshots__/init.test.ts.snap +++ b/packages/better-auth/src/__snapshots__/init.test.ts.snap @@ -62,6 +62,7 @@ exports[`init > should match config 1`] = ` "deleteVerificationByIdentifier": [Function], "deleteVerificationValue": [Function], "findAccount": [Function], + "findAccountByUserId": [Function], "findAccounts": [Function], "findSession": [Function], "findSessions": [Function], diff --git a/packages/better-auth/src/db/internal-adapter.ts b/packages/better-auth/src/db/internal-adapter.ts index b8c2df53ee..b29ac05b56 100644 --- a/packages/better-auth/src/db/internal-adapter.ts +++ b/packages/better-auth/src/db/internal-adapter.ts @@ -622,6 +622,18 @@ export const createInternalAdapter = ( }); return account; }, + findAccountByUserId: async (userId: string) => { + const account = await adapter.findMany({ + model: "account", + where: [ + { + field: "userId", + value: userId, + }, + ], + }); + return account; + }, updateAccount: async (accountId: string, data: Partial) => { const account = await updateWithHooks( data, diff --git a/packages/better-auth/src/plugins/phone-number/index.ts b/packages/better-auth/src/plugins/phone-number/index.ts index c5063bc170..3773802112 100644 --- a/packages/better-auth/src/plugins/phone-number/index.ts +++ b/packages/better-auth/src/plugins/phone-number/index.ts @@ -48,7 +48,7 @@ export const phoneNumber = (options?: { * * by default any string is accepted */ - phoneNumberValidator?: (phoneNumber: string) => boolean; + phoneNumberValidator?: (phoneNumber: string) => boolean | Promise; /** * Callback when phone number is verified */ @@ -93,16 +93,111 @@ export const phoneNumber = (options?: { schema?: InferOptionSchema; }) => { 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({ + 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, diff --git a/packages/better-auth/src/plugins/phone-number/phone-number.test.ts b/packages/better-auth/src/plugins/phone-number/phone-number.test.ts index e1384193cd..f7d6791cd7 100644 --- a/packages/better-auth/src/plugins/phone-number/phone-number.test.ts +++ b/packages/better-auth/src/plugins/phone-number/phone-number.test.ts @@ -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,