Skip to main content

better_auth_api/plugins/
email_password.rs

1use async_trait::async_trait;
2use serde::{Deserialize, Serialize};
3use std::sync::Arc;
4use validator::{Validate, ValidateEmail};
5
6use better_auth_core::entity::{AuthAccount, AuthSession, AuthUser};
7use better_auth_core::{AuthContext, AuthPlugin, AuthRoute};
8use better_auth_core::{AuthError, AuthResult};
9use better_auth_core::{
10    AuthRequest, AuthResponse, CreateAccount, CreateSession, CreateUser, ErrorCodeMessageResponse,
11    HttpMethod, RequestMeta,
12};
13
14use super::{email_verification::EmailVerificationPlugin, two_factor};
15use better_auth_core::utils::cookie_utils::{
16    create_session_cookie, create_session_cookie_with_max_age,
17};
18use better_auth_core::utils::password::{self as password_utils, PasswordHasher};
19use better_auth_core::utils::username::{
20    UsernameValidationError, normalize_username, normalize_username_fields, validate_username,
21};
22use better_auth_core::wire::UserView;
23
24use crate::plugins::helpers::{SessionIssueError, apply_default_role, issue_user_session};
25
26const MESSAGE_INVALID_USERNAME_OR_PASSWORD: &str = "Invalid username or password";
27const MESSAGE_EMAIL_NOT_VERIFIED: &str = "Email not verified";
28const MESSAGE_USERNAME_TOO_SHORT: &str = "Username is too short";
29const MESSAGE_USERNAME_TOO_LONG: &str = "Username is too long";
30const MESSAGE_INVALID_USERNAME: &str = "Username is invalid";
31
32fn username_error_response(status: u16, code: &str, message: &str) -> AuthResult<AuthResponse> {
33    AuthResponse::json(
34        status,
35        &ErrorCodeMessageResponse {
36            code: code.to_string(),
37            message: message.to_string(),
38        },
39    )
40    .map_err(AuthError::from)
41}
42
43fn create_session_cookie_for_remember_me(
44    token: &str,
45    remember_me: Option<bool>,
46    config: &better_auth_core::AuthConfig,
47) -> String {
48    if remember_me == Some(false) {
49        create_session_cookie_with_max_age(Some(token), None, config)
50    } else {
51        create_session_cookie(token, config)
52    }
53}
54/// Email and password authentication plugin
55pub struct EmailPasswordPlugin {
56    config: EmailPasswordConfig,
57    /// Optional reference to the email-verification plugin so that
58    /// `send_on_sign_in` can be triggered during the sign-in flow.
59    email_verification: Option<Arc<EmailVerificationPlugin>>,
60}
61
62#[derive(Clone)]
63pub struct EmailPasswordConfig {
64    pub enable_signup: bool,
65    pub require_email_verification: bool,
66    pub password_min_length: usize,
67    /// Maximum password length (default: 128).
68    pub password_max_length: usize,
69    /// Whether to automatically sign in the user after sign-up (default: true).
70    /// When false, sign-up returns the user but doesn't create a session.
71    pub auto_sign_in: bool,
72    /// Custom password hasher. When `None`, the default Argon2 hasher is used.
73    pub password_hasher: Option<Arc<dyn PasswordHasher>>,
74}
75
76impl std::fmt::Debug for EmailPasswordConfig {
77    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78        f.debug_struct("EmailPasswordConfig")
79            .field("enable_signup", &self.enable_signup)
80            .field(
81                "require_email_verification",
82                &self.require_email_verification,
83            )
84            .field("password_min_length", &self.password_min_length)
85            .field("password_max_length", &self.password_max_length)
86            .field("auto_sign_in", &self.auto_sign_in)
87            .field(
88                "password_hasher",
89                &self.password_hasher.as_ref().map(|_| "custom"),
90            )
91            .finish()
92    }
93}
94
95#[derive(Debug, Deserialize, Validate)]
96#[expect(dead_code, reason = "fields deserialized from request body")]
97pub(crate) struct SignUpRequest {
98    #[validate(length(min = 1, message = "Name is required"))]
99    name: String,
100    #[validate(email(message = "Invalid email address"))]
101    email: String,
102    #[validate(length(min = 1, message = "Password is required"))]
103    password: String,
104    username: Option<String>,
105    #[serde(rename = "displayUsername")]
106    display_username: Option<String>,
107    #[serde(rename = "callbackURL")]
108    callback_url: Option<String>,
109}
110
111#[derive(Debug, Deserialize, Validate)]
112pub(crate) struct SignInRequest {
113    #[validate(email(message = "Invalid email address"))]
114    email: String,
115    #[validate(length(min = 1, message = "Password is required"))]
116    password: String,
117    #[serde(rename = "callbackURL")]
118    callback_url: Option<String>,
119    #[serde(rename = "rememberMe")]
120    remember_me: Option<bool>,
121}
122
123#[derive(Debug, Deserialize, Validate)]
124pub(crate) struct SignInUsernameRequest {
125    username: String,
126    password: String,
127    #[serde(rename = "rememberMe")]
128    remember_me: Option<bool>,
129    #[serde(rename = "callbackURL")]
130    callback_url: Option<String>,
131}
132
133#[derive(Debug, Deserialize, Validate)]
134struct IsUsernameAvailableRequest {
135    username: String,
136}
137
138#[derive(Debug, Serialize)]
139struct IsUsernameAvailableResponse {
140    available: bool,
141}
142
143#[derive(Debug, Serialize)]
144pub(crate) struct SignUpResponse<U: Serialize> {
145    token: Option<String>,
146    user: U,
147}
148
149#[derive(Debug, Serialize)]
150pub(crate) struct SignInResponse<U: Serialize> {
151    redirect: bool,
152    token: String,
153    #[serde(skip_serializing_if = "Option::is_none")]
154    url: Option<String>,
155    user: U,
156}
157
158#[derive(Debug, Serialize)]
159pub(crate) struct SignInUsernameResponse<U: Serialize> {
160    token: String,
161    user: U,
162}
163
164/// Result of sign-in: either a successful session or a 2FA redirect.
165pub(crate) enum SignInCoreResult<U: Serialize> {
166    Success {
167        response: SignInResponse<U>,
168        token: String,
169        set_cookie_headers: Vec<String>,
170    },
171    TwoFactorRedirect {
172        response: two_factor::TwoFactorRedirectResponse,
173        set_cookie_headers: Vec<String>,
174    },
175}
176
177impl EmailPasswordPlugin {
178    #[expect(
179        clippy::new_without_default,
180        reason = "plugin construction is intentionally explicit"
181    )]
182    pub fn new() -> Self {
183        Self {
184            config: EmailPasswordConfig::default(),
185            email_verification: None,
186        }
187    }
188
189    pub fn with_config(config: EmailPasswordConfig) -> Self {
190        Self {
191            config,
192            email_verification: None,
193        }
194    }
195
196    /// Attach an [`EmailVerificationPlugin`] so that `send_on_sign_in` is
197    /// automatically called when a user signs in with an unverified email.
198    pub fn with_email_verification(mut self, plugin: Arc<EmailVerificationPlugin>) -> Self {
199        self.email_verification = Some(plugin);
200        self
201    }
202
203    pub fn enable_signup(mut self, enable: bool) -> Self {
204        self.config.enable_signup = enable;
205        self
206    }
207
208    pub fn require_email_verification(mut self, require: bool) -> Self {
209        self.config.require_email_verification = require;
210        self
211    }
212
213    pub fn password_min_length(mut self, length: usize) -> Self {
214        self.config.password_min_length = length;
215        self
216    }
217
218    pub fn password_max_length(mut self, length: usize) -> Self {
219        self.config.password_max_length = length;
220        self
221    }
222
223    pub fn auto_sign_in(mut self, auto: bool) -> Self {
224        self.config.auto_sign_in = auto;
225        self
226    }
227
228    pub fn password_hasher(mut self, hasher: Arc<dyn PasswordHasher>) -> Self {
229        self.config.password_hasher = Some(hasher);
230        self
231    }
232
233    async fn handle_sign_up(
234        &self,
235        req: &AuthRequest,
236        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
237    ) -> AuthResult<AuthResponse> {
238        let mut signup_req: SignUpRequest = match better_auth_core::validate_request_body(req) {
239            Ok(v) => v,
240            Err(resp) => return Ok(resp),
241        };
242
243        let (username, display_username) = normalize_username_fields(
244            signup_req.username.take(),
245            signup_req.display_username.take(),
246        );
247        signup_req.username = username;
248        signup_req.display_username = display_username;
249
250        if let Some(username) = signup_req.username.as_deref() {
251            match validate_username(username) {
252                Ok(()) => {}
253                Err(UsernameValidationError::TooShort) => {
254                    return username_error_response(
255                        400,
256                        "USERNAME_TOO_SHORT",
257                        MESSAGE_USERNAME_TOO_SHORT,
258                    );
259                }
260                Err(UsernameValidationError::TooLong) => {
261                    return username_error_response(
262                        400,
263                        "USERNAME_IS_TOO_LONG",
264                        MESSAGE_USERNAME_TOO_LONG,
265                    );
266                }
267                Err(UsernameValidationError::Invalid) => {
268                    return username_error_response(
269                        400,
270                        "USERNAME_IS_INVALID",
271                        MESSAGE_INVALID_USERNAME,
272                    );
273                }
274            }
275        }
276
277        let meta = RequestMeta::from_request(req);
278        let (response, session_token) = sign_up_core(&signup_req, &self.config, &meta, ctx).await?;
279
280        if let Some(token) = session_token {
281            let cookie_header = create_session_cookie(&token, &ctx.config);
282            Ok(AuthResponse::json(200, &response)?.with_header("Set-Cookie", cookie_header))
283        } else {
284            Ok(AuthResponse::json(200, &response)?)
285        }
286    }
287
288    async fn handle_sign_in(
289        &self,
290        req: &AuthRequest,
291        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
292    ) -> AuthResult<AuthResponse> {
293        if let Ok(raw_body) = req.body_as_json::<serde_json::Value>()
294            && let Some(email) = raw_body.get("email").and_then(|value| value.as_str())
295            && !email.validate_email()
296        {
297            return Err(AuthError::bad_request("Invalid email"));
298        }
299
300        let signin_req: SignInRequest = match better_auth_core::validate_request_body(req) {
301            Ok(v) => v,
302            Err(resp) => return Ok(resp),
303        };
304
305        let meta = RequestMeta::from_request(req);
306        match sign_in_core(
307            req,
308            &signin_req,
309            &self.config,
310            self.email_verification.as_deref(),
311            &meta,
312            ctx,
313        )
314        .await?
315        {
316            SignInCoreResult::Success {
317                response,
318                token,
319                set_cookie_headers,
320            } => {
321                let mut auth_response = AuthResponse::json(200, &response)?.with_appended_header(
322                    "Set-Cookie",
323                    create_session_cookie_for_remember_me(
324                        &token,
325                        signin_req.remember_me,
326                        &ctx.config,
327                    ),
328                );
329                for cookie in set_cookie_headers {
330                    auth_response = auth_response.with_appended_header("Set-Cookie", cookie);
331                }
332                Ok(auth_response)
333            }
334            SignInCoreResult::TwoFactorRedirect {
335                response,
336                set_cookie_headers,
337            } => {
338                let mut auth_response = AuthResponse::json(200, &response)?;
339                for cookie in set_cookie_headers {
340                    auth_response = auth_response.with_appended_header("Set-Cookie", cookie);
341                }
342                Ok(auth_response)
343            }
344        }
345    }
346
347    async fn handle_sign_in_username(
348        &self,
349        req: &AuthRequest,
350        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
351    ) -> AuthResult<AuthResponse> {
352        let signin_req: SignInUsernameRequest = match better_auth_core::validate_request_body(req) {
353            Ok(v) => v,
354            Err(resp) => return Ok(resp),
355        };
356
357        if signin_req.username.is_empty() || signin_req.password.is_empty() {
358            return username_error_response(
359                401,
360                "INVALID_USERNAME_OR_PASSWORD",
361                MESSAGE_INVALID_USERNAME_OR_PASSWORD,
362            );
363        }
364
365        let username = normalize_username(&signin_req.username);
366
367        match validate_username(&username) {
368            Ok(()) => {}
369            Err(UsernameValidationError::TooShort) => {
370                return username_error_response(
371                    422,
372                    "USERNAME_TOO_SHORT",
373                    MESSAGE_USERNAME_TOO_SHORT,
374                );
375            }
376            Err(UsernameValidationError::TooLong) => {
377                return username_error_response(
378                    422,
379                    "USERNAME_IS_TOO_LONG",
380                    MESSAGE_USERNAME_TOO_LONG,
381                );
382            }
383            Err(UsernameValidationError::Invalid) => {
384                return username_error_response(
385                    422,
386                    "USERNAME_IS_INVALID",
387                    MESSAGE_INVALID_USERNAME,
388                );
389            }
390        }
391
392        let meta = RequestMeta::from_request(req);
393        match sign_in_username_core(
394            req,
395            &signin_req,
396            &username,
397            &self.config,
398            self.email_verification.as_deref(),
399            &meta,
400            ctx,
401        )
402        .await
403        {
404            Ok(SignInCoreResult::Success {
405                response,
406                token,
407                set_cookie_headers,
408            }) => {
409                let username_response = SignInUsernameResponse {
410                    token: response.token,
411                    user: response.user,
412                };
413                let mut auth_response = AuthResponse::json(200, &username_response)?
414                    .with_appended_header(
415                        "Set-Cookie",
416                        create_session_cookie_for_remember_me(
417                            &token,
418                            signin_req.remember_me,
419                            &ctx.config,
420                        ),
421                    );
422                for cookie in set_cookie_headers {
423                    auth_response = auth_response.with_appended_header("Set-Cookie", cookie);
424                }
425                Ok(auth_response)
426            }
427            Ok(SignInCoreResult::TwoFactorRedirect {
428                response,
429                set_cookie_headers,
430            }) => {
431                let mut auth_response = AuthResponse::json(200, &response)?;
432                for cookie in set_cookie_headers {
433                    auth_response = auth_response.with_appended_header("Set-Cookie", cookie);
434                }
435                Ok(auth_response)
436            }
437            Err(SignInUsernameFailure::InvalidUsernameOrPassword) => username_error_response(
438                401,
439                "INVALID_USERNAME_OR_PASSWORD",
440                MESSAGE_INVALID_USERNAME_OR_PASSWORD,
441            ),
442            Err(SignInUsernameFailure::EmailNotVerified) => {
443                username_error_response(403, "EMAIL_NOT_VERIFIED", MESSAGE_EMAIL_NOT_VERIFIED)
444            }
445            Err(SignInUsernameFailure::Auth(error)) => Err(error),
446        }
447    }
448
449    async fn handle_is_username_available(
450        &self,
451        req: &AuthRequest,
452        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
453    ) -> AuthResult<AuthResponse> {
454        let body: IsUsernameAvailableRequest = match better_auth_core::validate_request_body(req) {
455            Ok(v) => v,
456            Err(resp) => return Ok(resp),
457        };
458
459        match validate_username(&body.username) {
460            Ok(()) => {}
461            Err(UsernameValidationError::TooShort) => {
462                return username_error_response(
463                    422,
464                    "USERNAME_TOO_SHORT",
465                    MESSAGE_USERNAME_TOO_SHORT,
466                );
467            }
468            Err(UsernameValidationError::TooLong) => {
469                return username_error_response(
470                    422,
471                    "USERNAME_IS_TOO_LONG",
472                    MESSAGE_USERNAME_TOO_LONG,
473                );
474            }
475            Err(UsernameValidationError::Invalid) => {
476                return username_error_response(
477                    422,
478                    "USERNAME_IS_INVALID",
479                    MESSAGE_INVALID_USERNAME,
480                );
481            }
482        }
483
484        let normalized = normalize_username(&body.username);
485        let user = ctx.database.get_user_by_username(&normalized).await?;
486        let available = user.is_none();
487
488        Ok(AuthResponse::json(
489            200,
490            &IsUsernameAvailableResponse { available },
491        )?)
492    }
493}
494
495// ---------------------------------------------------------------------------
496// Core functions — framework-agnostic business logic
497// ---------------------------------------------------------------------------
498
499/// Core sign-up logic.
500///
501/// Returns `(response, Option<session_token>)`. The session token is present
502/// only when `auto_sign_in` is true.
503pub(crate) async fn sign_up_core(
504    body: &SignUpRequest,
505    config: &EmailPasswordConfig,
506    meta: &RequestMeta,
507    ctx: &AuthContext<impl better_auth_core::AuthSchema>,
508) -> AuthResult<(SignUpResponse<UserView>, Option<String>)> {
509    if !config.enable_signup {
510        return Err(AuthError::forbidden("User registration is not enabled"));
511    }
512
513    password_utils::validate_password(
514        &body.password,
515        config.password_min_length,
516        config.password_max_length,
517        ctx,
518    )?;
519
520    // Check if user already exists
521    if ctx.database.get_user_by_email(&body.email).await?.is_some() {
522        // TS returns 422 UNPROCESSABLE_ENTITY for duplicate email
523        return Err(AuthError::UnprocessableEntity(
524            "User already exists. Use another email.".to_string(),
525        ));
526    }
527
528    // Hash password
529    let password_hash =
530        password_utils::hash_password(config.password_hasher.as_ref(), &body.password).await?;
531
532    let mut create_user = CreateUser::new()
533        .with_email(&body.email)
534        .with_name(&body.name);
535    apply_default_role(ctx, &mut create_user);
536    if let Some(ref username) = body.username {
537        create_user = create_user.with_username(normalize_username(username));
538    }
539    if let Some(ref display_username) = body.display_username {
540        create_user.display_username = Some(display_username.clone());
541    } else if let Some(ref username) = body.username {
542        create_user.display_username = Some(username.clone());
543    }
544    let auto_sign_in = config.auto_sign_in;
545    let expires_in = ctx.config.session.expires_in;
546    let ip_address = meta.ip_address.clone();
547    let user_agent = meta.user_agent.clone();
548    let database = ctx.database.clone();
549    let transaction_database = database.clone();
550
551    better_auth_core::store::transaction(database.as_ref(), move |tx| {
552        let _database = transaction_database.clone();
553        Box::pin(async move {
554            let user = match tx.create_user(create_user).await {
555                Ok(user) => user,
556                Err(AuthError::Database(_)) => {
557                    return Err(AuthError::UnprocessableEntity(
558                        "Failed to create user".to_string(),
559                    ));
560                }
561                Err(error) => return Err(error),
562            };
563
564            let _ = tx
565                .create_account(CreateAccount {
566                    user_id: user.id().to_string(),
567                    account_id: user.id().to_string(),
568                    provider_id: "credential".to_string(),
569                    access_token: None,
570                    refresh_token: None,
571                    id_token: None,
572                    access_token_expires_at: None,
573                    refresh_token_expires_at: None,
574                    scope: None,
575                    password: Some(password_hash.clone()),
576                })
577                .await?;
578
579            if auto_sign_in {
580                let session = tx
581                    .create_session(CreateSession {
582                        user_id: user.id().to_string(),
583                        expires_at: chrono::Utc::now() + expires_in,
584                        ip_address,
585                        user_agent,
586                        impersonated_by: None,
587                        active_organization_id: None,
588                    })
589                    .await?;
590                let token = session.token().to_string();
591
592                Ok((
593                    SignUpResponse {
594                        token: Some(token.clone()),
595                        user: UserView::from(&user),
596                    },
597                    Some(token),
598                ))
599            } else {
600                Ok((
601                    SignUpResponse {
602                        token: None,
603                        user: UserView::from(&user),
604                    },
605                    None,
606                ))
607            }
608        })
609    })
610    .await
611}
612
613async fn load_credential_password_hash(
614    user: &impl AuthUser,
615    ctx: &AuthContext<impl better_auth_core::AuthSchema>,
616) -> AuthResult<String> {
617    ctx.database
618        .get_user_accounts(&user.id())
619        .await?
620        .into_iter()
621        .find(|account| account.provider_id() == "credential" && account.password().is_some())
622        .and_then(|account| account.password().map(str::to_string))
623        .ok_or(AuthError::InvalidCredentials)
624}
625
626async fn verify_user_password(
627    user: &impl AuthUser,
628    password: &str,
629    config: &EmailPasswordConfig,
630    ctx: &AuthContext<impl better_auth_core::AuthSchema>,
631) -> AuthResult<()> {
632    let stored_hash = load_credential_password_hash(user, ctx).await?;
633    password_utils::verify_password(config.password_hasher.as_ref(), password, &stored_hash).await
634}
635
636/// Shared sign-in finalization logic after user lookup and credential verification.
637async fn finalize_sign_in_with_user_core(
638    req: &AuthRequest,
639    user: impl AuthUser,
640    remember_me: Option<bool>,
641    email_verification: Option<&EmailVerificationPlugin>,
642    callback_url: Option<&str>,
643    meta: &RequestMeta,
644    ctx: &AuthContext<impl better_auth_core::AuthSchema>,
645) -> AuthResult<SignInCoreResult<UserView>> {
646    let mut set_cookie_headers = Vec::new();
647    if two_factor::is_enabled(ctx) && user.two_factor_enabled() {
648        let trusted_device = two_factor::inspect_trusted_device(req, &user, ctx).await?;
649        if trusted_device.trusted {
650            set_cookie_headers.extend(trusted_device.set_cookie_headers);
651        } else {
652            let redirect = two_factor::begin_sign_in_challenge(&user, remember_me, ctx).await?;
653            let mut redirect_headers = trusted_device.set_cookie_headers;
654            redirect_headers.extend(redirect.set_cookie_headers);
655            return Ok(SignInCoreResult::TwoFactorRedirect {
656                response: redirect.response,
657                set_cookie_headers: redirect_headers,
658            });
659        }
660    }
661
662    // Send verification email on sign-in if configured
663    if let Some(ev) = email_verification
664        && let Err(e) = ev
665            .send_verification_on_sign_in(&user, callback_url, ctx)
666            .await
667    {
668        tracing::warn!(
669            error = %e,
670            "Failed to send verification email on sign-in"
671        );
672    }
673
674    let issued = issue_user_session(
675        ctx,
676        &user.id(),
677        meta.ip_address.clone(),
678        meta.user_agent.clone(),
679    )
680    .await
681    .map_err(SessionIssueError::into_auth_error)?;
682    let session = issued.session;
683    let token = session.token().to_string();
684
685    let response = SignInResponse {
686        redirect: false,
687        token: token.clone(),
688        url: None,
689        user: UserView::from(&issued.user),
690    };
691    Ok(SignInCoreResult::Success {
692        response,
693        token,
694        set_cookie_headers,
695    })
696}
697
698/// Core sign-in by email.
699pub(crate) async fn sign_in_core(
700    req: &AuthRequest,
701    body: &SignInRequest,
702    config: &EmailPasswordConfig,
703    email_verification: Option<&EmailVerificationPlugin>,
704    meta: &RequestMeta,
705    ctx: &AuthContext<impl better_auth_core::AuthSchema>,
706) -> AuthResult<SignInCoreResult<UserView>> {
707    let user = ctx
708        .database
709        .get_user_by_email(&body.email)
710        .await?
711        .ok_or(AuthError::InvalidCredentials)?;
712
713    verify_user_password(&user, &body.password, config, ctx).await?;
714
715    finalize_sign_in_with_user_core(
716        req,
717        user,
718        body.remember_me,
719        email_verification,
720        body.callback_url.as_deref(),
721        meta,
722        ctx,
723    )
724    .await
725}
726
727/// Core sign-in by username.
728pub(crate) async fn sign_in_username_core(
729    req: &AuthRequest,
730    body: &SignInUsernameRequest,
731    normalized_username: &str,
732    config: &EmailPasswordConfig,
733    email_verification: Option<&EmailVerificationPlugin>,
734    meta: &RequestMeta,
735    ctx: &AuthContext<impl better_auth_core::AuthSchema>,
736) -> Result<SignInCoreResult<UserView>, SignInUsernameFailure> {
737    let Some(user) = ctx
738        .database
739        .get_user_by_username(normalized_username)
740        .await
741        .map_err(SignInUsernameFailure::Auth)?
742    else {
743        let _ = password_utils::hash_password(config.password_hasher.as_ref(), &body.password)
744            .await
745            .map_err(SignInUsernameFailure::Auth)?;
746        return Err(SignInUsernameFailure::InvalidUsernameOrPassword);
747    };
748
749    verify_user_password(&user, &body.password, config, ctx)
750        .await
751        .map_err(|error| match error {
752            AuthError::InvalidCredentials => SignInUsernameFailure::InvalidUsernameOrPassword,
753            other => SignInUsernameFailure::Auth(other),
754        })?;
755
756    if let Some(ev) = email_verification
757        && ev.is_verification_required()
758        && !user.email_verified()
759    {
760        if let Err(error) = ev
761            .send_verification_on_sign_in(&user, body.callback_url.as_deref(), ctx)
762            .await
763        {
764            tracing::warn!(
765                error = %error,
766                "Failed to send verification email on username sign-in"
767            );
768        }
769        return Err(SignInUsernameFailure::EmailNotVerified);
770    }
771
772    finalize_sign_in_with_user_core(
773        req,
774        user,
775        body.remember_me,
776        email_verification,
777        body.callback_url.as_deref(),
778        meta,
779        ctx,
780    )
781    .await
782    .map_err(SignInUsernameFailure::Auth)
783}
784
785pub(crate) enum SignInUsernameFailure {
786    InvalidUsernameOrPassword,
787    EmailNotVerified,
788    Auth(AuthError),
789}
790
791impl Default for EmailPasswordConfig {
792    fn default() -> Self {
793        Self {
794            enable_signup: true,
795            require_email_verification: false,
796            password_min_length: 8,
797            password_max_length: 128,
798            auto_sign_in: true,
799            password_hasher: None,
800        }
801    }
802}
803
804#[async_trait]
805impl<S: better_auth_core::AuthSchema> AuthPlugin<S> for EmailPasswordPlugin {
806    fn name(&self) -> &'static str {
807        "email-password"
808    }
809
810    fn routes(&self) -> Vec<AuthRoute> {
811        let mut routes = vec![
812            AuthRoute::post("/sign-in/email", "sign_in_email"),
813            AuthRoute::post("/sign-in/username", "sign_in_username"),
814            AuthRoute::post("/is-username-available", "is_username_available"),
815        ];
816
817        if self.config.enable_signup {
818            routes.push(AuthRoute::post("/sign-up/email", "sign_up_email"));
819        }
820
821        routes
822    }
823
824    async fn on_request(
825        &self,
826        req: &AuthRequest,
827        ctx: &AuthContext<S>,
828    ) -> AuthResult<Option<AuthResponse>> {
829        match (req.method(), req.path()) {
830            (HttpMethod::Post, "/sign-up/email") if self.config.enable_signup => {
831                Ok(Some(self.handle_sign_up(req, ctx).await?))
832            }
833            (HttpMethod::Post, "/sign-in/email") => Ok(Some(self.handle_sign_in(req, ctx).await?)),
834            (HttpMethod::Post, "/sign-in/username") => {
835                Ok(Some(self.handle_sign_in_username(req, ctx).await?))
836            }
837            (HttpMethod::Post, "/is-username-available") => {
838                Ok(Some(self.handle_is_username_available(req, ctx).await?))
839            }
840            _ => Ok(None),
841        }
842    }
843
844    async fn on_user_created(&self, user: &S::User, _ctx: &AuthContext<S>) -> AuthResult<()> {
845        if self.config.require_email_verification
846            && !user.email_verified()
847            && let Some(email) = user.email()
848        {
849            println!("Email verification required for user: {}", email);
850        }
851        Ok(())
852    }
853}
854
855#[cfg(test)]
856mod tests {
857    use super::*;
858    use better_auth_core::AuthContext;
859    use better_auth_core::config::AuthConfig;
860    use std::collections::HashMap;
861    use std::sync::Arc;
862    use std::sync::atomic::{AtomicUsize, Ordering};
863
864    type TestSchema =
865        better_auth_seaorm::store::__private_test_support::bundled_schema::BundledSchema;
866
867    async fn create_test_context() -> AuthContext<TestSchema> {
868        let config = AuthConfig::new("test-secret-key-at-least-32-chars-long");
869        let config = Arc::new(config);
870        let database = crate::plugins::test_helpers::create_test_database().await;
871        AuthContext::new(config, database)
872    }
873
874    fn create_signup_request(email: &str, password: &str) -> AuthRequest {
875        let body = serde_json::json!({
876            "name": "Test User",
877            "email": email,
878            "password": password,
879        });
880        AuthRequest::from_parts(
881            HttpMethod::Post,
882            "/sign-up/email".to_string(),
883            HashMap::new(),
884            Some(body.to_string().into_bytes()),
885            HashMap::new(),
886        )
887    }
888
889    // Upstream reference: packages/better-auth/src/api/routes/sign-up.test.ts :: describe("sign-up with custom fields") and packages/better-auth/src/api/routes/sign-in.test.ts :: describe("sign-in"); adapted to the Rust email-password plugin behavior.
890    #[tokio::test]
891    async fn test_auto_sign_in_false_returns_no_session() {
892        let plugin = EmailPasswordPlugin::new().auto_sign_in(false);
893        let ctx = create_test_context().await;
894
895        let req = create_signup_request("auto@example.com", "Password123!");
896        let response = plugin.handle_sign_up(&req, &ctx).await.unwrap();
897        assert_eq!(response.status, 200);
898
899        // Response should NOT have a Set-Cookie header
900        let has_cookie = response
901            .headers
902            .iter()
903            .any(|(k, _)| k.eq_ignore_ascii_case("Set-Cookie"));
904        assert!(!has_cookie, "auto_sign_in=false should not set a cookie");
905
906        // Response body token should be null
907        let body: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
908        assert!(
909            body["token"].is_null(),
910            "auto_sign_in=false should return null token"
911        );
912        // But the user should still be created
913        assert!(body["user"]["id"].is_string());
914    }
915
916    // Upstream reference: packages/better-auth/src/api/routes/sign-up.test.ts :: describe("sign-up with custom fields") and packages/better-auth/src/api/routes/sign-in.test.ts :: describe("sign-in"); adapted to the Rust email-password plugin behavior.
917    #[tokio::test]
918    async fn test_auto_sign_in_true_returns_session() {
919        let plugin = EmailPasswordPlugin::new(); // default auto_sign_in=true
920        let ctx = create_test_context().await;
921
922        let req = create_signup_request("autotrue@example.com", "Password123!");
923        let response = plugin.handle_sign_up(&req, &ctx).await.unwrap();
924        assert_eq!(response.status, 200);
925
926        // Response SHOULD have a Set-Cookie header
927        let has_cookie = response
928            .headers
929            .iter()
930            .any(|(k, _)| k.eq_ignore_ascii_case("Set-Cookie"));
931        assert!(has_cookie, "auto_sign_in=true should set a cookie");
932
933        // Response body token should be a string
934        let body: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
935        assert!(
936            body["token"].is_string(),
937            "auto_sign_in=true should return a session token"
938        );
939    }
940
941    // Upstream reference: packages/better-auth/src/api/routes/sign-up.test.ts :: describe("sign-up with custom fields") and packages/better-auth/src/api/routes/sign-in.test.ts :: describe("sign-in"); adapted to the Rust email-password plugin behavior.
942    #[tokio::test]
943    async fn test_password_max_length_rejection() {
944        let plugin = EmailPasswordPlugin::new().password_max_length(128);
945        let ctx = create_test_context().await;
946
947        // Password of exactly 129 chars should be rejected
948        let long_password = format!("A1!{}", "a".repeat(126)); // 129 chars total
949        let req = create_signup_request("long@example.com", &long_password);
950        let err = plugin.handle_sign_up(&req, &ctx).await.unwrap_err();
951        assert_eq!(err.status_code(), 400);
952
953        // Password of exactly 128 chars should be accepted
954        let ok_password = format!("A1!{}", "a".repeat(125)); // 128 chars total
955        let req = create_signup_request("ok@example.com", &ok_password);
956        let response = plugin.handle_sign_up(&req, &ctx).await.unwrap();
957        assert_eq!(response.status, 200);
958    }
959
960    // Upstream reference: packages/better-auth/src/api/routes/sign-up.test.ts :: describe("sign-up with custom fields") and packages/better-auth/src/api/routes/sign-in.test.ts :: describe("sign-in"); adapted to the Rust email-password plugin behavior.
961    #[tokio::test]
962    async fn test_custom_password_hasher() {
963        /// A simple test hasher that prefixes the password with "hashed:"
964        struct TestHasher;
965
966        #[async_trait]
967        impl PasswordHasher for TestHasher {
968            async fn hash(&self, password: &str) -> AuthResult<String> {
969                Ok(format!("hashed:{}", password))
970            }
971            async fn verify(&self, hash: &str, password: &str) -> AuthResult<bool> {
972                Ok(hash == format!("hashed:{}", password))
973            }
974        }
975
976        let hasher: Arc<dyn PasswordHasher> = Arc::new(TestHasher);
977        let plugin = EmailPasswordPlugin::new().password_hasher(hasher);
978        let ctx = create_test_context().await;
979
980        // Sign up with custom hasher
981        let req = create_signup_request("hasher@example.com", "Password123!");
982        let response = plugin.handle_sign_up(&req, &ctx).await.unwrap();
983        assert_eq!(response.status, 200);
984
985        // Verify the stored hash uses our custom hasher
986        let user = ctx
987            .database
988            .get_user_by_email("hasher@example.com")
989            .await
990            .unwrap()
991            .unwrap();
992        let stored_hash = ctx
993            .database
994            .get_user_accounts(&user.id())
995            .await
996            .unwrap()
997            .into_iter()
998            .find(|account| account.provider_id() == "credential")
999            .and_then(|account| account.password().map(str::to_string))
1000            .expect("credential account should store hashed password");
1001        assert_eq!(stored_hash, "hashed:Password123!");
1002
1003        // Sign in should work with the custom hasher
1004        let signin_body = serde_json::json!({
1005            "email": "hasher@example.com",
1006            "password": "Password123!",
1007        });
1008        let signin_req = AuthRequest::from_parts(
1009            HttpMethod::Post,
1010            "/sign-in/email".to_string(),
1011            HashMap::new(),
1012            Some(signin_body.to_string().into_bytes()),
1013            HashMap::new(),
1014        );
1015        let response = plugin.handle_sign_in(&signin_req, &ctx).await.unwrap();
1016        assert_eq!(response.status, 200);
1017
1018        // Sign in with wrong password should fail
1019        let bad_body = serde_json::json!({
1020            "email": "hasher@example.com",
1021            "password": "WrongPassword!",
1022        });
1023        let bad_req = AuthRequest::from_parts(
1024            HttpMethod::Post,
1025            "/sign-in/email".to_string(),
1026            HashMap::new(),
1027            Some(bad_body.to_string().into_bytes()),
1028            HashMap::new(),
1029        );
1030        let err = plugin.handle_sign_in(&bad_req, &ctx).await.unwrap_err();
1031        assert_eq!(err.to_string(), AuthError::InvalidCredentials.to_string());
1032    }
1033
1034    // Upstream reference: packages/better-auth/src/plugins/username/index.ts :: sign-in path verifies the password once before creating a session; adapted to ensure the Rust username path does not duplicate expensive password verification.
1035    #[tokio::test]
1036    async fn test_sign_in_username_verifies_password_once() {
1037        struct CountingHasher {
1038            verify_calls: Arc<AtomicUsize>,
1039        }
1040
1041        #[async_trait]
1042        impl PasswordHasher for CountingHasher {
1043            async fn hash(&self, password: &str) -> AuthResult<String> {
1044                Ok(format!("hashed:{password}"))
1045            }
1046
1047            async fn verify(&self, hash: &str, password: &str) -> AuthResult<bool> {
1048                self.verify_calls.fetch_add(1, Ordering::SeqCst);
1049                Ok(hash == format!("hashed:{password}"))
1050            }
1051        }
1052
1053        let verify_calls = Arc::new(AtomicUsize::new(0));
1054        let hasher: Arc<dyn PasswordHasher> = Arc::new(CountingHasher {
1055            verify_calls: verify_calls.clone(),
1056        });
1057        let plugin = EmailPasswordPlugin::new().password_hasher(hasher);
1058        let ctx = create_test_context().await;
1059
1060        let signup_body = serde_json::json!({
1061            "email": "username-counter@example.com",
1062            "password": "Password123!",
1063            "name": "Counter User",
1064            "username": "Counter_User",
1065        });
1066        let signup_req = AuthRequest::from_parts(
1067            HttpMethod::Post,
1068            "/sign-up/email".to_string(),
1069            HashMap::new(),
1070            Some(signup_body.to_string().into_bytes()),
1071            HashMap::new(),
1072        );
1073        let signup_response = plugin.handle_sign_up(&signup_req, &ctx).await.unwrap();
1074        assert_eq!(signup_response.status, 200);
1075
1076        verify_calls.store(0, Ordering::SeqCst);
1077
1078        let signin_body = serde_json::json!({
1079            "username": "COUNTER_USER",
1080            "password": "Password123!",
1081        });
1082        let signin_req = AuthRequest::from_parts(
1083            HttpMethod::Post,
1084            "/sign-in/username".to_string(),
1085            HashMap::new(),
1086            Some(signin_body.to_string().into_bytes()),
1087            HashMap::new(),
1088        );
1089        let signin_response = plugin
1090            .handle_sign_in_username(&signin_req, &ctx)
1091            .await
1092            .unwrap();
1093        assert_eq!(signin_response.status, 200);
1094        assert_eq!(verify_calls.load(Ordering::SeqCst), 1);
1095    }
1096
1097    // Rust-specific surface: route-table registration for the endpoint declared in
1098    // packages/better-auth/src/plugins/username/index.ts :: isUsernameAvailable.
1099    #[tokio::test]
1100    async fn test_is_username_available_route_registered() {
1101        let plugin = EmailPasswordPlugin::new();
1102        let routes =
1103            <EmailPasswordPlugin as better_auth_core::AuthPlugin<TestSchema>>::routes(&plugin);
1104        assert!(
1105            routes.iter().any(|r| r.path == "/is-username-available"),
1106            "route /is-username-available should be registered"
1107        );
1108    }
1109
1110    // Upstream reference: packages/better-auth/src/plugins/username/index.ts ::
1111    // isUsernameAvailable returns `{ available: true }` when no user holds the
1112    // normalized username; adapted to the Rust email-password plugin.
1113    #[tokio::test]
1114    async fn test_is_username_available_fresh() {
1115        let plugin = EmailPasswordPlugin::new();
1116        let ctx = create_test_context().await;
1117
1118        let body = serde_json::json!({ "username": "fresh_user" });
1119        let req = AuthRequest::from_parts(
1120            HttpMethod::Post,
1121            "/is-username-available".to_string(),
1122            HashMap::new(),
1123            Some(body.to_string().into_bytes()),
1124            HashMap::new(),
1125        );
1126        let response = plugin
1127            .handle_is_username_available(&req, &ctx)
1128            .await
1129            .unwrap();
1130        assert_eq!(response.status, 200);
1131        let json: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
1132        assert_eq!(json["available"], true);
1133    }
1134
1135    // Upstream reference: packages/better-auth/src/plugins/username/index.ts ::
1136    // isUsernameAvailable returns `{ available: false }` when the adapter finds a
1137    // user on the normalized username; adapted to the Rust email-password plugin.
1138    #[tokio::test]
1139    async fn test_is_username_available_taken() {
1140        let plugin = EmailPasswordPlugin::new();
1141        let ctx = create_test_context().await;
1142
1143        // Sign up a user with a username
1144        let signup_body = serde_json::json!({
1145            "name": "Taken User",
1146            "email": "taken@example.com",
1147            "password": "Password123!",
1148            "username": "taken_user",
1149        });
1150        let signup_req = AuthRequest::from_parts(
1151            HttpMethod::Post,
1152            "/sign-up/email".to_string(),
1153            HashMap::new(),
1154            Some(signup_body.to_string().into_bytes()),
1155            HashMap::new(),
1156        );
1157        let resp = plugin.handle_sign_up(&signup_req, &ctx).await.unwrap();
1158        assert_eq!(resp.status, 200);
1159
1160        let body = serde_json::json!({ "username": "taken_user" });
1161        let req = AuthRequest::from_parts(
1162            HttpMethod::Post,
1163            "/is-username-available".to_string(),
1164            HashMap::new(),
1165            Some(body.to_string().into_bytes()),
1166            HashMap::new(),
1167        );
1168        let response = plugin
1169            .handle_is_username_available(&req, &ctx)
1170            .await
1171            .unwrap();
1172        assert_eq!(response.status, 200);
1173        let json: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
1174        assert_eq!(json["available"], false);
1175    }
1176
1177    // Upstream reference: packages/better-auth/src/plugins/username/index.ts ::
1178    // isUsernameAvailable throws UNPROCESSABLE_ENTITY with code USERNAME_TOO_SHORT
1179    // below `minUsernameLength` (default 3); adapted to the Rust email-password plugin.
1180    #[tokio::test]
1181    async fn test_is_username_available_too_short() {
1182        let plugin = EmailPasswordPlugin::new();
1183        let ctx = create_test_context().await;
1184
1185        let body = serde_json::json!({ "username": "ab" });
1186        let req = AuthRequest::from_parts(
1187            HttpMethod::Post,
1188            "/is-username-available".to_string(),
1189            HashMap::new(),
1190            Some(body.to_string().into_bytes()),
1191            HashMap::new(),
1192        );
1193        let response = plugin
1194            .handle_is_username_available(&req, &ctx)
1195            .await
1196            .unwrap();
1197        assert_eq!(response.status, 422);
1198        let json: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
1199        assert_eq!(json["code"], "USERNAME_TOO_SHORT");
1200    }
1201
1202    // Upstream reference: packages/better-auth/src/plugins/username/index.ts ::
1203    // isUsernameAvailable rejects usernames that fail `defaultUsernameValidator`
1204    // with UNPROCESSABLE_ENTITY; adapted to the Rust email-password plugin.
1205    #[tokio::test]
1206    async fn test_is_username_available_invalid_chars() {
1207        let plugin = EmailPasswordPlugin::new();
1208        let ctx = create_test_context().await;
1209
1210        let body = serde_json::json!({ "username": "bad user!" });
1211        let req = AuthRequest::from_parts(
1212            HttpMethod::Post,
1213            "/is-username-available".to_string(),
1214            HashMap::new(),
1215            Some(body.to_string().into_bytes()),
1216            HashMap::new(),
1217        );
1218        let response = plugin
1219            .handle_is_username_available(&req, &ctx)
1220            .await
1221            .unwrap();
1222        assert_eq!(response.status, 422);
1223        let json: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
1224        assert_eq!(json["code"], "USERNAME_IS_INVALID");
1225    }
1226}