Skip to main content

better_auth_api/plugins/phone_number/
mod.rs

1use std::{future::Future, pin::Pin, sync::Arc};
2
3use better_auth_core::utils::password::{hash_password, verify_password};
4use better_auth_core::wire::UserView;
5use better_auth_core::{
6    AuthContext, AuthError, AuthRequest, AuthResponse, AuthResult, AuthSchema, AuthSession,
7    AuthUser, CreateAccount, CreateUser, CreateVerification, RequestMeta, UpdateAccount,
8    UpdateUser,
9};
10use chrono::{Duration, Utc};
11use rand::Rng;
12use serde_json::{Value, json};
13
14use super::helpers::{SessionIssueError, get_credential_account, issue_user_session_with_lifetime};
15
16mod callbacks;
17mod native;
18mod request;
19use crate::plugins::endpoint_context::EndpointContext;
20use callbacks::Delivery;
21pub use callbacks::{PhoneCallbackFuture, PhoneNumberCallbacks};
22pub use native::PhoneNumberApi;
23
24type CallbackFuture<T> = Pin<Box<dyn Future<Output = AuthResult<T>> + Send>>;
25type OtpSender = dyn Fn(PhoneOtp, AuthRequest) -> CallbackFuture<()> + Send + Sync;
26type OtpVerifier = dyn Fn(PhoneOtp, AuthRequest) -> CallbackFuture<bool> + Send + Sync;
27type Validator = dyn Fn(String) -> CallbackFuture<bool> + Send + Sync;
28type VerifiedCallback = dyn Fn(PhoneVerification, AuthRequest) -> CallbackFuture<()> + Send + Sync;
29type TempField = dyn Fn(&str) -> String + Send + Sync;
30
31/// A phone number and its verification or password reset code.
32#[derive(Debug, Clone, serde::Deserialize)]
33#[serde(rename_all = "camelCase")]
34pub struct PhoneOtp {
35    /// Destination number accepted by the configured validator.
36    pub phone_number: String,
37    /// Generated code, or the code submitted for external verification.
38    pub code: String,
39}
40/// Persisted identity delivered to the verification callback.
41#[derive(Debug, Clone)]
42pub struct PhoneVerification {
43    /// Phone number proven by the consumed OTP.
44    pub phone_number: String,
45    /// User after the phone number has been verified.
46    pub user: UserView,
47}
48
49/// Phone authentication backed by atomic, single-use verification records.
50#[derive(Clone)]
51pub struct PhoneNumberPlugin {
52    send_otp: Option<Arc<OtpSender>>,
53    verify_otp: Option<Arc<OtpVerifier>>,
54    send_password_reset_otp: Option<Arc<OtpSender>>,
55    phone_number_validator: Option<Arc<Validator>>,
56    callback_on_verification: Option<Arc<VerifiedCallback>>,
57    temp_email: Option<Arc<TempField>>,
58    temp_name: Option<Arc<TempField>>,
59    expires_in: f64,
60    otp_length: usize,
61    allowed_attempts: u64,
62    require_verification: bool,
63}
64impl Default for PhoneNumberPlugin {
65    fn default() -> Self {
66        Self {
67            send_otp: None,
68            verify_otp: None,
69            send_password_reset_otp: None,
70            phone_number_validator: None,
71            callback_on_verification: None,
72            temp_email: None,
73            temp_name: None,
74            expires_in: 300.0,
75            otp_length: 6,
76            allowed_attempts: 3,
77            require_verification: false,
78        }
79    }
80}
81impl PhoneNumberPlugin {
82    /// Use upstream OTP defaults without enabling automatic registration.
83    pub fn new() -> Self {
84        Self::default()
85    }
86    /// Set the generated decimal code length; the default is six digits.
87    pub fn otp_length(mut self, length: usize) -> Self {
88        self.otp_length = length;
89        self
90    }
91    /// Set code validity in seconds, including fractional values; the default is 300.
92    /// An explicit zero remains zero.
93    pub fn expires_in(mut self, seconds: f64) -> Self {
94        self.expires_in = seconds;
95        self
96    }
97    /// Set the incorrect attempt budget; the default is three.
98    pub fn allowed_attempts(mut self, attempts: u64) -> Self {
99        self.allowed_attempts = attempts;
100        self
101    }
102    /// Require a verified number before password sign-in.
103    pub fn require_verification(mut self, require: bool) -> Self {
104        self.require_verification = require;
105        self
106    }
107    /// Create users after successful verification with the supplied temporary email.
108    pub fn sign_up_on_verification(
109        mut self,
110        email: impl Fn(&str) -> String + Send + Sync + 'static,
111    ) -> Self {
112        self.temp_email = Some(Arc::new(email));
113        self
114    }
115    /// Generate names for new users; the default name is the phone number.
116    pub fn temporary_name(mut self, name: impl Fn(&str) -> String + Send + Sync + 'static) -> Self {
117        self.temp_name = Some(Arc::new(name));
118        self
119    }
120    /// Deliver verification codes through the application's SMS provider.
121    pub fn send_otp<F, Fut>(mut self, callback: F) -> Self
122    where
123        F: Fn(PhoneOtp, AuthRequest) -> Fut + Send + Sync + 'static,
124        Fut: Future<Output = AuthResult<()>> + Send + 'static,
125    {
126        self.send_otp = Some(Arc::new(move |otp, req| Box::pin(callback(otp, req))));
127        self
128    }
129    /// Deliver password reset codes for registered phone numbers.
130    pub fn send_password_reset_otp<F, Fut>(mut self, callback: F) -> Self
131    where
132        F: Fn(PhoneOtp, AuthRequest) -> Fut + Send + Sync + 'static,
133        Fut: Future<Output = AuthResult<()>> + Send + 'static,
134    {
135        self.send_password_reset_otp = Some(Arc::new(move |otp, req| Box::pin(callback(otp, req))));
136        self
137    }
138    /// Delegate phone verification to the SMS provider instead of checking a stored code.
139    pub fn verify_otp<F, Fut>(mut self, callback: F) -> Self
140    where
141        F: Fn(PhoneOtp, AuthRequest) -> Fut + Send + Sync + 'static,
142        Fut: Future<Output = AuthResult<bool>> + Send + 'static,
143    {
144        self.verify_otp = Some(Arc::new(move |otp, req| Box::pin(callback(otp, req))));
145        self
146    }
147    /// Validate numbers before sending verification codes or accepting password sign-in.
148    pub fn phone_number_validator<F, Fut>(mut self, callback: F) -> Self
149    where
150        F: Fn(String) -> Fut + Send + Sync + 'static,
151        Fut: Future<Output = AuthResult<bool>> + Send + 'static,
152    {
153        self.phone_number_validator = Some(Arc::new(move |phone| Box::pin(callback(phone))));
154        self
155    }
156    /// Run after persisting the verified user and before returning or issuing a session.
157    pub fn callback_on_verification<F, Fut>(mut self, callback: F) -> Self
158    where
159        F: Fn(PhoneVerification, AuthRequest) -> Fut + Send + Sync + 'static,
160        Fut: Future<Output = AuthResult<()>> + Send + 'static,
161    {
162        self.callback_on_verification =
163            Some(Arc::new(move |data, req| Box::pin(callback(data, req))));
164        self
165    }
166
167    async fn validate(&self, phone: &str) -> AuthResult<()> {
168        if let Some(validate) = &self.phone_number_validator
169            && !validate(phone.to_owned()).await?
170        {
171            return Err(error(400, "INVALID_PHONE_NUMBER", "Invalid phone number"));
172        }
173        Ok(())
174    }
175    async fn save_otp(
176        &self,
177        ctx: &AuthContext<impl AuthSchema>,
178        identifier: String,
179        with_attempts: bool,
180    ) -> AuthResult<String> {
181        let code: String = (0..self.otp_length)
182            .map(|_| char::from(b'0' + rand::thread_rng().gen_range(0..10)))
183            .collect();
184        let _ = ctx
185            .database
186            .create_verification(CreateVerification {
187                identifier: (identifier).into(),
188                value: (if with_attempts {
189                    format!("{code}:0")
190                } else {
191                    code.clone()
192                })
193                .into(),
194                expires_at: better_auth_core::utils::date::from_milliseconds(
195                    Utc::now().timestamp_millis() as f64 + self.expires_in * 1000.0,
196                )
197                .ok_or_else(|| AuthError::config("Phone OTP expiry is out of range"))?
198                .into(),
199                ..Default::default()
200            })
201            .await?;
202        Ok(code)
203    }
204    async fn verify_stored_otp(
205        &self,
206        endpoint: &EndpointContext<'_, impl AuthSchema>,
207        identifier: &str,
208        code: &str,
209    ) -> AuthResult<()> {
210        let ctx = endpoint.auth;
211        let existing = match endpoint.transaction {
212            Some(transaction) => {
213                transaction
214                    .get_verification_including_expired(identifier)
215                    .await?
216            }
217            None => {
218                ctx.database
219                    .get_verification_including_expired(identifier)
220                    .await?
221            }
222        }
223        .ok_or_else(|| error(400, "OTP_NOT_FOUND", "OTP not found"))?;
224        if existing.expires_at.is_before(Utc::now())? {
225            native::delete_verification(endpoint, identifier).await?;
226            return Err(error(400, "OTP_EXPIRED", "OTP expired"));
227        }
228        if attempts(existing.value.typed()?) >= self.allowed_attempts {
229            native::delete_verification(endpoint, identifier).await?;
230            return Err(error(403, "TOO_MANY_ATTEMPTS", "Too many attempts"));
231        }
232        let consumed = match endpoint.transaction {
233            Some(transaction) => {
234                transaction
235                    .consume_verification_by_identifier(identifier)
236                    .await?
237            }
238            None => {
239                ctx.database
240                    .consume_verification_by_identifier(identifier)
241                    .await?
242            }
243        }
244        .ok_or_else(|| error(400, "INVALID_OTP", "Invalid OTP"))?;
245        let count = attempts(consumed.value.typed()?);
246        if count >= self.allowed_attempts {
247            return Err(error(403, "TOO_MANY_ATTEMPTS", "Too many attempts"));
248        }
249        let expected = consumed
250            .value
251            .typed()?
252            .split(':')
253            .next()
254            .unwrap_or_default();
255        if expected != code {
256            let input = CreateVerification {
257                identifier: (identifier.to_owned()).into(),
258                value: (format!("{expected}:{}", count + 1)).into(),
259                expires_at: consumed.expires_at.clone(),
260                ..Default::default()
261            };
262            let _ = match endpoint.transaction {
263                Some(transaction) => transaction.create_verification(input).await?,
264                None => ctx.database.create_verification(input).await?,
265            };
266            return Err(error(400, "INVALID_OTP", "Invalid OTP"));
267        }
268        Ok(())
269    }
270    /// Consume an OTP without updating a user or creating a session.
271    pub async fn consume_otp(
272        &self,
273        ctx: &AuthContext<impl AuthSchema>,
274        req: &AuthRequest,
275        otp: PhoneOtp,
276    ) -> AuthResult<()> {
277        let endpoint = EndpointContext::new(
278            Some(req),
279            better_auth_core::FieldValue::from_json(
280                json!({"phoneNumber":otp.phone_number,"code":otp.code}),
281            )?,
282            ctx,
283        );
284        self.consume_with_context(&endpoint, otp).await
285    }
286    async fn consume_with_context<S: AuthSchema>(
287        &self,
288        endpoint: &EndpointContext<'_, S>,
289        otp: PhoneOtp,
290    ) -> AuthResult<()> {
291        let ctx = endpoint.auth;
292        let verified = if let Some(verify) = ctx
293            .extensions
294            .get::<Arc<PhoneNumberCallbacks<S>>>()
295            .and_then(|callbacks| callbacks.verify.as_ref())
296        {
297            Some(verify(&otp, endpoint).await?)
298        } else if let Some(verify) = &self.verify_otp {
299            let request = endpoint
300                .request
301                .ok_or_else(|| AuthError::config("Legacy phone callbacks require a request"))?;
302            Some(verify(otp.clone(), request.clone()).await?)
303        } else {
304            None
305        };
306        match verified {
307            Some(false) => Err(error(400, "INVALID_OTP", "Invalid OTP")),
308            Some(true) => native::delete_verification(endpoint, &otp.phone_number).await,
309            None => {
310                self.verify_stored_otp(endpoint, &otp.phone_number, &otp.code)
311                    .await
312            }
313        }
314    }
315    async fn send(
316        &self,
317        req: &AuthRequest,
318        ctx: &AuthContext<impl AuthSchema>,
319    ) -> AuthResult<AuthResponse> {
320        let body = match request::read(req) {
321            Ok(body) => body,
322            Err(response) => return Ok(response),
323        };
324        let phone = string(&body, "phoneNumber");
325        if !self.has_sender(ctx, Delivery::Verification) {
326            return Err(error(
327                501,
328                "SEND_OTP_NOT_IMPLEMENTED",
329                "sendOTP not implemented",
330            ));
331        }
332        let endpoint = EndpointContext::new(
333            Some(req),
334            better_auth_core::FieldValue::from_json(body.clone())?,
335            ctx,
336        );
337        self.validate(phone).await?;
338        let code = self.save_otp(ctx, phone.to_owned(), true).await?;
339        let task = self.delivery(
340            PhoneOtp {
341                phone_number: phone.to_owned(),
342                code,
343            },
344            &endpoint,
345            Delivery::Verification,
346        )?;
347        if ctx.config.advanced.background_tasks.is_some() {
348            better_auth_core::background::run_or_await(
349                task,
350                ctx.config.advanced.background_tasks.as_ref(),
351                &ctx.config.logger,
352            )
353            .await;
354        } else if let Some(task) = task {
355            // The direct send endpoint propagates asynchronous failures without a handler.
356            task.await?;
357        }
358        Ok(AuthResponse::json(200, &json!({"message":"code sent"}))?)
359    }
360    async fn verify(
361        &self,
362        req: &AuthRequest,
363        ctx: &AuthContext<impl AuthSchema>,
364    ) -> AuthResult<AuthResponse> {
365        let body = match request::read(req) {
366            Ok(body) => body,
367            Err(response) => return Ok(response),
368        };
369        let phone = string(&body, "phoneNumber");
370        let mut endpoint = EndpointContext::new(
371            Some(req),
372            better_auth_core::FieldValue::from_json(body.clone())?,
373            ctx,
374        );
375        self.consume_with_context(
376            &endpoint,
377            PhoneOtp {
378                phone_number: phone.to_owned(),
379                code: string(&body, "code").to_owned(),
380            },
381        )
382        .await?;
383        let update_phone = body.get("updatePhoneNumber") == Some(&Value::Bool(true));
384        let existing_session = if update_phone {
385            Some(ctx.require_session(req).await.map_err(|error| {
386                if matches!(error, AuthError::Unauthenticated) {
387                    self::error(401, "USER_NOT_FOUND", "User not found")
388                } else {
389                    error
390                }
391            })?)
392        } else {
393            None
394        };
395        let found = ctx.database.get_user_by_phone_number(phone).await?;
396        let user = if let Some((user, _)) = &existing_session {
397            if found.is_some() {
398                return Err(error(
399                    400,
400                    "PHONE_NUMBER_EXIST",
401                    "Phone number already exists",
402                ));
403            }
404            ctx.database
405                .update_user(
406                    user.id.typed()?,
407                    UpdateUser {
408                        phone_number: Some(Some(phone.to_owned())),
409                        phone_number_verified: Some(true),
410                        ..Default::default()
411                    },
412                )
413                .await?
414        } else if let Some(user) = found {
415            ctx.database
416                .update_user(
417                    user.id().typed()?,
418                    UpdateUser {
419                        phone_number_verified: Some(true),
420                        ..Default::default()
421                    },
422                )
423                .await?
424        } else if let Some(email) = &self.temp_email {
425            let mut create = CreateUser::new().with_email(email(phone)).with_name(
426                self.temp_name
427                    .as_ref()
428                    .map_or_else(|| phone.to_owned(), |name| name(phone)),
429            );
430            let rest = body
431                .as_object()
432                .into_iter()
433                .flatten()
434                .filter(|(key, _)| {
435                    !["phoneNumber", "code", "disableSession", "updatePhoneNumber"]
436                        .contains(&key.as_str())
437                })
438                .map(|(key, value)| (key.clone(), value.clone()))
439                .collect();
440            super::helpers::apply_user_create_fields(ctx, &rest, &mut create)?;
441            create.phone_number = Some(phone.to_owned());
442            create.phone_number_verified = Some(true);
443
444            super::user_admission::create_user(create, "phone-number", &endpoint).await?
445        } else {
446            return Err(error(500, "FAILED_TO_UPDATE_USER", "Failed to update user"));
447        };
448        endpoint.session = existing_session.clone();
449        self.notify_verified(
450            PhoneVerification {
451                phone_number: phone.to_owned(),
452                user: ctx.internal_user_view(&user).await?,
453            },
454            &endpoint,
455        )
456        .await?;
457        if let Some((_, session)) = existing_session {
458            return Ok(AuthResponse::json(
459                200,
460                &json!({"status":true,"token":session.token,"user":ctx.user_view(&user).await?}),
461            )?);
462        }
463        if body.get("disableSession") == Some(&Value::Bool(true)) {
464            return Ok(AuthResponse::json(
465                200,
466                &json!({"status":true,"token":null,"user":ctx.user_view(&user).await?}),
467            )?);
468        }
469        self.session_response(ctx, req, &user, false, true).await
470    }
471    async fn sign_in(
472        &self,
473        req: &AuthRequest,
474        ctx: &AuthContext<impl AuthSchema>,
475    ) -> AuthResult<AuthResponse> {
476        let body = match request::read(req) {
477            Ok(body) => body,
478            Err(response) => return Ok(response),
479        };
480        let phone = string(&body, "phoneNumber");
481        let password = string(&body, "password");
482        self.validate(phone).await?;
483        check_password_length(ctx, password, false)?;
484        let user = ctx
485            .database
486            .get_user_by_phone_number(phone)
487            .await?
488            .ok_or_else(invalid_credentials)?;
489        if self.require_verification && user.phone_number_verified() != Some(true) {
490            let code = self.save_otp(ctx, phone.to_owned(), false).await?;
491            if self.has_sender(ctx, Delivery::Verification) {
492                let endpoint = EndpointContext::new(
493                    Some(req),
494                    better_auth_core::FieldValue::from_json(body.clone())?,
495                    ctx,
496                );
497                let task = self.delivery(
498                    PhoneOtp {
499                        phone_number: phone.to_owned(),
500                        code,
501                    },
502                    &endpoint,
503                    Delivery::Verification,
504                )?;
505                better_auth_core::background::run_or_await(
506                    task,
507                    ctx.config.advanced.background_tasks.as_ref(),
508                    &ctx.config.logger,
509                )
510                .await;
511            }
512            return Err(error(
513                401,
514                "PHONE_NUMBER_NOT_VERIFIED",
515                "Phone number not verified",
516            ));
517        }
518        let account = get_credential_account(ctx, user.id().into_owned())
519            .await?
520            .ok_or_else(invalid_credentials)?;
521        if account.password.is_absent() {
522            return Err(error(401, "UNEXPECTED_ERROR", "Unexpected error"));
523        }
524        let hash = account
525            .password
526            .typed()?
527            .as_deref()
528            .ok_or_else(|| error(401, "UNEXPECTED_ERROR", "Unexpected error"))?;
529        match verify_password(ctx.password_policy.hasher.as_ref(), password, hash).await {
530            Err(AuthError::InvalidCredentials) => return Err(invalid_credentials()),
531            result => result?,
532        }
533        self.session_response(
534            ctx,
535            req,
536            &user,
537            body.get("rememberMe") == Some(&Value::Bool(false)),
538            false,
539        )
540        .await
541    }
542    async fn session_response<S: AuthSchema>(
543        &self,
544        ctx: &AuthContext<S>,
545        req: &AuthRequest,
546        user: &better_auth_core::wire::UserView,
547        dont_remember: bool,
548        status: bool,
549    ) -> AuthResult<AuthResponse> {
550        let meta = RequestMeta::from_request_with_config(req, &ctx.config.advanced.ip_address);
551        let lifetime = if dont_remember {
552            Duration::days(1)
553        } else {
554            ctx.config.session.expires_in()
555        };
556        let issued = issue_user_session_with_lifetime(
557            ctx,
558            user.id().typed()?,
559            meta.ip_address,
560            meta.user_agent,
561            lifetime,
562        )
563        .await
564        .map_err(SessionIssueError::into_auth_error)?;
565        let mut output = serde_json::Map::from_iter([
566            (String::from("token"), json!(issued.session.token())),
567            (
568                String::from("user"),
569                json!(ctx.user_view(&issued.user).await?),
570            ),
571        ]);
572        if status {
573            let _ = output.insert("status".into(), json!(true));
574        }
575        let manager = ctx.session_manager();
576        manager
577            .set_session_cookie(
578                req,
579                manager.internal_data(&issued.user, &issued.session).await?,
580                Some(dont_remember),
581            )
582            .await?;
583        Ok(AuthResponse::json(200, &output)?)
584    }
585    async fn request_reset(
586        &self,
587        req: &AuthRequest,
588        ctx: &AuthContext<impl AuthSchema>,
589    ) -> AuthResult<AuthResponse> {
590        let body = match request::read(req) {
591            Ok(body) => body,
592            Err(response) => return Ok(response),
593        };
594        let phone = string(&body, "phoneNumber");
595        let user = ctx.database.get_user_by_phone_number(phone).await?;
596        let code = self
597            .save_otp(ctx, format!("{phone}-request-password-reset"), true)
598            .await?;
599        if user.is_some() && self.has_sender(ctx, Delivery::PasswordReset) {
600            let endpoint = EndpointContext::new(
601                Some(req),
602                better_auth_core::FieldValue::from_json(body.clone())?,
603                ctx,
604            );
605            let task = self.delivery(
606                PhoneOtp {
607                    phone_number: phone.to_owned(),
608                    code,
609                },
610                &endpoint,
611                Delivery::PasswordReset,
612            )?;
613            better_auth_core::background::run_or_await(
614                task,
615                ctx.config.advanced.background_tasks.as_ref(),
616                &ctx.config.logger,
617            )
618            .await;
619        }
620        Ok(AuthResponse::json(200, &json!({"status":true}))?)
621    }
622    async fn reset(
623        &self,
624        req: &AuthRequest,
625        ctx: &AuthContext<impl AuthSchema>,
626    ) -> AuthResult<AuthResponse> {
627        let body = match request::read(req) {
628            Ok(body) => body,
629            Err(response) => return Ok(response),
630        };
631        let phone = string(&body, "phoneNumber");
632        let password = string(&body, "newPassword");
633        self.verify_stored_otp(
634            &EndpointContext::new(
635                Some(req),
636                better_auth_core::FieldValue::from_json(body.clone())?,
637                ctx,
638            ),
639            &format!("{phone}-request-password-reset"),
640            string(&body, "otp"),
641        )
642        .await?;
643        let user = ctx
644            .database
645            .get_user_by_phone_number(phone)
646            .await?
647            .ok_or_else(|| error(400, "UNEXPECTED_ERROR", "Unexpected error"))?;
648        check_password_length(ctx, password, true)?;
649        let hash = hash_password(ctx.password_policy.hasher.as_ref(), password).await?;
650        if let Some(account) = get_credential_account(ctx, user.id().into_owned()).await? {
651            let _ = ctx
652                .database
653                .update_account(
654                    account.id.typed()?,
655                    UpdateAccount {
656                        password: (Some(hash))
657                            .map(|value| better_auth_core::SchemaValue::Typed(Some(value)))
658                            .unwrap_or_default(),
659                        ..Default::default()
660                    },
661                )
662                .await?;
663        } else {
664            let _ = ctx
665                .database
666                .create_account(CreateAccount {
667                    user_id: user.id().into_owned(),
668                    account_id: user.id().into_owned(),
669                    provider_id: "credential".into(),
670                    password: (Some(hash))
671                        .map(|value| better_auth_core::SchemaValue::Typed(Some(value)))
672                        .unwrap_or_default(),
673                    access_token: Default::default(),
674                    refresh_token: Default::default(),
675                    id_token: Default::default(),
676                    access_token_expires_at: Default::default(),
677                    refresh_token_expires_at: Default::default(),
678                    scope: Default::default(),
679                    ..Default::default()
680                })
681                .await?;
682        }
683        if let Some(callback) = &ctx.password_policy.on_password_reset {
684            callback(better_auth_core::utils::password::PasswordResetEvent {
685                user: ctx.internal_user_view(&user).await?,
686                request: Some(req.clone()),
687            })
688            .await?;
689        }
690        if ctx.password_policy.revoke_sessions_on_password_reset {
691            ctx.database
692                .delete_user_sessions(user.id().typed()?)
693                .await?;
694        }
695        Ok(AuthResponse::json(200, &json!({"status":true}))?)
696    }
697}
698
699fn check_password_length(
700    ctx: &AuthContext<impl AuthSchema>,
701    password: &str,
702    check_min: bool,
703) -> AuthResult<()> {
704    if check_min && password.encode_utf16().count() < ctx.password_policy.min_length {
705        return Err(error(400, "PASSWORD_TOO_SHORT", "Password too short"));
706    }
707    if password.encode_utf16().count() > ctx.password_policy.max_length {
708        return Err(error(400, "PASSWORD_TOO_LONG", "Password too long"));
709    }
710    Ok(())
711}
712fn attempts(value: &str) -> u64 {
713    value
714        .split(':')
715        .nth(1)
716        .and_then(|value| value.parse().ok())
717        .filter(|count| *count <= 9_007_199_254_740_991)
718        .unwrap_or(0)
719}
720fn invalid_credentials() -> AuthError {
721    error(
722        401,
723        "INVALID_PHONE_NUMBER_OR_PASSWORD",
724        "Invalid phone number or password",
725    )
726}
727fn error(status: u16, code: &'static str, message: &'static str) -> AuthError {
728    AuthError::Upstream {
729        status,
730        code,
731        message,
732    }
733}
734fn string<'a>(body: &'a Value, key: &str) -> &'a str {
735    body.get(key).and_then(Value::as_str).unwrap_or_default()
736}
737
738better_auth_core::impl_auth_plugin!(PhoneNumberPlugin, "phone-number";
739    routes {
740        post "/sign-in/phone-number" => sign_in, "signInPhoneNumber", body = request::validate;
741        post "/phone-number/send-otp" => send, "sendPhoneNumberOTP", body = request::validate;
742        post "/phone-number/verify" => verify, "verifyPhoneNumber", body = request::validate;
743        post "/phone-number/request-password-reset" => request_reset, "requestPasswordResetPhoneNumber", body = request::validate;
744        post "/phone-number/reset-password" => reset, "resetPasswordPhoneNumber", body = request::validate;
745    }
746    extra {
747        async fn on_init(
748            &self,
749            ctx: &mut better_auth_core::AuthInitContext<S>,
750        ) -> AuthResult<()> {
751            S::User::require_plugin_fields(
752                "phone-number",
753                &["phone_number", "phone_number_verified"],
754            )?;
755            ctx.set_metadata("phone-number.enabled", json!(true));
756            ctx.extensions.insert(self.clone());
757            Ok(())
758        }
759        fn rate_limits(
760            &self,
761        ) -> AuthResult<Vec<better_auth_core::middleware::PluginRateLimit>> {
762            Ok(vec![better_auth_core::middleware::PluginRateLimit::prefix(
763                "/phone-number",
764                better_auth_core::middleware::EndpointRateLimit {
765                    window: 60.0,
766                    max_requests: 10.0,
767                },
768            )])
769        }
770        async fn before_request(
771            &self,
772            req: &AuthRequest,
773            _ctx: &AuthContext<S>,
774        ) -> AuthResult<Option<better_auth_core::BeforeRequestAction>> {
775            if req.path() == "/update-user" {
776                let body = match super::json_body::parse(req) {
777                    Ok(body) => body,
778                    Err(response) => {
779                        return Ok(Some(better_auth_core::BeforeRequestAction::Respond(response)));
780                    }
781                };
782                if body
783                    .as_ref()
784                    .and_then(|body| body.get("phoneNumber"))
785                    .is_some_and(|value| !value.is_null())
786                {
787                    return better_auth_core::observability::instrumentation::with_endpoint_hook(&_ctx.config, req, "before", "plugin:phone-number", async {
788                        Err(error(400, "PHONE_NUMBER_CANNOT_BE_UPDATED", "Phone number cannot be updated"))
789                    }).await;
790                }
791            }
792            Ok(None)
793        }
794    }
795);