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