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
30pub type OnPasswordResetCallback =
32 dyn Fn(serde_json::Value) -> Pin<Box<dyn Future<Output = AuthResult<()>> + Send>> + Send + Sync;
33
34#[async_trait]
40pub trait SendResetPassword: Send + Sync {
41 async fn send(&self, user: &serde_json::Value, url: &str, token: &str) -> AuthResult<()>;
47}
48
49pub 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 #[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 #[config(default = false)]
76 pub revoke_sessions_on_password_reset: bool,
77 #[config(default = None)]
80 pub send_reset_password: Option<Arc<dyn SendResetPassword>>,
81 #[config(default = None)]
85 pub on_password_reset: Option<Arc<OnPasswordResetCallback>>,
86 #[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 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 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 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 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 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 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 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 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#[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 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 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 #[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 #[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 #[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 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 #[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 #[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 #[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 ); 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 #[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 ); }
916
917 #[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 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 #[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 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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 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 #[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 #[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 #[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 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 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 #[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 #[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 #[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 assert!(
1501 callback_called.load(Ordering::SeqCst),
1502 "on_password_reset callback should be invoked after password reset"
1503 );
1504 }
1505
1506 #[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 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 #[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 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