pub(super) mod handlers;
pub(super) mod types;
mod update;
use alibi_core::wire::UserView;
use alibi_core::{
AuthContext, AuthError, AuthPlugin, AuthRequest, AuthResponse, AuthResult, AuthRoute,
HttpMethod,
};
use async_trait::async_trait;
use chrono::Duration;
use handlers::{change_email_core, delete_user_callback_core, delete_user_core};
use std::sync::Arc;
pub use types::{ChangeEmailRequest, DeleteUserCallbackQuery, DeleteUserRequest};
pub use update::{
BuiltinUserEndpoints, error_endpoint, handle_update_user, ok_endpoint, update_user_endpoint,
};
pub type UserInfo = UserView;
#[async_trait]
pub trait SendChangeEmailConfirmation: Send + Sync {
async fn send(
&self,
user: &UserInfo,
new_email: &str,
url: &str,
token: &str,
) -> AuthResult<()>;
}
#[async_trait]
pub trait SendDeleteAccountVerification: Send + Sync {
async fn send(&self, user: &UserInfo, url: &str, token: &str) -> AuthResult<()>;
}
#[async_trait]
pub trait BeforeDeleteUser: Send + Sync {
async fn before_delete(&self, user: &UserInfo) -> AuthResult<()>;
}
#[async_trait]
pub trait AfterDeleteUser: Send + Sync {
async fn after_delete(&self, user: &UserInfo) -> AuthResult<()>;
}
#[derive(Clone, Default)]
pub struct ChangeEmailConfig {
pub enabled: bool,
pub update_without_verification: bool,
pub send_change_email_confirmation: Option<Arc<dyn SendChangeEmailConfirmation>>,
}
impl std::fmt::Debug for ChangeEmailConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ChangeEmailConfig")
.field("enabled", &self.enabled)
.field(
"update_without_verification",
&self.update_without_verification,
)
.field(
"send_change_email_confirmation",
&self.send_change_email_confirmation.is_some(),
)
.finish()
}
}
#[derive(Clone)]
pub struct DeleteUserConfig {
pub enabled: bool,
pub delete_token_expires_in: Duration,
pub require_verification: bool,
pub send_delete_account_verification: Option<Arc<dyn SendDeleteAccountVerification>>,
pub before_delete: Option<Arc<dyn BeforeDeleteUser>>,
pub after_delete: Option<Arc<dyn AfterDeleteUser>>,
}
impl std::fmt::Debug for DeleteUserConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DeleteUserConfig")
.field("enabled", &self.enabled)
.field("delete_token_expires_in", &self.delete_token_expires_in)
.field("require_verification", &self.require_verification)
.field(
"send_delete_account_verification",
&self.send_delete_account_verification.is_some(),
)
.field("before_delete", &self.before_delete.is_some())
.field("after_delete", &self.after_delete.is_some())
.finish()
}
}
impl Default for DeleteUserConfig {
fn default() -> Self {
Self {
enabled: false,
delete_token_expires_in: Duration::hours(24),
require_verification: false,
send_delete_account_verification: None,
before_delete: None,
after_delete: None,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct UserManagementConfig {
pub change_email: ChangeEmailConfig,
pub delete_user: DeleteUserConfig,
}
#[derive(Default)]
pub struct UserManagementPlugin {
config: UserManagementConfig,
}
impl std::fmt::Debug for UserManagementPlugin {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("UserManagementPlugin")
.finish_non_exhaustive()
}
}
impl UserManagementPlugin {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub const fn with_config(config: UserManagementConfig) -> Self {
Self { config }
}
#[must_use]
pub const fn change_email_enabled(mut self, enabled: bool) -> Self {
self.config.change_email.enabled = enabled;
self
}
#[must_use]
pub const fn update_without_verification(mut self, flag: bool) -> Self {
self.config.change_email.update_without_verification = flag;
self
}
#[must_use]
pub fn send_change_email_confirmation(
mut self,
cb: Arc<dyn SendChangeEmailConfirmation>,
) -> Self {
self.config.change_email.send_change_email_confirmation = Some(cb);
self
}
#[must_use]
pub const fn delete_user_enabled(mut self, enabled: bool) -> Self {
self.config.delete_user.enabled = enabled;
self
}
#[must_use]
pub const fn delete_token_expires_in(mut self, duration: Duration) -> Self {
self.config.delete_user.delete_token_expires_in = duration;
self
}
#[must_use]
pub const fn require_delete_verification(mut self, require: bool) -> Self {
self.config.delete_user.require_verification = require;
self
}
#[must_use]
pub fn send_delete_account_verification(
mut self,
sender: Arc<dyn SendDeleteAccountVerification>,
) -> Self {
self.config.delete_user.send_delete_account_verification = Some(sender);
self
}
#[must_use]
pub fn before_delete(mut self, hook: Arc<dyn BeforeDeleteUser>) -> Self {
self.config.delete_user.before_delete = Some(hook);
self
}
#[must_use]
pub fn after_delete(mut self, hook: Arc<dyn AfterDeleteUser>) -> Self {
self.config.delete_user.after_delete = Some(hook);
self
}
}
impl UserManagementPlugin {
pub fn change_email_endpoint(
body: &ChangeEmailRequest,
) -> AuthResult<alibi_core::endpoint::ServerEndpoint<alibi_core::StatusResponse>> {
alibi_core::endpoint::ServerEndpoint::new("user-management", "changeEmail").with_body(body)
}
pub fn delete_user_callback_endpoint(
query: &DeleteUserCallbackQuery,
) -> AuthResult<alibi_core::endpoint::ServerEndpoint<alibi_core::SuccessMessageResponse>> {
alibi_core::endpoint::ServerEndpoint::new("user-management", "deleteUserCallback")
.with_query(query)
}
pub fn delete_user_endpoint(
body: &DeleteUserRequest,
) -> AuthResult<alibi_core::endpoint::ServerEndpoint<alibi_core::SuccessMessageResponse>> {
alibi_core::endpoint::ServerEndpoint::new("user-management", "deleteUser").with_body(body)
}
async fn handle_delete_user_callback_endpoint(
&self,
call: &alibi_core::endpoint::EndpointCall,
ctx: &AuthContext<impl alibi_core::AuthSchema>,
) -> AuthResult<alibi_core::endpoint::EndpointResponse> {
let query: DeleteUserCallbackQuery = call.query_as()?;
if call.request().is_some()
&& let Some(url) = query.callback_url.as_deref().filter(|url| !url.is_empty())
&& !ctx.config.current_origin_check_disabled()
&& !ctx.config.is_redirect_target_trusted(url)
{
return Err(AuthError::forbidden("Invalid callbackURL"));
}
if !self.config.delete_user.enabled {
return Err(AuthError::Upstream {
status: 404,
code: "NOT_FOUND",
message: "Not found",
});
}
let mut input = call.clone();
if ctx.config.session.has_server_session_store() {
alibi_core::endpoint::EndpointContextPatch {
query: Some(alibi_core::utils::json::parse_value(
r#"{"disableCookieCache":true}"#,
)?),
..Default::default()
}
.apply(&mut input);
}
let read = alibi_core::session::cookie_cache::runtime::authenticated(ctx, &input, false)
.await
.ok()
.flatten()
.ok_or(AuthError::Upstream {
status: 404,
code: "FAILED_TO_GET_USER_INFO",
message: "Failed to get user info",
})?;
call.record_authenticated_session(ctx.user_view(&read.user), read.session.clone());
let response =
delete_user_callback_core(&query.token, &read.user, call, true, &self.config, ctx)
.await?;
alibi_core::session::cookie_cache::runtime::clear_session_cookies(ctx, call)?;
if let Some(url) = query.callback_url.filter(|url| !url.is_empty()) {
return Ok(alibi_core::endpoint::EndpointResponse::redirect(url));
}
alibi_core::endpoint::EndpointResponse::json(&response)
}
async fn handle_change_email(
&self,
req: &AuthRequest,
ctx: &AuthContext<impl alibi_core::AuthSchema>,
) -> AuthResult<AuthResponse> {
let (user, session) = handlers::authoritative_session(req, ctx).await?;
let body: ChangeEmailRequest = match alibi_core::validate_request_body(req) {
Ok(v) => v,
Err(resp) => return Ok(resp),
};
if !self.config.change_email.enabled {
return Err(AuthError::bad_request("Change email is disabled"));
}
let projection = change_email_core(&body, &user, &session, &self.config, ctx).await?;
let mut response = AuthResponse::json(200, &alibi_core::StatusResponse { status: true })?;
if projection.is_some() {
append_session_cookie(&mut response, req, &session.token, &ctx.config)?;
}
Ok(response)
}
async fn handle_delete_user(
&self,
req: &AuthRequest,
ctx: &AuthContext<impl alibi_core::AuthSchema>,
) -> AuthResult<AuthResponse> {
let (user, session) = handlers::authoritative_session(req, ctx).await?;
let body: DeleteUserRequest = match alibi_core::validate_request_body(req) {
Ok(v) => v,
Err(resp) => return Ok(resp),
};
if !self.config.delete_user.enabled {
return Ok(AuthResponse::new(404).with_header("Content-Type", "application/json"));
}
let response = delete_user_core(&body, &user, &session, req, &self.config, ctx).await?;
let deleted =
response.message == "User deleted" && body.token.as_deref().is_none_or(str::is_empty);
let mut response = AuthResponse::json(200, &response)?;
if deleted {
append_clear_session_cookies(&mut response, &ctx.config)?;
}
Ok(response)
}
async fn handle_delete_user_callback(
&self,
req: &AuthRequest,
ctx: &AuthContext<impl alibi_core::AuthSchema>,
) -> AuthResult<AuthResponse> {
if !self.config.delete_user.enabled {
return Err(AuthError::Api {
status: 404,
code: Some("NOT_FOUND".into()),
message: "Not found".into(),
});
}
let (user, _) = handlers::authoritative_session(req, ctx)
.await
.map_err(|_error| AuthError::not_found("Failed to get user info"))?;
let query: DeleteUserCallbackQuery = serde_json::from_value(serde_json::json!({
"token": req.query.get("token").cloned(),
"callbackURL": req.query.get("callbackURL").cloned(),
}))
.map_err(|_error| AuthError::bad_request("Verification token is required"))?;
let response =
delete_user_callback_core(&query.token, &user, req, true, &self.config, ctx).await?;
if let Some(callback_url) = query.callback_url.filter(|url| !url.is_empty()) {
let mut headers = alibi_core::Headers::new();
_ = headers.insert("Location", callback_url);
_ = headers.insert("Content-Type", "application/json");
let mut redirect = AuthResponse {
status: 302,
headers,
body: Vec::new(),
};
append_clear_session_cookies(&mut redirect, &ctx.config)?;
return Ok(redirect);
}
let mut response = AuthResponse::json(200, &response)?;
append_clear_session_cookies(&mut response, &ctx.config)?;
Ok(response)
}
}
#[async_trait]
impl<S: alibi_core::AuthSchema> AuthPlugin<S> for UserManagementPlugin {
route_openapi_metadata!(S);
fn name(&self) -> &'static str {
"user-management"
}
fn server_endpoints(&self) -> Vec<alibi_core::endpoint::EndpointDefinition> {
vec![
crate::endpoint::definition(
"changeEmail",
"changeEmail",
Some("/change-email"),
HttpMethod::Post,
),
crate::endpoint::definition(
"deleteUserCallback",
"deleteUserCallback",
Some("/delete-user/callback"),
HttpMethod::Get,
),
crate::endpoint::definition(
"deleteUser",
"deleteUser",
Some("/delete-user"),
HttpMethod::Post,
),
]
}
fn validate_endpoint(
&self,
call: &alibi_core::endpoint::EndpointCall,
_: &AuthContext<S>,
) -> AuthResult<alibi_core::endpoint::EndpointInput> {
use crate::authentication_helpers::RequestBody;
if call.operation_id() == "deleteUserCallback" {
return Ok(alibi_core::endpoint::EndpointInput {
body: call.body().cloned(),
query: Some(crate::endpoint::validate_fields(
call.query(),
"query",
DeleteUserCallbackQuery::FIELDS,
)?),
});
}
Ok(alibi_core::endpoint::EndpointInput {
body: Some(crate::endpoint::validate_fields(
call.body(),
"body",
if call.operation_id() == "changeEmail" {
ChangeEmailRequest::FIELDS
} else {
DeleteUserRequest::FIELDS
},
)?),
query: call.query().cloned(),
})
}
async fn on_endpoint(
&self,
call: &alibi_core::endpoint::EndpointCall,
ctx: &AuthContext<S>,
) -> AuthResult<alibi_core::endpoint::EndpointResponse> {
if call.operation_id() == "deleteUserCallback" {
return self.handle_delete_user_callback_endpoint(call, ctx).await;
}
let (user, session) = ctx
.require_authoritative_session_record(call)
.await
.map_err(|_| AuthError::Upstream {
status: 401,
code: "UNAUTHORIZED",
message: "Unauthorized",
})?;
call.record_authenticated_session(ctx.user_view(&user), session.clone());
if call.operation_id() == "changeEmail" {
if !self.config.change_email.enabled {
return Err(AuthError::bad_request("Change email is disabled"));
}
let body: ChangeEmailRequest = call.body_as()?;
let projection = change_email_core(&body, &user, &session, &self.config, ctx).await?;
let mut response =
alibi_core::endpoint::EndpointResponse::json(&alibi_core::StatusResponse {
status: true,
})?;
if projection.is_some() {
response.merge_headers(crate::authentication_helpers::session_cookie_headers(
&ctx.config,
&session.token,
ctx.session_manager().has_dont_remember_cookie(call),
)?);
}
return Ok(response);
}
if !self.config.delete_user.enabled {
return Ok(
alibi_core::endpoint::EndpointResponse::error(AuthError::Api {
status: 404,
code: None,
message: String::new(),
})
.without_error_body(),
);
}
let body: DeleteUserRequest = call.body_as()?;
let response = delete_user_core(&body, &user, &session, call, &self.config, ctx).await?;
let deleted =
response.message == "User deleted" && body.token.as_deref().is_none_or(str::is_empty);
if deleted {
alibi_core::session::cookie_cache::runtime::clear_session_cookies(ctx, call)?;
}
alibi_core::endpoint::EndpointResponse::json(&response)
}
fn routes(&self) -> Vec<AuthRoute> {
vec![
AuthRoute::post("/change-email", "change_email"),
AuthRoute::post("/delete-user", "delete_user"),
AuthRoute::get("/delete-user/callback", "delete_user_callback"),
]
}
async fn on_request(
&self,
req: &AuthRequest,
ctx: &AuthContext<S>,
) -> AuthResult<Option<AuthResponse>> {
let response = match (req.method(), req.path()) {
(HttpMethod::Post, "/change-email") => self.handle_change_email(req, ctx).await?,
(HttpMethod::Post, "/delete-user") => self.handle_delete_user(req, ctx).await?,
(HttpMethod::Get, "/delete-user/callback") => {
self.handle_delete_user_callback(req, ctx).await?
}
_ => return Ok(None),
};
Ok(Some(response))
}
}
pub(super) fn append_clear_session_cookies(
response: &mut AuthResponse,
config: &alibi_core::AuthConfig,
) -> AuthResult<()> {
response.headers.append(
"Set-Cookie",
alibi_core::utils::cookie_utils::create_clear_session_cookie(config)?,
);
response.headers.append(
"Set-Cookie",
alibi_core::utils::cookie_utils::create_clear_cookie(
&related_cookie_name(config, "session_data"),
config,
)?,
);
response.headers.append(
"Set-Cookie",
alibi_core::utils::cookie_utils::create_clear_cookie(
&related_cookie_name(config, "dont_remember"),
config,
)?,
);
if config.account.store_account_cookie {
response.headers.append(
"Set-Cookie",
alibi_core::utils::cookie_utils::create_clear_cookie(
&related_cookie_name(config, "account_data"),
config,
)?,
);
}
Ok(())
}
pub(crate) fn append_session_cookie(
response: &mut AuthResponse,
req: &AuthRequest,
token: &str,
config: &alibi_core::AuthConfig,
) -> AuthResult<()> {
use alibi_core::utils::cookie_utils::{
create_session_cookie_with_max_age, verify_cookie_value,
};
let dont_remember =
super::helpers::get_cookie(req, &related_cookie_name(config, "dont_remember"))
.and_then(|value| verify_cookie_value(&value, config.current_secret()))
.is_some_and(|value| !value.is_empty());
response.headers.append(
"Set-Cookie",
create_session_cookie_with_max_age(
Some(token),
(!dont_remember).then(|| config.session.expires_in.num_seconds()),
config,
)?,
);
Ok(())
}
fn related_cookie_name(config: &alibi_core::AuthConfig, suffix: &str) -> String {
config
.session
.cookie_name
.strip_suffix("session_token")
.map_or_else(
|| format!("better-auth.{suffix}"),
|prefix| format!("{prefix}{suffix}"),
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_helpers;
use alibi_core::AuthUser;
use alibi_core::{AccountConfig, CreateUser};
use async_trait::async_trait;
use chrono::Duration;
use std::collections::HashMap;
use std::sync::Arc;
#[tokio::test]
async fn test_change_email_success() {
let plugin = UserManagementPlugin::new().change_email_enabled(true);
let (mut ctx, _user, session) = test_helpers::create_test_context_with_user(
CreateUser::new()
.with_email("test@example.com")
.with_name("Test User")
.with_email_verified(true),
Duration::hours(24),
)
.await;
ctx.email_provider = Some(Arc::new(alibi_core::email::ConsoleEmailProvider));
let body = serde_json::json!({ "newEmail": "new@example.com" });
let req = test_helpers::create_auth_request(
HttpMethod::Post,
"/change-email",
Some(&session.token),
Some(body.to_string().into_bytes()),
HashMap::new(),
);
let response = plugin.handle_change_email(&req, &ctx).await.unwrap();
assert_eq!(response.status, 200);
}
#[tokio::test]
async fn test_change_email_same_email() {
let plugin = UserManagementPlugin::new().change_email_enabled(true);
let (ctx, _user, session) = test_helpers::create_test_context_with_user(
CreateUser::new()
.with_email("test@example.com")
.with_name("Test User")
.with_email_verified(true),
Duration::hours(24),
)
.await;
let body = serde_json::json!({ "newEmail": "test@example.com" });
let req = test_helpers::create_auth_request(
HttpMethod::Post,
"/change-email",
Some(&session.token),
Some(body.to_string().into_bytes()),
HashMap::new(),
);
let err = plugin.handle_change_email(&req, &ctx).await.unwrap_err();
assert_eq!(err.status_code(), 400);
}
#[tokio::test]
async fn test_change_email_unauthenticated() {
let plugin = UserManagementPlugin::new().change_email_enabled(true);
let (ctx, _user, _session) = test_helpers::create_test_context_with_user(
CreateUser::new()
.with_email("test@example.com")
.with_name("Test User")
.with_email_verified(true),
Duration::hours(24),
)
.await;
let body = serde_json::json!({ "newEmail": "new@example.com" });
let req = test_helpers::create_auth_request(
HttpMethod::Post,
"/change-email",
None,
Some(body.to_string().into_bytes()),
HashMap::new(),
);
let err = plugin.handle_change_email(&req, &ctx).await.unwrap_err();
assert_eq!(err.status_code(), 401);
}
#[tokio::test]
async fn test_change_email_immediate_when_update_without_verification() {
let plugin = UserManagementPlugin::new()
.change_email_enabled(true)
.update_without_verification(true);
let (ctx, user, session) = test_helpers::create_test_context_with_user(
CreateUser::new()
.with_email("test@example.com")
.with_name("Test User")
.with_email_verified(false),
Duration::hours(24),
)
.await;
let body = serde_json::json!({ "newEmail": "new@example.com" });
let req = test_helpers::create_auth_request(
HttpMethod::Post,
"/change-email",
Some(&session.token),
Some(body.to_string().into_bytes()),
HashMap::new(),
);
let response = plugin.handle_change_email(&req, &ctx).await.unwrap();
assert_eq!(response.status, 200);
let updated_user = ctx
.database
.get_user_by_id(&user.id)
.await
.unwrap()
.unwrap();
assert_eq!(updated_user.email(), Some("new@example.com"));
assert!(!updated_user.email_verified);
let identifier = format!("change_email:{}:new@example.com", user.id);
let verification = ctx
.database
.get_verification_by_identifier(&identifier)
.await
.unwrap();
assert!(
verification.is_none(),
"no verification token should be created when update_without_verification=true"
);
}
#[tokio::test]
async fn test_delete_user_immediate() {
let plugin = UserManagementPlugin::new()
.delete_user_enabled(true)
.require_delete_verification(false);
let (ctx, user, session) = test_helpers::create_test_context_with_user(
CreateUser::new()
.with_email("test@example.com")
.with_name("Test User")
.with_email_verified(true),
Duration::hours(24),
)
.await;
let req = test_helpers::create_auth_request(
HttpMethod::Post,
"/delete-user",
Some(&session.token),
Some(b"{}".to_vec()),
HashMap::new(),
);
let response = plugin.handle_delete_user(&req, &ctx).await.unwrap();
assert_eq!(response.status, 200);
let deleted_user = ctx.database.get_user_by_id(&user.id).await.unwrap();
assert!(deleted_user.is_none());
}
#[tokio::test]
async fn test_delete_user_immediate_clears_account_cookie_when_enabled() {
let plugin = UserManagementPlugin::new()
.delete_user_enabled(true)
.require_delete_verification(false);
let config = test_helpers::create_test_config().account(AccountConfig {
store_account_cookie: true,
..Default::default()
});
let ctx = test_helpers::create_test_context_with_config(config).await;
let (_user, session) = test_helpers::create_user_and_session(
&ctx,
CreateUser::new()
.with_email("account-cookie@test.com")
.with_name("Account Cookie")
.with_email_verified(true),
Duration::hours(24),
)
.await;
let req = test_helpers::create_auth_request(
HttpMethod::Post,
"/delete-user",
Some(&session.token),
Some(b"{}".to_vec()),
HashMap::new(),
);
let response = plugin.handle_delete_user(&req, &ctx).await.unwrap();
let account_cookie_name = format!("{}=", related_cookie_name(&ctx.config, "account_data"));
assert!(
response
.headers
.get_all("Set-Cookie")
.any(|cookie| cookie.starts_with(&account_cookie_name)),
"delete-user should clear the account_data cookie when store_account_cookie is enabled"
);
}
#[tokio::test]
async fn test_delete_user_with_verification() {
let plugin = UserManagementPlugin::new()
.delete_user_enabled(true)
.require_delete_verification(true);
let (ctx, user, session) = test_helpers::create_test_context_with_user(
CreateUser::new()
.with_email("test@example.com")
.with_name("Test User")
.with_email_verified(true),
Duration::hours(24),
)
.await;
let req = test_helpers::create_auth_request(
HttpMethod::Post,
"/delete-user",
Some(&session.token),
Some(b"{}".to_vec()),
HashMap::new(),
);
let response = plugin.handle_delete_user(&req, &ctx).await.unwrap();
assert_eq!(response.status, 200);
let still_exists = ctx.database.get_user_by_id(&user.id).await.unwrap();
assert!(still_exists.is_some());
let token = "delete-token-123";
ctx.database
.create_verification(alibi_core::CreateVerification {
identifier: format!("delete-account-{token}"),
value: user.id.clone(),
expires_at: chrono::Utc::now() + Duration::hours(24),
})
.await
.unwrap();
let mut query = HashMap::new();
query.insert("token".to_owned(), token.to_owned());
let req_2 = test_helpers::create_auth_request(
HttpMethod::Get,
"/delete-user/callback",
Some(&session.token),
None,
query,
);
let response_2 = plugin
.handle_delete_user_callback(&req_2, &ctx)
.await
.unwrap();
assert_eq!(response_2.status, 200);
let deleted = ctx.database.get_user_by_id(&user.id).await.unwrap();
assert!(deleted.is_none());
}
#[tokio::test]
async fn test_delete_user_callback_clears_account_cookie_when_enabled() {
let plugin = UserManagementPlugin::new()
.delete_user_enabled(true)
.require_delete_verification(true);
let config = test_helpers::create_test_config().account(AccountConfig {
store_account_cookie: true,
..Default::default()
});
let ctx = test_helpers::create_test_context_with_config(config).await;
let (user, session) = test_helpers::create_user_and_session(
&ctx,
CreateUser::new()
.with_email("callback-cookie@test.com")
.with_name("Callback Cookie")
.with_email_verified(true),
Duration::hours(24),
)
.await;
let token = "delete-cookie-token";
ctx.database
.create_verification(alibi_core::CreateVerification {
identifier: format!("delete-account-{token}"),
value: user.id.clone(),
expires_at: chrono::Utc::now() + Duration::hours(24),
})
.await
.unwrap();
let mut query = HashMap::new();
query.insert("token".to_owned(), token.to_owned());
query.insert(
"callbackURL".to_owned(),
"https://example.com/goodbye".to_owned(),
);
let req = test_helpers::create_auth_request(
HttpMethod::Get,
"/delete-user/callback",
Some(&session.token),
None,
query,
);
let response = plugin
.handle_delete_user_callback(&req, &ctx)
.await
.unwrap();
assert_eq!(response.status, 302);
let account_cookie_name = format!("{}=", related_cookie_name(&ctx.config, "account_data"));
assert!(
response
.headers
.get_all("Set-Cookie")
.any(|cookie| cookie.starts_with(&account_cookie_name)),
"delete-user callback should clear the account_data cookie when store_account_cookie is enabled"
);
}
#[tokio::test]
async fn test_delete_user_unauthenticated() {
let plugin = UserManagementPlugin::new()
.delete_user_enabled(true)
.require_delete_verification(false);
let (ctx, _user, _session) = test_helpers::create_test_context_with_user(
CreateUser::new()
.with_email("test@example.com")
.with_name("Test User")
.with_email_verified(true),
Duration::hours(24),
)
.await;
let req = test_helpers::create_auth_request(
HttpMethod::Post,
"/delete-user",
None,
Some(b"{}".to_vec()),
HashMap::new(),
);
let err = plugin.handle_delete_user(&req, &ctx).await.unwrap_err();
assert_eq!(err.status_code(), 401);
}
#[tokio::test]
async fn test_delete_user_verify_invalid_token() {
let plugin = UserManagementPlugin::new().delete_user_enabled(true);
let (ctx, _user, fixture_session) = test_helpers::create_test_context_with_user(
CreateUser::new()
.with_email("test@example.com")
.with_name("Test User")
.with_email_verified(true),
Duration::hours(24),
)
.await;
let mut query = HashMap::new();
query.insert("token".to_owned(), "invalid-token".to_owned());
let req = test_helpers::create_auth_request(
HttpMethod::Get,
"/delete-user/callback",
Some(&fixture_session.token),
None,
query,
);
let err = plugin
.handle_delete_user_callback(&req, &ctx)
.await
.unwrap_err();
assert_eq!(err.status_code(), 404);
}
#[tokio::test]
async fn test_delete_user_before_hook_abort() {
use std::sync::atomic::{AtomicBool, Ordering};
struct AbortHook;
#[async_trait]
impl BeforeDeleteUser for AbortHook {
async fn before_delete(&self, _user: &UserInfo) -> AuthResult<()> {
Err(AuthError::forbidden("Deletion blocked by policy"))
}
}
struct AfterHook(Arc<AtomicBool>);
#[async_trait]
impl AfterDeleteUser for AfterHook {
async fn after_delete(&self, _user: &UserInfo) -> AuthResult<()> {
self.0.store(true, Ordering::SeqCst);
Ok(())
}
}
let called = Arc::new(AtomicBool::new(false));
let called_clone = std::sync::Arc::clone(&called);
let plugin = UserManagementPlugin::new()
.delete_user_enabled(true)
.require_delete_verification(false)
.before_delete(Arc::new(AbortHook))
.after_delete(Arc::new(AfterHook(called_clone)));
let (ctx, user, session) = test_helpers::create_test_context_with_user(
CreateUser::new()
.with_email("test@example.com")
.with_name("Test User")
.with_email_verified(true),
Duration::hours(24),
)
.await;
let req = test_helpers::create_auth_request(
HttpMethod::Post,
"/delete-user",
Some(&session.token),
Some(b"{}".to_vec()),
HashMap::new(),
);
let err = plugin.handle_delete_user(&req, &ctx).await.unwrap_err();
assert_eq!(err.status_code(), 403);
let still_exists = ctx.database.get_user_by_id(&user.id).await.unwrap();
assert!(still_exists.is_some());
assert!(!called.load(Ordering::SeqCst));
}
#[tokio::test]
async fn disabled_user_management_rejects_authenticated_mutations() {
let plugin = UserManagementPlugin::new();
let (ctx, user, session) = test_helpers::create_test_context_with_user(
CreateUser::new()
.with_email("test@example.com")
.with_name("Test User")
.with_email_verified(true),
Duration::hours(24),
)
.await;
for (path, body, status) in [
(
"/change-email",
serde_json::json!({"newEmail":"changed@example.com"}),
400,
),
("/delete-user", serde_json::json!({}), 404),
] {
let req = test_helpers::create_auth_request(
HttpMethod::Post,
path,
Some(&session.token),
Some(body.to_string().into_bytes()),
HashMap::new(),
);
let result = plugin.on_request(&req, &ctx).await;
match result {
Ok(Some(response)) => assert_eq!(response.status, status),
Err(error) => assert_eq!(error.status_code(), status),
other => panic!("registered disabled handler did not reject: {other:?}"),
}
let stored = ctx
.database
.get_user_by_id(&user.id)
.await
.unwrap()
.unwrap();
assert_eq!(Some(stored.email), user.email);
assert!(
ctx.database
.get_session(&session.token)
.await
.unwrap()
.is_some()
);
}
}
}