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