Skip to main content

better_auth_api/plugins/
email_verification.rs

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/// Trait for custom email sending logic.
23///
24/// When set on [`EmailVerificationConfig::send_verification_email`], this
25/// callback overrides the default `EmailProvider`-based sending.
26#[async_trait]
27pub trait SendVerificationEmail: Send + Sync {
28    async fn send(&self, user: &User, url: &str, token: &str) -> AuthResult<()>;
29}
30
31/// Shorthand for the async hook closure type used by
32/// [`EmailVerificationConfig::before_email_verification`] and
33/// [`EmailVerificationConfig::after_email_verification`].
34pub type EmailVerificationHook =
35    Arc<dyn Fn(&User) -> Pin<Box<dyn Future<Output = AuthResult<()>> + Send>> + Send + Sync>;
36
37/// Email verification plugin for handling email verification flows
38pub struct EmailVerificationPlugin {
39    config: EmailVerificationConfig,
40}
41
42pub struct EmailVerificationConfig {
43    /// How long a verification token stays valid. Default: 1 hour.
44    pub verification_token_expiry: Duration,
45    /// Whether to send email notifications (on sign-up). Default: true.
46    pub send_email_notifications: bool,
47    /// Whether email verification is required before sign-in. Default: false.
48    pub require_verification_for_signin: bool,
49    /// Whether to auto-verify newly created users. Default: false.
50    pub auto_verify_new_users: bool,
51    /// When true, automatically send a verification email on sign-in if the
52    /// user is unverified. Default: false.
53    pub send_on_sign_in: bool,
54    /// When true, create a session after email verification and return the
55    /// session token in the verify-email response. Default: false.
56    pub auto_sign_in_after_verification: bool,
57    /// Optional custom email sender. When set this overrides the default
58    /// `EmailProvider`-based sending.
59    pub send_verification_email: Option<Arc<dyn SendVerificationEmail>>,
60    /// Hook invoked **before** email verification (before updating the user).
61    pub before_email_verification: Option<EmailVerificationHook>,
62    /// Hook invoked **after** email verification (after the user has been updated).
63    pub after_email_verification: Option<EmailVerificationHook>,
64}
65
66impl EmailVerificationConfig {
67    /// Backward-compatible helper: return the expiry duration expressed as
68    /// whole hours (truncated).
69    pub fn expiry_hours(&self) -> i64 {
70        self.verification_token_expiry.num_hours()
71    }
72}
73
74// Request structures for email verification endpoints
75#[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// Response structures
84
85#[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    /// Set the token expiry as a [`Duration`].
110    pub fn verification_token_expiry(mut self, duration: Duration) -> Self {
111        self.config.verification_token_expiry = duration;
112        self
113    }
114
115    /// Backward-compatible builder: set token expiry in hours.
116    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        // Send verification email for new users if configured.
218        // Also fire when a custom sender is set, even if send_email_notifications is false.
219        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
236// Implementation methods outside the trait
237impl 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        // Check if user exists
250        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        // Check if user is already verified
257        if user.email_verified() {
258            return Err(AuthError::bad_request("Email is already verified"));
259        }
260
261        // Send verification email
262        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        // Extract token from query parameters
280        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        // Find verification token
288        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        // Get user by email (stored in identifier field)
295        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        // Check if already verified
302        if user.email_verified() {
303            let response = VerifyEmailResponse { user, status: true };
304            return Ok(AuthResponse::json(200, &response)?);
305        }
306
307        // Run before_email_verification hook
308        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        // Update user email verification status
314        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        // Delete the used verification token
322        ctx.database.delete_verification(verification.id()).await?;
323
324        // Run after_email_verification hook
325        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        // Optionally create a session when auto_sign_in_after_verification is
331        // enabled.  The cookie is attached to **both** the redirect and the
332        // JSON responses below.
333        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 callback URL is provided, redirect
346        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        // Return JSON — include session when auto sign-in was performed
361        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    /// Send a verification email for a specific user.
378    ///
379    /// If [`EmailVerificationConfig::send_verification_email`] is set the
380    /// custom callback is used; otherwise the default `EmailProvider` path is
381    /// taken.
382    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        // Generate verification token
390        let verification_token = format!("verify_{}", Uuid::new_v4());
391        let expires_at = Utc::now() + self.config.verification_token_expiry;
392
393        // Create verification token
394        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        // Use custom sender if configured, otherwise fall back to EmailProvider
414        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            // Gracefully skip if no email provider is configured
421            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    /// Send a verification email on sign-in for an unverified user.
445    ///
446    /// Callers (e.g. the sign-in plugin) should invoke this when
447    /// [`EmailVerificationConfig::send_on_sign_in`] is `true` and the user is
448    /// not yet verified.
449    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    /// Check if `send_on_sign_in` is enabled.
472    pub fn should_send_on_sign_in(&self) -> bool {
473        self.config.send_on_sign_in
474    }
475
476    /// Check if email verification is required for signin
477    pub fn is_verification_required(&self) -> bool {
478        self.config.require_verification_for_signin
479    }
480
481    /// Check if user is verified or verification is not required
482    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    // ------------------------------------------------------------------
497    // Config defaults
498    // ------------------------------------------------------------------
499
500    #[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), // 1.5 hours
527            ..Default::default()
528        };
529        assert_eq!(config.expiry_hours(), 1); // truncated
530    }
531
532    // ------------------------------------------------------------------
533    // Builder methods
534    // ------------------------------------------------------------------
535
536    #[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    // ------------------------------------------------------------------
598    // Custom sender builder
599    // ------------------------------------------------------------------
600
601    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    // ------------------------------------------------------------------
618    // Hook builders
619    // ------------------------------------------------------------------
620
621    #[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    // ------------------------------------------------------------------
636    // Helper methods
637    // ------------------------------------------------------------------
638
639    #[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    /// Helper to create a minimal User for unit tests.
658    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        // verification not required → true even if unverified
683        assert!(plugin.is_user_verified_or_not_required(&user).await);
684
685        let plugin = EmailVerificationPlugin::new().require_verification_for_signin(true);
686        // verification required + unverified → false
687        assert!(!plugin.is_user_verified_or_not_required(&user).await);
688
689        let verified_user = make_test_user("a@b.com", true);
690        // verified → always true
691        assert!(
692            plugin
693                .is_user_verified_or_not_required(&verified_user)
694                .await
695        );
696    }
697
698    // ------------------------------------------------------------------
699    // to_user conversion
700    // ------------------------------------------------------------------
701
702    #[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    // ------------------------------------------------------------------
739    // Plugin trait basics
740    // ------------------------------------------------------------------
741
742    #[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    // ------------------------------------------------------------------
784    // send_verification_on_sign_in
785    // ------------------------------------------------------------------
786
787    #[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        // Should return Ok(()) immediately when disabled
801        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        // Mark user as verified
821        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        // Should return Ok(()) for already-verified user
827        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        // Use a custom sender that records calls instead of needing an
836        // email provider.
837        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) // disable default path
851            .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    // ------------------------------------------------------------------
873    // on_user_created – custom sender fires even when
874    // send_email_notifications is false
875    // ------------------------------------------------------------------
876
877    #[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        // Custom sender should have been called even though
908        // send_email_notifications is false.
909        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        // Mark verified
939        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        // Should NOT have been called because user is already verified.
948        assert_eq!(call_count.load(Ordering::Relaxed), 0);
949    }
950
951    // ------------------------------------------------------------------
952    // handle_verify_email – basic flow
953    // ------------------------------------------------------------------
954
955    #[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        // Create an unverified user
961        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        // Create a verification token
972        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        // Call verify-email
983        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        // User should now be verified in the database
995        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        // Verification token should be deleted
1004        let v = ctx
1005            .database
1006            .get_verification_by_value(&token_value)
1007            .await
1008            .unwrap();
1009        assert!(v.is_none());
1010    }
1011
1012    // ------------------------------------------------------------------
1013    // handle_verify_email – hooks are called
1014    // ------------------------------------------------------------------
1015
1016    #[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        // User should still be unverified
1110        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    // ------------------------------------------------------------------
1120    // handle_verify_email – auto_sign_in_after_verification
1121    // ------------------------------------------------------------------
1122
1123    #[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        // Session should be present
1158        assert!(body["session"]["token"].is_string());
1159
1160        // Set-Cookie header should be present
1161        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        // No session field expected
1201        assert!(body.get("session").is_none());
1202        // No Set-Cookie header expected
1203        assert!(!response.headers.contains_key("Set-Cookie"));
1204    }
1205
1206    // ------------------------------------------------------------------
1207    // handle_verify_email – auto_sign_in + callbackURL → 302 with cookie
1208    // ------------------------------------------------------------------
1209
1210    #[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        // Session cookie should be present on the redirect
1250        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    // ------------------------------------------------------------------
1294    // handle_verify_email – invalid token
1295    // ------------------------------------------------------------------
1296
1297    #[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    // ------------------------------------------------------------------
1327    // handle_verify_email – already-verified user returns early
1328    // ------------------------------------------------------------------
1329
1330    #[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        // Mark verified
1345        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    // ------------------------------------------------------------------
1377    // handle_send_verification_email
1378    // ------------------------------------------------------------------
1379
1380    #[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    // ------------------------------------------------------------------
1445    // create_session_cookie – uses cookie crate
1446    // ------------------------------------------------------------------
1447
1448    #[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        // Should contain the cookie name and value
1453        assert!(cookie_str.contains("better-auth.session-token=my-token-123"));
1454        // Should contain Path
1455        assert!(cookie_str.contains("Path=/"));
1456        // Should contain HttpOnly (default)
1457        assert!(cookie_str.contains("HttpOnly"));
1458        // Should contain SameSite
1459        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        // The cookie crate should handle encoding properly
1468        assert!(cookie_str.contains("better-auth.session-token="));
1469    }
1470}