Skip to main content

better_auth_api/plugins/
password_management.rs

1use async_trait::async_trait;
2use chrono::{Duration, Utc};
3use serde::{Deserialize, Serialize};
4use uuid::Uuid;
5use validator::Validate;
6
7use better_auth_core::{AuthContext, AuthPlugin, AuthRoute};
8use better_auth_core::{AuthError, AuthResult};
9use better_auth_core::{AuthRequest, AuthResponse, CreateVerification, HttpMethod, UpdateUser};
10use better_auth_core::{AuthSession, AuthUser, AuthVerification, DatabaseAdapter};
11
12/// Password management plugin for password reset and change functionality
13pub struct PasswordManagementPlugin {
14    config: PasswordManagementConfig,
15}
16
17#[derive(Debug, Clone)]
18pub struct PasswordManagementConfig {
19    pub reset_token_expiry_hours: i64,
20    pub require_current_password: bool,
21    pub send_email_notifications: bool,
22}
23
24// Request structures for password endpoints
25#[derive(Debug, Deserialize, Validate)]
26struct ForgetPasswordRequest {
27    #[validate(email(message = "Invalid email address"))]
28    email: String,
29    #[serde(rename = "redirectTo")]
30    redirect_to: Option<String>,
31}
32
33#[derive(Debug, Deserialize, Validate)]
34struct ResetPasswordRequest {
35    #[serde(rename = "newPassword")]
36    #[validate(length(min = 1, message = "New password is required"))]
37    new_password: String,
38    token: Option<String>,
39}
40
41#[derive(Debug, Deserialize, Validate)]
42struct SetPasswordRequest {
43    #[serde(rename = "newPassword")]
44    #[validate(length(min = 1, message = "New password is required"))]
45    new_password: String,
46}
47
48#[derive(Debug, Deserialize, Validate)]
49struct ChangePasswordRequest {
50    #[serde(rename = "newPassword")]
51    #[validate(length(min = 1, message = "New password is required"))]
52    new_password: String,
53    #[serde(rename = "currentPassword")]
54    #[validate(length(min = 1, message = "Current password is required"))]
55    current_password: String,
56    #[serde(rename = "revokeOtherSessions")]
57    revoke_other_sessions: Option<String>,
58}
59
60// Response structures
61#[derive(Debug, Serialize, Deserialize)]
62struct StatusResponse {
63    status: bool,
64}
65
66#[derive(Debug, Serialize)]
67struct ChangePasswordResponse<U: Serialize> {
68    token: Option<String>,
69    user: U,
70}
71
72#[derive(Debug, Serialize, Deserialize)]
73struct ResetPasswordTokenResponse {
74    token: String,
75}
76
77impl PasswordManagementPlugin {
78    pub fn new() -> Self {
79        Self {
80            config: PasswordManagementConfig::default(),
81        }
82    }
83
84    pub fn with_config(config: PasswordManagementConfig) -> Self {
85        Self { config }
86    }
87
88    pub fn reset_token_expiry_hours(mut self, hours: i64) -> Self {
89        self.config.reset_token_expiry_hours = hours;
90        self
91    }
92
93    pub fn require_current_password(mut self, require: bool) -> Self {
94        self.config.require_current_password = require;
95        self
96    }
97
98    pub fn send_email_notifications(mut self, send: bool) -> Self {
99        self.config.send_email_notifications = send;
100        self
101    }
102}
103
104impl Default for PasswordManagementPlugin {
105    fn default() -> Self {
106        Self::new()
107    }
108}
109
110impl Default for PasswordManagementConfig {
111    fn default() -> Self {
112        Self {
113            reset_token_expiry_hours: 24, // 24 hours default expiry
114            require_current_password: true,
115            send_email_notifications: true,
116        }
117    }
118}
119
120#[async_trait]
121impl<DB: DatabaseAdapter> AuthPlugin<DB> for PasswordManagementPlugin {
122    fn name(&self) -> &'static str {
123        "password-management"
124    }
125
126    fn routes(&self) -> Vec<AuthRoute> {
127        vec![
128            AuthRoute::post("/forget-password", "forget_password"),
129            AuthRoute::post("/reset-password", "reset_password"),
130            AuthRoute::get("/reset-password/{token}", "reset_password_token"),
131            AuthRoute::post("/change-password", "change_password"),
132            AuthRoute::post("/set-password", "set_password"),
133        ]
134    }
135
136    async fn on_request(
137        &self,
138        req: &AuthRequest,
139        ctx: &AuthContext<DB>,
140    ) -> AuthResult<Option<AuthResponse>> {
141        match (req.method(), req.path()) {
142            (HttpMethod::Post, "/forget-password") => {
143                Ok(Some(self.handle_forget_password(req, ctx).await?))
144            }
145            (HttpMethod::Post, "/reset-password") => {
146                Ok(Some(self.handle_reset_password(req, ctx).await?))
147            }
148            (HttpMethod::Post, "/change-password") => {
149                Ok(Some(self.handle_change_password(req, ctx).await?))
150            }
151            (HttpMethod::Post, "/set-password") => {
152                Ok(Some(self.handle_set_password(req, ctx).await?))
153            }
154            (HttpMethod::Get, path) if path.starts_with("/reset-password/") => {
155                let token = &path[16..]; // Remove "/reset-password/" prefix
156                Ok(Some(
157                    self.handle_reset_password_token(token, req, ctx).await?,
158                ))
159            }
160            _ => Ok(None),
161        }
162    }
163}
164
165// Implementation methods outside the trait
166impl PasswordManagementPlugin {
167    async fn handle_forget_password<DB: DatabaseAdapter>(
168        &self,
169        req: &AuthRequest,
170        ctx: &AuthContext<DB>,
171    ) -> AuthResult<AuthResponse> {
172        let forget_req: ForgetPasswordRequest = match better_auth_core::validate_request_body(req) {
173            Ok(v) => v,
174            Err(resp) => return Ok(resp),
175        };
176
177        // Check if user exists
178        let user = match ctx.database.get_user_by_email(&forget_req.email).await? {
179            Some(user) => user,
180            None => {
181                // Don't reveal whether email exists or not for security
182                let response = StatusResponse { status: true };
183                return Ok(AuthResponse::json(200, &response)?);
184            }
185        };
186
187        // Generate password reset token
188        let reset_token = format!("reset_{}", Uuid::new_v4());
189        let expires_at = Utc::now() + Duration::hours(self.config.reset_token_expiry_hours);
190
191        // Create verification token
192        let create_verification = CreateVerification {
193            identifier: user.email().unwrap_or_default().to_string(),
194            value: reset_token.clone(),
195            expires_at,
196        };
197
198        ctx.database
199            .create_verification(create_verification)
200            .await?;
201
202        // Send email with reset link
203        if self.config.send_email_notifications {
204            let reset_url = if let Some(redirect_to) = &forget_req.redirect_to {
205                format!("{}?token={}", redirect_to, reset_token)
206            } else {
207                format!(
208                    "{}/reset-password?token={}",
209                    ctx.config.base_url, reset_token
210                )
211            };
212
213            if let Ok(provider) = ctx.email_provider() {
214                let subject = "Reset your password";
215                let html = format!(
216                    "<p>Click the link below to reset your password:</p>\
217                     <p><a href=\"{url}\">Reset Password</a></p>",
218                    url = reset_url
219                );
220                let text = format!("Reset your password: {}", reset_url);
221
222                if let Err(e) = provider
223                    .send(&forget_req.email, subject, &html, &text)
224                    .await
225                {
226                    eprintln!(
227                        "[password-management] Failed to send reset email to {}: {}",
228                        forget_req.email, e
229                    );
230                }
231            } else {
232                eprintln!(
233                    "[password-management] No email provider configured, skipping password reset email for {}",
234                    forget_req.email
235                );
236            }
237        }
238
239        let response = StatusResponse { status: true };
240        Ok(AuthResponse::json(200, &response)?)
241    }
242
243    async fn handle_reset_password<DB: DatabaseAdapter>(
244        &self,
245        req: &AuthRequest,
246        ctx: &AuthContext<DB>,
247    ) -> AuthResult<AuthResponse> {
248        let reset_req: ResetPasswordRequest = match better_auth_core::validate_request_body(req) {
249            Ok(v) => v,
250            Err(resp) => return Ok(resp),
251        };
252
253        // Validate password
254        self.validate_password(&reset_req.new_password, ctx)?;
255
256        // Find user by reset token
257        let token = reset_req.token.as_deref().unwrap_or("");
258        if token.is_empty() {
259            return Err(AuthError::bad_request("Reset token is required"));
260        }
261
262        let (user, verification) = self
263            .find_user_by_reset_token(token, ctx)
264            .await?
265            .ok_or_else(|| AuthError::bad_request("Invalid or expired reset token"))?;
266
267        // Hash new password
268        let password_hash = self.hash_password(&reset_req.new_password)?;
269
270        // Update user password
271        let mut metadata = user.metadata().clone();
272        metadata.insert(
273            "password_hash".to_string(),
274            serde_json::Value::String(password_hash),
275        );
276
277        let update_user = UpdateUser {
278            email: None,
279            name: None,
280            image: None,
281            email_verified: None,
282            username: None,
283            display_username: None,
284            role: None,
285            banned: None,
286            ban_reason: None,
287            ban_expires: None,
288            two_factor_enabled: None,
289            metadata: Some(metadata),
290        };
291
292        ctx.database.update_user(user.id(), update_user).await?;
293
294        // Delete the used verification token
295        ctx.database.delete_verification(verification.id()).await?;
296
297        // Revoke all existing sessions for security
298        ctx.database.delete_user_sessions(user.id()).await?;
299
300        let response = StatusResponse { status: true };
301        Ok(AuthResponse::json(200, &response)?)
302    }
303
304    async fn handle_change_password<DB: DatabaseAdapter>(
305        &self,
306        req: &AuthRequest,
307        ctx: &AuthContext<DB>,
308    ) -> AuthResult<AuthResponse> {
309        let change_req: ChangePasswordRequest = match better_auth_core::validate_request_body(req) {
310            Ok(v) => v,
311            Err(resp) => return Ok(resp),
312        };
313
314        // Get current user from session
315        let user = self
316            .get_current_user(req, ctx)
317            .await?
318            .ok_or(AuthError::Unauthenticated)?;
319
320        // Verify current password
321        if self.config.require_current_password {
322            let stored_hash = user
323                .metadata()
324                .get("password_hash")
325                .and_then(|v| v.as_str())
326                .ok_or_else(|| AuthError::bad_request("No password set for this user"))?;
327
328            self.verify_password(&change_req.current_password, stored_hash)
329                .map_err(|_| AuthError::InvalidCredentials)?;
330        }
331
332        // Validate new password
333        self.validate_password(&change_req.new_password, ctx)?;
334
335        // Hash new password
336        let password_hash = self.hash_password(&change_req.new_password)?;
337
338        // Update user password
339        let mut metadata = user.metadata().clone();
340        metadata.insert(
341            "password_hash".to_string(),
342            serde_json::Value::String(password_hash),
343        );
344
345        let update_user = UpdateUser {
346            email: None,
347            name: None,
348            image: None,
349            email_verified: None,
350            username: None,
351            display_username: None,
352            role: None,
353            banned: None,
354            ban_reason: None,
355            ban_expires: None,
356            two_factor_enabled: None,
357            metadata: Some(metadata),
358        };
359
360        let updated_user = ctx.database.update_user(user.id(), update_user).await?;
361
362        // Handle session revocation
363        let new_token = if change_req.revoke_other_sessions.as_deref() == Some("true") {
364            // Revoke all sessions except current one
365            ctx.database.delete_user_sessions(user.id()).await?;
366
367            // Create new session
368            let session_manager =
369                better_auth_core::SessionManager::new(ctx.config.clone(), ctx.database.clone());
370            let session = session_manager
371                .create_session(&updated_user, None, None)
372                .await?;
373            Some(session.token().to_string())
374        } else {
375            None
376        };
377
378        let response = ChangePasswordResponse {
379            token: new_token,
380            user: updated_user,
381        };
382
383        Ok(AuthResponse::json(200, &response)?)
384    }
385
386    async fn handle_set_password<DB: DatabaseAdapter>(
387        &self,
388        req: &AuthRequest,
389        ctx: &AuthContext<DB>,
390    ) -> AuthResult<AuthResponse> {
391        let set_req: SetPasswordRequest = match better_auth_core::validate_request_body(req) {
392            Ok(v) => v,
393            Err(resp) => return Ok(resp),
394        };
395
396        // Authenticate user
397        let user = self
398            .get_current_user(req, ctx)
399            .await?
400            .ok_or(AuthError::Unauthenticated)?;
401
402        // Verify the user does NOT already have a password
403        if user
404            .metadata()
405            .get("password_hash")
406            .and_then(|v| v.as_str())
407            .is_some()
408        {
409            return Err(AuthError::bad_request(
410                "User already has a password. Use /change-password instead.",
411            ));
412        }
413
414        // Validate new password
415        self.validate_password(&set_req.new_password, ctx)?;
416
417        // Hash and store the new password
418        let password_hash = self.hash_password(&set_req.new_password)?;
419
420        let mut metadata = user.metadata().clone();
421        metadata.insert(
422            "password_hash".to_string(),
423            serde_json::Value::String(password_hash),
424        );
425
426        let update_user = UpdateUser {
427            email: None,
428            name: None,
429            image: None,
430            email_verified: None,
431            username: None,
432            display_username: None,
433            role: None,
434            banned: None,
435            ban_reason: None,
436            ban_expires: None,
437            two_factor_enabled: None,
438            metadata: Some(metadata),
439        };
440
441        ctx.database.update_user(user.id(), update_user).await?;
442
443        let response = StatusResponse { status: true };
444        Ok(AuthResponse::json(200, &response)?)
445    }
446
447    async fn handle_reset_password_token<DB: DatabaseAdapter>(
448        &self,
449        token: &str,
450        _req: &AuthRequest,
451        ctx: &AuthContext<DB>,
452    ) -> AuthResult<AuthResponse> {
453        // Check if token is valid and get callback URL from query parameters
454        let callback_url = _req.query.get("callbackURL").cloned();
455
456        // Validate the reset token exists and is not expired
457        let (_user, _verification) = match self.find_user_by_reset_token(token, ctx).await? {
458            Some((user, verification)) => (user, verification),
459            None => {
460                // Redirect to callback URL with error if provided
461                if let Some(callback_url) = callback_url {
462                    let redirect_url = format!("{}?error=INVALID_TOKEN", callback_url);
463                    let mut headers = std::collections::HashMap::new();
464                    headers.insert("Location".to_string(), redirect_url);
465                    return Ok(AuthResponse {
466                        status: 302,
467                        headers,
468                        body: Vec::new(),
469                    });
470                }
471
472                return Err(AuthError::bad_request("Invalid or expired reset token"));
473            }
474        };
475
476        // If callback URL is provided, redirect with valid token
477        if let Some(callback_url) = callback_url {
478            let redirect_url = format!("{}?token={}", callback_url, token);
479            let mut headers = std::collections::HashMap::new();
480            headers.insert("Location".to_string(), redirect_url);
481            return Ok(AuthResponse {
482                status: 302,
483                headers,
484                body: Vec::new(),
485            });
486        }
487
488        // Otherwise return the token directly
489        let response = ResetPasswordTokenResponse {
490            token: token.to_string(),
491        };
492        Ok(AuthResponse::json(200, &response)?)
493    }
494
495    async fn find_user_by_reset_token<DB: DatabaseAdapter>(
496        &self,
497        token: &str,
498        ctx: &AuthContext<DB>,
499    ) -> AuthResult<Option<(DB::User, DB::Verification)>> {
500        // Find verification token by value
501        let verification = match ctx.database.get_verification_by_value(token).await? {
502            Some(verification) => verification,
503            None => return Ok(None),
504        };
505
506        // Get user by email (stored in identifier field)
507        let user = match ctx
508            .database
509            .get_user_by_email(verification.identifier())
510            .await?
511        {
512            Some(user) => user,
513            None => return Ok(None),
514        };
515
516        Ok(Some((user, verification)))
517    }
518
519    async fn get_current_user<DB: DatabaseAdapter>(
520        &self,
521        req: &AuthRequest,
522        ctx: &AuthContext<DB>,
523    ) -> AuthResult<Option<DB::User>> {
524        let session_manager =
525            better_auth_core::SessionManager::new(ctx.config.clone(), ctx.database.clone());
526
527        if let Some(token) = session_manager.extract_session_token(req)
528            && let Some(session) = session_manager.get_session(&token).await?
529        {
530            return ctx.database.get_user_by_id(session.user_id()).await;
531        }
532
533        Ok(None)
534    }
535
536    fn validate_password<DB: DatabaseAdapter>(
537        &self,
538        password: &str,
539        ctx: &AuthContext<DB>,
540    ) -> AuthResult<()> {
541        let config = &ctx.config.password;
542
543        if password.len() < config.min_length {
544            return Err(AuthError::bad_request(format!(
545                "Password must be at least {} characters long",
546                config.min_length
547            )));
548        }
549
550        if config.require_uppercase && !password.chars().any(|c| c.is_uppercase()) {
551            return Err(AuthError::bad_request(
552                "Password must contain at least one uppercase letter",
553            ));
554        }
555
556        if config.require_lowercase && !password.chars().any(|c| c.is_lowercase()) {
557            return Err(AuthError::bad_request(
558                "Password must contain at least one lowercase letter",
559            ));
560        }
561
562        if config.require_numbers && !password.chars().any(|c| c.is_ascii_digit()) {
563            return Err(AuthError::bad_request(
564                "Password must contain at least one number",
565            ));
566        }
567
568        if config.require_special
569            && !password
570                .chars()
571                .any(|c| "!@#$%^&*()_+-=[]{}|;:,.<>?".contains(c))
572        {
573            return Err(AuthError::bad_request(
574                "Password must contain at least one special character",
575            ));
576        }
577
578        Ok(())
579    }
580
581    fn hash_password(&self, password: &str) -> AuthResult<String> {
582        use argon2::password_hash::{SaltString, rand_core::OsRng};
583        use argon2::{Argon2, PasswordHasher};
584
585        let salt = SaltString::generate(&mut OsRng);
586        let argon2 = Argon2::default();
587
588        let password_hash = argon2
589            .hash_password(password.as_bytes(), &salt)
590            .map_err(|e| AuthError::PasswordHash(format!("Failed to hash password: {}", e)))?;
591
592        Ok(password_hash.to_string())
593    }
594
595    fn verify_password(&self, password: &str, hash: &str) -> AuthResult<()> {
596        use argon2::password_hash::PasswordHash;
597        use argon2::{Argon2, PasswordVerifier};
598
599        let parsed_hash = PasswordHash::new(hash)
600            .map_err(|e| AuthError::PasswordHash(format!("Invalid password hash: {}", e)))?;
601
602        let argon2 = Argon2::default();
603        argon2
604            .verify_password(password.as_bytes(), &parsed_hash)
605            .map_err(|_| AuthError::InvalidCredentials)?;
606
607        Ok(())
608    }
609}
610
611#[cfg(test)]
612mod tests {
613    use super::*;
614    use better_auth_core::adapters::{DatabaseAdapter, MemoryDatabaseAdapter};
615    use better_auth_core::config::{Argon2Config, AuthConfig, PasswordConfig};
616    use better_auth_core::{CreateSession, CreateUser, CreateVerification, Session, User};
617    use chrono::{Duration, Utc};
618    use std::collections::HashMap;
619    use std::sync::Arc;
620
621    async fn create_test_context_with_user() -> (AuthContext<MemoryDatabaseAdapter>, User, Session)
622    {
623        let mut config = AuthConfig::new("test-secret-key-at-least-32-chars-long");
624        config.password = PasswordConfig {
625            min_length: 8,
626            require_uppercase: true,
627            require_lowercase: true,
628            require_numbers: true,
629            require_special: true,
630            argon2_config: Argon2Config::default(),
631        };
632
633        let config = Arc::new(config);
634        let database = Arc::new(MemoryDatabaseAdapter::new());
635        let ctx = AuthContext::new(config.clone(), database.clone());
636
637        // Create test user with hashed password
638        let plugin = PasswordManagementPlugin::new();
639        let password_hash = plugin.hash_password("Password123!").unwrap();
640
641        let mut metadata = HashMap::new();
642        metadata.insert(
643            "password_hash".to_string(),
644            serde_json::Value::String(password_hash),
645        );
646
647        let create_user = CreateUser::new()
648            .with_email("test@example.com")
649            .with_name("Test User")
650            .with_metadata(metadata);
651        let user = database.create_user(create_user).await.unwrap();
652
653        // Create test session
654        let create_session = CreateSession {
655            user_id: user.id.clone(),
656            expires_at: Utc::now() + Duration::hours(24),
657            ip_address: Some("127.0.0.1".to_string()),
658            user_agent: Some("test-agent".to_string()),
659            impersonated_by: None,
660            active_organization_id: None,
661        };
662        let session = database.create_session(create_session).await.unwrap();
663
664        (ctx, user, session)
665    }
666
667    fn create_auth_request(
668        method: HttpMethod,
669        path: &str,
670        token: Option<&str>,
671        body: Option<Vec<u8>>,
672    ) -> AuthRequest {
673        let mut headers = HashMap::new();
674        if let Some(token) = token {
675            headers.insert("authorization".to_string(), format!("Bearer {}", token));
676        }
677
678        AuthRequest {
679            method,
680            path: path.to_string(),
681            headers,
682            body,
683            query: HashMap::new(),
684        }
685    }
686
687    #[tokio::test]
688    async fn test_forget_password_success() {
689        let plugin = PasswordManagementPlugin::new();
690        let (ctx, _user, _session) = create_test_context_with_user().await;
691
692        let body = serde_json::json!({
693            "email": "test@example.com",
694            "redirectTo": "http://localhost:3000/reset"
695        });
696
697        let req = create_auth_request(
698            HttpMethod::Post,
699            "/forget-password",
700            None,
701            Some(body.to_string().into_bytes()),
702        );
703
704        let response = plugin.handle_forget_password(&req, &ctx).await.unwrap();
705        assert_eq!(response.status, 200);
706
707        let body_str = String::from_utf8(response.body).unwrap();
708        let response_data: StatusResponse = serde_json::from_str(&body_str).unwrap();
709        assert!(response_data.status);
710    }
711
712    #[tokio::test]
713    async fn test_forget_password_unknown_email() {
714        let plugin = PasswordManagementPlugin::new();
715        let (ctx, _user, _session) = create_test_context_with_user().await;
716
717        let body = serde_json::json!({
718            "email": "unknown@example.com"
719        });
720
721        let req = create_auth_request(
722            HttpMethod::Post,
723            "/forget-password",
724            None,
725            Some(body.to_string().into_bytes()),
726        );
727
728        let response = plugin.handle_forget_password(&req, &ctx).await.unwrap();
729        assert_eq!(response.status, 200);
730
731        // Should return success even for unknown emails (security)
732        let body_str = String::from_utf8(response.body).unwrap();
733        let response_data: StatusResponse = serde_json::from_str(&body_str).unwrap();
734        assert!(response_data.status);
735    }
736
737    #[tokio::test]
738    async fn test_reset_password_success() {
739        let plugin = PasswordManagementPlugin::new();
740        let (ctx, user, _session) = create_test_context_with_user().await;
741
742        // Create verification token
743        let reset_token = format!("reset_{}", uuid::Uuid::new_v4());
744        let create_verification = CreateVerification {
745            identifier: user.email.clone().unwrap(),
746            value: reset_token.clone(),
747            expires_at: Utc::now() + Duration::hours(24),
748        };
749        ctx.database
750            .create_verification(create_verification)
751            .await
752            .unwrap();
753
754        let body = serde_json::json!({
755            "newPassword": "NewPassword123!",
756            "token": reset_token
757        });
758
759        let req = create_auth_request(
760            HttpMethod::Post,
761            "/reset-password",
762            None,
763            Some(body.to_string().into_bytes()),
764        );
765
766        let response = plugin.handle_reset_password(&req, &ctx).await.unwrap();
767        assert_eq!(response.status, 200);
768
769        let body_str = String::from_utf8(response.body).unwrap();
770        let response_data: StatusResponse = serde_json::from_str(&body_str).unwrap();
771        assert!(response_data.status);
772
773        // Verify password was updated
774        let updated_user = ctx
775            .database
776            .get_user_by_id(&user.id)
777            .await
778            .unwrap()
779            .unwrap();
780        let stored_hash = updated_user
781            .metadata
782            .get("password_hash")
783            .unwrap()
784            .as_str()
785            .unwrap();
786        assert!(
787            plugin
788                .verify_password("NewPassword123!", stored_hash)
789                .is_ok()
790        );
791
792        // Verify token was deleted
793        let verification_check = ctx
794            .database
795            .get_verification_by_value(&reset_token)
796            .await
797            .unwrap();
798        assert!(verification_check.is_none());
799    }
800
801    #[tokio::test]
802    async fn test_reset_password_invalid_token() {
803        let plugin = PasswordManagementPlugin::new();
804        let (ctx, _user, _session) = create_test_context_with_user().await;
805
806        let body = serde_json::json!({
807            "newPassword": "NewPassword123!",
808            "token": "invalid_token"
809        });
810
811        let req = create_auth_request(
812            HttpMethod::Post,
813            "/reset-password",
814            None,
815            Some(body.to_string().into_bytes()),
816        );
817
818        let err = plugin.handle_reset_password(&req, &ctx).await.unwrap_err();
819        assert_eq!(err.status_code(), 400);
820    }
821
822    #[tokio::test]
823    async fn test_reset_password_weak_password() {
824        let plugin = PasswordManagementPlugin::new();
825        let (ctx, user, _session) = create_test_context_with_user().await;
826
827        // Create verification token
828        let reset_token = format!("reset_{}", uuid::Uuid::new_v4());
829        let create_verification = CreateVerification {
830            identifier: user.email.clone().unwrap(),
831            value: reset_token.clone(),
832            expires_at: Utc::now() + Duration::hours(24),
833        };
834        ctx.database
835            .create_verification(create_verification)
836            .await
837            .unwrap();
838
839        let body = serde_json::json!({
840            "newPassword": "weak",
841            "token": reset_token
842        });
843
844        let req = create_auth_request(
845            HttpMethod::Post,
846            "/reset-password",
847            None,
848            Some(body.to_string().into_bytes()),
849        );
850
851        let err = plugin.handle_reset_password(&req, &ctx).await.unwrap_err();
852        assert_eq!(err.status_code(), 400);
853    }
854
855    #[tokio::test]
856    async fn test_change_password_success() {
857        let plugin = PasswordManagementPlugin::new();
858        let (ctx, _user, session) = create_test_context_with_user().await;
859
860        let body = serde_json::json!({
861            "currentPassword": "Password123!",
862            "newPassword": "NewPassword123!",
863            "revokeOtherSessions": "false"
864        });
865
866        let req = create_auth_request(
867            HttpMethod::Post,
868            "/change-password",
869            Some(&session.token),
870            Some(body.to_string().into_bytes()),
871        );
872
873        let response = plugin.handle_change_password(&req, &ctx).await.unwrap();
874        assert_eq!(response.status, 200);
875
876        let body_str = String::from_utf8(response.body).unwrap();
877        let response_data: serde_json::Value = serde_json::from_str(&body_str).unwrap();
878        assert!(response_data["token"].is_null()); // No new token when not revoking sessions
879
880        // Verify password was updated by checking the database directly
881        let user_id = response_data["user"]["id"].as_str().unwrap();
882        let updated_user = ctx.database.get_user_by_id(user_id).await.unwrap().unwrap();
883        let stored_hash = updated_user
884            .metadata
885            .get("password_hash")
886            .unwrap()
887            .as_str()
888            .unwrap();
889        assert!(
890            plugin
891                .verify_password("NewPassword123!", stored_hash)
892                .is_ok()
893        );
894    }
895
896    #[tokio::test]
897    async fn test_change_password_with_session_revocation() {
898        let plugin = PasswordManagementPlugin::new();
899        let (ctx, _user, session) = create_test_context_with_user().await;
900
901        let body = serde_json::json!({
902            "currentPassword": "Password123!",
903            "newPassword": "NewPassword123!",
904            "revokeOtherSessions": "true"
905        });
906
907        let req = create_auth_request(
908            HttpMethod::Post,
909            "/change-password",
910            Some(&session.token),
911            Some(body.to_string().into_bytes()),
912        );
913
914        let response = plugin.handle_change_password(&req, &ctx).await.unwrap();
915        assert_eq!(response.status, 200);
916
917        let body_str = String::from_utf8(response.body).unwrap();
918        let response_data: serde_json::Value = serde_json::from_str(&body_str).unwrap();
919        assert!(response_data["token"].is_string()); // New token when revoking sessions
920    }
921
922    #[tokio::test]
923    async fn test_change_password_wrong_current_password() {
924        let plugin = PasswordManagementPlugin::new();
925        let (ctx, _user, session) = create_test_context_with_user().await;
926
927        let body = serde_json::json!({
928            "currentPassword": "WrongPassword123!",
929            "newPassword": "NewPassword123!"
930        });
931
932        let req = create_auth_request(
933            HttpMethod::Post,
934            "/change-password",
935            Some(&session.token),
936            Some(body.to_string().into_bytes()),
937        );
938
939        let err = plugin.handle_change_password(&req, &ctx).await.unwrap_err();
940        assert_eq!(err.status_code(), 401);
941    }
942
943    #[tokio::test]
944    async fn test_change_password_unauthorized() {
945        let plugin = PasswordManagementPlugin::new();
946        let (ctx, _user, _session) = create_test_context_with_user().await;
947
948        let body = serde_json::json!({
949            "currentPassword": "Password123!",
950            "newPassword": "NewPassword123!"
951        });
952
953        let req = create_auth_request(
954            HttpMethod::Post,
955            "/change-password",
956            None,
957            Some(body.to_string().into_bytes()),
958        );
959
960        let err = plugin.handle_change_password(&req, &ctx).await.unwrap_err();
961        assert_eq!(err.status_code(), 401);
962    }
963
964    #[tokio::test]
965    async fn test_reset_password_token_endpoint_success() {
966        let plugin = PasswordManagementPlugin::new();
967        let (ctx, user, _session) = create_test_context_with_user().await;
968
969        // Create verification token
970        let reset_token = format!("reset_{}", uuid::Uuid::new_v4());
971        let create_verification = CreateVerification {
972            identifier: user.email.clone().unwrap(),
973            value: reset_token.clone(),
974            expires_at: Utc::now() + Duration::hours(24),
975        };
976        ctx.database
977            .create_verification(create_verification)
978            .await
979            .unwrap();
980
981        let req = create_auth_request(HttpMethod::Get, "/reset-password/token", None, None);
982
983        let response = plugin
984            .handle_reset_password_token(&reset_token, &req, &ctx)
985            .await
986            .unwrap();
987        assert_eq!(response.status, 200);
988
989        let body_str = String::from_utf8(response.body).unwrap();
990        let response_data: ResetPasswordTokenResponse = serde_json::from_str(&body_str).unwrap();
991        assert_eq!(response_data.token, reset_token);
992    }
993
994    #[tokio::test]
995    async fn test_reset_password_token_endpoint_with_callback() {
996        let plugin = PasswordManagementPlugin::new();
997        let (ctx, user, _session) = create_test_context_with_user().await;
998
999        // Create verification token
1000        let reset_token = format!("reset_{}", uuid::Uuid::new_v4());
1001        let create_verification = CreateVerification {
1002            identifier: user.email.clone().unwrap(),
1003            value: reset_token.clone(),
1004            expires_at: Utc::now() + Duration::hours(24),
1005        };
1006        ctx.database
1007            .create_verification(create_verification)
1008            .await
1009            .unwrap();
1010
1011        let mut query = HashMap::new();
1012        query.insert(
1013            "callbackURL".to_string(),
1014            "http://localhost:3000/reset".to_string(),
1015        );
1016
1017        let req = AuthRequest {
1018            method: HttpMethod::Get,
1019            path: "/reset-password/token".to_string(),
1020            headers: HashMap::new(),
1021            body: None,
1022            query,
1023        };
1024
1025        let response = plugin
1026            .handle_reset_password_token(&reset_token, &req, &ctx)
1027            .await
1028            .unwrap();
1029        assert_eq!(response.status, 302);
1030
1031        // Check redirect URL
1032        let location_header = response
1033            .headers
1034            .iter()
1035            .find(|(key, _)| *key == "Location")
1036            .map(|(_, value)| value);
1037        assert!(location_header.is_some());
1038        assert!(
1039            location_header
1040                .unwrap()
1041                .contains("http://localhost:3000/reset")
1042        );
1043        assert!(location_header.unwrap().contains(&reset_token));
1044    }
1045
1046    #[tokio::test]
1047    async fn test_reset_password_token_endpoint_invalid_token() {
1048        let plugin = PasswordManagementPlugin::new();
1049        let (ctx, _user, _session) = create_test_context_with_user().await;
1050
1051        let req = create_auth_request(HttpMethod::Get, "/reset-password/token", None, None);
1052
1053        let err = plugin
1054            .handle_reset_password_token("invalid_token", &req, &ctx)
1055            .await
1056            .unwrap_err();
1057        assert_eq!(err.status_code(), 400);
1058    }
1059
1060    #[tokio::test]
1061    async fn test_password_validation() {
1062        let plugin = PasswordManagementPlugin::new();
1063        let mut config = AuthConfig::new("test-secret");
1064        config.password = PasswordConfig {
1065            min_length: 8,
1066            require_uppercase: true,
1067            require_lowercase: true,
1068            require_numbers: true,
1069            require_special: true,
1070            argon2_config: Argon2Config::default(),
1071        };
1072        let ctx = AuthContext::new(Arc::new(config), Arc::new(MemoryDatabaseAdapter::new()));
1073
1074        // Test valid password
1075        assert!(plugin.validate_password("Password123!", &ctx).is_ok());
1076
1077        // Test too short
1078        assert!(plugin.validate_password("Pass1!", &ctx).is_err());
1079
1080        // Test missing uppercase
1081        assert!(plugin.validate_password("password123!", &ctx).is_err());
1082
1083        // Test missing lowercase
1084        assert!(plugin.validate_password("PASSWORD123!", &ctx).is_err());
1085
1086        // Test missing number
1087        assert!(plugin.validate_password("Password!", &ctx).is_err());
1088
1089        // Test missing special character
1090        assert!(plugin.validate_password("Password123", &ctx).is_err());
1091    }
1092
1093    #[tokio::test]
1094    async fn test_password_hashing_and_verification() {
1095        let plugin = PasswordManagementPlugin::new();
1096
1097        let password = "TestPassword123!";
1098        let hash = plugin.hash_password(password).unwrap();
1099
1100        // Should verify correctly
1101        assert!(plugin.verify_password(password, &hash).is_ok());
1102
1103        // Should fail with wrong password
1104        assert!(plugin.verify_password("WrongPassword123!", &hash).is_err());
1105    }
1106
1107    #[tokio::test]
1108    async fn test_plugin_routes() {
1109        let plugin = PasswordManagementPlugin::new();
1110        let routes = AuthPlugin::<MemoryDatabaseAdapter>::routes(&plugin);
1111
1112        assert_eq!(routes.len(), 5);
1113        assert!(
1114            routes
1115                .iter()
1116                .any(|r| r.path == "/forget-password" && r.method == HttpMethod::Post)
1117        );
1118        assert!(
1119            routes
1120                .iter()
1121                .any(|r| r.path == "/reset-password" && r.method == HttpMethod::Post)
1122        );
1123        assert!(
1124            routes
1125                .iter()
1126                .any(|r| r.path == "/reset-password/{token}" && r.method == HttpMethod::Get)
1127        );
1128        assert!(
1129            routes
1130                .iter()
1131                .any(|r| r.path == "/change-password" && r.method == HttpMethod::Post)
1132        );
1133    }
1134
1135    #[tokio::test]
1136    async fn test_plugin_on_request_routing() {
1137        let plugin = PasswordManagementPlugin::new();
1138        let (ctx, _user, session) = create_test_context_with_user().await;
1139
1140        // Test forget password
1141        let body = serde_json::json!({"email": "test@example.com"});
1142        let req = create_auth_request(
1143            HttpMethod::Post,
1144            "/forget-password",
1145            None,
1146            Some(body.to_string().into_bytes()),
1147        );
1148        let response = plugin.on_request(&req, &ctx).await.unwrap();
1149        assert!(response.is_some());
1150        assert_eq!(response.unwrap().status, 200);
1151
1152        // Test change password
1153        let body = serde_json::json!({
1154            "currentPassword": "Password123!",
1155            "newPassword": "NewPassword123!"
1156        });
1157        let req = create_auth_request(
1158            HttpMethod::Post,
1159            "/change-password",
1160            Some(&session.token),
1161            Some(body.to_string().into_bytes()),
1162        );
1163        let response = plugin.on_request(&req, &ctx).await.unwrap();
1164        assert!(response.is_some());
1165        assert_eq!(response.unwrap().status, 200);
1166
1167        // Test invalid route
1168        let req = create_auth_request(HttpMethod::Get, "/invalid-route", None, None);
1169        let response = plugin.on_request(&req, &ctx).await.unwrap();
1170        assert!(response.is_none());
1171    }
1172
1173    #[tokio::test]
1174    async fn test_configuration() {
1175        let config = PasswordManagementConfig {
1176            reset_token_expiry_hours: 48,
1177            require_current_password: false,
1178            send_email_notifications: false,
1179        };
1180
1181        let plugin = PasswordManagementPlugin::with_config(config);
1182        assert_eq!(plugin.config.reset_token_expiry_hours, 48);
1183        assert!(!plugin.config.require_current_password);
1184        assert!(!plugin.config.send_email_notifications);
1185    }
1186}