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