pub(super) mod handlers;
pub(crate) mod token;
pub(super) mod types;
use super::StatusResponse;
use alibi_core::AuthUser;
use alibi_core::wire::UserView;
use alibi_core::{AuthContext, AuthError, AuthResult};
use alibi_core::{AuthRequest, AuthResponse};
use async_trait::async_trait;
use chrono::Duration;
use handlers::{send_verification_email_core, verification_url, verify_email_core};
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use types::VerifyEmailResult;
pub use types::{SendVerificationEmailRequest, VerifyEmailQuery, VerifyEmailResponse};
#[async_trait]
pub trait SendVerificationEmail: Send + Sync {
async fn send(&self, user: &UserView, url: &str, token: &str) -> AuthResult<()>;
}
pub type EmailVerificationHook =
Arc<dyn Fn(&UserView) -> Pin<Box<dyn Future<Output = AuthResult<()>> + Send>> + Send + Sync>;
pub struct EmailVerificationPlugin {
config: EmailVerificationConfig,
}
impl std::fmt::Debug for EmailVerificationPlugin {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("EmailVerificationPlugin")
.finish_non_exhaustive()
}
}
#[derive(Clone, alibi_core::PluginConfig)]
#[plugin(name = "EmailVerificationPlugin")]
#[expect(
clippy::struct_excessive_bools,
reason = "Independent configuration switches model distinct upstream behavior, rather than mutually exclusive states"
)]
pub struct EmailVerificationConfig {
#[config(default = Duration::hours(1))]
pub verification_token_expiry: Duration,
#[config(default = true)]
pub send_email_notifications: bool,
#[config(default = None)]
pub send_on_sign_up: Option<bool>,
#[config(default = false)]
pub require_verification_for_signin: bool,
#[config(default = false)]
pub auto_verify_new_users: bool,
#[config(default = false)]
pub send_on_sign_in: bool,
#[config(default = false)]
pub auto_sign_in_after_verification: bool,
#[config(default = None, skip)]
pub send_verification_email: Option<Arc<dyn SendVerificationEmail>>,
#[config(default = None)]
pub before_email_verification: Option<EmailVerificationHook>,
#[config(default = None)]
pub after_email_verification: Option<EmailVerificationHook>,
}
impl std::fmt::Debug for EmailVerificationConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("EmailVerificationConfig")
.finish_non_exhaustive()
}
}
impl EmailVerificationPlugin {
#[must_use]
pub fn custom_send_verification_email(
mut self,
sender: Arc<dyn SendVerificationEmail>,
) -> Self {
self.config.send_verification_email = Some(sender);
self
}
}
alibi_core::impl_auth_plugin! {
EmailVerificationPlugin, "email-verification";
routes {
post "/send-verification-email" => handle_send_verification_email, "send_verification_email";
get "/verify-email" => handle_verify_email, "verify_email";
}
extra {
route_openapi_metadata!(S);
fn server_endpoints(&self) -> Vec<alibi_core::endpoint::EndpointDefinition> {
vec![
crate::endpoint::definition(
"sendVerificationEmail", "sendVerificationEmail", Some("/send-verification-email"), alibi_core::HttpMethod::Post,
),
crate::endpoint::definition(
"verifyEmail", "verifyEmail", Some("/verify-email"), alibi_core::HttpMethod::Get,
),
]
}
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() == "sendVerificationEmail" {
return Ok(alibi_core::endpoint::EndpointInput {
body: Some(crate::endpoint::validate_fields(
call.body(), "body", SendVerificationEmailRequest::FIELDS,
)?),
query: call.query().cloned(),
});
}
Ok(alibi_core::endpoint::EndpointInput {
body: call.body().cloned(),
query: Some(crate::endpoint::validate_fields(
call.query(), "query", VerifyEmailQuery::FIELDS,
)?),
})
}
async fn on_endpoint(
&self,
call: &alibi_core::endpoint::EndpointCall,
ctx: &AuthContext<S>,
) -> AuthResult<alibi_core::endpoint::EndpointResponse> {
if call.operation_id() == "sendVerificationEmail" {
let body: SendVerificationEmailRequest = call.body_as()?;
let current = alibi_core::session::cookie_cache::runtime::authenticated(ctx, call, false)
.await.ok().flatten();
if let Some(read) = ¤t {
call.record_authenticated_session(ctx.user_view(&read.user), read.session.clone());
}
return alibi_core::endpoint::EndpointResponse::json(
&send_verification_email_core(&body, current.as_ref().map(|read| &read.user), &self.config, ctx).await?,
);
}
let mut query: VerifyEmailQuery = call.query_as()?;
query.callback_url = query.callback_url.filter(|url| !url.is_empty());
if call.request().is_some()
&& let Some(url) = &query.callback_url
&& !ctx.config.current_origin_check_disabled()
&& !ctx.config.is_redirect_target_trusted(url)
{
return Err(AuthError::forbidden("Invalid callbackURL"));
}
let headers = call.headers().cloned().unwrap_or_default();
let meta = alibi_core::RequestMeta {
ip_address: Some(ctx.config.advanced.ip_address.resolve_ip(&headers).unwrap_or_default()),
user_agent: Some(headers.get("user-agent").cloned().unwrap_or_default()),
};
let (mut response, token) = match verify_email_core(&query, &self.config, call, ctx, meta).await? {
VerifyEmailResult::Redirect { url, session_token } =>
(alibi_core::endpoint::EndpointResponse::redirect(url), session_token),
VerifyEmailResult::Json { body, session_token } =>
(alibi_core::endpoint::EndpointResponse::json(&body)?, session_token),
};
if let Some(token) = token {
response.merge_headers(crate::authentication_helpers::session_cookie_headers(
&ctx.config, &token, ctx.session_manager().has_dont_remember_cookie(call),
)?);
}
Ok(response)
}
async fn on_init(&self, ctx: &mut alibi_core::AuthInitContext<S>) -> AuthResult<()> {
ctx.extensions.insert(self.config.clone());
Ok(())
}
async fn on_user_created(&self, user: &S::User, ctx: &AuthContext<S>) -> AuthResult<()> {
if (self.config.send_email_notifications || self.config.send_verification_email.is_some())
&& !user.email_verified()
&& let Some(email) = user.email()
{
self.send_verification_email_for_user(user, email, None, ctx)
.await?;
}
Ok(())
}
}
}
impl EmailVerificationPlugin {
pub fn send_verification_email_endpoint(
body: &SendVerificationEmailRequest,
) -> AuthResult<alibi_core::endpoint::ServerEndpoint<alibi_core::StatusResponse>> {
alibi_core::endpoint::ServerEndpoint::new("email-verification", "sendVerificationEmail")
.with_body(body)
}
pub fn verify_email_endpoint(
query: &VerifyEmailQuery,
) -> AuthResult<alibi_core::endpoint::ServerEndpoint<VerifyEmailResponse>> {
alibi_core::endpoint::ServerEndpoint::new("email-verification", "verifyEmail")
.with_query(query)
}
async fn handle_send_verification_email(
&self,
req: &AuthRequest,
ctx: &AuthContext<impl alibi_core::AuthSchema>,
) -> AuthResult<AuthResponse> {
let body: SendVerificationEmailRequest =
match super::authentication_helpers::parse_body(req) {
Ok(v) => v,
Err(resp) => return Ok(resp),
};
let current_user =
alibi_core::session::cookie_cache::runtime::authenticated(ctx, req, false)
.await
.ok()
.flatten()
.map(|read| read.user);
let response =
send_verification_email_core(&body, current_user.as_ref(), &self.config, ctx).await?;
Ok(AuthResponse::json(200, &response)?)
}
async fn handle_verify_email(
&self,
req: &AuthRequest,
ctx: &AuthContext<impl alibi_core::AuthSchema>,
) -> AuthResult<AuthResponse> {
let token = req.query.get("token").ok_or(AuthError::Upstream {
status: 400,
code: "VALIDATION_ERROR",
message: "[query.token] Invalid input: expected string, received undefined",
})?;
let callback_url = req
.query
.get("callbackURL")
.filter(|url| !url.is_empty())
.cloned();
if let Some(ref url) = callback_url
&& !ctx.config.current_origin_check_disabled()
&& !ctx.config.is_redirect_target_trusted(url)
{
return Ok(AuthError::forbidden("Invalid callbackURL").to_auth_response());
}
let query = VerifyEmailQuery {
token: token.clone(),
callback_url,
};
match verify_email_core(
&query,
&self.config,
req,
ctx,
alibi_core::RequestMeta::from_request(req),
)
.await?
{
VerifyEmailResult::Redirect { url, session_token } => {
let mut headers = alibi_core::Headers::new();
_ = headers.insert("Location".to_owned(), url);
_ = headers.insert("content-type".to_owned(), "application/json".to_owned());
let mut response = AuthResponse {
status: 302,
headers,
body: Vec::new(),
};
if let Some(token) = session_token {
super::user_management::append_session_cookie(
&mut response,
req,
&token,
&ctx.config,
)?;
}
Ok(response)
}
VerifyEmailResult::Json {
body,
session_token,
} => {
let mut response = AuthResponse::json(200, &body)?;
if let Some(token) = session_token {
super::user_management::append_session_cookie(
&mut response,
req,
&token,
&ctx.config,
)?;
}
Ok(response)
}
}
}
pub(crate) async fn send_verification_email_for_user(
&self,
user: &impl AuthUser,
email: &str,
callback_url: Option<&str>,
ctx: &AuthContext<impl alibi_core::AuthSchema>,
) -> AuthResult<()> {
if self.config.send_verification_email.is_none()
&& let Some(sender) = ctx.email_verification_override()
{
return sender.0.send(&ctx.trusted_user_view(user), None, ctx).await;
}
let verification_token = token::create_email_verification_token(
ctx.config.current_secret(),
email,
None,
self.config.verification_token_expiry,
None,
)?;
let verification_url = verification_url(&ctx.config, &verification_token, callback_url);
if let Some(ref custom_sender) = self.config.send_verification_email {
let user = ctx.trusted_user_view(user);
send_custom_verification_email(
ctx,
custom_sender,
user,
verification_url,
verification_token,
ctx.config.awaited_notification_errors,
)
.await?;
} else if self.config.send_email_notifications {
if let Some(provider) = ctx.email_provider.clone() {
let subject = "Verify your email address";
let html = format!(
"<p>Click the link below to verify your email address:</p>\
<p><a href=\"{verification_url}\">Verify Email</a></p>"
);
let text = format!("Verify your email address: {verification_url}");
let email = email.to_owned();
super::authentication_helpers::run_owned_notification(
ctx,
async move { provider.send(&email, subject, &html, &text).await },
ctx.config.awaited_notification_errors,
)
.await?;
} else {
tracing::warn!("No email provider configured, skipping verification email");
}
}
Ok(())
}
pub async fn send_verification_on_sign_in(
&self,
user: &impl AuthUser,
callback_url: Option<&str>,
ctx: &AuthContext<impl alibi_core::AuthSchema>,
) -> AuthResult<()> {
if !self.config.send_on_sign_in {
return Ok(());
}
if user.email_verified() {
return Ok(());
}
if let Some(email) = user.email() {
self.send_verification_email_for_user(user, email, callback_url, ctx)
.await?;
}
Ok(())
}
#[must_use]
pub const fn should_send_on_sign_in(&self) -> bool {
self.config.send_on_sign_in
}
#[must_use]
pub const fn is_verification_required(&self) -> bool {
self.config.require_verification_for_signin
}
pub fn is_user_verified_or_not_required(&self, user: &impl AuthUser) -> bool {
user.email_verified() || !self.config.require_verification_for_signin
}
}
pub(crate) async fn send_custom_verification_email(
ctx: &AuthContext<impl alibi_core::AuthSchema>,
sender: &Arc<dyn SendVerificationEmail>,
user: alibi_core::wire::UserView,
url: String,
token: String,
error_policy: alibi_core::AwaitedNotificationErrorPolicy,
) -> AuthResult<()> {
let sender = Arc::clone(sender);
super::authentication_helpers::run_owned_notification(
ctx,
async move { sender.send(&user, &url, &token).await },
error_policy,
)
.await
}
pub(crate) async fn send_signup_verification<S: alibi_core::AuthSchema>(
user: &impl AuthUser,
callback_url: Option<&str>,
required: bool,
ctx: &AuthContext<S>,
tx: &dyn alibi_core::store::AuthTransaction<S>,
) -> AuthResult<()> {
let config = ctx.extensions.get::<EmailVerificationConfig>();
if !config
.as_ref()
.and_then(|config| config.send_on_sign_up)
.unwrap_or(required)
{
return Ok(());
}
let user = ctx.trusted_user_view(user);
if config
.as_ref()
.is_none_or(|config| config.send_verification_email.is_none())
&& let Some(sender) = ctx.email_verification_override()
{
return sender.0.send_in_transaction(&user, None, ctx, tx).await;
}
let Some(config) = config else {
return Ok(());
};
let Some(sender) = &config.send_verification_email else {
return Ok(());
};
let Some(email) = user.email.as_deref() else {
return Ok(());
};
let token = token::create_email_verification_token(
ctx.config.current_secret(),
email,
None,
config.verification_token_expiry,
None,
)?;
let url = verification_url(&ctx.config, &token, callback_url);
send_custom_verification_email(
ctx,
sender,
user,
url,
token,
ctx.config.awaited_notification_errors,
)
.await
}
#[cfg(test)]
mod tests {
use super::token::create_email_verification_token;
use super::*;
use crate::test_helpers;
use alibi_core::utils::cookie_utils::create_session_cookie;
use alibi_core::wire::UserView;
use alibi_core::{AuthPlugin, HttpMethod};
use alibi_core::{AuthResult, AuthSession, CreateUser, UpdateUser};
use async_trait::async_trait;
use chrono::{Duration, Utc};
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
struct DummySender;
#[async_trait]
impl SendVerificationEmail for DummySender {
async fn send(&self, _user: &UserView, _url: &str, _token: &str) -> AuthResult<()> {
Ok(())
}
}
#[test]
fn test_default_config() {
let config = EmailVerificationConfig::default();
assert_eq!(config.verification_token_expiry, Duration::hours(1));
assert!(config.send_email_notifications);
assert!(!config.require_verification_for_signin);
assert!(!config.auto_verify_new_users);
assert!(!config.send_on_sign_in);
assert!(!config.auto_sign_in_after_verification);
assert!(config.send_verification_email.is_none());
assert!(config.before_email_verification.is_none());
assert!(config.after_email_verification.is_none());
}
#[test]
fn test_builder_verification_token_expiry() {
let plugin =
EmailVerificationPlugin::new().verification_token_expiry(Duration::minutes(30));
assert_eq!(
plugin.config.verification_token_expiry,
Duration::minutes(30)
);
}
#[test]
fn test_builder_send_on_sign_in() {
let plugin = EmailVerificationPlugin::new().send_on_sign_in(true);
assert!(plugin.config.send_on_sign_in);
}
#[test]
fn test_builder_auto_sign_in_after_verification() {
let plugin = EmailVerificationPlugin::new().auto_sign_in_after_verification(true);
assert!(plugin.config.auto_sign_in_after_verification);
}
#[test]
fn test_builder_send_email_notifications() {
let plugin = EmailVerificationPlugin::new().send_email_notifications(false);
assert!(!plugin.config.send_email_notifications);
}
#[test]
fn test_builder_require_verification_for_signin() {
let plugin = EmailVerificationPlugin::new().require_verification_for_signin(true);
assert!(plugin.config.require_verification_for_signin);
}
#[test]
fn test_builder_auto_verify_new_users() {
let plugin = EmailVerificationPlugin::new().auto_verify_new_users(true);
assert!(plugin.config.auto_verify_new_users);
}
#[test]
fn test_builder_chaining() {
let plugin = EmailVerificationPlugin::new()
.verification_token_expiry(Duration::hours(2))
.send_on_sign_in(true)
.auto_sign_in_after_verification(true)
.send_email_notifications(false)
.require_verification_for_signin(true);
assert_eq!(plugin.config.verification_token_expiry, Duration::hours(2));
assert!(plugin.config.send_on_sign_in);
assert!(plugin.config.auto_sign_in_after_verification);
assert!(!plugin.config.send_email_notifications);
assert!(plugin.config.require_verification_for_signin);
}
#[test]
fn test_builder_custom_send_verification_email() {
let plugin =
EmailVerificationPlugin::new().custom_send_verification_email(Arc::new(DummySender));
assert!(plugin.config.send_verification_email.is_some());
}
#[test]
fn test_builder_before_email_verification_hook() {
let hook: EmailVerificationHook = Arc::new(|_user: &UserView| Box::pin(async { Ok(()) }));
let plugin = EmailVerificationPlugin::new().before_email_verification(hook);
assert!(plugin.config.before_email_verification.is_some());
}
#[test]
fn test_builder_after_email_verification_hook() {
let hook: EmailVerificationHook = Arc::new(|_user: &UserView| Box::pin(async { Ok(()) }));
let plugin = EmailVerificationPlugin::new().after_email_verification(hook);
assert!(plugin.config.after_email_verification.is_some());
}
fn make_test_user(email: &str, verified: bool) -> UserView {
UserView {
omitted_fields: std::collections::BTreeSet::default(),
id: "test-id".into(),
name: Some("Test".into()),
email: Some(email.into()),
email_verified: verified,
image: None,
created_at: Utc::now(),
updated_at: Utc::now(),
username: None,
display_username: None,
two_factor_enabled: Some(false),
role: None,
banned: Some(false),
ban_reason: None,
ban_expires: None,
metadata: serde_json::Value::Null,
is_anonymous: None,
phone_number: None,
phone_number_verified: None,
last_login_method: None,
extension_fields: std::collections::BTreeMap::default(),
}
}
fn jwt_token(
ctx: &AuthContext<impl alibi_core::AuthSchema>,
email: &str,
update_to: Option<&str>,
request_type: Option<&str>,
) -> String {
create_email_verification_token(
&ctx.config.secret,
email,
update_to,
Duration::hours(1),
request_type,
)
.unwrap()
}
#[tokio::test]
#[expect(
clippy::too_many_lines,
reason = "Keep this ordered integration scenario and its assertions together; Result propagates setup failures"
)]
async fn legacy_email_change_reuses_or_issues_session_and_default_lifetime_followup() {
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
struct Sender {
calls: Arc<std::sync::Mutex<Vec<(UserView, String)>>>,
fail: bool,
}
#[derive(Default)]
struct Observer(std::sync::Mutex<Option<alibi_core::BackgroundTaskCompletion>>);
impl alibi_core::BackgroundTaskHandler for Observer {
fn handle(&self, completion: alibi_core::BackgroundTaskCompletion) -> AuthResult<()> {
*self.0.lock().unwrap() = Some(completion);
Ok(())
}
}
#[async_trait]
impl SendVerificationEmail for Sender {
async fn send(&self, user: &UserView, _: &str, token: &str) -> AuthResult<()> {
self.calls
.lock()
.unwrap()
.push((user.clone(), token.into()));
if self.fail {
Err(AuthError::bad_request("fixture delivery failed"))
} else {
Ok(())
}
}
}
for (authenticated, fail, background) in [
(false, false, false),
(true, false, false),
(false, true, false),
(true, true, false),
(false, true, true),
(true, true, true),
] {
let mut ctx = test_helpers::create_test_context().await;
let mut init = alibi_core::AuthInitContext::new(
Arc::clone(&ctx.config),
Arc::clone(&ctx.database),
);
init.extensions
.insert(alibi_core::field_policy::SessionFields::default());
ctx.database = init.database_with_registered_transforms();
ctx.extensions = init.into_parts().extensions;
let observer = Arc::new(Observer::default());
if background {
Arc::make_mut(&mut ctx.config).background_tasks = Some(observer.clone());
}
let calls = Arc::new(std::sync::Mutex::new(Vec::new()));
let plugin = EmailVerificationPlugin::new()
.verification_token_expiry(Duration::seconds(90))
.custom_send_verification_email(Arc::new(Sender {
calls: Arc::clone(&calls),
fail,
}));
let user = ctx
.database
.create_user(CreateUser::new().with_email("before@legacy.fixture.test"))
.await
.unwrap();
let current = if authenticated {
Some(
ctx.database
.create_session(alibi_core::CreateSession {
additional_fields: alibi_core::field_policy::FieldValues::default(),
token: None,
user_id: user.id().to_string(),
expires_at: Utc::now() + ctx.config.session.expires_in,
ip_address: None,
user_agent: None,
impersonated_by: None,
active_organization_id: None,
active_team_id: None,
})
.await
.unwrap(),
)
} else {
None
};
let mut query = HashMap::new();
query.insert(
"token".into(),
jwt_token(
&ctx,
"before@legacy.fixture.test",
Some("After@legacy.fixture.test"),
None,
),
);
let req = test_helpers::create_auth_request(
HttpMethod::Get,
"/verify-email",
current.as_ref().map(|session| session.token.as_str()),
None,
query,
);
let result = plugin.handle_verify_email(&req, &ctx).await;
let response = if fail && !background {
assert_eq!(result.unwrap_err().status_code(), 400);
None
} else {
let response = result.unwrap();
assert_eq!(response.status, 200);
let body: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
assert_eq!(
(*(*(body).get("user").unwrap_or(&serde_json::Value::Null))
.get("id")
.unwrap_or(&serde_json::Value::Null))
.as_str(),
Some(user.id().as_ref())
);
assert_eq!(
(*(*(body).get("user").unwrap_or(&serde_json::Value::Null))
.get("email")
.unwrap_or(&serde_json::Value::Null)),
"after@legacy.fixture.test"
);
assert_eq!(
(*(*(body).get("user").unwrap_or(&serde_json::Value::Null))
.get("emailVerified")
.unwrap_or(&serde_json::Value::Null)),
false
);
Some(response)
};
if background {
let completion = observer.0.lock().unwrap().take().unwrap();
completion.await.unwrap();
}
let sessions = ctx.database.get_user_sessions(&user.id()).await.unwrap();
assert_eq!(sessions.len(), 1, "Anonymous proof must issue a session");
let session = sessions.first().unwrap();
if let Some(previous) = current {
assert_eq!(session.id(), previous.id());
assert_eq!(session.token(), previous.token());
}
if let Some(response) = response {
assert_eq!(
response.headers.get("set-cookie"),
Some(&create_session_cookie(session.token(), &ctx.config).unwrap())
);
}
let updated = ctx
.database
.get_user_by_email("after@legacy.fixture.test")
.await
.unwrap()
.unwrap();
assert_eq!(updated.id(), user.id());
assert!(!updated.email_verified());
let calls = calls.lock().unwrap();
assert_eq!(calls.len(), 1);
let (recipient, token) = calls.first().unwrap();
assert_eq!(recipient.id, user.id());
assert_eq!(
recipient.email.as_deref(),
Some("after@legacy.fixture.test")
);
let encoded = token.split('.').nth(1).unwrap();
let claims: serde_json::Value =
serde_json::from_slice(&URL_SAFE_NO_PAD.decode(encoded).unwrap()).unwrap();
assert_eq!(
(*(claims).get("email").unwrap_or(&serde_json::Value::Null)),
"after@legacy.fixture.test"
);
assert_eq!(
(*(claims).get("exp").unwrap_or(&serde_json::Value::Null))
.as_i64()
.unwrap()
- (*(claims).get("iat").unwrap_or(&serde_json::Value::Null))
.as_i64()
.unwrap(),
3600
);
assert!(claims.get("updateTo").is_none());
drop(calls);
}
}
fn external_verification_token(
secret: &str,
algorithm: jsonwebtoken::Algorithm,
payload: &serde_json::Value,
) -> String {
let mut header = jsonwebtoken::Header::new(algorithm);
header.typ = None;
jsonwebtoken::encode(
&header,
payload,
&jsonwebtoken::EncodingKey::from_secret(secret.as_bytes()),
)
.unwrap()
}
#[tokio::test]
async fn external_verification_proofs_enforce_signature_algorithm_and_numeric_dates() {
use alibi_core::AuthUser;
let ctx = test_helpers::create_test_context().await;
let plugin = EmailVerificationPlugin::new().auto_sign_in_after_verification(true);
let user = ctx
.database
.create_user(CreateUser::new().with_email("proof-owner@fixture.test"))
.await
.unwrap();
let now = Utc::now().timestamp();
for (secret, algorithm, dates, code) in [
(
ctx.config.secret.as_str(),
jsonwebtoken::Algorithm::HS256,
serde_json::json!({"exp":now-30}),
"TOKEN_EXPIRED",
),
(
ctx.config.secret.as_str(),
jsonwebtoken::Algorithm::HS256,
serde_json::json!({"exp":now}),
"TOKEN_EXPIRED",
),
(
ctx.config.secret.as_str(),
jsonwebtoken::Algorithm::HS256,
serde_json::json!({"exp":now+300,"nbf":now+30}),
"INVALID_TOKEN",
),
(
ctx.config.secret.as_str(),
jsonwebtoken::Algorithm::HS256,
serde_json::json!({"exp":now+300,"iat":"invalid"}),
"INVALID_TOKEN",
),
(
ctx.config.secret.as_str(),
jsonwebtoken::Algorithm::HS384,
serde_json::json!({"exp":now+300}),
"INVALID_TOKEN",
),
(
"different-signing-key-for-fixture-only",
jsonwebtoken::Algorithm::HS256,
serde_json::json!({"exp":now+300}),
"INVALID_TOKEN",
),
] {
let mut payload = dates;
_ = payload
.as_object_mut()
.expect("token payload is an object")
.insert(
"email".to_owned(),
serde_json::json!("proof-owner@fixture.test"),
);
let token = external_verification_token(secret, algorithm, &payload);
let query = HashMap::from([("token".to_owned(), token.clone())]);
let req = test_helpers::create_auth_request(
HttpMethod::Get,
"/verify-email",
None,
None,
query,
);
let error = plugin.handle_verify_email(&req, &ctx).await.unwrap_err();
assert_eq!(error.status_code(), 401);
assert_eq!(error.error_payload().1.as_deref(), Some(code));
let callback = "/verification-error?source=caller&error=existing#details";
let query_2 = HashMap::from([
("token".to_owned(), token),
("callbackURL".to_owned(), callback.to_owned()),
]);
let req_2 = test_helpers::create_auth_request(
HttpMethod::Get,
"/verify-email",
None,
None,
query_2,
);
let response = plugin.handle_verify_email(&req_2, &ctx).await.unwrap();
assert_eq!(response.status, 302);
assert_eq!(
response.headers.get("Location"),
Some(&format!(
"/verification-error?source=caller&error=existing&error={code}#details"
))
);
let persisted = ctx
.database
.get_user_by_id(&user.id())
.await
.unwrap()
.unwrap();
assert!(!persisted.email_verified());
assert_eq!(
ctx.database
.get_user_sessions(&user.id())
.await
.unwrap()
.len(),
0
);
}
}
#[tokio::test]
async fn externally_signed_verification_without_dates_or_matching_audience_is_accepted() {
use alibi_core::AuthUser;
let ctx = test_helpers::create_test_context().await;
let plugin = EmailVerificationPlugin::new();
let user = ctx
.database
.create_user(CreateUser::new().with_email("legacy-proof@fixture.test"))
.await
.unwrap();
let token = external_verification_token(
&ctx.config.secret,
jsonwebtoken::Algorithm::HS256,
&serde_json::json!({"email":"legacy-proof@fixture.test","aud":"embedding-app","iss":"embedding-issuer"}),
);
let query = HashMap::from([("token".to_owned(), token)]);
let req =
test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
let response = plugin.handle_verify_email(&req, &ctx).await.unwrap();
assert_eq!(response.status, 200);
let persisted = ctx
.database
.get_user_by_id(&user.id())
.await
.unwrap()
.unwrap();
assert!(persisted.email_verified());
assert_eq!(
ctx.database
.get_user_sessions(&user.id())
.await
.unwrap()
.len(),
0
);
}
#[test]
fn test_should_send_on_sign_in() {
let plugin = EmailVerificationPlugin::new();
assert!(!plugin.should_send_on_sign_in());
let plugin_2 = EmailVerificationPlugin::new().send_on_sign_in(true);
assert!(plugin_2.should_send_on_sign_in());
}
#[test]
fn test_is_verification_required() {
let plugin = EmailVerificationPlugin::new();
assert!(!plugin.is_verification_required());
let plugin_2 = EmailVerificationPlugin::new().require_verification_for_signin(true);
assert!(plugin_2.is_verification_required());
}
#[tokio::test]
async fn test_is_user_verified_or_not_required() {
let plugin = EmailVerificationPlugin::new();
let user = make_test_user("a@b.com", false);
assert!(plugin.is_user_verified_or_not_required(&user));
let plugin_2 = EmailVerificationPlugin::new().require_verification_for_signin(true);
assert!(!plugin_2.is_user_verified_or_not_required(&user));
let verified_user = make_test_user("a@b.com", true);
assert!(plugin_2.is_user_verified_or_not_required(&verified_user));
}
#[test]
fn test_to_user_preserves_fields() {
let user = UserView {
omitted_fields: std::collections::BTreeSet::default(),
id: "test-id".into(),
name: Some("Test User".into()),
email: Some("test@example.com".into()),
email_verified: true,
image: Some("https://img.example.com/a.png".into()),
created_at: Utc::now(),
updated_at: Utc::now(),
username: Some("testuser".into()),
display_username: Some("TestUser".into()),
two_factor_enabled: Some(true),
role: Some("admin".into()),
banned: Some(true),
ban_reason: Some("spam".into()),
ban_expires: None,
metadata: serde_json::Value::Null,
is_anonymous: None,
phone_number: None,
phone_number_verified: None,
last_login_method: None,
extension_fields: std::collections::BTreeMap::default(),
};
let converted = UserView::from(&user);
assert_eq!(converted.id, "test-id");
assert_eq!(converted.name.as_deref(), Some("Test User"));
assert_eq!(converted.email.as_deref(), Some("test@example.com"));
assert!(converted.email_verified);
assert_eq!(
converted.image.as_deref(),
Some("https://img.example.com/a.png")
);
assert_eq!(converted.username.as_deref(), Some("testuser"));
assert_eq!(converted.display_username.as_deref(), Some("TestUser"));
assert_eq!(converted.two_factor_enabled, Some(true));
assert_eq!(converted.role.as_deref(), Some("admin"));
assert_eq!(converted.banned, Some(true));
assert_eq!(converted.ban_reason.as_deref(), Some("spam"));
}
#[test]
fn test_plugin_name() {
let plugin = EmailVerificationPlugin::new();
assert_eq!(
AuthPlugin::<
alibi_seaorm::store::__private_test_support::bundled_schema::BundledSchema,
>::name(&plugin,),
"email-verification"
);
}
#[test]
fn test_plugin_routes() {
let plugin = EmailVerificationPlugin::new();
let routes = AuthPlugin::<
alibi_seaorm::store::__private_test_support::bundled_schema::BundledSchema,
>::routes(&plugin);
assert_eq!(routes.len(), 2);
assert!(
routes
.iter()
.any(|r| r.path == "/send-verification-email" && r.method == HttpMethod::Post)
);
assert!(
routes
.iter()
.any(|r| r.path == "/verify-email" && r.method == HttpMethod::Get)
);
}
#[tokio::test]
async fn test_on_request_unknown_route_returns_none() {
let plugin = EmailVerificationPlugin::new();
let ctx = test_helpers::create_test_context().await;
let req = test_helpers::create_auth_request(
HttpMethod::Get,
"/unknown",
None,
None,
HashMap::new(),
);
let result = plugin.on_request(&req, &ctx).await.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn test_send_verification_on_sign_in_disabled() {
let plugin = EmailVerificationPlugin::new().send_on_sign_in(false);
let ctx = test_helpers::create_test_context().await;
let user = ctx
.database
.create_user(
CreateUser::new()
.with_email("unverified@test.com")
.with_name("Test"),
)
.await
.unwrap();
plugin
.send_verification_on_sign_in(&user, None, &ctx)
.await
.unwrap();
}
#[tokio::test]
async fn test_send_verification_on_sign_in_verified_user() {
let plugin = EmailVerificationPlugin::new().send_on_sign_in(true);
let ctx = test_helpers::create_test_context().await;
let user = ctx
.database
.create_user(
CreateUser::new()
.with_email("verified@test.com")
.with_name("Test"),
)
.await
.unwrap();
let update = UpdateUser {
email_verified: Some(true),
..Default::default()
};
let verified = ctx.database.update_user(&user.id, update).await.unwrap();
plugin
.send_verification_on_sign_in(&verified, None, &ctx)
.await
.unwrap();
}
#[tokio::test]
async fn test_send_verification_on_sign_in_creates_token() {
struct CountingSender(Arc<AtomicU32>);
#[async_trait]
impl SendVerificationEmail for CountingSender {
async fn send(&self, _user: &UserView, _url: &str, _token: &str) -> AuthResult<()> {
self.0.fetch_add(1, Ordering::Relaxed);
Ok(())
}
}
let call_count = Arc::new(AtomicU32::new(0));
let counter = std::sync::Arc::clone(&call_count);
let plugin = EmailVerificationPlugin::new()
.send_on_sign_in(true)
.send_email_notifications(false) .custom_send_verification_email(Arc::new(CountingSender(counter)));
let ctx = test_helpers::create_test_context().await;
let user = ctx
.database
.create_user(
CreateUser::new()
.with_email("unverified@test.com")
.with_name("Test"),
)
.await
.unwrap();
plugin
.send_verification_on_sign_in(&user, None, &ctx)
.await
.unwrap();
assert_eq!(call_count.load(Ordering::Relaxed), 1);
}
#[tokio::test]
async fn test_on_user_created_custom_sender_fires_without_notifications() {
struct CountingSender(Arc<AtomicU32>);
#[async_trait]
impl SendVerificationEmail for CountingSender {
async fn send(&self, _user: &UserView, _url: &str, _token: &str) -> AuthResult<()> {
self.0.fetch_add(1, Ordering::Relaxed);
Ok(())
}
}
let call_count = Arc::new(AtomicU32::new(0));
let counter = std::sync::Arc::clone(&call_count);
let plugin = EmailVerificationPlugin::new()
.send_email_notifications(false)
.custom_send_verification_email(Arc::new(CountingSender(counter)));
let ctx = test_helpers::create_test_context().await;
let user = ctx
.database
.create_user(
CreateUser::new()
.with_email("newuser@test.com")
.with_name("New"),
)
.await
.unwrap();
plugin.on_user_created(&user, &ctx).await.unwrap();
assert_eq!(call_count.load(Ordering::Relaxed), 1);
}
#[tokio::test]
async fn test_on_user_created_verified_user_skips_email() {
struct CountingSender(Arc<AtomicU32>);
#[async_trait]
impl SendVerificationEmail for CountingSender {
async fn send(&self, _user: &UserView, _url: &str, _token: &str) -> AuthResult<()> {
self.0.fetch_add(1, Ordering::Relaxed);
Ok(())
}
}
let call_count = Arc::new(AtomicU32::new(0));
let counter = std::sync::Arc::clone(&call_count);
let plugin = EmailVerificationPlugin::new()
.custom_send_verification_email(Arc::new(CountingSender(counter)));
let ctx = test_helpers::create_test_context().await;
let user = ctx
.database
.create_user(
CreateUser::new()
.with_email("newuser@test.com")
.with_name("New"),
)
.await
.unwrap();
let update = UpdateUser {
email_verified: Some(true),
..Default::default()
};
let verified = ctx.database.update_user(&user.id, update).await.unwrap();
plugin.on_user_created(&verified, &ctx).await.unwrap();
assert_eq!(call_count.load(Ordering::Relaxed), 0);
}
#[tokio::test]
async fn test_verify_email_basic_flow() {
let plugin = EmailVerificationPlugin::new();
let ctx = test_helpers::create_test_context().await;
_ = ctx
.database
.create_user(
CreateUser::new()
.with_email("verify@test.com")
.with_name("Verify Me"),
)
.await
.unwrap();
let token_value = jwt_token(&ctx, "verify@test.com", None, None);
let mut query = HashMap::new();
query.insert("token".to_owned(), token_value.clone());
let req =
test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
let response = plugin.handle_verify_email(&req, &ctx).await.unwrap();
assert_eq!(response.status, 200);
let body: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
assert_eq!(
(*(body).get("status").unwrap_or(&serde_json::Value::Null)),
true
);
assert!((*(body).get("user").unwrap_or(&serde_json::Value::Null)).is_null());
let updated = ctx
.database
.get_user_by_email("verify@test.com")
.await
.unwrap()
.unwrap();
assert!(updated.email_verified);
}
#[tokio::test]
async fn test_verify_email_calls_before_and_after_hooks() {
let before_count = Arc::new(AtomicU32::new(0));
let after_count = Arc::new(AtomicU32::new(0));
let bc = std::sync::Arc::clone(&before_count);
let ac = std::sync::Arc::clone(&after_count);
let before_hook: EmailVerificationHook = Arc::new(move |_user: &UserView| {
let c = std::sync::Arc::clone(&bc);
Box::pin(async move {
c.fetch_add(1, Ordering::Relaxed);
Ok(())
})
});
let after_hook: EmailVerificationHook = Arc::new(move |_user: &UserView| {
let c = std::sync::Arc::clone(&ac);
Box::pin(async move {
c.fetch_add(1, Ordering::Relaxed);
Ok(())
})
});
let plugin = EmailVerificationPlugin::new()
.before_email_verification(before_hook)
.after_email_verification(after_hook);
let ctx = test_helpers::create_test_context().await;
_ = ctx
.database
.create_user(
CreateUser::new()
.with_email("hooks@test.com")
.with_name("Hooks"),
)
.await
.unwrap();
let token_value = jwt_token(&ctx, "hooks@test.com", None, None);
let mut query = HashMap::new();
query.insert("token".to_owned(), token_value);
let req =
test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
let response = plugin.handle_verify_email(&req, &ctx).await.unwrap();
assert_eq!(response.status, 200);
assert_eq!(before_count.load(Ordering::Relaxed), 1);
assert_eq!(after_count.load(Ordering::Relaxed), 1);
}
#[tokio::test]
async fn test_change_email_verification_after_hook_observes_updated_user() {
let captured = Arc::new(std::sync::Mutex::new(Vec::<(Option<String>, bool)>::new()));
let hook_state = std::sync::Arc::clone(&captured);
let after_hook: EmailVerificationHook = Arc::new(move |user: &UserView| {
let hook_state = std::sync::Arc::clone(&hook_state);
let email = user.email.clone();
let verified = user.email_verified;
Box::pin(async move {
hook_state.lock().unwrap().push((email, verified));
Ok(())
})
});
let plugin = EmailVerificationPlugin::new().after_email_verification(after_hook);
let ctx = test_helpers::create_test_context().await;
_ = ctx
.database
.create_user(
CreateUser::new()
.with_email("change-email@test.com")
.with_name("ChangeEmail"),
)
.await
.unwrap();
let token_value = jwt_token(
&ctx,
"change-email@test.com",
Some("updated-email@test.com"),
Some("change-email-verification"),
);
let mut query = HashMap::new();
query.insert("token".to_owned(), token_value);
let req =
test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
let response = plugin.handle_verify_email(&req, &ctx).await.unwrap();
assert_eq!(response.status, 200);
assert_eq!(
*captured.lock().unwrap(),
vec![(Some("updated-email@test.com".to_owned()), true)]
);
let updated_user = ctx
.database
.get_user_by_email("updated-email@test.com")
.await
.unwrap()
.unwrap();
assert!(updated_user.email_verified);
}
#[tokio::test]
async fn test_change_email_verification_does_not_fire_after_hook_when_update_fails() {
let after_count = Arc::new(AtomicU32::new(0));
let counter = std::sync::Arc::clone(&after_count);
let after_hook: EmailVerificationHook = Arc::new(move |_user: &UserView| {
let counter = std::sync::Arc::clone(&counter);
Box::pin(async move {
counter.fetch_add(1, Ordering::Relaxed);
Ok(())
})
});
let plugin = EmailVerificationPlugin::new().after_email_verification(after_hook);
let ctx = test_helpers::create_test_context().await;
_ = ctx
.database
.create_user(
CreateUser::new()
.with_email("duplicate-source@test.com")
.with_name("DuplicateSource"),
)
.await
.unwrap();
_ = ctx
.database
.create_user(
CreateUser::new()
.with_email("duplicate-target@test.com")
.with_name("DuplicateTarget"),
)
.await
.unwrap();
let token_value = jwt_token(
&ctx,
"duplicate-source@test.com",
Some("duplicate-target@test.com"),
Some("change-email-verification"),
);
let mut query = HashMap::new();
query.insert("token".to_owned(), token_value);
let req =
test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
let err = plugin.handle_verify_email(&req, &ctx).await.unwrap_err();
assert_ne!(err.status_code(), 200);
assert_eq!(after_count.load(Ordering::Relaxed), 0);
let source_user = ctx
.database
.get_user_by_email("duplicate-source@test.com")
.await
.unwrap()
.unwrap();
assert_eq!(source_user.email(), Some("duplicate-source@test.com"));
assert!(!source_user.email_verified);
}
#[tokio::test]
async fn test_verify_email_before_hook_error_aborts() {
let before_hook: EmailVerificationHook = Arc::new(|_user: &UserView| {
Box::pin(async { Err(AuthError::forbidden("hook rejected")) })
});
let plugin = EmailVerificationPlugin::new().before_email_verification(before_hook);
let ctx = test_helpers::create_test_context().await;
_ = ctx
.database
.create_user(
CreateUser::new()
.with_email("hook-err@test.com")
.with_name("HookErr"),
)
.await
.unwrap();
let token_value = jwt_token(&ctx, "hook-err@test.com", None, None);
let mut query = HashMap::new();
query.insert("token".to_owned(), token_value.clone());
let req =
test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
let err = plugin.handle_verify_email(&req, &ctx).await.unwrap_err();
assert_eq!(err.status_code(), 403);
let u = ctx
.database
.get_user_by_email("hook-err@test.com")
.await
.unwrap()
.unwrap();
assert!(!u.email_verified);
}
#[tokio::test]
async fn test_verify_email_auto_sign_in_creates_session() {
struct CapturingSender(Arc<std::sync::Mutex<String>>);
#[async_trait]
impl SendVerificationEmail for CapturingSender {
async fn send(&self, _user: &UserView, _url: &str, token: &str) -> AuthResult<()> {
*self.0.lock().unwrap() = token.to_owned();
Ok(())
}
}
let captured = Arc::new(std::sync::Mutex::new(String::new()));
let plugin = EmailVerificationPlugin::new()
.auto_sign_in_after_verification(true)
.custom_send_verification_email(Arc::new(CapturingSender(std::sync::Arc::clone(
&captured,
))));
let ctx = test_helpers::create_test_context().await;
_ = ctx
.database
.create_user(
CreateUser::new()
.with_email("autosign@test.com")
.with_name("AutoSign"),
)
.await
.unwrap();
let body = serde_json::json!({ "email": "autosign@test.com" });
let mut headers = HashMap::new();
headers.insert("content-type".to_owned(), "application/json".to_owned());
let send_req = AuthRequest::from_parts(
HttpMethod::Post,
"/send-verification-email".to_owned(),
headers,
Some(body.to_string().into_bytes()),
HashMap::new(),
);
let send_response = plugin
.handle_send_verification_email(&send_req, &ctx)
.await
.unwrap();
assert_eq!(send_response.status, 200);
let mut query = HashMap::new();
query.insert("token".to_owned(), captured.lock().unwrap().clone());
let req =
test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
let response = plugin.handle_verify_email(&req, &ctx).await.unwrap();
assert_eq!(response.status, 200);
let body_2: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
assert_eq!(
(*(body_2).get("status").unwrap_or(&serde_json::Value::Null)),
true
);
assert!((*(body_2).get("user").unwrap_or(&serde_json::Value::Null)).is_null());
assert!(response.headers.contains_key("Set-Cookie"));
let cookie_header = (response.headers)
.get("Set-Cookie")
.expect("fixture contains the requested index");
assert!(cookie_header.contains("better-auth.session"));
}
#[tokio::test]
async fn test_verify_email_no_auto_sign_in_no_session() {
let plugin = EmailVerificationPlugin::new().auto_sign_in_after_verification(false);
let ctx = test_helpers::create_test_context().await;
_ = ctx
.database
.create_user(
CreateUser::new()
.with_email("noautosign@test.com")
.with_name("NoAutoSign"),
)
.await
.unwrap();
let token_value = jwt_token(&ctx, "noautosign@test.com", None, None);
let mut query = HashMap::new();
query.insert("token".to_owned(), token_value);
let req =
test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
let response = plugin.handle_verify_email(&req, &ctx).await.unwrap();
assert_eq!(response.status, 200);
let body: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
assert_eq!(
(*(body).get("status").unwrap_or(&serde_json::Value::Null)),
true
);
assert!(body.get("session").is_none());
assert!(!response.headers.contains_key("Set-Cookie"));
}
#[tokio::test]
async fn test_verify_email_auto_sign_in_redirect_includes_cookie() {
let plugin = EmailVerificationPlugin::new().auto_sign_in_after_verification(true);
let ctx = test_helpers::create_test_context().await;
_ = ctx
.database
.create_user(
CreateUser::new()
.with_email("redirect@test.com")
.with_name("Redirect"),
)
.await
.unwrap();
let token_value = jwt_token(&ctx, "redirect@test.com", None, None);
let mut query = HashMap::new();
query.insert("token".to_owned(), token_value);
query.insert("callbackURL".to_owned(), "/verified".to_owned());
let req =
test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
let response = plugin.handle_verify_email(&req, &ctx).await.unwrap();
assert_eq!(response.status, 302);
assert_eq!(
(*(response.headers)
.get("Location")
.expect("fixture contains the requested index")),
"/verified"
);
assert!(response.headers.contains_key("Set-Cookie"));
assert!(
(*(response.headers)
.get("Set-Cookie")
.expect("fixture contains the requested index"))
.contains("better-auth.session")
);
}
#[tokio::test]
async fn test_verify_email_redirect_without_auto_sign_in_no_cookie() {
let plugin = EmailVerificationPlugin::new().auto_sign_in_after_verification(false);
let ctx = test_helpers::create_test_context().await;
_ = ctx
.database
.create_user(
CreateUser::new()
.with_email("redir-nocookie@test.com")
.with_name("Redir"),
)
.await
.unwrap();
let token_value = jwt_token(&ctx, "redir-nocookie@test.com", None, None);
let mut query = HashMap::new();
query.insert("token".to_owned(), token_value);
query.insert("callbackURL".to_owned(), "/verified".to_owned());
let req =
test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
let response = plugin.handle_verify_email(&req, &ctx).await.unwrap();
assert_eq!(response.status, 302);
assert!(!response.headers.contains_key("Set-Cookie"));
}
#[tokio::test]
async fn test_verify_email_invalid_token() {
let plugin = EmailVerificationPlugin::new();
let ctx = test_helpers::create_test_context().await;
let mut query = HashMap::new();
query.insert("token".to_owned(), "bogus-token".to_owned());
let req =
test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
let err = plugin.handle_verify_email(&req, &ctx).await.unwrap_err();
assert_eq!(err.status_code(), 401);
assert_eq!(err.error_payload().1.as_deref(), Some("INVALID_TOKEN"));
}
#[tokio::test]
async fn test_verify_email_missing_token() {
let plugin = EmailVerificationPlugin::new();
let ctx = test_helpers::create_test_context().await;
let req = test_helpers::create_auth_request(
HttpMethod::Get,
"/verify-email",
None,
None,
HashMap::new(),
);
let err = plugin.handle_verify_email(&req, &ctx).await.unwrap_err();
assert_eq!(err.status_code(), 400);
}
#[tokio::test]
async fn test_verify_email_already_verified_returns_ok() {
let plugin = EmailVerificationPlugin::new();
let ctx = test_helpers::create_test_context().await;
let user = ctx
.database
.create_user(
CreateUser::new()
.with_email("already@test.com")
.with_name("Already"),
)
.await
.unwrap();
ctx.database
.update_user(
&user.id,
UpdateUser {
email_verified: Some(true),
..Default::default()
},
)
.await
.unwrap();
let token_value = jwt_token(&ctx, "already@test.com", None, None);
let mut query = HashMap::new();
query.insert("token".to_owned(), token_value);
let req =
test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
let response = plugin.handle_verify_email(&req, &ctx).await.unwrap();
assert_eq!(response.status, 200);
let body: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
assert_eq!(
(*(body).get("status").unwrap_or(&serde_json::Value::Null)),
true
);
}
#[tokio::test]
async fn test_send_verification_email_already_verified_returns_error() {
let plugin = EmailVerificationPlugin::new();
let ctx = test_helpers::create_test_context().await;
let user = ctx
.database
.create_user(
CreateUser::new()
.with_email("verified@test.com")
.with_name("Verified"),
)
.await
.unwrap();
ctx.database
.update_user(
&user.id,
UpdateUser {
email_verified: Some(true),
..Default::default()
},
)
.await
.unwrap();
let body = serde_json::json!({ "email": "verified@test.com" });
let mut headers = HashMap::new();
headers.insert("content-type".to_owned(), "application/json".to_owned());
let req = AuthRequest::from_parts(
HttpMethod::Post,
"/send-verification-email".to_owned(),
headers,
Some(body.to_string().into_bytes()),
HashMap::new(),
);
let err = plugin
.handle_send_verification_email(&req, &ctx)
.await
.unwrap_err();
assert_eq!(err.status_code(), 400);
}
#[tokio::test]
async fn unauthenticated_verification_email_has_fixed_floor_for_all_mailbox_states() {
struct Sender {
calls: Arc<AtomicU32>,
fail: bool,
}
#[async_trait]
impl SendVerificationEmail for Sender {
async fn send(&self, _: &UserView, _: &str, _: &str) -> AuthResult<()> {
self.calls.fetch_add(1, Ordering::SeqCst);
if self.fail {
Err(AuthError::bad_request("fixture delivery failed"))
} else {
Ok(())
}
}
}
for (existing, fail) in [
(None, false),
(Some(true), false),
(Some(false), false),
(Some(false), true),
] {
let ctx = test_helpers::create_test_context().await;
let email = "enumeration@fixture.test";
let user = if let Some(verified) = existing {
Some(
ctx.database
.create_user(
CreateUser::new()
.with_email(email)
.with_email_verified(verified),
)
.await
.unwrap(),
)
} else {
None
};
let calls = Arc::new(AtomicU32::new(0));
let plugin =
EmailVerificationPlugin::new().custom_send_verification_email(Arc::new(Sender {
calls: std::sync::Arc::clone(&calls),
fail,
}));
let req = test_helpers::create_auth_json_request_no_query(
HttpMethod::Post,
"/send-verification-email",
None,
Some(serde_json::json!({"email":email})),
);
let start = std::time::Instant::now();
let response = plugin.handle_send_verification_email(&req, &ctx).await;
assert!(
start.elapsed() >= std::time::Duration::from_millis(500),
"Every unauthenticated mailbox result must retain the timing floor"
);
if fail {
assert_eq!(response.unwrap_err().status_code(), 400);
} else {
assert_eq!(response.unwrap().status, 200);
}
assert_eq!(
calls.load(Ordering::SeqCst),
u32::from(existing == Some(false))
);
let after = ctx.database.get_user_by_email(email).await.unwrap();
assert_eq!(after.as_ref().map(AuthUser::email_verified), existing);
if let Some(user) = user {
assert_eq!(after.as_ref().unwrap().id(), user.id());
assert_eq!(
ctx.database
.get_user_sessions(&user.id())
.await
.unwrap()
.len(),
0
);
}
}
}
#[test]
fn test_create_session_cookie_format() {
use alibi_core::utils::cookie_utils::create_session_cookie;
let ctx = test_helpers::create_test_context_blocking();
let cookie_str = create_session_cookie("my-token-123", &ctx.config).unwrap();
assert!(cookie_str.contains("better-auth.session_token=my-token-123"));
assert!(cookie_str.contains("Path=/"));
assert!(cookie_str.contains("HttpOnly"));
assert!(cookie_str.contains("SameSite=Lax"));
}
#[test]
fn test_create_session_cookie_special_characters_in_token() {
use alibi_core::utils::cookie_utils::create_session_cookie;
let ctx = test_helpers::create_test_context_blocking();
let token = "token+with/special=chars&more";
let cookie_str = create_session_cookie(token, &ctx.config).unwrap();
assert!(cookie_str.contains("better-auth.session_token="));
}
#[tokio::test]
async fn test_verify_email_rejects_untrusted_callback_url() {
let plugin = EmailVerificationPlugin::new();
let ctx = test_helpers::create_test_context().await;
_ = ctx
.database
.create_user(
CreateUser::new()
.with_email("redirect-guard@test.com")
.with_name("Redirect Guard"),
)
.await
.unwrap();
let token_value = jwt_token(&ctx, "redirect-guard@test.com", None, None);
let mut query = HashMap::new();
query.insert("token".to_owned(), token_value);
query.insert(
"callbackURL".to_owned(),
"https://evil.com/phish".to_owned(),
);
let req =
test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
let response = plugin.handle_verify_email(&req, &ctx).await.unwrap();
assert_eq!(response.status, 403);
}
#[tokio::test]
async fn test_verify_email_allows_relative_callback_url() {
let plugin = EmailVerificationPlugin::new();
let ctx = test_helpers::create_test_context().await;
_ = ctx
.database
.create_user(
CreateUser::new()
.with_email("redirect-rel@test.com")
.with_name("Redirect Rel"),
)
.await
.unwrap();
let token_value = jwt_token(&ctx, "redirect-rel@test.com", None, None);
let mut query = HashMap::new();
query.insert("token".to_owned(), token_value);
query.insert("callbackURL".to_owned(), "/dashboard".to_owned());
let req =
test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
let response = plugin.handle_verify_email(&req, &ctx).await.unwrap();
assert_eq!(response.status, 302);
assert_eq!(
(*(response.headers)
.get("Location")
.expect("fixture contains the requested index")),
"/dashboard"
);
}
#[tokio::test]
async fn test_verify_email_allows_trusted_origin_callback_url() {
let plugin = EmailVerificationPlugin::new();
let config = test_helpers::create_test_config().trusted_origin("https://trusted.com");
let ctx = test_helpers::create_test_context_with_config(config).await;
_ = ctx
.database
.create_user(
CreateUser::new()
.with_email("redirect-trusted@test.com")
.with_name("Redirect Trusted"),
)
.await
.unwrap();
let token_value = jwt_token(&ctx, "redirect-trusted@test.com", None, None);
let mut query = HashMap::new();
query.insert("token".to_owned(), token_value);
query.insert(
"callbackURL".to_owned(),
"https://trusted.com/verified".to_owned(),
);
let req =
test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
let response = plugin.handle_verify_email(&req, &ctx).await.unwrap();
assert_eq!(response.status, 302);
assert_eq!(
(*(response.headers)
.get("Location")
.expect("fixture contains the requested index")),
"https://trusted.com/verified"
);
}
#[tokio::test]
async fn test_verify_email_skips_origin_check_when_disabled() {
let plugin = EmailVerificationPlugin::new();
let config = test_helpers::create_test_config().disable_origin_check(true);
let ctx = test_helpers::create_test_context_with_config(config).await;
_ = ctx
.database
.create_user(
CreateUser::new()
.with_email("redirect-disabled@test.com")
.with_name("Redirect Disabled"),
)
.await
.unwrap();
let token_value = jwt_token(&ctx, "redirect-disabled@test.com", None, None);
let mut query = HashMap::new();
query.insert("token".to_owned(), token_value);
query.insert(
"callbackURL".to_owned(),
"https://evil.com/anywhere".to_owned(),
);
let req =
test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
let response = plugin.handle_verify_email(&req, &ctx).await.unwrap();
assert_eq!(response.status, 302);
}
}