1use async_trait::async_trait;
2use chrono::{Duration, Utc};
3use serde::{Deserialize, Serialize};
4use std::future::Future;
5use std::pin::Pin;
6use std::sync::Arc;
7use uuid::Uuid;
8use validator::Validate;
9
10use better_auth_core::{AuthContext, AuthPlugin, AuthRoute};
11
12use better_auth_core::{AuthError, AuthResult};
13use better_auth_core::{AuthRequest, AuthResponse, CreateVerification, HttpMethod, UpdateUser};
14use better_auth_core::{
15 AuthSession, AuthUser, AuthVerification, DatabaseAdapter, SessionManager, User,
16};
17
18use better_auth_core::utils::cookie_utils::create_session_cookie;
19
20use super::StatusResponse;
21
22#[async_trait]
27pub trait SendVerificationEmail: Send + Sync {
28 async fn send(&self, user: &User, url: &str, token: &str) -> AuthResult<()>;
29}
30
31pub type EmailVerificationHook =
35 Arc<dyn Fn(&User) -> Pin<Box<dyn Future<Output = AuthResult<()>> + Send>> + Send + Sync>;
36
37pub struct EmailVerificationPlugin {
39 config: EmailVerificationConfig,
40}
41
42pub struct EmailVerificationConfig {
43 pub verification_token_expiry: Duration,
45 pub send_email_notifications: bool,
47 pub require_verification_for_signin: bool,
49 pub auto_verify_new_users: bool,
51 pub send_on_sign_in: bool,
54 pub auto_sign_in_after_verification: bool,
57 pub send_verification_email: Option<Arc<dyn SendVerificationEmail>>,
60 pub before_email_verification: Option<EmailVerificationHook>,
62 pub after_email_verification: Option<EmailVerificationHook>,
64}
65
66impl EmailVerificationConfig {
67 pub fn expiry_hours(&self) -> i64 {
70 self.verification_token_expiry.num_hours()
71 }
72}
73
74#[derive(Debug, Deserialize, Validate)]
76struct SendVerificationEmailRequest {
77 #[validate(email(message = "Invalid email address"))]
78 email: String,
79 #[serde(rename = "callbackURL")]
80 callback_url: Option<String>,
81}
82
83#[derive(Debug, Serialize)]
86struct VerifyEmailResponse<U: Serialize> {
87 user: U,
88 status: bool,
89}
90
91#[derive(Debug, Serialize)]
92struct VerifyEmailWithSessionResponse<U: Serialize, S: Serialize> {
93 user: U,
94 session: S,
95 status: bool,
96}
97
98impl EmailVerificationPlugin {
99 pub fn new() -> Self {
100 Self {
101 config: EmailVerificationConfig::default(),
102 }
103 }
104
105 pub fn with_config(config: EmailVerificationConfig) -> Self {
106 Self { config }
107 }
108
109 pub fn verification_token_expiry(mut self, duration: Duration) -> Self {
111 self.config.verification_token_expiry = duration;
112 self
113 }
114
115 pub fn verification_token_expiry_hours(mut self, hours: i64) -> Self {
117 self.config.verification_token_expiry = Duration::hours(hours);
118 self
119 }
120
121 pub fn send_email_notifications(mut self, send: bool) -> Self {
122 self.config.send_email_notifications = send;
123 self
124 }
125
126 pub fn require_verification_for_signin(mut self, require: bool) -> Self {
127 self.config.require_verification_for_signin = require;
128 self
129 }
130
131 pub fn auto_verify_new_users(mut self, auto_verify: bool) -> Self {
132 self.config.auto_verify_new_users = auto_verify;
133 self
134 }
135
136 pub fn send_on_sign_in(mut self, send: bool) -> Self {
137 self.config.send_on_sign_in = send;
138 self
139 }
140
141 pub fn auto_sign_in_after_verification(mut self, auto_sign_in: bool) -> Self {
142 self.config.auto_sign_in_after_verification = auto_sign_in;
143 self
144 }
145
146 pub fn custom_send_verification_email(
147 mut self,
148 sender: Arc<dyn SendVerificationEmail>,
149 ) -> Self {
150 self.config.send_verification_email = Some(sender);
151 self
152 }
153
154 pub fn before_email_verification(mut self, hook: EmailVerificationHook) -> Self {
155 self.config.before_email_verification = Some(hook);
156 self
157 }
158
159 pub fn after_email_verification(mut self, hook: EmailVerificationHook) -> Self {
160 self.config.after_email_verification = Some(hook);
161 self
162 }
163}
164
165impl Default for EmailVerificationPlugin {
166 fn default() -> Self {
167 Self::new()
168 }
169}
170
171impl Default for EmailVerificationConfig {
172 fn default() -> Self {
173 Self {
174 verification_token_expiry: Duration::hours(24),
175 send_email_notifications: true,
176 require_verification_for_signin: false,
177 auto_verify_new_users: false,
178 send_on_sign_in: false,
179 auto_sign_in_after_verification: false,
180 send_verification_email: None,
181 before_email_verification: None,
182 after_email_verification: None,
183 }
184 }
185}
186
187#[async_trait]
188impl<DB: DatabaseAdapter> AuthPlugin<DB> for EmailVerificationPlugin {
189 fn name(&self) -> &'static str {
190 "email-verification"
191 }
192
193 fn routes(&self) -> Vec<AuthRoute> {
194 vec![
195 AuthRoute::post("/send-verification-email", "send_verification_email"),
196 AuthRoute::get("/verify-email", "verify_email"),
197 ]
198 }
199
200 async fn on_request(
201 &self,
202 req: &AuthRequest,
203 ctx: &AuthContext<DB>,
204 ) -> AuthResult<Option<AuthResponse>> {
205 match (req.method(), req.path()) {
206 (HttpMethod::Post, "/send-verification-email") => {
207 Ok(Some(self.handle_send_verification_email(req, ctx).await?))
208 }
209 (HttpMethod::Get, "/verify-email") => {
210 Ok(Some(self.handle_verify_email(req, ctx).await?))
211 }
212 _ => Ok(None),
213 }
214 }
215
216 async fn on_user_created(&self, user: &DB::User, ctx: &AuthContext<DB>) -> AuthResult<()> {
217 if (self.config.send_email_notifications || self.config.send_verification_email.is_some())
220 && !user.email_verified()
221 && let Some(email) = user.email()
222 && let Err(e) = self
223 .send_verification_email_for_user(user, email, None, ctx)
224 .await
225 {
226 tracing::warn!(
227 email = %email,
228 error = %e,
229 "Failed to send verification email"
230 );
231 }
232 Ok(())
233 }
234}
235
236impl EmailVerificationPlugin {
238 async fn handle_send_verification_email<DB: DatabaseAdapter>(
239 &self,
240 req: &AuthRequest,
241 ctx: &AuthContext<DB>,
242 ) -> AuthResult<AuthResponse> {
243 let send_req: SendVerificationEmailRequest =
244 match better_auth_core::validate_request_body(req) {
245 Ok(v) => v,
246 Err(resp) => return Ok(resp),
247 };
248
249 let user = ctx
251 .database
252 .get_user_by_email(&send_req.email)
253 .await?
254 .ok_or_else(|| AuthError::not_found("No user found with this email address"))?;
255
256 if user.email_verified() {
258 return Err(AuthError::bad_request("Email is already verified"));
259 }
260
261 self.send_verification_email_for_user(
263 &user,
264 &send_req.email,
265 send_req.callback_url.as_deref(),
266 ctx,
267 )
268 .await?;
269
270 let response = StatusResponse { status: true };
271 Ok(AuthResponse::json(200, &response)?)
272 }
273
274 async fn handle_verify_email<DB: DatabaseAdapter>(
275 &self,
276 req: &AuthRequest,
277 ctx: &AuthContext<DB>,
278 ) -> AuthResult<AuthResponse> {
279 let token = req
281 .query
282 .get("token")
283 .ok_or_else(|| AuthError::bad_request("Verification token is required"))?;
284
285 let callback_url = req.query.get("callbackURL");
286
287 let verification = ctx
289 .database
290 .get_verification_by_value(token)
291 .await?
292 .ok_or_else(|| AuthError::bad_request("Invalid or expired verification token"))?;
293
294 let user = ctx
296 .database
297 .get_user_by_email(verification.identifier())
298 .await?
299 .ok_or_else(|| AuthError::not_found("User associated with this token not found"))?;
300
301 if user.email_verified() {
303 let response = VerifyEmailResponse { user, status: true };
304 return Ok(AuthResponse::json(200, &response)?);
305 }
306
307 if let Some(ref hook) = self.config.before_email_verification {
309 let hook_user = User::from(&user);
310 hook(&hook_user).await?;
311 }
312
313 let update_user = UpdateUser {
315 email_verified: Some(true),
316 ..Default::default()
317 };
318
319 let updated_user = ctx.database.update_user(user.id(), update_user).await?;
320
321 ctx.database.delete_verification(verification.id()).await?;
323
324 if let Some(ref hook) = self.config.after_email_verification {
326 let hook_user = User::from(&updated_user);
327 hook(&hook_user).await?;
328 }
329
330 let session_cookie = if self.config.auto_sign_in_after_verification {
334 let ip_address = req.headers.get("x-forwarded-for").cloned();
335 let user_agent = req.headers.get("user-agent").cloned();
336 let session_manager = SessionManager::new(ctx.config.clone(), ctx.database.clone());
337 let session = session_manager
338 .create_session(&updated_user, ip_address, user_agent)
339 .await?;
340 Some((create_session_cookie(session.token(), ctx), session))
341 } else {
342 None
343 };
344
345 if let Some(callback_url) = callback_url {
347 let redirect_url = format!("{}?verified=true", callback_url);
348 let mut headers = std::collections::HashMap::new();
349 headers.insert("Location".to_string(), redirect_url);
350 if let Some((cookie, _)) = &session_cookie {
351 headers.insert("Set-Cookie".to_string(), cookie.clone());
352 }
353 return Ok(AuthResponse {
354 status: 302,
355 headers,
356 body: Vec::new(),
357 });
358 }
359
360 if let Some((cookie_header, session)) = session_cookie {
362 let response = VerifyEmailWithSessionResponse {
363 user: updated_user,
364 session,
365 status: true,
366 };
367 return Ok(AuthResponse::json(200, &response)?.with_header("Set-Cookie", cookie_header));
368 }
369
370 let response = VerifyEmailResponse {
371 user: updated_user,
372 status: true,
373 };
374 Ok(AuthResponse::json(200, &response)?)
375 }
376
377 async fn send_verification_email_for_user<DB: DatabaseAdapter>(
383 &self,
384 user: &DB::User,
385 email: &str,
386 callback_url: Option<&str>,
387 ctx: &AuthContext<DB>,
388 ) -> AuthResult<()> {
389 let verification_token = format!("verify_{}", Uuid::new_v4());
391 let expires_at = Utc::now() + self.config.verification_token_expiry;
392
393 let create_verification = CreateVerification {
395 identifier: email.to_string(),
396 value: verification_token.clone(),
397 expires_at,
398 };
399
400 ctx.database
401 .create_verification(create_verification)
402 .await?;
403
404 let verification_url = if let Some(callback_url) = callback_url {
405 format!("{}?token={}", callback_url, verification_token)
406 } else {
407 format!(
408 "{}/verify-email?token={}",
409 ctx.config.base_url, verification_token
410 )
411 };
412
413 if let Some(ref custom_sender) = self.config.send_verification_email {
415 let user = User::from(user);
416 custom_sender
417 .send(&user, &verification_url, &verification_token)
418 .await?;
419 } else if self.config.send_email_notifications {
420 if ctx.email_provider.is_some() {
422 let subject = "Verify your email address";
423 let html = format!(
424 "<p>Click the link below to verify your email address:</p>\
425 <p><a href=\"{url}\">Verify Email</a></p>",
426 url = verification_url
427 );
428 let text = format!("Verify your email address: {}", verification_url);
429
430 ctx.email_provider()?
431 .send(email, subject, &html, &text)
432 .await?;
433 } else {
434 tracing::warn!(
435 email = %email,
436 "No email provider configured, skipping verification email"
437 );
438 }
439 }
440
441 Ok(())
442 }
443
444 pub async fn send_verification_on_sign_in<DB: DatabaseAdapter>(
450 &self,
451 user: &DB::User,
452 callback_url: Option<&str>,
453 ctx: &AuthContext<DB>,
454 ) -> AuthResult<()> {
455 if !self.config.send_on_sign_in {
456 return Ok(());
457 }
458
459 if user.email_verified() {
460 return Ok(());
461 }
462
463 if let Some(email) = user.email() {
464 self.send_verification_email_for_user(user, email, callback_url, ctx)
465 .await?;
466 }
467
468 Ok(())
469 }
470
471 pub fn should_send_on_sign_in(&self) -> bool {
473 self.config.send_on_sign_in
474 }
475
476 pub fn is_verification_required(&self) -> bool {
478 self.config.require_verification_for_signin
479 }
480
481 pub async fn is_user_verified_or_not_required(&self, user: &impl AuthUser) -> bool {
483 user.email_verified() || !self.config.require_verification_for_signin
484 }
485}
486
487#[cfg(test)]
488mod tests {
489 use super::*;
490 use crate::plugins::test_helpers;
491 use better_auth_core::adapters::{MemoryDatabaseAdapter, UserOps, VerificationOps};
492 use better_auth_core::{CreateUser, CreateVerification};
493 use std::collections::HashMap;
494 use std::sync::atomic::{AtomicU32, Ordering};
495
496 #[test]
501 fn test_default_config() {
502 let config = EmailVerificationConfig::default();
503 assert_eq!(config.verification_token_expiry, Duration::hours(24));
504 assert!(config.send_email_notifications);
505 assert!(!config.require_verification_for_signin);
506 assert!(!config.auto_verify_new_users);
507 assert!(!config.send_on_sign_in);
508 assert!(!config.auto_sign_in_after_verification);
509 assert!(config.send_verification_email.is_none());
510 assert!(config.before_email_verification.is_none());
511 assert!(config.after_email_verification.is_none());
512 }
513
514 #[test]
515 fn test_expiry_hours_helper() {
516 let config = EmailVerificationConfig {
517 verification_token_expiry: Duration::hours(3),
518 ..Default::default()
519 };
520 assert_eq!(config.expiry_hours(), 3);
521 }
522
523 #[test]
524 fn test_expiry_hours_truncates() {
525 let config = EmailVerificationConfig {
526 verification_token_expiry: Duration::minutes(90), ..Default::default()
528 };
529 assert_eq!(config.expiry_hours(), 1); }
531
532 #[test]
537 fn test_builder_verification_token_expiry() {
538 let plugin =
539 EmailVerificationPlugin::new().verification_token_expiry(Duration::minutes(30));
540 assert_eq!(
541 plugin.config.verification_token_expiry,
542 Duration::minutes(30)
543 );
544 }
545
546 #[test]
547 fn test_builder_verification_token_expiry_hours() {
548 let plugin = EmailVerificationPlugin::new().verification_token_expiry_hours(12);
549 assert_eq!(plugin.config.verification_token_expiry, Duration::hours(12));
550 }
551
552 #[test]
553 fn test_builder_send_on_sign_in() {
554 let plugin = EmailVerificationPlugin::new().send_on_sign_in(true);
555 assert!(plugin.config.send_on_sign_in);
556 }
557
558 #[test]
559 fn test_builder_auto_sign_in_after_verification() {
560 let plugin = EmailVerificationPlugin::new().auto_sign_in_after_verification(true);
561 assert!(plugin.config.auto_sign_in_after_verification);
562 }
563
564 #[test]
565 fn test_builder_send_email_notifications() {
566 let plugin = EmailVerificationPlugin::new().send_email_notifications(false);
567 assert!(!plugin.config.send_email_notifications);
568 }
569
570 #[test]
571 fn test_builder_require_verification_for_signin() {
572 let plugin = EmailVerificationPlugin::new().require_verification_for_signin(true);
573 assert!(plugin.config.require_verification_for_signin);
574 }
575
576 #[test]
577 fn test_builder_auto_verify_new_users() {
578 let plugin = EmailVerificationPlugin::new().auto_verify_new_users(true);
579 assert!(plugin.config.auto_verify_new_users);
580 }
581
582 #[test]
583 fn test_builder_chaining() {
584 let plugin = EmailVerificationPlugin::new()
585 .verification_token_expiry(Duration::hours(2))
586 .send_on_sign_in(true)
587 .auto_sign_in_after_verification(true)
588 .send_email_notifications(false)
589 .require_verification_for_signin(true);
590 assert_eq!(plugin.config.verification_token_expiry, Duration::hours(2));
591 assert!(plugin.config.send_on_sign_in);
592 assert!(plugin.config.auto_sign_in_after_verification);
593 assert!(!plugin.config.send_email_notifications);
594 assert!(plugin.config.require_verification_for_signin);
595 }
596
597 struct DummySender;
602
603 #[async_trait]
604 impl SendVerificationEmail for DummySender {
605 async fn send(&self, _user: &User, _url: &str, _token: &str) -> AuthResult<()> {
606 Ok(())
607 }
608 }
609
610 #[test]
611 fn test_builder_custom_send_verification_email() {
612 let plugin =
613 EmailVerificationPlugin::new().custom_send_verification_email(Arc::new(DummySender));
614 assert!(plugin.config.send_verification_email.is_some());
615 }
616
617 #[test]
622 fn test_builder_before_email_verification_hook() {
623 let hook: EmailVerificationHook = Arc::new(|_user: &User| Box::pin(async { Ok(()) }));
624 let plugin = EmailVerificationPlugin::new().before_email_verification(hook);
625 assert!(plugin.config.before_email_verification.is_some());
626 }
627
628 #[test]
629 fn test_builder_after_email_verification_hook() {
630 let hook: EmailVerificationHook = Arc::new(|_user: &User| Box::pin(async { Ok(()) }));
631 let plugin = EmailVerificationPlugin::new().after_email_verification(hook);
632 assert!(plugin.config.after_email_verification.is_some());
633 }
634
635 #[test]
640 fn test_should_send_on_sign_in() {
641 let plugin = EmailVerificationPlugin::new();
642 assert!(!plugin.should_send_on_sign_in());
643
644 let plugin = EmailVerificationPlugin::new().send_on_sign_in(true);
645 assert!(plugin.should_send_on_sign_in());
646 }
647
648 #[test]
649 fn test_is_verification_required() {
650 let plugin = EmailVerificationPlugin::new();
651 assert!(!plugin.is_verification_required());
652
653 let plugin = EmailVerificationPlugin::new().require_verification_for_signin(true);
654 assert!(plugin.is_verification_required());
655 }
656
657 fn make_test_user(email: &str, verified: bool) -> User {
659 User {
660 id: "test-id".into(),
661 name: Some("Test".into()),
662 email: Some(email.into()),
663 email_verified: verified,
664 image: None,
665 created_at: Utc::now(),
666 updated_at: Utc::now(),
667 username: None,
668 display_username: None,
669 two_factor_enabled: false,
670 role: None,
671 banned: false,
672 ban_reason: None,
673 ban_expires: None,
674 metadata: serde_json::Value::Null,
675 }
676 }
677
678 #[tokio::test]
679 async fn test_is_user_verified_or_not_required() {
680 let plugin = EmailVerificationPlugin::new();
681 let user = make_test_user("a@b.com", false);
682 assert!(plugin.is_user_verified_or_not_required(&user).await);
684
685 let plugin = EmailVerificationPlugin::new().require_verification_for_signin(true);
686 assert!(!plugin.is_user_verified_or_not_required(&user).await);
688
689 let verified_user = make_test_user("a@b.com", true);
690 assert!(
692 plugin
693 .is_user_verified_or_not_required(&verified_user)
694 .await
695 );
696 }
697
698 #[test]
703 fn test_to_user_preserves_fields() {
704 let user = User {
705 id: "test-id".into(),
706 name: Some("Test User".into()),
707 email: Some("test@example.com".into()),
708 email_verified: true,
709 image: Some("https://img.example.com/a.png".into()),
710 created_at: Utc::now(),
711 updated_at: Utc::now(),
712 username: Some("testuser".into()),
713 display_username: Some("TestUser".into()),
714 two_factor_enabled: true,
715 role: Some("admin".into()),
716 banned: true,
717 ban_reason: Some("spam".into()),
718 ban_expires: None,
719 metadata: serde_json::Value::Null,
720 };
721 let converted = User::from(&user);
722 assert_eq!(converted.id, "test-id");
723 assert_eq!(converted.name.as_deref(), Some("Test User"));
724 assert_eq!(converted.email.as_deref(), Some("test@example.com"));
725 assert!(converted.email_verified);
726 assert_eq!(
727 converted.image.as_deref(),
728 Some("https://img.example.com/a.png")
729 );
730 assert_eq!(converted.username.as_deref(), Some("testuser"));
731 assert_eq!(converted.display_username.as_deref(), Some("TestUser"));
732 assert!(converted.two_factor_enabled);
733 assert_eq!(converted.role.as_deref(), Some("admin"));
734 assert!(converted.banned);
735 assert_eq!(converted.ban_reason.as_deref(), Some("spam"));
736 }
737
738 #[test]
743 fn test_plugin_name() {
744 let plugin = EmailVerificationPlugin::new();
745 assert_eq!(
746 AuthPlugin::<MemoryDatabaseAdapter>::name(&plugin),
747 "email-verification"
748 );
749 }
750
751 #[test]
752 fn test_plugin_routes() {
753 let plugin = EmailVerificationPlugin::new();
754 let routes = AuthPlugin::<MemoryDatabaseAdapter>::routes(&plugin);
755 assert_eq!(routes.len(), 2);
756 assert!(
757 routes
758 .iter()
759 .any(|r| r.path == "/send-verification-email" && r.method == HttpMethod::Post)
760 );
761 assert!(
762 routes
763 .iter()
764 .any(|r| r.path == "/verify-email" && r.method == HttpMethod::Get)
765 );
766 }
767
768 #[tokio::test]
769 async fn test_on_request_unknown_route_returns_none() {
770 let plugin = EmailVerificationPlugin::new();
771 let ctx = test_helpers::create_test_context();
772 let req = test_helpers::create_auth_request(
773 HttpMethod::Get,
774 "/unknown",
775 None,
776 None,
777 HashMap::new(),
778 );
779 let result = plugin.on_request(&req, &ctx).await.unwrap();
780 assert!(result.is_none());
781 }
782
783 #[tokio::test]
788 async fn test_send_verification_on_sign_in_disabled() {
789 let plugin = EmailVerificationPlugin::new().send_on_sign_in(false);
790 let ctx = test_helpers::create_test_context();
791 let user = ctx
792 .database
793 .create_user(
794 CreateUser::new()
795 .with_email("unverified@test.com")
796 .with_name("Test"),
797 )
798 .await
799 .unwrap();
800 plugin
802 .send_verification_on_sign_in(&user, None, &ctx)
803 .await
804 .unwrap();
805 }
806
807 #[tokio::test]
808 async fn test_send_verification_on_sign_in_verified_user() {
809 let plugin = EmailVerificationPlugin::new().send_on_sign_in(true);
810 let ctx = test_helpers::create_test_context();
811 let user = ctx
812 .database
813 .create_user(
814 CreateUser::new()
815 .with_email("verified@test.com")
816 .with_name("Test"),
817 )
818 .await
819 .unwrap();
820 let update = UpdateUser {
822 email_verified: Some(true),
823 ..Default::default()
824 };
825 let verified = ctx.database.update_user(&user.id, update).await.unwrap();
826 plugin
828 .send_verification_on_sign_in(&verified, None, &ctx)
829 .await
830 .unwrap();
831 }
832
833 #[tokio::test]
834 async fn test_send_verification_on_sign_in_creates_token() {
835 let call_count = Arc::new(AtomicU32::new(0));
838 let counter = call_count.clone();
839 struct CountingSender(Arc<AtomicU32>);
840 #[async_trait]
841 impl SendVerificationEmail for CountingSender {
842 async fn send(&self, _user: &User, _url: &str, _token: &str) -> AuthResult<()> {
843 self.0.fetch_add(1, Ordering::Relaxed);
844 Ok(())
845 }
846 }
847
848 let plugin = EmailVerificationPlugin::new()
849 .send_on_sign_in(true)
850 .send_email_notifications(false) .custom_send_verification_email(Arc::new(CountingSender(counter)));
852
853 let ctx = test_helpers::create_test_context();
854 let user = ctx
855 .database
856 .create_user(
857 CreateUser::new()
858 .with_email("unverified@test.com")
859 .with_name("Test"),
860 )
861 .await
862 .unwrap();
863
864 plugin
865 .send_verification_on_sign_in(&user, None, &ctx)
866 .await
867 .unwrap();
868
869 assert_eq!(call_count.load(Ordering::Relaxed), 1);
870 }
871
872 #[tokio::test]
878 async fn test_on_user_created_custom_sender_fires_without_notifications() {
879 let call_count = Arc::new(AtomicU32::new(0));
880 let counter = call_count.clone();
881 struct CountingSender(Arc<AtomicU32>);
882 #[async_trait]
883 impl SendVerificationEmail for CountingSender {
884 async fn send(&self, _user: &User, _url: &str, _token: &str) -> AuthResult<()> {
885 self.0.fetch_add(1, Ordering::Relaxed);
886 Ok(())
887 }
888 }
889
890 let plugin = EmailVerificationPlugin::new()
891 .send_email_notifications(false)
892 .custom_send_verification_email(Arc::new(CountingSender(counter)));
893
894 let ctx = test_helpers::create_test_context();
895 let user = ctx
896 .database
897 .create_user(
898 CreateUser::new()
899 .with_email("newuser@test.com")
900 .with_name("New"),
901 )
902 .await
903 .unwrap();
904
905 plugin.on_user_created(&user, &ctx).await.unwrap();
906
907 assert_eq!(call_count.load(Ordering::Relaxed), 1);
910 }
911
912 #[tokio::test]
913 async fn test_on_user_created_verified_user_skips_email() {
914 let call_count = Arc::new(AtomicU32::new(0));
915 let counter = call_count.clone();
916 struct CountingSender(Arc<AtomicU32>);
917 #[async_trait]
918 impl SendVerificationEmail for CountingSender {
919 async fn send(&self, _user: &User, _url: &str, _token: &str) -> AuthResult<()> {
920 self.0.fetch_add(1, Ordering::Relaxed);
921 Ok(())
922 }
923 }
924
925 let plugin = EmailVerificationPlugin::new()
926 .custom_send_verification_email(Arc::new(CountingSender(counter)));
927
928 let ctx = test_helpers::create_test_context();
929 let user = ctx
930 .database
931 .create_user(
932 CreateUser::new()
933 .with_email("newuser@test.com")
934 .with_name("New"),
935 )
936 .await
937 .unwrap();
938 let update = UpdateUser {
940 email_verified: Some(true),
941 ..Default::default()
942 };
943 let verified = ctx.database.update_user(&user.id, update).await.unwrap();
944
945 plugin.on_user_created(&verified, &ctx).await.unwrap();
946
947 assert_eq!(call_count.load(Ordering::Relaxed), 0);
949 }
950
951 #[tokio::test]
956 async fn test_verify_email_basic_flow() {
957 let plugin = EmailVerificationPlugin::new();
958 let ctx = test_helpers::create_test_context();
959
960 let _user = ctx
962 .database
963 .create_user(
964 CreateUser::new()
965 .with_email("verify@test.com")
966 .with_name("Verify Me"),
967 )
968 .await
969 .unwrap();
970
971 let token_value = format!("verify_{}", Uuid::new_v4());
973 ctx.database
974 .create_verification(CreateVerification {
975 identifier: "verify@test.com".to_string(),
976 value: token_value.clone(),
977 expires_at: Utc::now() + Duration::hours(1),
978 })
979 .await
980 .unwrap();
981
982 let mut query = HashMap::new();
984 query.insert("token".to_string(), token_value.clone());
985 let req =
986 test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
987 let response = plugin.handle_verify_email(&req, &ctx).await.unwrap();
988
989 assert_eq!(response.status, 200);
990 let body: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
991 assert_eq!(body["status"], true);
992 assert_eq!(body["user"]["email"], "verify@test.com");
993
994 let updated = ctx
996 .database
997 .get_user_by_email("verify@test.com")
998 .await
999 .unwrap()
1000 .unwrap();
1001 assert!(updated.email_verified);
1002
1003 let v = ctx
1005 .database
1006 .get_verification_by_value(&token_value)
1007 .await
1008 .unwrap();
1009 assert!(v.is_none());
1010 }
1011
1012 #[tokio::test]
1017 async fn test_verify_email_calls_before_and_after_hooks() {
1018 let before_count = Arc::new(AtomicU32::new(0));
1019 let after_count = Arc::new(AtomicU32::new(0));
1020 let bc = before_count.clone();
1021 let ac = after_count.clone();
1022
1023 let before_hook: EmailVerificationHook = Arc::new(move |_user: &User| {
1024 let c = bc.clone();
1025 Box::pin(async move {
1026 c.fetch_add(1, Ordering::Relaxed);
1027 Ok(())
1028 })
1029 });
1030 let after_hook: EmailVerificationHook = Arc::new(move |_user: &User| {
1031 let c = ac.clone();
1032 Box::pin(async move {
1033 c.fetch_add(1, Ordering::Relaxed);
1034 Ok(())
1035 })
1036 });
1037
1038 let plugin = EmailVerificationPlugin::new()
1039 .before_email_verification(before_hook)
1040 .after_email_verification(after_hook);
1041
1042 let ctx = test_helpers::create_test_context();
1043 let _user = ctx
1044 .database
1045 .create_user(
1046 CreateUser::new()
1047 .with_email("hooks@test.com")
1048 .with_name("Hooks"),
1049 )
1050 .await
1051 .unwrap();
1052
1053 let token_value = format!("verify_{}", Uuid::new_v4());
1054 ctx.database
1055 .create_verification(CreateVerification {
1056 identifier: "hooks@test.com".to_string(),
1057 value: token_value.clone(),
1058 expires_at: Utc::now() + Duration::hours(1),
1059 })
1060 .await
1061 .unwrap();
1062
1063 let mut query = HashMap::new();
1064 query.insert("token".to_string(), token_value);
1065 let req =
1066 test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
1067 let response = plugin.handle_verify_email(&req, &ctx).await.unwrap();
1068 assert_eq!(response.status, 200);
1069
1070 assert_eq!(before_count.load(Ordering::Relaxed), 1);
1071 assert_eq!(after_count.load(Ordering::Relaxed), 1);
1072 }
1073
1074 #[tokio::test]
1075 async fn test_verify_email_before_hook_error_aborts() {
1076 let before_hook: EmailVerificationHook =
1077 Arc::new(|_user: &User| Box::pin(async { Err(AuthError::forbidden("hook rejected")) }));
1078
1079 let plugin = EmailVerificationPlugin::new().before_email_verification(before_hook);
1080
1081 let ctx = test_helpers::create_test_context();
1082 let _user = ctx
1083 .database
1084 .create_user(
1085 CreateUser::new()
1086 .with_email("hook-err@test.com")
1087 .with_name("HookErr"),
1088 )
1089 .await
1090 .unwrap();
1091
1092 let token_value = format!("verify_{}", Uuid::new_v4());
1093 ctx.database
1094 .create_verification(CreateVerification {
1095 identifier: "hook-err@test.com".to_string(),
1096 value: token_value.clone(),
1097 expires_at: Utc::now() + Duration::hours(1),
1098 })
1099 .await
1100 .unwrap();
1101
1102 let mut query = HashMap::new();
1103 query.insert("token".to_string(), token_value.clone());
1104 let req =
1105 test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
1106 let err = plugin.handle_verify_email(&req, &ctx).await.unwrap_err();
1107 assert_eq!(err.status_code(), 403);
1108
1109 let u = ctx
1111 .database
1112 .get_user_by_email("hook-err@test.com")
1113 .await
1114 .unwrap()
1115 .unwrap();
1116 assert!(!u.email_verified);
1117 }
1118
1119 #[tokio::test]
1124 async fn test_verify_email_auto_sign_in_creates_session() {
1125 let plugin = EmailVerificationPlugin::new().auto_sign_in_after_verification(true);
1126
1127 let ctx = test_helpers::create_test_context();
1128 let _user = ctx
1129 .database
1130 .create_user(
1131 CreateUser::new()
1132 .with_email("autosign@test.com")
1133 .with_name("AutoSign"),
1134 )
1135 .await
1136 .unwrap();
1137
1138 let token_value = format!("verify_{}", Uuid::new_v4());
1139 ctx.database
1140 .create_verification(CreateVerification {
1141 identifier: "autosign@test.com".to_string(),
1142 value: token_value.clone(),
1143 expires_at: Utc::now() + Duration::hours(1),
1144 })
1145 .await
1146 .unwrap();
1147
1148 let mut query = HashMap::new();
1149 query.insert("token".to_string(), token_value);
1150 let req =
1151 test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
1152 let response = plugin.handle_verify_email(&req, &ctx).await.unwrap();
1153
1154 assert_eq!(response.status, 200);
1155 let body: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
1156 assert_eq!(body["status"], true);
1157 assert!(body["session"]["token"].is_string());
1159
1160 assert!(response.headers.contains_key("Set-Cookie"));
1162 let cookie_header = &response.headers["Set-Cookie"];
1163 assert!(cookie_header.contains("better-auth.session"));
1164 }
1165
1166 #[tokio::test]
1167 async fn test_verify_email_no_auto_sign_in_no_session() {
1168 let plugin = EmailVerificationPlugin::new().auto_sign_in_after_verification(false);
1169
1170 let ctx = test_helpers::create_test_context();
1171 let _user = ctx
1172 .database
1173 .create_user(
1174 CreateUser::new()
1175 .with_email("noautosign@test.com")
1176 .with_name("NoAutoSign"),
1177 )
1178 .await
1179 .unwrap();
1180
1181 let token_value = format!("verify_{}", Uuid::new_v4());
1182 ctx.database
1183 .create_verification(CreateVerification {
1184 identifier: "noautosign@test.com".to_string(),
1185 value: token_value.clone(),
1186 expires_at: Utc::now() + Duration::hours(1),
1187 })
1188 .await
1189 .unwrap();
1190
1191 let mut query = HashMap::new();
1192 query.insert("token".to_string(), token_value);
1193 let req =
1194 test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
1195 let response = plugin.handle_verify_email(&req, &ctx).await.unwrap();
1196
1197 assert_eq!(response.status, 200);
1198 let body: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
1199 assert_eq!(body["status"], true);
1200 assert!(body.get("session").is_none());
1202 assert!(!response.headers.contains_key("Set-Cookie"));
1204 }
1205
1206 #[tokio::test]
1211 async fn test_verify_email_auto_sign_in_redirect_includes_cookie() {
1212 let plugin = EmailVerificationPlugin::new().auto_sign_in_after_verification(true);
1213
1214 let ctx = test_helpers::create_test_context();
1215 let _user = ctx
1216 .database
1217 .create_user(
1218 CreateUser::new()
1219 .with_email("redirect@test.com")
1220 .with_name("Redirect"),
1221 )
1222 .await
1223 .unwrap();
1224
1225 let token_value = format!("verify_{}", Uuid::new_v4());
1226 ctx.database
1227 .create_verification(CreateVerification {
1228 identifier: "redirect@test.com".to_string(),
1229 value: token_value.clone(),
1230 expires_at: Utc::now() + Duration::hours(1),
1231 })
1232 .await
1233 .unwrap();
1234
1235 let mut query = HashMap::new();
1236 query.insert("token".to_string(), token_value);
1237 query.insert(
1238 "callbackURL".to_string(),
1239 "https://myapp.com/verified".to_string(),
1240 );
1241 let req =
1242 test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
1243 let response = plugin.handle_verify_email(&req, &ctx).await.unwrap();
1244
1245 assert_eq!(response.status, 302);
1246 assert!(
1247 response.headers["Location"].starts_with("https://myapp.com/verified?verified=true")
1248 );
1249 assert!(response.headers.contains_key("Set-Cookie"));
1251 assert!(response.headers["Set-Cookie"].contains("better-auth.session"));
1252 }
1253
1254 #[tokio::test]
1255 async fn test_verify_email_redirect_without_auto_sign_in_no_cookie() {
1256 let plugin = EmailVerificationPlugin::new().auto_sign_in_after_verification(false);
1257
1258 let ctx = test_helpers::create_test_context();
1259 let _user = ctx
1260 .database
1261 .create_user(
1262 CreateUser::new()
1263 .with_email("redir-nocookie@test.com")
1264 .with_name("Redir"),
1265 )
1266 .await
1267 .unwrap();
1268
1269 let token_value = format!("verify_{}", Uuid::new_v4());
1270 ctx.database
1271 .create_verification(CreateVerification {
1272 identifier: "redir-nocookie@test.com".to_string(),
1273 value: token_value.clone(),
1274 expires_at: Utc::now() + Duration::hours(1),
1275 })
1276 .await
1277 .unwrap();
1278
1279 let mut query = HashMap::new();
1280 query.insert("token".to_string(), token_value);
1281 query.insert(
1282 "callbackURL".to_string(),
1283 "https://myapp.com/verified".to_string(),
1284 );
1285 let req =
1286 test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
1287 let response = plugin.handle_verify_email(&req, &ctx).await.unwrap();
1288
1289 assert_eq!(response.status, 302);
1290 assert!(!response.headers.contains_key("Set-Cookie"));
1291 }
1292
1293 #[tokio::test]
1298 async fn test_verify_email_invalid_token() {
1299 let plugin = EmailVerificationPlugin::new();
1300 let ctx = test_helpers::create_test_context();
1301
1302 let mut query = HashMap::new();
1303 query.insert("token".to_string(), "bogus-token".to_string());
1304 let req =
1305 test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
1306 let err = plugin.handle_verify_email(&req, &ctx).await.unwrap_err();
1307 assert_eq!(err.status_code(), 400);
1308 }
1309
1310 #[tokio::test]
1311 async fn test_verify_email_missing_token() {
1312 let plugin = EmailVerificationPlugin::new();
1313 let ctx = test_helpers::create_test_context();
1314
1315 let req = test_helpers::create_auth_request(
1316 HttpMethod::Get,
1317 "/verify-email",
1318 None,
1319 None,
1320 HashMap::new(),
1321 );
1322 let err = plugin.handle_verify_email(&req, &ctx).await.unwrap_err();
1323 assert_eq!(err.status_code(), 400);
1324 }
1325
1326 #[tokio::test]
1331 async fn test_verify_email_already_verified_returns_ok() {
1332 let plugin = EmailVerificationPlugin::new();
1333 let ctx = test_helpers::create_test_context();
1334
1335 let user = ctx
1336 .database
1337 .create_user(
1338 CreateUser::new()
1339 .with_email("already@test.com")
1340 .with_name("Already"),
1341 )
1342 .await
1343 .unwrap();
1344 ctx.database
1346 .update_user(
1347 &user.id,
1348 UpdateUser {
1349 email_verified: Some(true),
1350 ..Default::default()
1351 },
1352 )
1353 .await
1354 .unwrap();
1355
1356 let token_value = format!("verify_{}", Uuid::new_v4());
1357 ctx.database
1358 .create_verification(CreateVerification {
1359 identifier: "already@test.com".to_string(),
1360 value: token_value.clone(),
1361 expires_at: Utc::now() + Duration::hours(1),
1362 })
1363 .await
1364 .unwrap();
1365
1366 let mut query = HashMap::new();
1367 query.insert("token".to_string(), token_value);
1368 let req =
1369 test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
1370 let response = plugin.handle_verify_email(&req, &ctx).await.unwrap();
1371 assert_eq!(response.status, 200);
1372 let body: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
1373 assert_eq!(body["status"], true);
1374 }
1375
1376 #[tokio::test]
1381 async fn test_send_verification_email_already_verified_returns_error() {
1382 let plugin = EmailVerificationPlugin::new();
1383 let ctx = test_helpers::create_test_context();
1384
1385 let user = ctx
1386 .database
1387 .create_user(
1388 CreateUser::new()
1389 .with_email("verified@test.com")
1390 .with_name("Verified"),
1391 )
1392 .await
1393 .unwrap();
1394 ctx.database
1395 .update_user(
1396 &user.id,
1397 UpdateUser {
1398 email_verified: Some(true),
1399 ..Default::default()
1400 },
1401 )
1402 .await
1403 .unwrap();
1404
1405 let body = serde_json::json!({ "email": "verified@test.com" });
1406 let mut headers = HashMap::new();
1407 headers.insert("content-type".to_string(), "application/json".to_string());
1408 let req = AuthRequest::from_parts(
1409 HttpMethod::Post,
1410 "/send-verification-email".to_string(),
1411 headers,
1412 Some(body.to_string().into_bytes()),
1413 HashMap::new(),
1414 );
1415 let err = plugin
1416 .handle_send_verification_email(&req, &ctx)
1417 .await
1418 .unwrap_err();
1419 assert_eq!(err.status_code(), 400);
1420 }
1421
1422 #[tokio::test]
1423 async fn test_send_verification_email_user_not_found() {
1424 let plugin = EmailVerificationPlugin::new();
1425 let ctx = test_helpers::create_test_context();
1426
1427 let body = serde_json::json!({ "email": "nobody@test.com" });
1428 let mut headers = HashMap::new();
1429 headers.insert("content-type".to_string(), "application/json".to_string());
1430 let req = AuthRequest::from_parts(
1431 HttpMethod::Post,
1432 "/send-verification-email".to_string(),
1433 headers,
1434 Some(body.to_string().into_bytes()),
1435 HashMap::new(),
1436 );
1437 let err = plugin
1438 .handle_send_verification_email(&req, &ctx)
1439 .await
1440 .unwrap_err();
1441 assert_eq!(err.status_code(), 404);
1442 }
1443
1444 #[test]
1449 fn test_create_session_cookie_format() {
1450 let ctx = test_helpers::create_test_context();
1451 let cookie_str = create_session_cookie("my-token-123", &ctx);
1452 assert!(cookie_str.contains("better-auth.session-token=my-token-123"));
1454 assert!(cookie_str.contains("Path=/"));
1456 assert!(cookie_str.contains("HttpOnly"));
1458 assert!(cookie_str.contains("SameSite=Lax"));
1460 }
1461
1462 #[test]
1463 fn test_create_session_cookie_special_characters_in_token() {
1464 let ctx = test_helpers::create_test_context();
1465 let token = "token+with/special=chars&more";
1466 let cookie_str = create_session_cookie(token, &ctx);
1467 assert!(cookie_str.contains("better-auth.session-token="));
1469 }
1470}