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