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