1pub(super) mod handlers;
2pub(super) mod types;
3mod update;
4
5use alibi_core::wire::UserView;
6use alibi_core::{
7 AuthContext, AuthError, AuthPlugin, AuthRequest, AuthResponse, AuthResult, AuthRoute,
8 HttpMethod,
9};
10use async_trait::async_trait;
11use chrono::Duration;
12use handlers::{change_email_core, delete_user_callback_core, delete_user_core};
13use std::sync::Arc;
14pub use types::{ChangeEmailRequest, DeleteUserCallbackQuery, DeleteUserRequest};
15pub use update::{
16 BuiltinUserEndpoints, error_endpoint, handle_update_user, ok_endpoint, update_user_endpoint,
17};
18
19pub type UserInfo = UserView;
21
22#[async_trait]
28pub trait SendChangeEmailConfirmation: Send + Sync {
29 async fn send(
30 &self,
31 user: &UserInfo,
32 new_email: &str,
33 url: &str,
34 token: &str,
35 ) -> AuthResult<()>;
36}
37
38#[async_trait]
41pub trait SendDeleteAccountVerification: Send + Sync {
42 async fn send(&self, user: &UserInfo, url: &str, token: &str) -> AuthResult<()>;
43}
44
45#[async_trait]
50pub trait BeforeDeleteUser: Send + Sync {
51 async fn before_delete(&self, user: &UserInfo) -> AuthResult<()>;
52}
53
54#[async_trait]
56pub trait AfterDeleteUser: Send + Sync {
57 async fn after_delete(&self, user: &UserInfo) -> AuthResult<()>;
58}
59
60#[derive(Clone, Default)]
62pub struct ChangeEmailConfig {
63 pub enabled: bool,
65 pub update_without_verification: bool,
68 pub send_change_email_confirmation: Option<Arc<dyn SendChangeEmailConfirmation>>,
70}
71
72impl std::fmt::Debug for ChangeEmailConfig {
73 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74 f.debug_struct("ChangeEmailConfig")
75 .field("enabled", &self.enabled)
76 .field(
77 "update_without_verification",
78 &self.update_without_verification,
79 )
80 .field(
81 "send_change_email_confirmation",
82 &self.send_change_email_confirmation.is_some(),
83 )
84 .finish()
85 }
86}
87
88#[derive(Clone)]
90pub struct DeleteUserConfig {
91 pub enabled: bool,
93 pub delete_token_expires_in: Duration,
95 pub require_verification: bool,
98 pub send_delete_account_verification: Option<Arc<dyn SendDeleteAccountVerification>>,
101 pub before_delete: Option<Arc<dyn BeforeDeleteUser>>,
103 pub after_delete: Option<Arc<dyn AfterDeleteUser>>,
105}
106
107impl std::fmt::Debug for DeleteUserConfig {
108 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109 f.debug_struct("DeleteUserConfig")
110 .field("enabled", &self.enabled)
111 .field("delete_token_expires_in", &self.delete_token_expires_in)
112 .field("require_verification", &self.require_verification)
113 .field(
114 "send_delete_account_verification",
115 &self.send_delete_account_verification.is_some(),
116 )
117 .field("before_delete", &self.before_delete.is_some())
118 .field("after_delete", &self.after_delete.is_some())
119 .finish()
120 }
121}
122
123impl Default for DeleteUserConfig {
124 fn default() -> Self {
125 Self {
126 enabled: false,
127 delete_token_expires_in: Duration::hours(24),
128 require_verification: false,
129 send_delete_account_verification: None,
130 before_delete: None,
131 after_delete: None,
132 }
133 }
134}
135
136#[derive(Debug, Clone, Default)]
138pub struct UserManagementConfig {
139 pub change_email: ChangeEmailConfig,
140 pub delete_user: DeleteUserConfig,
141}
142
143#[derive(Default)]
145pub struct UserManagementPlugin {
146 config: UserManagementConfig,
147}
148
149impl std::fmt::Debug for UserManagementPlugin {
150 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
151 f.debug_struct("UserManagementPlugin")
152 .finish_non_exhaustive()
153 }
154}
155
156impl UserManagementPlugin {
157 #[must_use]
158 pub fn new() -> Self {
159 Self::default()
160 }
161
162 #[must_use]
163 pub const fn with_config(config: UserManagementConfig) -> Self {
164 Self { config }
165 }
166
167 #[must_use]
168 pub const fn change_email_enabled(mut self, enabled: bool) -> Self {
169 self.config.change_email.enabled = enabled;
170 self
171 }
172
173 #[must_use]
174 pub const fn update_without_verification(mut self, flag: bool) -> Self {
175 self.config.change_email.update_without_verification = flag;
176 self
177 }
178
179 #[must_use]
180 pub fn send_change_email_confirmation(
181 mut self,
182 cb: Arc<dyn SendChangeEmailConfirmation>,
183 ) -> Self {
184 self.config.change_email.send_change_email_confirmation = Some(cb);
185 self
186 }
187
188 #[must_use]
189 pub const fn delete_user_enabled(mut self, enabled: bool) -> Self {
190 self.config.delete_user.enabled = enabled;
191 self
192 }
193
194 #[must_use]
195 pub const fn delete_token_expires_in(mut self, duration: Duration) -> Self {
196 self.config.delete_user.delete_token_expires_in = duration;
197 self
198 }
199
200 #[must_use]
201 pub const fn require_delete_verification(mut self, require: bool) -> Self {
202 self.config.delete_user.require_verification = require;
203 self
204 }
205
206 #[must_use]
207 pub fn send_delete_account_verification(
208 mut self,
209 sender: Arc<dyn SendDeleteAccountVerification>,
210 ) -> Self {
211 self.config.delete_user.send_delete_account_verification = Some(sender);
212 self
213 }
214
215 #[must_use]
216 pub fn before_delete(mut self, hook: Arc<dyn BeforeDeleteUser>) -> Self {
217 self.config.delete_user.before_delete = Some(hook);
218 self
219 }
220
221 #[must_use]
222 pub fn after_delete(mut self, hook: Arc<dyn AfterDeleteUser>) -> Self {
223 self.config.delete_user.after_delete = Some(hook);
224 self
225 }
226}
227
228impl UserManagementPlugin {
229 pub fn change_email_endpoint(
231 body: &ChangeEmailRequest,
232 ) -> AuthResult<alibi_core::endpoint::ServerEndpoint<alibi_core::StatusResponse>> {
233 alibi_core::endpoint::ServerEndpoint::new("user-management", "changeEmail").with_body(body)
234 }
235
236 pub fn delete_user_callback_endpoint(
238 query: &DeleteUserCallbackQuery,
239 ) -> AuthResult<alibi_core::endpoint::ServerEndpoint<alibi_core::SuccessMessageResponse>> {
240 alibi_core::endpoint::ServerEndpoint::new("user-management", "deleteUserCallback")
241 .with_query(query)
242 }
243
244 pub fn delete_user_endpoint(
246 body: &DeleteUserRequest,
247 ) -> AuthResult<alibi_core::endpoint::ServerEndpoint<alibi_core::SuccessMessageResponse>> {
248 alibi_core::endpoint::ServerEndpoint::new("user-management", "deleteUser").with_body(body)
249 }
250
251 async fn handle_delete_user_callback_endpoint(
252 &self,
253 call: &alibi_core::endpoint::EndpointCall,
254 ctx: &AuthContext<impl alibi_core::AuthSchema>,
255 ) -> AuthResult<alibi_core::endpoint::EndpointResponse> {
256 let query: DeleteUserCallbackQuery = call.query_as()?;
257 if call.request().is_some()
258 && let Some(url) = query.callback_url.as_deref().filter(|url| !url.is_empty())
259 && !ctx.config.current_origin_check_disabled()
260 && !ctx.config.is_redirect_target_trusted(url)
261 {
262 return Err(AuthError::forbidden("Invalid callbackURL"));
263 }
264 if !self.config.delete_user.enabled {
265 return Err(AuthError::Upstream {
266 status: 404,
267 code: "NOT_FOUND",
268 message: "Not found",
269 });
270 }
271 let mut input = call.clone();
274 if ctx.config.session.has_server_session_store() {
275 alibi_core::endpoint::EndpointContextPatch {
276 query: Some(alibi_core::utils::json::parse_value(
277 r#"{"disableCookieCache":true}"#,
278 )?),
279 ..Default::default()
280 }
281 .apply(&mut input);
282 }
283 let read = alibi_core::session::cookie_cache::runtime::authenticated(ctx, &input, false)
284 .await
285 .ok()
286 .flatten()
287 .ok_or(AuthError::Upstream {
288 status: 404,
289 code: "FAILED_TO_GET_USER_INFO",
290 message: "Failed to get user info",
291 })?;
292 call.record_authenticated_session(ctx.user_view(&read.user), read.session.clone());
293 let response =
294 delete_user_callback_core(&query.token, &read.user, call, true, &self.config, ctx)
295 .await?;
296 alibi_core::session::cookie_cache::runtime::clear_session_cookies(ctx, call)?;
297 if let Some(url) = query.callback_url.filter(|url| !url.is_empty()) {
298 return Ok(alibi_core::endpoint::EndpointResponse::redirect(url));
299 }
300 alibi_core::endpoint::EndpointResponse::json(&response)
301 }
302
303 async fn handle_change_email(
305 &self,
306 req: &AuthRequest,
307 ctx: &AuthContext<impl alibi_core::AuthSchema>,
308 ) -> AuthResult<AuthResponse> {
309 let (user, session) = handlers::authoritative_session(req, ctx).await?;
310 let body: ChangeEmailRequest = match alibi_core::validate_request_body(req) {
311 Ok(v) => v,
312 Err(resp) => return Ok(resp),
313 };
314 if !self.config.change_email.enabled {
315 return Err(AuthError::bad_request("Change email is disabled"));
316 }
317 let projection = change_email_core(&body, &user, &session, &self.config, ctx).await?;
318 let mut response = AuthResponse::json(200, &alibi_core::StatusResponse { status: true })?;
319 if projection.is_some() {
320 append_session_cookie(&mut response, req, &session.token, &ctx.config)?;
321 }
322 Ok(response)
323 }
324
325 async fn handle_delete_user(
327 &self,
328 req: &AuthRequest,
329 ctx: &AuthContext<impl alibi_core::AuthSchema>,
330 ) -> AuthResult<AuthResponse> {
331 let (user, session) = handlers::authoritative_session(req, ctx).await?;
332 let body: DeleteUserRequest = match alibi_core::validate_request_body(req) {
333 Ok(v) => v,
334 Err(resp) => return Ok(resp),
335 };
336 if !self.config.delete_user.enabled {
337 return Ok(AuthResponse::new(404).with_header("Content-Type", "application/json"));
338 }
339 let response = delete_user_core(&body, &user, &session, req, &self.config, ctx).await?;
340 let deleted =
341 response.message == "User deleted" && body.token.as_deref().is_none_or(str::is_empty);
342 let mut response = AuthResponse::json(200, &response)?;
343 if deleted {
344 append_clear_session_cookies(&mut response, &ctx.config)?;
345 }
346 Ok(response)
347 }
348
349 async fn handle_delete_user_callback(
351 &self,
352 req: &AuthRequest,
353 ctx: &AuthContext<impl alibi_core::AuthSchema>,
354 ) -> AuthResult<AuthResponse> {
355 if !self.config.delete_user.enabled {
356 return Err(AuthError::Api {
357 status: 404,
358 code: Some("NOT_FOUND".into()),
359 message: "Not found".into(),
360 });
361 }
362 let (user, _) = handlers::authoritative_session(req, ctx)
363 .await
364 .map_err(|_error| AuthError::not_found("Failed to get user info"))?;
365 let query: DeleteUserCallbackQuery = serde_json::from_value(serde_json::json!({
366 "token": req.query.get("token").cloned(),
367 "callbackURL": req.query.get("callbackURL").cloned(),
368 }))
369 .map_err(|_error| AuthError::bad_request("Verification token is required"))?;
370 let response =
371 delete_user_callback_core(&query.token, &user, req, true, &self.config, ctx).await?;
372 if let Some(callback_url) = query.callback_url.filter(|url| !url.is_empty()) {
373 let mut headers = alibi_core::Headers::new();
374 _ = headers.insert("Location", callback_url);
375 _ = headers.insert("Content-Type", "application/json");
376 let mut redirect = AuthResponse {
377 status: 302,
378 headers,
379 body: Vec::new(),
380 };
381 append_clear_session_cookies(&mut redirect, &ctx.config)?;
382 return Ok(redirect);
383 }
384
385 let mut response = AuthResponse::json(200, &response)?;
386 append_clear_session_cookies(&mut response, &ctx.config)?;
387 Ok(response)
388 }
389}
390
391#[async_trait]
392impl<S: alibi_core::AuthSchema> AuthPlugin<S> for UserManagementPlugin {
393 route_openapi_metadata!(S);
394
395 fn name(&self) -> &'static str {
396 "user-management"
397 }
398
399 fn server_endpoints(&self) -> Vec<alibi_core::endpoint::EndpointDefinition> {
400 vec![
401 crate::endpoint::definition(
402 "changeEmail",
403 "changeEmail",
404 Some("/change-email"),
405 HttpMethod::Post,
406 ),
407 crate::endpoint::definition(
408 "deleteUserCallback",
409 "deleteUserCallback",
410 Some("/delete-user/callback"),
411 HttpMethod::Get,
412 ),
413 crate::endpoint::definition(
414 "deleteUser",
415 "deleteUser",
416 Some("/delete-user"),
417 HttpMethod::Post,
418 ),
419 ]
420 }
421
422 fn validate_endpoint(
423 &self,
424 call: &alibi_core::endpoint::EndpointCall,
425 _: &AuthContext<S>,
426 ) -> AuthResult<alibi_core::endpoint::EndpointInput> {
427 use crate::authentication_helpers::RequestBody;
428 if call.operation_id() == "deleteUserCallback" {
429 return Ok(alibi_core::endpoint::EndpointInput {
430 body: call.body().cloned(),
431 query: Some(crate::endpoint::validate_fields(
432 call.query(),
433 "query",
434 DeleteUserCallbackQuery::FIELDS,
435 )?),
436 });
437 }
438 Ok(alibi_core::endpoint::EndpointInput {
439 body: Some(crate::endpoint::validate_fields(
440 call.body(),
441 "body",
442 if call.operation_id() == "changeEmail" {
443 ChangeEmailRequest::FIELDS
444 } else {
445 DeleteUserRequest::FIELDS
446 },
447 )?),
448 query: call.query().cloned(),
449 })
450 }
451
452 async fn on_endpoint(
453 &self,
454 call: &alibi_core::endpoint::EndpointCall,
455 ctx: &AuthContext<S>,
456 ) -> AuthResult<alibi_core::endpoint::EndpointResponse> {
457 if call.operation_id() == "deleteUserCallback" {
458 return self.handle_delete_user_callback_endpoint(call, ctx).await;
459 }
460 let (user, session) = ctx
461 .require_authoritative_session_record(call)
462 .await
463 .map_err(|_| AuthError::Upstream {
464 status: 401,
465 code: "UNAUTHORIZED",
466 message: "Unauthorized",
467 })?;
468 call.record_authenticated_session(ctx.user_view(&user), session.clone());
469 if call.operation_id() == "changeEmail" {
470 if !self.config.change_email.enabled {
471 return Err(AuthError::bad_request("Change email is disabled"));
472 }
473 let body: ChangeEmailRequest = call.body_as()?;
474 let projection = change_email_core(&body, &user, &session, &self.config, ctx).await?;
475 let mut response =
476 alibi_core::endpoint::EndpointResponse::json(&alibi_core::StatusResponse {
477 status: true,
478 })?;
479 if projection.is_some() {
480 response.merge_headers(crate::authentication_helpers::session_cookie_headers(
481 &ctx.config,
482 &session.token,
483 ctx.session_manager().has_dont_remember_cookie(call),
484 )?);
485 }
486 return Ok(response);
487 }
488 if !self.config.delete_user.enabled {
489 return Ok(
490 alibi_core::endpoint::EndpointResponse::error(AuthError::Api {
491 status: 404,
492 code: None,
493 message: String::new(),
494 })
495 .without_error_body(),
496 );
497 }
498 let body: DeleteUserRequest = call.body_as()?;
499 let response = delete_user_core(&body, &user, &session, call, &self.config, ctx).await?;
500 let deleted =
501 response.message == "User deleted" && body.token.as_deref().is_none_or(str::is_empty);
502 if deleted {
503 alibi_core::session::cookie_cache::runtime::clear_session_cookies(ctx, call)?;
504 }
505 alibi_core::endpoint::EndpointResponse::json(&response)
506 }
507
508 fn routes(&self) -> Vec<AuthRoute> {
509 vec![
510 AuthRoute::post("/change-email", "change_email"),
511 AuthRoute::post("/delete-user", "delete_user"),
512 AuthRoute::get("/delete-user/callback", "delete_user_callback"),
513 ]
514 }
515
516 async fn on_request(
517 &self,
518 req: &AuthRequest,
519 ctx: &AuthContext<S>,
520 ) -> AuthResult<Option<AuthResponse>> {
521 let response = match (req.method(), req.path()) {
522 (HttpMethod::Post, "/change-email") => self.handle_change_email(req, ctx).await?,
523 (HttpMethod::Post, "/delete-user") => self.handle_delete_user(req, ctx).await?,
524 (HttpMethod::Get, "/delete-user/callback") => {
525 self.handle_delete_user_callback(req, ctx).await?
526 }
527 _ => return Ok(None),
528 };
529 Ok(Some(response))
530 }
531}
532
533pub(super) fn append_clear_session_cookies(
534 response: &mut AuthResponse,
535 config: &alibi_core::AuthConfig,
536) -> AuthResult<()> {
537 response.headers.append(
538 "Set-Cookie",
539 alibi_core::utils::cookie_utils::create_clear_session_cookie(config)?,
540 );
541 response.headers.append(
542 "Set-Cookie",
543 alibi_core::utils::cookie_utils::create_clear_cookie(
544 &related_cookie_name(config, "session_data"),
545 config,
546 )?,
547 );
548 response.headers.append(
549 "Set-Cookie",
550 alibi_core::utils::cookie_utils::create_clear_cookie(
551 &related_cookie_name(config, "dont_remember"),
552 config,
553 )?,
554 );
555 if config.account.store_account_cookie {
556 response.headers.append(
557 "Set-Cookie",
558 alibi_core::utils::cookie_utils::create_clear_cookie(
559 &related_cookie_name(config, "account_data"),
560 config,
561 )?,
562 );
563 }
564 Ok(())
565}
566
567pub(crate) fn append_session_cookie(
568 response: &mut AuthResponse,
569 req: &AuthRequest,
570 token: &str,
571 config: &alibi_core::AuthConfig,
572) -> AuthResult<()> {
573 use alibi_core::utils::cookie_utils::{
574 create_session_cookie_with_max_age, verify_cookie_value,
575 };
576 let dont_remember =
577 super::helpers::get_cookie(req, &related_cookie_name(config, "dont_remember"))
578 .and_then(|value| verify_cookie_value(&value, config.current_secret()))
579 .is_some_and(|value| !value.is_empty());
580 response.headers.append(
581 "Set-Cookie",
582 create_session_cookie_with_max_age(
583 Some(token),
584 (!dont_remember).then(|| config.session.expires_in.num_seconds()),
585 config,
586 )?,
587 );
588 Ok(())
589}
590
591fn related_cookie_name(config: &alibi_core::AuthConfig, suffix: &str) -> String {
592 config
593 .session
594 .cookie_name
595 .strip_suffix("session_token")
596 .map_or_else(
597 || format!("better-auth.{suffix}"),
598 |prefix| format!("{prefix}{suffix}"),
599 )
600}
601
602#[cfg(test)]
604mod tests {
605 use super::*;
606 use crate::test_helpers;
607 use alibi_core::AuthUser;
608 use alibi_core::{AccountConfig, CreateUser};
609 use async_trait::async_trait;
610 use chrono::Duration;
611 use std::collections::HashMap;
612 use std::sync::Arc;
613
614 #[tokio::test]
618 async fn test_change_email_success() {
619 let plugin = UserManagementPlugin::new().change_email_enabled(true);
620 let (mut ctx, _user, session) = test_helpers::create_test_context_with_user(
621 CreateUser::new()
622 .with_email("test@example.com")
623 .with_name("Test User")
624 .with_email_verified(true),
625 Duration::hours(24),
626 )
627 .await;
628
629 ctx.email_provider = Some(Arc::new(alibi_core::email::ConsoleEmailProvider));
630
631 let body = serde_json::json!({ "newEmail": "new@example.com" });
632 let req = test_helpers::create_auth_request(
633 HttpMethod::Post,
634 "/change-email",
635 Some(&session.token),
636 Some(body.to_string().into_bytes()),
637 HashMap::new(),
638 );
639
640 let response = plugin.handle_change_email(&req, &ctx).await.unwrap();
641 assert_eq!(response.status, 200);
642 }
643
644 #[tokio::test]
646 async fn test_change_email_same_email() {
647 let plugin = UserManagementPlugin::new().change_email_enabled(true);
648 let (ctx, _user, session) = test_helpers::create_test_context_with_user(
649 CreateUser::new()
650 .with_email("test@example.com")
651 .with_name("Test User")
652 .with_email_verified(true),
653 Duration::hours(24),
654 )
655 .await;
656
657 let body = serde_json::json!({ "newEmail": "test@example.com" });
658 let req = test_helpers::create_auth_request(
659 HttpMethod::Post,
660 "/change-email",
661 Some(&session.token),
662 Some(body.to_string().into_bytes()),
663 HashMap::new(),
664 );
665
666 let err = plugin.handle_change_email(&req, &ctx).await.unwrap_err();
667 assert_eq!(err.status_code(), 400);
668 }
669
670 #[tokio::test]
672 async fn test_change_email_unauthenticated() {
673 let plugin = UserManagementPlugin::new().change_email_enabled(true);
674 let (ctx, _user, _session) = test_helpers::create_test_context_with_user(
675 CreateUser::new()
676 .with_email("test@example.com")
677 .with_name("Test User")
678 .with_email_verified(true),
679 Duration::hours(24),
680 )
681 .await;
682
683 let body = serde_json::json!({ "newEmail": "new@example.com" });
684 let req = test_helpers::create_auth_request(
685 HttpMethod::Post,
686 "/change-email",
687 None,
688 Some(body.to_string().into_bytes()),
689 HashMap::new(),
690 );
691
692 let err = plugin.handle_change_email(&req, &ctx).await.unwrap_err();
693 assert_eq!(err.status_code(), 401);
694 }
695
696 #[tokio::test]
698 async fn test_change_email_immediate_when_update_without_verification() {
699 let plugin = UserManagementPlugin::new()
700 .change_email_enabled(true)
701 .update_without_verification(true);
702 let (ctx, user, session) = test_helpers::create_test_context_with_user(
703 CreateUser::new()
704 .with_email("test@example.com")
705 .with_name("Test User")
706 .with_email_verified(false),
707 Duration::hours(24),
708 )
709 .await;
710
711 let body = serde_json::json!({ "newEmail": "new@example.com" });
713 let req = test_helpers::create_auth_request(
714 HttpMethod::Post,
715 "/change-email",
716 Some(&session.token),
717 Some(body.to_string().into_bytes()),
718 HashMap::new(),
719 );
720 let response = plugin.handle_change_email(&req, &ctx).await.unwrap();
721 assert_eq!(response.status, 200);
722
723 let updated_user = ctx
725 .database
726 .get_user_by_id(&user.id)
727 .await
728 .unwrap()
729 .unwrap();
730 assert_eq!(updated_user.email(), Some("new@example.com"));
731 assert!(!updated_user.email_verified);
733
734 let identifier = format!("change_email:{}:new@example.com", user.id);
736 let verification = ctx
737 .database
738 .get_verification_by_identifier(&identifier)
739 .await
740 .unwrap();
741 assert!(
742 verification.is_none(),
743 "no verification token should be created when update_without_verification=true"
744 );
745 }
746
747 #[tokio::test]
751 async fn test_delete_user_immediate() {
752 let plugin = UserManagementPlugin::new()
753 .delete_user_enabled(true)
754 .require_delete_verification(false);
755 let (ctx, user, session) = test_helpers::create_test_context_with_user(
756 CreateUser::new()
757 .with_email("test@example.com")
758 .with_name("Test User")
759 .with_email_verified(true),
760 Duration::hours(24),
761 )
762 .await;
763
764 let req = test_helpers::create_auth_request(
765 HttpMethod::Post,
766 "/delete-user",
767 Some(&session.token),
768 Some(b"{}".to_vec()),
769 HashMap::new(),
770 );
771
772 let response = plugin.handle_delete_user(&req, &ctx).await.unwrap();
773 assert_eq!(response.status, 200);
774
775 let deleted_user = ctx.database.get_user_by_id(&user.id).await.unwrap();
777 assert!(deleted_user.is_none());
778 }
779
780 #[tokio::test]
782 async fn test_delete_user_immediate_clears_account_cookie_when_enabled() {
783 let plugin = UserManagementPlugin::new()
784 .delete_user_enabled(true)
785 .require_delete_verification(false);
786 let config = test_helpers::create_test_config().account(AccountConfig {
787 store_account_cookie: true,
788 ..Default::default()
789 });
790 let ctx = test_helpers::create_test_context_with_config(config).await;
791 let (_user, session) = test_helpers::create_user_and_session(
792 &ctx,
793 CreateUser::new()
794 .with_email("account-cookie@test.com")
795 .with_name("Account Cookie")
796 .with_email_verified(true),
797 Duration::hours(24),
798 )
799 .await;
800
801 let req = test_helpers::create_auth_request(
802 HttpMethod::Post,
803 "/delete-user",
804 Some(&session.token),
805 Some(b"{}".to_vec()),
806 HashMap::new(),
807 );
808
809 let response = plugin.handle_delete_user(&req, &ctx).await.unwrap();
810 let account_cookie_name = format!("{}=", related_cookie_name(&ctx.config, "account_data"));
811 assert!(
812 response
813 .headers
814 .get_all("Set-Cookie")
815 .any(|cookie| cookie.starts_with(&account_cookie_name)),
816 "delete-user should clear the account_data cookie when store_account_cookie is enabled"
817 );
818 }
819
820 #[tokio::test]
822 async fn test_delete_user_with_verification() {
823 let plugin = UserManagementPlugin::new()
824 .delete_user_enabled(true)
825 .require_delete_verification(true);
826 let (ctx, user, session) = test_helpers::create_test_context_with_user(
827 CreateUser::new()
828 .with_email("test@example.com")
829 .with_name("Test User")
830 .with_email_verified(true),
831 Duration::hours(24),
832 )
833 .await;
834
835 let req = test_helpers::create_auth_request(
837 HttpMethod::Post,
838 "/delete-user",
839 Some(&session.token),
840 Some(b"{}".to_vec()),
841 HashMap::new(),
842 );
843
844 let response = plugin.handle_delete_user(&req, &ctx).await.unwrap();
845 assert_eq!(response.status, 200);
846
847 let still_exists = ctx.database.get_user_by_id(&user.id).await.unwrap();
849 assert!(still_exists.is_some());
850
851 let token = "delete-token-123";
853 ctx.database
854 .create_verification(alibi_core::CreateVerification {
855 identifier: format!("delete-account-{token}"),
856 value: user.id.clone(),
857 expires_at: chrono::Utc::now() + Duration::hours(24),
858 })
859 .await
860 .unwrap();
861
862 let mut query = HashMap::new();
863 query.insert("token".to_owned(), token.to_owned());
864 let req_2 = test_helpers::create_auth_request(
865 HttpMethod::Get,
866 "/delete-user/callback",
867 Some(&session.token),
868 None,
869 query,
870 );
871 let response_2 = plugin
872 .handle_delete_user_callback(&req_2, &ctx)
873 .await
874 .unwrap();
875 assert_eq!(response_2.status, 200);
876
877 let deleted = ctx.database.get_user_by_id(&user.id).await.unwrap();
879 assert!(deleted.is_none());
880 }
881
882 #[tokio::test]
884 async fn test_delete_user_callback_clears_account_cookie_when_enabled() {
885 let plugin = UserManagementPlugin::new()
886 .delete_user_enabled(true)
887 .require_delete_verification(true);
888 let config = test_helpers::create_test_config().account(AccountConfig {
889 store_account_cookie: true,
890 ..Default::default()
891 });
892 let ctx = test_helpers::create_test_context_with_config(config).await;
893 let (user, session) = test_helpers::create_user_and_session(
894 &ctx,
895 CreateUser::new()
896 .with_email("callback-cookie@test.com")
897 .with_name("Callback Cookie")
898 .with_email_verified(true),
899 Duration::hours(24),
900 )
901 .await;
902
903 let token = "delete-cookie-token";
904 ctx.database
905 .create_verification(alibi_core::CreateVerification {
906 identifier: format!("delete-account-{token}"),
907 value: user.id.clone(),
908 expires_at: chrono::Utc::now() + Duration::hours(24),
909 })
910 .await
911 .unwrap();
912
913 let mut query = HashMap::new();
914 query.insert("token".to_owned(), token.to_owned());
915 query.insert(
916 "callbackURL".to_owned(),
917 "https://example.com/goodbye".to_owned(),
918 );
919 let req = test_helpers::create_auth_request(
920 HttpMethod::Get,
921 "/delete-user/callback",
922 Some(&session.token),
923 None,
924 query,
925 );
926
927 let response = plugin
928 .handle_delete_user_callback(&req, &ctx)
929 .await
930 .unwrap();
931 assert_eq!(response.status, 302);
932 let account_cookie_name = format!("{}=", related_cookie_name(&ctx.config, "account_data"));
933 assert!(
934 response
935 .headers
936 .get_all("Set-Cookie")
937 .any(|cookie| cookie.starts_with(&account_cookie_name)),
938 "delete-user callback should clear the account_data cookie when store_account_cookie is enabled"
939 );
940 }
941
942 #[tokio::test]
944 async fn test_delete_user_unauthenticated() {
945 let plugin = UserManagementPlugin::new()
946 .delete_user_enabled(true)
947 .require_delete_verification(false);
948 let (ctx, _user, _session) = test_helpers::create_test_context_with_user(
949 CreateUser::new()
950 .with_email("test@example.com")
951 .with_name("Test User")
952 .with_email_verified(true),
953 Duration::hours(24),
954 )
955 .await;
956
957 let req = test_helpers::create_auth_request(
958 HttpMethod::Post,
959 "/delete-user",
960 None,
961 Some(b"{}".to_vec()),
962 HashMap::new(),
963 );
964
965 let err = plugin.handle_delete_user(&req, &ctx).await.unwrap_err();
966 assert_eq!(err.status_code(), 401);
967 }
968
969 #[tokio::test]
971 async fn test_delete_user_verify_invalid_token() {
972 let plugin = UserManagementPlugin::new().delete_user_enabled(true);
973 let (ctx, _user, fixture_session) = test_helpers::create_test_context_with_user(
974 CreateUser::new()
975 .with_email("test@example.com")
976 .with_name("Test User")
977 .with_email_verified(true),
978 Duration::hours(24),
979 )
980 .await;
981
982 let mut query = HashMap::new();
983 query.insert("token".to_owned(), "invalid-token".to_owned());
984 let req = test_helpers::create_auth_request(
985 HttpMethod::Get,
986 "/delete-user/callback",
987 Some(&fixture_session.token),
988 None,
989 query,
990 );
991
992 let err = plugin
993 .handle_delete_user_callback(&req, &ctx)
994 .await
995 .unwrap_err();
996 assert_eq!(err.status_code(), 404);
997 }
998
999 #[tokio::test]
1001 async fn test_delete_user_before_hook_abort() {
1002 use std::sync::atomic::{AtomicBool, Ordering};
1003
1004 struct AbortHook;
1005
1006 #[async_trait]
1007 impl BeforeDeleteUser for AbortHook {
1008 async fn before_delete(&self, _user: &UserInfo) -> AuthResult<()> {
1009 Err(AuthError::forbidden("Deletion blocked by policy"))
1010 }
1011 }
1012
1013 struct AfterHook(Arc<AtomicBool>);
1014
1015 #[async_trait]
1016 impl AfterDeleteUser for AfterHook {
1017 async fn after_delete(&self, _user: &UserInfo) -> AuthResult<()> {
1018 self.0.store(true, Ordering::SeqCst);
1019 Ok(())
1020 }
1021 }
1022
1023 let called = Arc::new(AtomicBool::new(false));
1024 let called_clone = std::sync::Arc::clone(&called);
1025
1026 let plugin = UserManagementPlugin::new()
1027 .delete_user_enabled(true)
1028 .require_delete_verification(false)
1029 .before_delete(Arc::new(AbortHook))
1030 .after_delete(Arc::new(AfterHook(called_clone)));
1031 let (ctx, user, session) = test_helpers::create_test_context_with_user(
1032 CreateUser::new()
1033 .with_email("test@example.com")
1034 .with_name("Test User")
1035 .with_email_verified(true),
1036 Duration::hours(24),
1037 )
1038 .await;
1039
1040 let req = test_helpers::create_auth_request(
1041 HttpMethod::Post,
1042 "/delete-user",
1043 Some(&session.token),
1044 Some(b"{}".to_vec()),
1045 HashMap::new(),
1046 );
1047
1048 let err = plugin.handle_delete_user(&req, &ctx).await.unwrap_err();
1049 assert_eq!(err.status_code(), 403);
1050
1051 let still_exists = ctx.database.get_user_by_id(&user.id).await.unwrap();
1053 assert!(still_exists.is_some());
1054
1055 assert!(!called.load(Ordering::SeqCst));
1057 }
1058
1059 #[tokio::test]
1062 async fn disabled_user_management_rejects_authenticated_mutations() {
1063 let plugin = UserManagementPlugin::new();
1064 let (ctx, user, session) = test_helpers::create_test_context_with_user(
1065 CreateUser::new()
1066 .with_email("test@example.com")
1067 .with_name("Test User")
1068 .with_email_verified(true),
1069 Duration::hours(24),
1070 )
1071 .await;
1072 for (path, body, status) in [
1073 (
1074 "/change-email",
1075 serde_json::json!({"newEmail":"changed@example.com"}),
1076 400,
1077 ),
1078 ("/delete-user", serde_json::json!({}), 404),
1079 ] {
1080 let req = test_helpers::create_auth_request(
1081 HttpMethod::Post,
1082 path,
1083 Some(&session.token),
1084 Some(body.to_string().into_bytes()),
1085 HashMap::new(),
1086 );
1087 let result = plugin.on_request(&req, &ctx).await;
1088 match result {
1089 Ok(Some(response)) => assert_eq!(response.status, status),
1090 Err(error) => assert_eq!(error.status_code(), status),
1091 other => panic!("registered disabled handler did not reject: {other:?}"),
1092 }
1093 let stored = ctx
1094 .database
1095 .get_user_by_id(&user.id)
1096 .await
1097 .unwrap()
1098 .unwrap();
1099 assert_eq!(Some(stored.email), user.email);
1100 assert!(
1101 ctx.database
1102 .get_session(&session.token)
1103 .await
1104 .unwrap()
1105 .is_some()
1106 );
1107 }
1108 }
1109}
1110