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;
5
6use better_auth_core::adapters::DatabaseAdapter;
7use better_auth_core::entity::{AuthSession, AuthUser};
8use better_auth_core::{AuthContext, AuthPlugin, AuthRoute};
9use better_auth_core::{AuthError, AuthResult};
10use better_auth_core::{AuthRequest, AuthResponse, CreateUser, CreateVerification, HttpMethod};
11
12use super::email_verification::EmailVerificationPlugin;
13use better_auth_core::utils::cookie_utils::create_session_cookie;
14use better_auth_core::utils::password::{self as password_utils, PasswordHasher};
15/// Email and password authentication plugin
16pub struct EmailPasswordPlugin {
17    config: EmailPasswordConfig,
18    /// Optional reference to the email-verification plugin so that
19    /// `send_on_sign_in` can be triggered during the sign-in flow.
20    email_verification: Option<Arc<EmailVerificationPlugin>>,
21}
22
23#[derive(Clone)]
24pub struct EmailPasswordConfig {
25    pub enable_signup: bool,
26    pub require_email_verification: bool,
27    pub password_min_length: usize,
28    /// Maximum password length (default: 128).
29    pub password_max_length: usize,
30    /// Whether to automatically sign in the user after sign-up (default: true).
31    /// When false, sign-up returns the user but doesn't create a session.
32    pub auto_sign_in: bool,
33    /// Custom password hasher. When `None`, the default Argon2 hasher is used.
34    pub password_hasher: Option<Arc<dyn PasswordHasher>>,
35}
36
37impl std::fmt::Debug for EmailPasswordConfig {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        f.debug_struct("EmailPasswordConfig")
40            .field("enable_signup", &self.enable_signup)
41            .field(
42                "require_email_verification",
43                &self.require_email_verification,
44            )
45            .field("password_min_length", &self.password_min_length)
46            .field("password_max_length", &self.password_max_length)
47            .field("auto_sign_in", &self.auto_sign_in)
48            .field(
49                "password_hasher",
50                &self.password_hasher.as_ref().map(|_| "custom"),
51            )
52            .finish()
53    }
54}
55
56#[derive(Debug, Deserialize, Validate)]
57#[allow(dead_code)]
58struct SignUpRequest {
59    #[validate(length(min = 1, message = "Name is required"))]
60    name: String,
61    #[validate(email(message = "Invalid email address"))]
62    email: String,
63    #[validate(length(min = 1, message = "Password is required"))]
64    password: String,
65    username: Option<String>,
66    #[serde(rename = "displayUsername")]
67    display_username: Option<String>,
68    #[serde(rename = "callbackURL")]
69    callback_url: Option<String>,
70}
71
72#[derive(Debug, Deserialize, Validate)]
73#[allow(dead_code)]
74struct SignInRequest {
75    #[validate(email(message = "Invalid email address"))]
76    email: String,
77    #[validate(length(min = 1, message = "Password is required"))]
78    password: String,
79    #[serde(rename = "callbackURL")]
80    callback_url: Option<String>,
81    #[serde(rename = "rememberMe")]
82    remember_me: Option<bool>,
83}
84
85#[derive(Debug, Deserialize, Validate)]
86#[allow(dead_code)]
87struct SignInUsernameRequest {
88    #[validate(length(min = 1, message = "Username is required"))]
89    username: String,
90    #[validate(length(min = 1, message = "Password is required"))]
91    password: String,
92    #[serde(rename = "rememberMe")]
93    remember_me: Option<bool>,
94}
95
96#[derive(Debug, Serialize)]
97struct SignUpResponse<U: Serialize> {
98    token: Option<String>,
99    user: U,
100}
101
102#[derive(Debug, Serialize)]
103struct SignInResponse<U: Serialize> {
104    redirect: bool,
105    token: String,
106    url: Option<String>,
107    user: U,
108}
109
110impl EmailPasswordPlugin {
111    #[allow(clippy::new_without_default)]
112    pub fn new() -> Self {
113        Self {
114            config: EmailPasswordConfig::default(),
115            email_verification: None,
116        }
117    }
118
119    pub fn with_config(config: EmailPasswordConfig) -> Self {
120        Self {
121            config,
122            email_verification: None,
123        }
124    }
125
126    /// Attach an [`EmailVerificationPlugin`] so that `send_on_sign_in` is
127    /// automatically called when a user signs in with an unverified email.
128    pub fn with_email_verification(mut self, plugin: Arc<EmailVerificationPlugin>) -> Self {
129        self.email_verification = Some(plugin);
130        self
131    }
132
133    pub fn enable_signup(mut self, enable: bool) -> Self {
134        self.config.enable_signup = enable;
135        self
136    }
137
138    pub fn require_email_verification(mut self, require: bool) -> Self {
139        self.config.require_email_verification = require;
140        self
141    }
142
143    pub fn password_min_length(mut self, length: usize) -> Self {
144        self.config.password_min_length = length;
145        self
146    }
147
148    pub fn password_max_length(mut self, length: usize) -> Self {
149        self.config.password_max_length = length;
150        self
151    }
152
153    pub fn auto_sign_in(mut self, auto: bool) -> Self {
154        self.config.auto_sign_in = auto;
155        self
156    }
157
158    pub fn password_hasher(mut self, hasher: Arc<dyn PasswordHasher>) -> Self {
159        self.config.password_hasher = Some(hasher);
160        self
161    }
162
163    async fn handle_sign_up<DB: DatabaseAdapter>(
164        &self,
165        req: &AuthRequest,
166        ctx: &AuthContext<DB>,
167    ) -> AuthResult<AuthResponse> {
168        if !self.config.enable_signup {
169            return Err(AuthError::forbidden("User registration is not enabled"));
170        }
171
172        let signup_req: SignUpRequest = match better_auth_core::validate_request_body(req) {
173            Ok(v) => v,
174            Err(resp) => return Ok(resp),
175        };
176
177        // Validate password
178        self.validate_password(&signup_req.password, ctx)?;
179
180        // Check if user already exists
181        if ctx
182            .database
183            .get_user_by_email(&signup_req.email)
184            .await?
185            .is_some()
186        {
187            return Err(AuthError::conflict("A user with this email already exists"));
188        }
189
190        // Hash password
191        let password_hash = self.hash_password(&signup_req.password).await?;
192
193        // Create user with password hash in metadata
194        let metadata = serde_json::json!({
195            "password_hash": password_hash,
196        });
197
198        let mut create_user = CreateUser::new()
199            .with_email(&signup_req.email)
200            .with_name(&signup_req.name);
201        if let Some(username) = signup_req.username {
202            create_user = create_user.with_username(username);
203        }
204        if let Some(display_username) = signup_req.display_username {
205            create_user.display_username = Some(display_username);
206        }
207        create_user.metadata = Some(metadata);
208
209        let user = ctx.database.create_user(create_user).await?;
210
211        if self.config.auto_sign_in {
212            // Create session
213            let session_manager =
214                better_auth_core::SessionManager::new(ctx.config.clone(), ctx.database.clone());
215            let session = session_manager.create_session(&user, None, None).await?;
216
217            let response = SignUpResponse {
218                token: Some(session.token().to_string()),
219                user,
220            };
221
222            // Create session cookie
223            let cookie_header = create_session_cookie(session.token(), ctx);
224
225            Ok(AuthResponse::json(200, &response)?.with_header("Set-Cookie", cookie_header))
226        } else {
227            let response = SignUpResponse { token: None, user };
228
229            Ok(AuthResponse::json(200, &response)?)
230        }
231    }
232
233    async fn handle_sign_in<DB: DatabaseAdapter>(
234        &self,
235        req: &AuthRequest,
236        ctx: &AuthContext<DB>,
237    ) -> AuthResult<AuthResponse> {
238        let signin_req: SignInRequest = match better_auth_core::validate_request_body(req) {
239            Ok(v) => v,
240            Err(resp) => return Ok(resp),
241        };
242
243        // Get user by email
244        let user = ctx
245            .database
246            .get_user_by_email(&signin_req.email)
247            .await?
248            .ok_or(AuthError::InvalidCredentials)?;
249
250        // Verify password
251        let stored_hash = user
252            .metadata()
253            .get("password_hash")
254            .and_then(|v| v.as_str())
255            .ok_or(AuthError::InvalidCredentials)?;
256
257        self.verify_password(&signin_req.password, stored_hash)
258            .await?;
259
260        // Check if 2FA is enabled
261        if user.two_factor_enabled() {
262            let pending_token = format!("2fa_{}", uuid::Uuid::new_v4());
263            ctx.database
264                .create_verification(CreateVerification {
265                    identifier: format!("2fa_pending:{}", pending_token),
266                    value: user.id().to_string(),
267                    expires_at: chrono::Utc::now() + chrono::Duration::minutes(5),
268                })
269                .await?;
270            return Ok(AuthResponse::json(
271                200,
272                &serde_json::json!({
273                    "twoFactorRedirect": true,
274                    "token": pending_token,
275                }),
276            )?);
277        }
278
279        // Send verification email on sign-in if configured
280        if let Some(ref ev) = self.email_verification
281            && let Err(e) = ev
282                .send_verification_on_sign_in(&user, signin_req.callback_url.as_deref(), ctx)
283                .await
284        {
285            tracing::warn!(
286                error = %e,
287                "Failed to send verification email on sign-in"
288            );
289        }
290
291        // Create session
292        let session_manager =
293            better_auth_core::SessionManager::new(ctx.config.clone(), ctx.database.clone());
294        let session = session_manager.create_session(&user, None, None).await?;
295
296        let response = SignInResponse {
297            redirect: false,
298            token: session.token().to_string(),
299            url: None,
300            user,
301        };
302
303        // Create session cookie
304        let cookie_header = create_session_cookie(session.token(), ctx);
305
306        Ok(AuthResponse::json(200, &response)?.with_header("Set-Cookie", cookie_header))
307    }
308
309    async fn handle_sign_in_username<DB: DatabaseAdapter>(
310        &self,
311        req: &AuthRequest,
312        ctx: &AuthContext<DB>,
313    ) -> AuthResult<AuthResponse> {
314        let signin_req: SignInUsernameRequest = match better_auth_core::validate_request_body(req) {
315            Ok(v) => v,
316            Err(resp) => return Ok(resp),
317        };
318
319        // Get user by username
320        let user = ctx
321            .database
322            .get_user_by_username(&signin_req.username)
323            .await?
324            .ok_or(AuthError::InvalidCredentials)?;
325
326        // Verify password
327        let stored_hash = user
328            .metadata()
329            .get("password_hash")
330            .and_then(|v| v.as_str())
331            .ok_or(AuthError::InvalidCredentials)?;
332
333        self.verify_password(&signin_req.password, stored_hash)
334            .await?;
335
336        // Check if 2FA is enabled
337        if user.two_factor_enabled() {
338            let pending_token = format!("2fa_{}", uuid::Uuid::new_v4());
339            ctx.database
340                .create_verification(CreateVerification {
341                    identifier: format!("2fa_pending:{}", pending_token),
342                    value: user.id().to_string(),
343                    expires_at: chrono::Utc::now() + chrono::Duration::minutes(5),
344                })
345                .await?;
346            return Ok(AuthResponse::json(
347                200,
348                &serde_json::json!({
349                    "twoFactorRedirect": true,
350                    "token": pending_token,
351                }),
352            )?);
353        }
354
355        // Send verification email on sign-in if configured
356        if let Some(ref ev) = self.email_verification
357            && let Err(e) = ev.send_verification_on_sign_in(&user, None, ctx).await
358        {
359            tracing::warn!(
360                error = %e,
361                "Failed to send verification email on sign-in"
362            );
363        }
364
365        // Create session
366        let session_manager =
367            better_auth_core::SessionManager::new(ctx.config.clone(), ctx.database.clone());
368        let session = session_manager.create_session(&user, None, None).await?;
369
370        let response = SignInResponse {
371            redirect: false,
372            token: session.token().to_string(),
373            url: None,
374            user,
375        };
376
377        // Create session cookie
378        let cookie_header = create_session_cookie(session.token(), ctx);
379
380        Ok(AuthResponse::json(200, &response)?.with_header("Set-Cookie", cookie_header))
381    }
382
383    fn validate_password<DB: DatabaseAdapter>(
384        &self,
385        password: &str,
386        ctx: &AuthContext<DB>,
387    ) -> AuthResult<()> {
388        password_utils::validate_password(
389            password,
390            self.config.password_min_length,
391            self.config.password_max_length,
392            ctx,
393        )
394    }
395
396    async fn hash_password(&self, password: &str) -> AuthResult<String> {
397        password_utils::hash_password(self.config.password_hasher.as_ref(), password).await
398    }
399
400    async fn verify_password(&self, password: &str, hash: &str) -> AuthResult<()> {
401        password_utils::verify_password(self.config.password_hasher.as_ref(), password, hash).await
402    }
403}
404
405impl Default for EmailPasswordConfig {
406    fn default() -> Self {
407        Self {
408            enable_signup: true,
409            require_email_verification: false,
410            password_min_length: 8,
411            password_max_length: 128,
412            auto_sign_in: true,
413            password_hasher: None,
414        }
415    }
416}
417
418#[async_trait]
419impl<DB: DatabaseAdapter> AuthPlugin<DB> for EmailPasswordPlugin {
420    fn name(&self) -> &'static str {
421        "email-password"
422    }
423
424    fn routes(&self) -> Vec<AuthRoute> {
425        let mut routes = vec![
426            AuthRoute::post("/sign-in/email", "sign_in_email"),
427            AuthRoute::post("/sign-in/username", "sign_in_username"),
428        ];
429
430        if self.config.enable_signup {
431            routes.push(AuthRoute::post("/sign-up/email", "sign_up_email"));
432        }
433
434        routes
435    }
436
437    async fn on_request(
438        &self,
439        req: &AuthRequest,
440        ctx: &AuthContext<DB>,
441    ) -> AuthResult<Option<AuthResponse>> {
442        match (req.method(), req.path()) {
443            (HttpMethod::Post, "/sign-up/email") if self.config.enable_signup => {
444                Ok(Some(self.handle_sign_up(req, ctx).await?))
445            }
446            (HttpMethod::Post, "/sign-in/email") => Ok(Some(self.handle_sign_in(req, ctx).await?)),
447            (HttpMethod::Post, "/sign-in/username") => {
448                Ok(Some(self.handle_sign_in_username(req, ctx).await?))
449            }
450            _ => Ok(None),
451        }
452    }
453
454    async fn on_user_created(&self, user: &DB::User, _ctx: &AuthContext<DB>) -> AuthResult<()> {
455        if self.config.require_email_verification
456            && !user.email_verified()
457            && let Some(email) = user.email()
458        {
459            println!("Email verification required for user: {}", email);
460        }
461        Ok(())
462    }
463}
464
465#[cfg(test)]
466mod tests {
467    use super::*;
468    use better_auth_core::AuthContext;
469    use better_auth_core::adapters::{MemoryDatabaseAdapter, UserOps};
470    use better_auth_core::config::AuthConfig;
471    use std::collections::HashMap;
472    use std::sync::Arc;
473
474    fn create_test_context() -> AuthContext<MemoryDatabaseAdapter> {
475        let config = AuthConfig::new("test-secret-key-at-least-32-chars-long");
476        let config = Arc::new(config);
477        let database = Arc::new(MemoryDatabaseAdapter::new());
478        AuthContext::new(config, database)
479    }
480
481    fn create_signup_request(email: &str, password: &str) -> AuthRequest {
482        let body = serde_json::json!({
483            "name": "Test User",
484            "email": email,
485            "password": password,
486        });
487        AuthRequest::from_parts(
488            HttpMethod::Post,
489            "/sign-up/email".to_string(),
490            HashMap::new(),
491            Some(body.to_string().into_bytes()),
492            HashMap::new(),
493        )
494    }
495
496    #[tokio::test]
497    async fn test_auto_sign_in_false_returns_no_session() {
498        let plugin = EmailPasswordPlugin::new().auto_sign_in(false);
499        let ctx = create_test_context();
500
501        let req = create_signup_request("auto@example.com", "Password123!");
502        let response = plugin.handle_sign_up(&req, &ctx).await.unwrap();
503        assert_eq!(response.status, 200);
504
505        // Response should NOT have a Set-Cookie header
506        let has_cookie = response
507            .headers
508            .iter()
509            .any(|(k, _)| k.eq_ignore_ascii_case("Set-Cookie"));
510        assert!(!has_cookie, "auto_sign_in=false should not set a cookie");
511
512        // Response body token should be null
513        let body: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
514        assert!(
515            body["token"].is_null(),
516            "auto_sign_in=false should return null token"
517        );
518        // But the user should still be created
519        assert!(body["user"]["id"].is_string());
520    }
521
522    #[tokio::test]
523    async fn test_auto_sign_in_true_returns_session() {
524        let plugin = EmailPasswordPlugin::new(); // default auto_sign_in=true
525        let ctx = create_test_context();
526
527        let req = create_signup_request("autotrue@example.com", "Password123!");
528        let response = plugin.handle_sign_up(&req, &ctx).await.unwrap();
529        assert_eq!(response.status, 200);
530
531        // Response SHOULD have a Set-Cookie header
532        let has_cookie = response
533            .headers
534            .iter()
535            .any(|(k, _)| k.eq_ignore_ascii_case("Set-Cookie"));
536        assert!(has_cookie, "auto_sign_in=true should set a cookie");
537
538        // Response body token should be a string
539        let body: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
540        assert!(
541            body["token"].is_string(),
542            "auto_sign_in=true should return a session token"
543        );
544    }
545
546    #[tokio::test]
547    async fn test_password_max_length_rejection() {
548        let plugin = EmailPasswordPlugin::new().password_max_length(128);
549        let ctx = create_test_context();
550
551        // Password of exactly 129 chars should be rejected
552        let long_password = format!("A1!{}", "a".repeat(126)); // 129 chars total
553        let req = create_signup_request("long@example.com", &long_password);
554        let err = plugin.handle_sign_up(&req, &ctx).await.unwrap_err();
555        assert_eq!(err.status_code(), 400);
556
557        // Password of exactly 128 chars should be accepted
558        let ok_password = format!("A1!{}", "a".repeat(125)); // 128 chars total
559        let req = create_signup_request("ok@example.com", &ok_password);
560        let response = plugin.handle_sign_up(&req, &ctx).await.unwrap();
561        assert_eq!(response.status, 200);
562    }
563
564    #[tokio::test]
565    async fn test_custom_password_hasher() {
566        /// A simple test hasher that prefixes the password with "hashed:"
567        struct TestHasher;
568
569        #[async_trait]
570        impl PasswordHasher for TestHasher {
571            async fn hash(&self, password: &str) -> AuthResult<String> {
572                Ok(format!("hashed:{}", password))
573            }
574            async fn verify(&self, hash: &str, password: &str) -> AuthResult<bool> {
575                Ok(hash == format!("hashed:{}", password))
576            }
577        }
578
579        let hasher: Arc<dyn PasswordHasher> = Arc::new(TestHasher);
580        let plugin = EmailPasswordPlugin::new().password_hasher(hasher);
581        let ctx = create_test_context();
582
583        // Sign up with custom hasher
584        let req = create_signup_request("hasher@example.com", "Password123!");
585        let response = plugin.handle_sign_up(&req, &ctx).await.unwrap();
586        assert_eq!(response.status, 200);
587
588        // Verify the stored hash uses our custom hasher
589        let user = ctx
590            .database
591            .get_user_by_email("hasher@example.com")
592            .await
593            .unwrap()
594            .unwrap();
595        let stored_hash = user
596            .metadata
597            .get("password_hash")
598            .unwrap()
599            .as_str()
600            .unwrap();
601        assert_eq!(stored_hash, "hashed:Password123!");
602
603        // Sign in should work with the custom hasher
604        let signin_body = serde_json::json!({
605            "email": "hasher@example.com",
606            "password": "Password123!",
607        });
608        let signin_req = AuthRequest::from_parts(
609            HttpMethod::Post,
610            "/sign-in/email".to_string(),
611            HashMap::new(),
612            Some(signin_body.to_string().into_bytes()),
613            HashMap::new(),
614        );
615        let response = plugin.handle_sign_in(&signin_req, &ctx).await.unwrap();
616        assert_eq!(response.status, 200);
617
618        // Sign in with wrong password should fail
619        let bad_body = serde_json::json!({
620            "email": "hasher@example.com",
621            "password": "WrongPassword!",
622        });
623        let bad_req = AuthRequest::from_parts(
624            HttpMethod::Post,
625            "/sign-in/email".to_string(),
626            HashMap::new(),
627            Some(bad_body.to_string().into_bytes()),
628            HashMap::new(),
629        );
630        let err = plugin.handle_sign_in(&bad_req, &ctx).await.unwrap_err();
631        assert_eq!(err.to_string(), AuthError::InvalidCredentials.to_string());
632    }
633}