Skip to main content

alibi_plugins/password_management/
mod.rs

1pub(super) mod handlers;
2mod set_password;
3pub(super) mod types;
4
5use super::StatusResponse;
6use alibi_core::utils::password::PasswordHasher;
7use alibi_core::{
8    AuthContext, AuthError, AuthPlugin, AuthRequest, AuthResponse, AuthResult, AuthRoute,
9    HttpMethod, RequestMeta,
10};
11use async_trait::async_trait;
12use handlers::{
13    change_password_core, request_password_reset_core, reset_password_core,
14    reset_password_token_core, verify_password_core,
15};
16pub use set_password::set_password;
17use std::future::Future;
18use std::pin::Pin;
19use std::sync::Arc;
20use types::{
21    ChangePasswordRequest, RequestPasswordResetRequest, ResetPasswordRequest,
22    ResetPasswordTokenQuery, VerifyPasswordRequest,
23};
24
25const RESET_TOKEN_ROUTE_PREFIX: &str = "/reset-password/";
26
27/// Async callback invoked after a password reset, with the user as JSON.
28pub type OnPasswordResetCallback =
29    dyn Fn(serde_json::Value) -> Pin<Box<dyn Future<Output = AuthResult<()>> + Send>> + Send + Sync;
30
31/// Trait for sending password reset emails.
32///
33/// This callback powers `POST /request-password-reset` and is required to
34/// enable that route. The user is provided as a serialized `serde_json::Value`
35/// since `AuthUser` is not object-safe.
36#[async_trait]
37pub trait SendResetPassword: Send + Sync {
38    /// Send a password reset notification.
39    ///
40    /// * `user` - The initialized user schema as a serialized JSON value
41    /// * `url` - The full reset URL including the token
42    /// * `token` - The raw reset token
43    async fn send(&self, user: &serde_json::Value, url: &str, token: &str) -> AuthResult<()>;
44}
45
46/// Password management plugin for password reset and change functionality
47pub struct PasswordManagementPlugin {
48    config: PasswordManagementConfig,
49}
50
51impl std::fmt::Debug for PasswordManagementPlugin {
52    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53        f.debug_struct("PasswordManagementPlugin")
54            .finish_non_exhaustive()
55    }
56}
57
58#[derive(Clone, alibi_core::PluginConfig)]
59#[plugin(name = "PasswordManagementPlugin")]
60pub struct PasswordManagementConfig {
61    #[config(default = 1)]
62    pub reset_token_expiry_hours: i64,
63    /// Overrides the legacy hour setting with a precise reset-token duration.
64    /// Zero retains the default expiry, matching the upstream option.
65    #[config(default = None)]
66    pub reset_token_expiry: Option<chrono::Duration>,
67    #[config(default = true)]
68    pub require_current_password: bool,
69    #[config(default = true)]
70    pub send_email_notifications: bool,
71    /// When true, all existing sessions are revoked on password reset (default: false).
72    #[config(default = false)]
73    pub revoke_sessions_on_password_reset: bool,
74    /// Password reset email sender for `POST /request-password-reset`.
75    /// This route is disabled when no sender is configured.
76    #[config(default = None)]
77    pub send_reset_password: Option<Arc<dyn SendResetPassword>>,
78    /// Callback invoked after a password is successfully reset.
79    /// The initialized user schema is provided as a serialized JSON value.
80    /// Errors propagate after the credential write and before session revocation.
81    #[config(default = None)]
82    pub on_password_reset: Option<Arc<OnPasswordResetCallback>>,
83    /// Custom password hasher. When `None`, the default scrypt hasher is used.
84    /// Resetting a password inherits an initialized email/password hasher first.
85    #[config(default = None)]
86    pub password_hasher: Option<Arc<dyn PasswordHasher>>,
87}
88
89impl std::fmt::Debug for PasswordManagementConfig {
90    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91        f.debug_struct("PasswordManagementConfig")
92            .field("reset_token_expiry_hours", &self.reset_token_expiry_hours)
93            .field("reset_token_expiry", &self.reset_token_expiry)
94            .field("require_current_password", &self.require_current_password)
95            .field("send_email_notifications", &self.send_email_notifications)
96            .field(
97                "revoke_sessions_on_password_reset",
98                &self.revoke_sessions_on_password_reset,
99            )
100            .field(
101                "send_reset_password",
102                &self.send_reset_password.as_ref().map(|_| "custom"),
103            )
104            .field(
105                "on_password_reset",
106                &self.on_password_reset.as_ref().map(|_| "custom"),
107            )
108            .field(
109                "password_hasher",
110                &self.password_hasher.as_ref().map(|_| "custom"),
111            )
112            .finish()
113    }
114}
115
116#[async_trait]
117impl<S: alibi_core::AuthSchema> AuthPlugin<S> for PasswordManagementPlugin {
118    route_openapi_metadata!(S);
119
120    fn name(&self) -> &'static str {
121        "password-management"
122    }
123
124    async fn on_init(&self, ctx: &mut alibi_core::AuthInitContext<S>) -> AuthResult<()> {
125        ctx.extensions.insert(self.config.clone());
126        Ok(())
127    }
128
129    fn routes(&self) -> Vec<AuthRoute> {
130        vec![
131            AuthRoute::post("/request-password-reset", "request_password_reset"),
132            AuthRoute::post("/reset-password", "reset_password"),
133            AuthRoute::get("/reset-password/{token}", "reset_password_token"),
134            AuthRoute::post("/change-password", "change_password"),
135            AuthRoute::post("/verify-password", "verify_password"),
136        ]
137    }
138
139    async fn on_request(
140        &self,
141        req: &AuthRequest,
142        ctx: &AuthContext<S>,
143    ) -> AuthResult<Option<AuthResponse>> {
144        let response = match (req.method(), req.path()) {
145            (HttpMethod::Post, "/request-password-reset") => {
146                self.handle_request_password_reset(req, ctx).await?
147            }
148            (HttpMethod::Post, "/reset-password") => self.handle_reset_password(req, ctx).await?,
149            (HttpMethod::Post, "/change-password") => self.handle_change_password(req, ctx).await?,
150            (HttpMethod::Post, "/verify-password") => self.handle_verify_password(req, ctx).await?,
151            (HttpMethod::Get, path) if path.starts_with(RESET_TOKEN_ROUTE_PREFIX) => {
152                let token = path
153                    .get(RESET_TOKEN_ROUTE_PREFIX.len()..)
154                    .unwrap_or_default();
155                self.handle_reset_password_token(token, req, ctx).await?
156            }
157            _ => return Ok(None),
158        };
159        Ok(Some(response))
160    }
161}
162
163impl PasswordManagementPlugin {
164    async fn handle_request_password_reset(
165        &self,
166        req: &AuthRequest,
167        ctx: &AuthContext<impl alibi_core::AuthSchema>,
168    ) -> AuthResult<AuthResponse> {
169        let body: RequestPasswordResetRequest = match alibi_core::validate_request_body(req) {
170            Ok(v) => v,
171            Err(resp) => return Ok(resp),
172        };
173        let response = request_password_reset_core(&body, &self.config, ctx).await?;
174        Ok(AuthResponse::json(200, &response)?)
175    }
176
177    async fn handle_reset_password(
178        &self,
179        req: &AuthRequest,
180        ctx: &AuthContext<impl alibi_core::AuthSchema>,
181    ) -> AuthResult<AuthResponse> {
182        let mut body: ResetPasswordRequest = match crate::authentication_helpers::parse_body(req) {
183            Ok(v) => v,
184            Err(resp) => return Ok(resp),
185        };
186        if body.token.as_ref().is_none_or(String::is_empty) {
187            body.token = req.query.get("token").cloned();
188        }
189        let response = reset_password_core(&body, &self.config, ctx).await?;
190        Ok(AuthResponse::json(200, &response)?)
191    }
192
193    async fn handle_change_password(
194        &self,
195        req: &AuthRequest,
196        ctx: &AuthContext<impl alibi_core::AuthSchema>,
197    ) -> AuthResult<AuthResponse> {
198        let body: ChangePasswordRequest = match crate::authentication_helpers::parse_body(req) {
199            Ok(v) => v,
200            Err(resp) => return Ok(resp),
201        };
202
203        let (user, _session) = ctx
204            .require_authoritative_session_record(req)
205            .await
206            .map_err(crate::helpers::unauthorized_if_session_missing)?;
207        let meta = RequestMeta::from_request(req);
208
209        let (response, new_token) =
210            change_password_core(&body, &user, &self.config, &meta, ctx).await?;
211
212        let auth_response = AuthResponse::json(200, &response)?;
213        let Some(token) = new_token else {
214            return Ok(auth_response);
215        };
216        let dont_remember =
217            crate::authentication_helpers::dont_remember_preference(req, &ctx.config);
218        crate::authentication_helpers::with_session_cookies(
219            auth_response,
220            &ctx.config,
221            &token,
222            dont_remember,
223        )
224    }
225
226    async fn handle_verify_password(
227        &self,
228        req: &AuthRequest,
229        ctx: &AuthContext<impl alibi_core::AuthSchema>,
230    ) -> AuthResult<AuthResponse> {
231        let body: VerifyPasswordRequest = match alibi_core::validate_request_body(req) {
232            Ok(v) => v,
233            Err(resp) => return Ok(resp),
234        };
235
236        let Some(user) = Self::get_current_user(req, ctx).await? else {
237            // better-call's default body for a session-gated endpoint hit
238            // without a session, which upstream returns verbatim.
239            return Ok(
240                AuthError::AuthenticationFailed("Unauthorized".to_owned()).to_auth_response()
241            );
242        };
243        let response = verify_password_core(&body, &user, &self.config, ctx).await?;
244        Ok(AuthResponse::json(200, &response)?)
245    }
246
247    async fn handle_reset_password_token(
248        &self,
249        token: &str,
250        req: &AuthRequest,
251        ctx: &AuthContext<impl alibi_core::AuthSchema>,
252    ) -> AuthResult<AuthResponse> {
253        let query = ResetPasswordTokenQuery {
254            callback_url: req.query.get("callbackURL").cloned(),
255        };
256        let url = reset_password_token_core(token, &query, ctx).await?;
257        let mut headers = alibi_core::Headers::new();
258        _ = headers.insert("Location", url);
259        _ = headers.insert("content-type", "application/json");
260        Ok(AuthResponse {
261            status: 302,
262            headers,
263            body: Vec::new(),
264        })
265    }
266
267    async fn get_current_user<S: alibi_core::AuthSchema>(
268        req: &AuthRequest,
269        ctx: &AuthContext<S>,
270    ) -> AuthResult<Option<S::User>> {
271        let session_manager = ctx.session_manager();
272
273        if let Some(token) = session_manager.extract_session_token(req)
274            && let Some(session) = session_manager.get_session(&token).await?
275        {
276            return ctx.session_user(&session).await;
277        }
278
279        Ok(None)
280    }
281}
282
283// LCOV_EXCL_START
284#[cfg(test)]
285mod tests {
286    use super::types::RequestPasswordResetResponse;
287    use super::*;
288    use crate::test_helpers;
289    use alibi_core::AuthContext;
290    use alibi_core::config::{AuthConfig, PasswordConfig};
291    use alibi_core::utils::password::{hash_password, verify_password};
292    use alibi_core::wire::{SessionView, UserView};
293    use alibi_core::{CreateAccount, CreateUser, CreateVerification};
294    use chrono::{Duration, Utc};
295    use std::collections::HashMap;
296    use std::sync::Arc;
297
298    type TestSchema = alibi_seaorm::store::__private_test_support::bundled_schema::BundledSchema;
299
300    const PASSWORD_RESET_SUCCESS_MESSAGE: &str =
301        "If this email exists in our system, check your email for the reset link";
302
303    struct NoopResetSender;
304
305    #[async_trait::async_trait]
306    impl SendResetPassword for NoopResetSender {
307        async fn send(
308            &self,
309            _user: &serde_json::Value,
310            _url: &str,
311            _token: &str,
312        ) -> AuthResult<()> {
313            Ok(())
314        }
315    }
316
317    fn plugin_with_reset_sender() -> PasswordManagementPlugin {
318        PasswordManagementPlugin::new().send_reset_password(Arc::new(NoopResetSender))
319    }
320
321    async fn create_test_context_with_user() -> (AuthContext<TestSchema>, UserView, SessionView) {
322        let mut config = AuthConfig::new("test-secret-key-at-least-32-chars-long");
323        config.password = PasswordConfig {
324            require_uppercase: true,
325            require_lowercase: true,
326            require_numbers: true,
327            require_special: true,
328        };
329
330        let ctx = test_helpers::create_test_context_with_config(config).await;
331
332        // Create test user with hashed password
333        let password_hash = hash_password(None, "Password123!").await.unwrap();
334
335        let create_user = CreateUser::new()
336            .with_email("test@example.com")
337            .with_name("Test User");
338        let user = test_helpers::create_user(&ctx, create_user).await;
339        _ = ctx
340            .database
341            .create_account(CreateAccount {
342                additional_fields: alibi_core::field_policy::FieldValues::default(),
343                user_id: user.id.clone(),
344                account_id: user.id.clone(),
345                provider_id: "credential".to_owned(),
346                access_token: None,
347                refresh_token: None,
348                id_token: None,
349                access_token_expires_at: None,
350                refresh_token_expires_at: None,
351                scope: None,
352                password: Some(password_hash),
353            })
354            .await
355            .unwrap();
356        let session =
357            test_helpers::create_session(&ctx, user.id.clone(), Duration::hours(24)).await;
358
359        (ctx, user, session)
360    }
361
362    async fn create_test_context_with_oauth_only_user()
363    -> (AuthContext<TestSchema>, UserView, SessionView) {
364        let (ctx, user, session) = create_test_context_with_user().await;
365
366        let existing_accounts = ctx.database.get_user_accounts(&user.id).await.unwrap();
367        for account in existing_accounts {
368            ctx.database.delete_account(&account.id).await.unwrap();
369        }
370
371        _ = ctx
372            .database
373            .create_account(CreateAccount {
374                additional_fields: alibi_core::field_policy::FieldValues::default(),
375                user_id: user.id.clone(),
376                account_id: "google-account-id".to_owned(),
377                provider_id: "google".to_owned(),
378                access_token: Some("oauth-access-token".to_owned()),
379                refresh_token: Some("oauth-refresh-token".to_owned()),
380                id_token: None,
381                access_token_expires_at: None,
382                refresh_token_expires_at: None,
383                scope: Some("email profile".to_owned()),
384                password: None,
385            })
386            .await
387            .unwrap();
388
389        (ctx, user, session)
390    }
391
392    /// Helper: create a reset-password verification token for the given user
393    /// and store it in the database. Returns the token string.
394    async fn create_reset_token(
395        ctx: &AuthContext<impl alibi_core::AuthSchema>,
396        user_id: &str,
397    ) -> String {
398        let reset_token = uuid::Uuid::new_v4().simple().to_string();
399        let create_verification = CreateVerification {
400            identifier: format!("reset-password:{reset_token}"),
401            value: user_id.to_owned(),
402            expires_at: Utc::now() + Duration::hours(24),
403        };
404        ctx.database
405            .create_verification(create_verification)
406            .await
407            .unwrap();
408        reset_token
409    }
410
411    // Upstream reference: packages/better-auth/src/api/routes/password.test.ts :: describe("forget password") and packages/better-auth/src/api/routes/password.ts; adapted to the Rust password-management plugin.
412    #[tokio::test]
413    async fn test_request_password_reset_success() {
414        let plugin = plugin_with_reset_sender();
415        let (ctx, _user, _session) = create_test_context_with_user().await;
416
417        let body = serde_json::json!({
418            "email": "test@example.com",
419            "redirectTo": "http://localhost:3000/reset"
420        });
421
422        let req = test_helpers::create_auth_request_no_query(
423            HttpMethod::Post,
424            "/request-password-reset",
425            None,
426            Some(body.to_string().into_bytes()),
427        );
428
429        let response = plugin
430            .handle_request_password_reset(&req, &ctx)
431            .await
432            .unwrap();
433        assert_eq!(response.status, 200);
434
435        let body_str = String::from_utf8(response.body).unwrap();
436        let response_data: RequestPasswordResetResponse = serde_json::from_str(&body_str).unwrap();
437        assert!(response_data.status);
438        assert_eq!(response_data.message, PASSWORD_RESET_SUCCESS_MESSAGE);
439    }
440
441    // Upstream reference: packages/better-auth/src/api/routes/password.test.ts :: describe("forget password") and packages/better-auth/src/api/routes/password.ts; adapted to the Rust password-management plugin.
442    #[tokio::test]
443    async fn test_request_password_reset_unknown_email() {
444        let plugin = plugin_with_reset_sender();
445        let (ctx, _user, _session) = create_test_context_with_user().await;
446
447        let body = serde_json::json!({
448            "email": "unknown@example.com"
449        });
450
451        let req = test_helpers::create_auth_request_no_query(
452            HttpMethod::Post,
453            "/request-password-reset",
454            None,
455            Some(body.to_string().into_bytes()),
456        );
457
458        let response = plugin
459            .handle_request_password_reset(&req, &ctx)
460            .await
461            .unwrap();
462        assert_eq!(response.status, 200);
463
464        let body_str = String::from_utf8(response.body).unwrap();
465        let response_data: RequestPasswordResetResponse = serde_json::from_str(&body_str).unwrap();
466        assert!(response_data.status);
467        assert_eq!(response_data.message, PASSWORD_RESET_SUCCESS_MESSAGE);
468    }
469
470    // Upstream reference: packages/better-auth/src/api/routes/password.test.ts :: describe("forget password") and packages/better-auth/src/api/routes/password.ts; adapted to the Rust password-management plugin.
471    #[tokio::test]
472    async fn test_reset_password_success() {
473        let plugin = PasswordManagementPlugin::new();
474        let (ctx, user, _session) = create_test_context_with_user().await;
475
476        let reset_token = create_reset_token(&ctx, &user.id).await;
477
478        let body = serde_json::json!({
479            "newPassword": "NewPassword123!",
480            "token": reset_token
481        });
482
483        let req = test_helpers::create_auth_request_no_query(
484            HttpMethod::Post,
485            "/reset-password",
486            None,
487            Some(body.to_string().into_bytes()),
488        );
489
490        let response = plugin.handle_reset_password(&req, &ctx).await.unwrap();
491        assert_eq!(response.status, 200);
492
493        let body_str = String::from_utf8(response.body).unwrap();
494        let response_data: StatusResponse = serde_json::from_str(&body_str).unwrap();
495        assert!(response_data.status);
496
497        // Verify password was updated
498        let accounts = ctx.database.get_user_accounts(&user.id).await.unwrap();
499        let stored_hash = accounts
500            .iter()
501            .find(|account| account.provider_id == "credential")
502            .and_then(|account| account.password.as_deref())
503            .unwrap();
504        assert!(
505            verify_password(None, "NewPassword123!", stored_hash)
506                .await
507                .is_ok()
508        );
509
510        let verification_check = ctx
511            .database
512            .get_verification_by_identifier(&format!("reset-password:{reset_token}"))
513            .await
514            .unwrap();
515        assert!(verification_check.is_none());
516    }
517
518    // Upstream reference: packages/better-auth/src/api/routes/password.test.ts :: describe("forget password") and packages/better-auth/src/api/routes/password.ts; adapted to the Rust password-management plugin.
519    #[tokio::test]
520    async fn test_reset_password_invalid_token() {
521        let plugin = PasswordManagementPlugin::new();
522        let (ctx, _user, _session) = create_test_context_with_user().await;
523
524        let body = serde_json::json!({
525            "newPassword": "NewPassword123!",
526            "token": "invalid_token"
527        });
528
529        let req = test_helpers::create_auth_request_no_query(
530            HttpMethod::Post,
531            "/reset-password",
532            None,
533            Some(body.to_string().into_bytes()),
534        );
535
536        let err = plugin.handle_reset_password(&req, &ctx).await.unwrap_err();
537        assert_eq!(err.status_code(), 400);
538    }
539
540    // Upstream reference: packages/better-auth/src/api/routes/password.test.ts :: describe("forget password") and packages/better-auth/src/api/routes/password.ts; adapted to the Rust password-management plugin.
541    #[tokio::test]
542    async fn test_reset_password_weak_password() {
543        let plugin = PasswordManagementPlugin::new();
544        let (ctx, user, _session) = create_test_context_with_user().await;
545
546        let reset_token = create_reset_token(&ctx, &user.id).await;
547
548        let body = serde_json::json!({
549            "newPassword": "weak",
550            "token": reset_token
551        });
552
553        let req = test_helpers::create_auth_request_no_query(
554            HttpMethod::Post,
555            "/reset-password",
556            None,
557            Some(body.to_string().into_bytes()),
558        );
559
560        let err = plugin.handle_reset_password(&req, &ctx).await.unwrap_err();
561        assert_eq!(err.status_code(), 400);
562    }
563
564    // Upstream reference: packages/better-auth/src/api/routes/password.test.ts :: describe("forget password") and packages/better-auth/src/api/routes/password.ts; adapted to the Rust password-management plugin.
565    #[tokio::test]
566    async fn test_change_password_success() {
567        let plugin = PasswordManagementPlugin::new();
568        let (ctx, _user, session) = create_test_context_with_user().await;
569
570        let body = serde_json::json!({
571            "currentPassword": "Password123!",
572            "newPassword": "NewPassword123!",
573            "revokeOtherSessions": false
574        });
575
576        let req = test_helpers::create_auth_request_no_query(
577            HttpMethod::Post,
578            "/change-password",
579            Some(&session.token),
580            Some(body.to_string().into_bytes()),
581        );
582
583        let response = plugin.handle_change_password(&req, &ctx).await.unwrap();
584        assert_eq!(response.status, 200);
585
586        let body_str = String::from_utf8(response.body).unwrap();
587        let response_data: serde_json::Value = serde_json::from_str(&body_str).unwrap();
588        assert!(
589            (*(response_data)
590                .get("token")
591                .unwrap_or(&serde_json::Value::Null))
592            .is_null()
593        ); // No new token when not revoking sessions
594
595        // Verify password was updated by checking the database directly
596        let user_id = (*(*(response_data)
597            .get("user")
598            .unwrap_or(&serde_json::Value::Null))
599        .get("id")
600        .unwrap_or(&serde_json::Value::Null))
601        .as_str()
602        .unwrap();
603        let accounts = ctx.database.get_user_accounts(user_id).await.unwrap();
604        let stored_hash = accounts
605            .iter()
606            .find(|account| account.provider_id == "credential")
607            .and_then(|account| account.password.as_deref())
608            .unwrap();
609        assert!(
610            verify_password(None, "NewPassword123!", stored_hash)
611                .await
612                .is_ok()
613        );
614    }
615
616    // Upstream reference: packages/better-auth/src/api/routes/password.test.ts :: describe("forget password") and packages/better-auth/src/api/routes/password.ts; adapted to the Rust password-management plugin.
617    #[tokio::test]
618    async fn test_change_password_with_session_revocation() {
619        let plugin = PasswordManagementPlugin::new();
620        let (ctx, _user, session) = create_test_context_with_user().await;
621
622        let body = serde_json::json!({
623            "currentPassword": "Password123!",
624            "newPassword": "NewPassword123!",
625            "revokeOtherSessions": true
626        });
627
628        let req = test_helpers::create_auth_request_no_query(
629            HttpMethod::Post,
630            "/change-password",
631            Some(&session.token),
632            Some(body.to_string().into_bytes()),
633        );
634
635        let response = plugin.handle_change_password(&req, &ctx).await.unwrap();
636        assert_eq!(response.status, 200);
637
638        let body_str = String::from_utf8(response.body).unwrap();
639        let response_data: serde_json::Value = serde_json::from_str(&body_str).unwrap();
640        assert!(
641            (*(response_data)
642                .get("token")
643                .unwrap_or(&serde_json::Value::Null))
644            .is_string()
645        ); // New token when revoking sessions
646    }
647
648    // Upstream reference: packages/better-auth/src/api/routes/password.test.ts :: describe("forget password") and packages/better-auth/src/api/routes/password.ts; adapted to the Rust password-management plugin.
649    #[tokio::test]
650    async fn test_change_password_sets_cookie_on_session_revocation() {
651        let plugin = PasswordManagementPlugin::new();
652        let (ctx, _user, session) = create_test_context_with_user().await;
653
654        let body = serde_json::json!({
655            "currentPassword": "Password123!",
656            "newPassword": "NewPassword123!",
657            "revokeOtherSessions": true
658        });
659
660        let req = test_helpers::create_auth_request_no_query(
661            HttpMethod::Post,
662            "/change-password",
663            Some(&session.token),
664            Some(body.to_string().into_bytes()),
665        );
666
667        let response = plugin.handle_change_password(&req, &ctx).await.unwrap();
668        assert_eq!(response.status, 200);
669
670        // Verify Set-Cookie header is present
671        let set_cookie = response.headers.get("Set-Cookie");
672        assert!(
673            set_cookie.is_some(),
674            "Set-Cookie header must be set when revokeOtherSessions is true"
675        );
676
677        let cookie_value = set_cookie.unwrap();
678        assert!(
679            cookie_value.contains(&ctx.config.session.cookie_name),
680            "Cookie must contain the session cookie name"
681        );
682        assert!(
683            cookie_value.contains("Path=/"),
684            "Cookie must contain Path=/"
685        );
686        assert!(
687            cookie_value.contains("Max-Age=604800"),
688            "Cookie must retain the configured session lifetime"
689        );
690        assert!(
691            !cookie_value.contains("Expires="),
692            "Source does not synthesize Expires from Max-Age"
693        );
694    }
695
696    // Upstream reference: packages/better-auth/src/api/routes/password.test.ts :: describe("forget password") and packages/better-auth/src/api/routes/password.ts; adapted to the Rust password-management plugin.
697    #[tokio::test]
698    async fn test_change_password_no_cookie_without_revocation() {
699        let plugin = PasswordManagementPlugin::new();
700        let (ctx, _user, session) = create_test_context_with_user().await;
701
702        let body = serde_json::json!({
703            "currentPassword": "Password123!",
704            "newPassword": "NewPassword123!"
705        });
706
707        let req = test_helpers::create_auth_request_no_query(
708            HttpMethod::Post,
709            "/change-password",
710            Some(&session.token),
711            Some(body.to_string().into_bytes()),
712        );
713
714        let response = plugin.handle_change_password(&req, &ctx).await.unwrap();
715        assert_eq!(response.status, 200);
716
717        // Verify Set-Cookie header is NOT present when not revoking sessions
718        let set_cookie = response.headers.get("Set-Cookie");
719        assert!(
720            set_cookie.is_none(),
721            "Set-Cookie header must not be set when revokeOtherSessions is not true"
722        );
723    }
724
725    // Upstream reference: packages/better-auth/src/api/routes/password.test.ts :: describe("forget password") and packages/better-auth/src/api/routes/password.ts; adapted to the Rust password-management plugin.
726    #[tokio::test]
727    async fn test_change_password_revoke_with_boolean() {
728        let plugin = PasswordManagementPlugin::new();
729        let (ctx, _user, session) = create_test_context_with_user().await;
730
731        // Send revokeOtherSessions as a boolean (as better-auth TS SDK does)
732        let body = serde_json::json!({
733            "currentPassword": "Password123!",
734            "newPassword": "NewPassword123!",
735            "revokeOtherSessions": true
736        });
737
738        let req = test_helpers::create_auth_request_no_query(
739            HttpMethod::Post,
740            "/change-password",
741            Some(&session.token),
742            Some(body.to_string().into_bytes()),
743        );
744
745        let response = plugin.handle_change_password(&req, &ctx).await.unwrap();
746        assert_eq!(response.status, 200);
747
748        let body_str = String::from_utf8(response.body).unwrap();
749        let response_data: serde_json::Value = serde_json::from_str(&body_str).unwrap();
750        assert!(
751            (*(response_data)
752                .get("token")
753                .unwrap_or(&serde_json::Value::Null))
754            .is_string(),
755            "New token must be returned when revokeOtherSessions is boolean true"
756        );
757    }
758
759    // Upstream reference: packages/better-auth/src/api/routes/password.test.ts :: describe("forget password") and packages/better-auth/src/api/routes/password.ts; adapted to the Rust password-management plugin.
760    #[tokio::test]
761    async fn test_change_password_wrong_current_password() {
762        let plugin = PasswordManagementPlugin::new();
763        let (ctx, _user, session) = create_test_context_with_user().await;
764
765        let body = serde_json::json!({
766            "currentPassword": "WrongPassword123!",
767            "newPassword": "NewPassword123!"
768        });
769
770        let req = test_helpers::create_auth_request_no_query(
771            HttpMethod::Post,
772            "/change-password",
773            Some(&session.token),
774            Some(body.to_string().into_bytes()),
775        );
776
777        let err = plugin.handle_change_password(&req, &ctx).await.unwrap_err();
778        assert_eq!(err.status_code(), 400);
779    }
780
781    // Upstream reference: packages/better-auth/src/api/routes/password.test.ts :: describe("forget password") and packages/better-auth/src/api/routes/password.ts; adapted to the Rust password-management plugin.
782    #[tokio::test]
783    async fn test_change_password_unauthorized() {
784        let plugin = PasswordManagementPlugin::new();
785        let (ctx, _user, _session) = create_test_context_with_user().await;
786
787        let body = serde_json::json!({
788            "currentPassword": "Password123!",
789            "newPassword": "NewPassword123!"
790        });
791
792        let req = test_helpers::create_auth_request_no_query(
793            HttpMethod::Post,
794            "/change-password",
795            None,
796            Some(body.to_string().into_bytes()),
797        );
798
799        let err = plugin.handle_change_password(&req, &ctx).await.unwrap_err();
800        assert_eq!(err.status_code(), 401);
801    }
802
803    // Upstream reference: packages/better-auth/src/api/routes/password.test.ts :: verifyPassword with
804    // a valid session and the correct credential password succeeds.
805    #[tokio::test]
806    async fn test_verify_password_success() {
807        let plugin = PasswordManagementPlugin::new();
808        let (ctx, _user, session) = create_test_context_with_user().await;
809
810        let body = serde_json::json!({
811            "password": "Password123!"
812        });
813
814        let req = test_helpers::create_auth_request_no_query(
815            HttpMethod::Post,
816            "/verify-password",
817            Some(&session.token),
818            Some(body.to_string().into_bytes()),
819        );
820
821        let response = plugin.handle_verify_password(&req, &ctx).await.unwrap();
822        assert_eq!(response.status, 200);
823
824        let body_str = String::from_utf8(response.body).unwrap();
825        let response_data: StatusResponse = serde_json::from_str(&body_str).unwrap();
826        assert!(response_data.status);
827    }
828
829    // Upstream reference: packages/better-auth/src/api/routes/password.test.ts :: verifyPassword with
830    // a bad password returns BAD_REQUEST / Invalid password.
831    #[tokio::test]
832    async fn test_verify_password_invalid_password() {
833        let plugin = PasswordManagementPlugin::new();
834        let (ctx, _user, session) = create_test_context_with_user().await;
835
836        let body = serde_json::json!({
837            "password": "wrong-password"
838        });
839
840        let req = test_helpers::create_auth_request_no_query(
841            HttpMethod::Post,
842            "/verify-password",
843            Some(&session.token),
844            Some(body.to_string().into_bytes()),
845        );
846
847        let err = plugin.handle_verify_password(&req, &ctx).await.unwrap_err();
848        assert_eq!(err.status_code(), 400);
849        assert_eq!(err.to_string(), "Invalid password");
850    }
851
852    // Upstream reference: packages/better-auth/src/api/routes/password.ts :: verifyPassword returns
853    // Invalid password when the signed-in user does not have a credential account.
854    #[tokio::test]
855    async fn test_verify_password_oauth_only_user_returns_invalid_password() {
856        let plugin = PasswordManagementPlugin::new();
857        let (ctx, _user, session) = create_test_context_with_oauth_only_user().await;
858
859        let body = serde_json::json!({
860            "password": "Password123!"
861        });
862
863        let req = test_helpers::create_auth_request_no_query(
864            HttpMethod::Post,
865            "/verify-password",
866            Some(&session.token),
867            Some(body.to_string().into_bytes()),
868        );
869
870        let err = plugin.handle_verify_password(&req, &ctx).await.unwrap_err();
871        assert_eq!(err.status_code(), 400);
872        assert_eq!(err.to_string(), "Invalid password");
873    }
874
875    // Upstream reference: packages/better-auth/src/api/routes/password.test.ts :: verifyPassword
876    // requires an authenticated session.
877    #[tokio::test]
878    async fn test_verify_password_requires_session() {
879        let plugin = PasswordManagementPlugin::new();
880        let (ctx, _user, _session) = create_test_context_with_user().await;
881
882        let body = serde_json::json!({
883            "password": "Password123!"
884        });
885
886        let req = test_helpers::create_auth_request_no_query(
887            HttpMethod::Post,
888            "/verify-password",
889            None,
890            Some(body.to_string().into_bytes()),
891        );
892
893        let response = plugin.handle_verify_password(&req, &ctx).await.unwrap();
894        assert_eq!(response.status, 401);
895        // Upstream returns better-call's default 401 body rather than an empty one.
896        let body_2: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
897        assert_eq!(
898            (*(body_2).get("code").unwrap_or(&serde_json::Value::Null)),
899            "UNAUTHORIZED"
900        );
901        assert_eq!(
902            (*(body_2).get("message").unwrap_or(&serde_json::Value::Null)),
903            "Unauthorized"
904        );
905    }
906
907    // Upstream reference: packages/better-auth/src/api/routes/password.test.ts :: describe("forget password") and packages/better-auth/src/api/routes/password.ts; adapted to the Rust password-management plugin.
908    #[tokio::test]
909    async fn test_reset_password_token_endpoint_redirects_with_callback_token() {
910        let plugin = PasswordManagementPlugin::new();
911        let (ctx, user, _session) = create_test_context_with_user().await;
912
913        let reset_token = create_reset_token(&ctx, &user.id).await;
914
915        let mut query = HashMap::new();
916        query.insert(
917            "callbackURL".to_owned(),
918            "http://localhost:3000/reset".to_owned(),
919        );
920
921        let req = AuthRequest::from_parts(
922            HttpMethod::Get,
923            "/reset-password/token".to_owned(),
924            HashMap::new(),
925            None,
926            query,
927        );
928
929        let response = plugin
930            .handle_reset_password_token(&reset_token, &req, &ctx)
931            .await
932            .unwrap();
933        assert_eq!(response.status, 302);
934        assert!(
935            (*(response.headers)
936                .get("Location")
937                .expect("fixture contains the requested index"))
938            .contains("http://localhost:3000/reset"),
939            "redirect must preserve the callback URL"
940        );
941        assert!(
942            (*(response.headers)
943                .get("Location")
944                .expect("fixture contains the requested index"))
945            .contains(&format!("token={reset_token}")),
946            "redirect must contain the reset token"
947        );
948    }
949
950    // Upstream reference: packages/better-auth/src/api/routes/password.test.ts :: describe("forget password") and packages/better-auth/src/api/routes/password.ts; adapted to the Rust password-management plugin.
951    #[tokio::test]
952    async fn test_reset_password_token_endpoint_with_callback() {
953        let plugin = PasswordManagementPlugin::new();
954        let (ctx, user, _session) = create_test_context_with_user().await;
955
956        let reset_token = create_reset_token(&ctx, &user.id).await;
957
958        let mut query = HashMap::new();
959        query.insert(
960            "callbackURL".to_owned(),
961            "http://localhost:3000/reset".to_owned(),
962        );
963
964        let req = AuthRequest::from_parts(
965            HttpMethod::Get,
966            "/reset-password/token".to_owned(),
967            HashMap::new(),
968            None,
969            query,
970        );
971
972        let response = plugin
973            .handle_reset_password_token(&reset_token, &req, &ctx)
974            .await
975            .unwrap();
976        assert_eq!(response.status, 302);
977
978        // Check redirect URL
979        let location_header = response
980            .headers
981            .iter()
982            .find(|(key, _)| *key == "Location")
983            .map(|(_, value)| value);
984        assert!(location_header.is_some());
985        assert!(
986            location_header
987                .unwrap()
988                .contains("http://localhost:3000/reset")
989        );
990        assert!(location_header.unwrap().contains(&reset_token));
991    }
992
993    // Upstream reference: packages/better-auth/src/api/routes/password.test.ts :: describe("forget password") and packages/better-auth/src/api/routes/password.ts; adapted to the Rust password-management plugin.
994    #[tokio::test]
995    async fn test_reset_password_token_endpoint_invalid_token() {
996        let plugin = PasswordManagementPlugin::new();
997        let (ctx, _user, _session) = create_test_context_with_user().await;
998
999        let mut query = HashMap::new();
1000        query.insert(
1001            "callbackURL".to_owned(),
1002            "http://localhost:3000/reset".to_owned(),
1003        );
1004        let req = AuthRequest::from_parts(
1005            HttpMethod::Get,
1006            "/reset-password/token".to_owned(),
1007            HashMap::new(),
1008            None,
1009            query,
1010        );
1011
1012        let response = plugin
1013            .handle_reset_password_token("invalid_token", &req, &ctx)
1014            .await
1015            .unwrap();
1016        assert_eq!(response.status, 302);
1017        assert!(
1018            (*(response.headers)
1019                .get("Location")
1020                .expect("fixture contains the requested index"))
1021            .contains("error=INVALID_TOKEN")
1022        );
1023    }
1024
1025    // Upstream reference: packages/better-auth/src/api/routes/password.test.ts :: describe("forget password") and packages/better-auth/src/api/routes/password.ts; adapted to the Rust password-management plugin.
1026    #[tokio::test]
1027    async fn test_plugin_routes() {
1028        let plugin = PasswordManagementPlugin::new();
1029        let routes = AuthPlugin::<
1030            alibi_seaorm::store::__private_test_support::bundled_schema::BundledSchema,
1031        >::routes(&plugin);
1032
1033        assert_eq!(routes.len(), 5);
1034        assert!(
1035            routes
1036                .iter()
1037                .any(|r| r.path == "/request-password-reset" && r.method == HttpMethod::Post)
1038        );
1039        assert!(
1040            routes
1041                .iter()
1042                .any(|r| r.path == "/reset-password" && r.method == HttpMethod::Post)
1043        );
1044        assert!(
1045            routes
1046                .iter()
1047                .any(|r| r.path == "/reset-password/{token}" && r.method == HttpMethod::Get)
1048        );
1049        assert!(
1050            routes
1051                .iter()
1052                .any(|r| r.path == "/verify-password" && r.method == HttpMethod::Post)
1053        );
1054        assert!(
1055            routes
1056                .iter()
1057                .any(|r| r.path == "/change-password" && r.method == HttpMethod::Post)
1058        );
1059    }
1060
1061    // Upstream reference: packages/better-auth/src/api/routes/password.test.ts :: describe("forget password") and packages/better-auth/src/api/routes/password.ts; adapted to the Rust password-management plugin.
1062    #[tokio::test]
1063    async fn test_plugin_on_request_routing() {
1064        let plugin = plugin_with_reset_sender();
1065        let (ctx, _user, session) = create_test_context_with_user().await;
1066
1067        let body = serde_json::json!({"email": "test@example.com"});
1068        let req = test_helpers::create_auth_request_no_query(
1069            HttpMethod::Post,
1070            "/request-password-reset",
1071            None,
1072            Some(body.to_string().into_bytes()),
1073        );
1074        let response = plugin.on_request(&req, &ctx).await.unwrap();
1075        assert!(response.is_some());
1076        assert_eq!(response.unwrap().status, 200);
1077
1078        // Test change password
1079        let body_2 = serde_json::json!({
1080            "currentPassword": "Password123!",
1081            "newPassword": "NewPassword123!"
1082        });
1083        let req_2 = test_helpers::create_auth_request_no_query(
1084            HttpMethod::Post,
1085            "/change-password",
1086            Some(&session.token),
1087            Some(body_2.to_string().into_bytes()),
1088        );
1089        let response_2 = plugin.on_request(&req_2, &ctx).await.unwrap();
1090        assert!(response_2.is_some());
1091        assert_eq!(response_2.unwrap().status, 200);
1092
1093        // Test invalid route
1094        let req_3 = test_helpers::create_auth_request_no_query(
1095            HttpMethod::Get,
1096            "/invalid-route",
1097            None,
1098            None,
1099        );
1100        let response_3 = plugin.on_request(&req_3, &ctx).await.unwrap();
1101        assert!(response_3.is_none());
1102    }
1103
1104    // Upstream reference: packages/better-auth/src/api/routes/password.test.ts :: describe("forget password") and packages/better-auth/src/api/routes/password.ts; adapted to the Rust password-management plugin.
1105    #[tokio::test]
1106    async fn test_configuration() {
1107        let config = PasswordManagementConfig {
1108            reset_token_expiry_hours: 48,
1109            require_current_password: false,
1110            send_email_notifications: false,
1111            ..Default::default()
1112        };
1113
1114        let plugin = PasswordManagementPlugin::with_config(config);
1115        assert_eq!(plugin.config.reset_token_expiry_hours, 48);
1116        assert!(!plugin.config.require_current_password);
1117        assert!(!plugin.config.send_email_notifications);
1118    }
1119
1120    // Upstream reference: packages/better-auth/src/api/routes/password.test.ts :: describe("forget password") and packages/better-auth/src/api/routes/password.ts; adapted to the Rust password-management plugin.
1121    #[tokio::test]
1122    async fn test_send_reset_password_custom_sender() {
1123        use alibi_core::{BackgroundTaskCompletion, BackgroundTaskHandler};
1124        use std::sync::{
1125            Mutex,
1126            atomic::{AtomicU32, Ordering},
1127        };
1128
1129        struct Sender {
1130            calls: Arc<AtomicU32>,
1131            fail: bool,
1132            token: Arc<Mutex<Option<String>>>,
1133        }
1134        #[async_trait::async_trait]
1135        impl SendResetPassword for Sender {
1136            async fn send(&self, _: &serde_json::Value, _: &str, token: &str) -> AuthResult<()> {
1137                self.calls.fetch_add(1, Ordering::SeqCst);
1138                *self.token.lock().unwrap() = Some(token.to_owned());
1139                if self.fail {
1140                    Err(AuthError::internal("Email queue unavailable"))
1141                } else {
1142                    Ok(())
1143                }
1144            }
1145        }
1146        #[derive(Default)]
1147        struct Observer(Mutex<Option<BackgroundTaskCompletion>>);
1148        impl BackgroundTaskHandler for Observer {
1149            fn handle(&self, completion: BackgroundTaskCompletion) -> AuthResult<()> {
1150                *self.0.lock().unwrap() = Some(completion);
1151                Ok(())
1152            }
1153        }
1154        for (fail, background) in [(false, false), (true, false), (true, true)] {
1155            let calls = Arc::new(AtomicU32::new(0));
1156            let token = Arc::new(Mutex::new(None));
1157            let plugin = PasswordManagementPlugin::new().send_reset_password(Arc::new(Sender {
1158                calls: Arc::clone(&calls),
1159                fail,
1160                token: Arc::clone(&token),
1161            }));
1162            let (mut ctx, user, _) = create_test_context_with_user().await;
1163            let observer = Arc::new(Observer::default());
1164            if background {
1165                Arc::make_mut(&mut ctx.config).background_tasks = Some(observer.clone());
1166            }
1167            let req = test_helpers::create_auth_json_request_no_query(
1168                HttpMethod::Post,
1169                "/request-password-reset",
1170                None,
1171                Some(serde_json::json!({"email":"test@example.com"})),
1172            );
1173            let result = plugin.handle_request_password_reset(&req, &ctx).await;
1174            if fail && !background {
1175                assert_eq!(result.unwrap_err().status_code(), 500);
1176            } else {
1177                assert_eq!(result.unwrap().status, 200);
1178            }
1179            if background {
1180                let completion = observer.0.lock().unwrap().take().unwrap();
1181                completion.await.unwrap();
1182            }
1183            assert_eq!(calls.load(Ordering::SeqCst), 1);
1184            let token = token.lock().unwrap().take().unwrap();
1185            let proof = ctx
1186                .database
1187                .get_verification_by_identifier(&format!("reset-password:{token}"))
1188                .await
1189                .unwrap()
1190                .unwrap();
1191            assert_eq!(proof.value, user.id);
1192        }
1193    }
1194
1195    // Upstream reference: packages/better-auth/src/api/routes/password.test.ts :: describe("forget password") and packages/better-auth/src/api/routes/password.ts; adapted to the Rust password-management plugin.
1196    #[tokio::test]
1197    async fn test_on_password_reset_callback() {
1198        use std::sync::atomic::{AtomicBool, Ordering};
1199
1200        let callback_called = Arc::new(AtomicBool::new(false));
1201        let called_clone = std::sync::Arc::clone(&callback_called);
1202
1203        let callback: Arc<OnPasswordResetCallback> = Arc::new(move |_user_value| {
1204            let called = std::sync::Arc::clone(&called_clone);
1205            Box::pin(async move {
1206                called.store(true, Ordering::SeqCst);
1207                Ok(())
1208            })
1209        });
1210
1211        let plugin = PasswordManagementPlugin::new().on_password_reset(callback);
1212        let (ctx, user, _session) = create_test_context_with_user().await;
1213
1214        let reset_token = create_reset_token(&ctx, &user.id).await;
1215
1216        let body = serde_json::json!({
1217            "newPassword": "NewPassword123!",
1218            "token": reset_token
1219        });
1220        let req = test_helpers::create_auth_request_no_query(
1221            HttpMethod::Post,
1222            "/reset-password",
1223            None,
1224            Some(body.to_string().into_bytes()),
1225        );
1226
1227        let response = plugin.handle_reset_password(&req, &ctx).await.unwrap();
1228        assert_eq!(response.status, 200);
1229
1230        // The on_password_reset callback should have been called
1231        assert!(
1232            callback_called.load(Ordering::SeqCst),
1233            "on_password_reset callback should be invoked after password reset"
1234        );
1235    }
1236
1237    // Upstream reference: packages/better-auth/src/api/routes/password.test.ts :: describe("forget password") and packages/better-auth/src/api/routes/password.ts; adapted to the Rust password-management plugin.
1238    #[tokio::test]
1239    async fn test_revoke_sessions_on_password_reset_false() {
1240        let plugin = PasswordManagementPlugin::new().revoke_sessions_on_password_reset(false);
1241        let (ctx, user, session) = create_test_context_with_user().await;
1242
1243        let reset_token = create_reset_token(&ctx, &user.id).await;
1244
1245        let body = serde_json::json!({
1246            "newPassword": "NewPassword123!",
1247            "token": reset_token
1248        });
1249        let req = test_helpers::create_auth_request_no_query(
1250            HttpMethod::Post,
1251            "/reset-password",
1252            None,
1253            Some(body.to_string().into_bytes()),
1254        );
1255
1256        let response = plugin.handle_reset_password(&req, &ctx).await.unwrap();
1257        assert_eq!(response.status, 200);
1258
1259        // Session should still exist since revoke_sessions_on_password_reset=false
1260        let sessions = ctx.database.get_user_sessions(&user.id).await.unwrap();
1261        assert!(
1262            !sessions.is_empty(),
1263            "Sessions should remain when revoke_sessions_on_password_reset=false"
1264        );
1265        assert!(
1266            sessions.iter().any(|s| s.token == session.token),
1267            "The original session should still exist"
1268        );
1269    }
1270
1271    // Upstream reference: packages/better-auth/src/api/routes/password.test.ts :: describe("forget password") and packages/better-auth/src/api/routes/password.ts; adapted to the Rust password-management plugin.
1272    #[tokio::test]
1273    async fn test_revoke_sessions_on_password_reset_true() {
1274        let plugin = PasswordManagementPlugin::new().revoke_sessions_on_password_reset(true);
1275        let (ctx, user, _session) = create_test_context_with_user().await;
1276
1277        let reset_token = create_reset_token(&ctx, &user.id).await;
1278
1279        let body = serde_json::json!({
1280            "newPassword": "NewPassword123!",
1281            "token": reset_token
1282        });
1283        let req = test_helpers::create_auth_request_no_query(
1284            HttpMethod::Post,
1285            "/reset-password",
1286            None,
1287            Some(body.to_string().into_bytes()),
1288        );
1289
1290        let response = plugin.handle_reset_password(&req, &ctx).await.unwrap();
1291        assert_eq!(response.status, 200);
1292
1293        // Sessions should be revoked since revoke_sessions_on_password_reset=true (default)
1294        let sessions = ctx.database.get_user_sessions(&user.id).await.unwrap();
1295        assert!(
1296            sessions.is_empty(),
1297            "Sessions should be revoked when revoke_sessions_on_password_reset=true"
1298        );
1299    }
1300}
1301// LCOV_EXCL_STOP