1pub(super) mod handlers;
2
3pub(in crate::plugins) mod token;
4
5pub(super) mod types;
6
7use super::StatusResponse;
8use alibi_core::AuthUser;
9use alibi_core::wire::UserView;
10use alibi_core::{AuthContext, AuthError, AuthResult};
11use alibi_core::{AuthRequest, AuthResponse};
12use async_trait::async_trait;
13use chrono::Duration;
14use handlers::{send_verification_email_core, verification_url, verify_email_core};
15use std::future::Future;
16use std::pin::Pin;
17use std::sync::Arc;
18use types::{SendVerificationEmailRequest, VerifyEmailQuery, VerifyEmailResult};
19
20#[async_trait]
25pub trait SendVerificationEmail: Send + Sync {
26 async fn send(&self, user: &UserView, url: &str, token: &str) -> AuthResult<()>;
27}
28
29pub type EmailVerificationHook =
33 Arc<dyn Fn(&UserView) -> Pin<Box<dyn Future<Output = AuthResult<()>> + Send>> + Send + Sync>;
34
35pub struct EmailVerificationPlugin {
37 config: EmailVerificationConfig,
38}
39
40impl std::fmt::Debug for EmailVerificationPlugin {
41 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42 f.debug_struct("EmailVerificationPlugin")
43 .finish_non_exhaustive()
44 }
45}
46
47#[derive(Clone, alibi_core::PluginConfig)]
48#[plugin(name = "EmailVerificationPlugin")]
49#[expect(
50 clippy::struct_excessive_bools,
51 reason = "Independent configuration switches model distinct upstream behavior, rather than mutually exclusive states"
52)]
53pub struct EmailVerificationConfig {
54 #[config(default = Duration::hours(1))]
56 pub verification_token_expiry: Duration,
57 #[config(default = true)]
59 pub send_email_notifications: bool,
60 #[config(default = None)]
63 pub send_on_sign_up: Option<bool>,
64 #[config(default = false)]
66 pub require_verification_for_signin: bool,
67 #[config(default = false)]
69 pub auto_verify_new_users: bool,
70 #[config(default = false)]
73 pub send_on_sign_in: bool,
74 #[config(default = false)]
77 pub auto_sign_in_after_verification: bool,
78 #[config(default = None, skip)]
81 pub send_verification_email: Option<Arc<dyn SendVerificationEmail>>,
82 #[config(default = None)]
84 pub before_email_verification: Option<EmailVerificationHook>,
85 #[config(default = None)]
87 pub after_email_verification: Option<EmailVerificationHook>,
88}
89
90impl std::fmt::Debug for EmailVerificationConfig {
91 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92 f.debug_struct("EmailVerificationConfig")
93 .finish_non_exhaustive()
94 }
95}
96
97impl EmailVerificationPlugin {
98 #[must_use]
99 pub fn custom_send_verification_email(
100 mut self,
101 sender: Arc<dyn SendVerificationEmail>,
102 ) -> Self {
103 self.config.send_verification_email = Some(sender);
104 self
105 }
106}
107
108alibi_core::impl_auth_plugin! {
109 EmailVerificationPlugin, "email-verification";
110 routes {
111 post "/send-verification-email" => handle_send_verification_email, "send_verification_email";
112 get "/verify-email" => handle_verify_email, "verify_email";
113 }
114 extra {
115 fn static_openapi_metadata(&self) -> alibi_core::PluginOpenApiMetadata {
116 crate::metadata::plugin_metadata(<Self as alibi_core::AuthPlugin<S>>::name(self), &<Self as alibi_core::AuthPlugin<S>>::routes(self))
117 }
118
119 fn openapi_metadata(&self, ctx: &alibi_core::AuthInitContext<S>) -> alibi_core::PluginOpenApiMetadata {
120 crate::metadata::instance_plugin_metadata(<Self as alibi_core::AuthPlugin<S>>::name(self), &<Self as alibi_core::AuthPlugin<S>>::routes(self), ctx)
121 }
122
123 async fn on_init(&self, ctx: &mut alibi_core::AuthInitContext<S>) -> AuthResult<()> {
124 ctx.extensions.insert(self.config.clone());
125 Ok(())
126 }
127
128 async fn on_user_created(&self, user: &S::User, ctx: &AuthContext<S>) -> AuthResult<()> {
129 if (self.config.send_email_notifications || self.config.send_verification_email.is_some())
132 && !user.email_verified()
133 && let Some(email) = user.email()
134 {
135 self.send_verification_email_for_user(user, email, None, ctx)
136 .await?;
137 }
138 Ok(())
139 }
140 }
141}
142
143impl EmailVerificationPlugin {
148 async fn handle_send_verification_email(
149 &self,
150 req: &AuthRequest,
151 ctx: &AuthContext<impl alibi_core::AuthSchema>,
152 ) -> AuthResult<AuthResponse> {
153 let body: SendVerificationEmailRequest =
154 match super::authentication_helpers::parse_body(req) {
155 Ok(v) => v,
156 Err(resp) => return Ok(resp),
157 };
158 let current_user =
159 alibi_core::session::cookie_cache::runtime::authenticated(ctx, req, false)
160 .await
161 .ok()
162 .flatten()
163 .map(|read| read.user);
164 let response =
165 send_verification_email_core(&body, current_user.as_ref(), &self.config, ctx).await?;
166 Ok(AuthResponse::json(200, &response)?)
167 }
168
169 async fn handle_verify_email(
170 &self,
171 req: &AuthRequest,
172 ctx: &AuthContext<impl alibi_core::AuthSchema>,
173 ) -> AuthResult<AuthResponse> {
174 let token = req.query.get("token").ok_or(AuthError::Upstream {
175 status: 400,
176 code: "VALIDATION_ERROR",
177 message: "[query.token] Invalid input: expected string, received undefined",
178 })?;
179 let callback_url = req
180 .query
181 .get("callbackURL")
182 .filter(|url| !url.is_empty())
183 .cloned();
184
185 if let Some(ref url) = callback_url
188 && !ctx.config.current_origin_check_disabled()
189 && !ctx.config.is_redirect_target_trusted(url)
190 {
191 return Ok(AuthError::forbidden("Invalid callbackURL").to_auth_response());
192 }
193
194 let query = VerifyEmailQuery {
195 token: token.clone(),
196 callback_url,
197 };
198
199 match verify_email_core(&query, &self.config, req, ctx).await? {
200 VerifyEmailResult::Redirect { url, session_token } => {
201 let mut headers = alibi_core::Headers::new();
202 drop(headers.insert("Location".to_owned(), url));
203 drop(headers.insert("content-type".to_owned(), "application/json".to_owned()));
204 let mut response = AuthResponse {
205 status: 302,
206 headers,
207 body: Vec::new(),
208 };
209 if let Some(token) = session_token {
210 super::user_management::append_session_cookie(
211 &mut response,
212 req,
213 &token,
214 &ctx.config,
215 )?;
216 }
217 Ok(response)
218 }
219 VerifyEmailResult::Json {
220 body,
221 session_token,
222 } => {
223 let mut response = AuthResponse::json(200, &body)?;
224 if let Some(token) = session_token {
225 super::user_management::append_session_cookie(
226 &mut response,
227 req,
228 &token,
229 &ctx.config,
230 )?;
231 }
232 Ok(response)
233 }
234 }
235 }
236
237 pub(in crate::plugins) async fn send_verification_email_for_user(
246 &self,
247 user: &impl AuthUser,
248 email: &str,
249 callback_url: Option<&str>,
250 ctx: &AuthContext<impl alibi_core::AuthSchema>,
251 ) -> AuthResult<()> {
252 if self.config.send_verification_email.is_none()
253 && let Some(sender) = ctx.email_verification_override()
254 {
255 return sender.0.send(&ctx.trusted_user_view(user), None, ctx).await;
256 }
257 let verification_token = token::create_email_verification_token(
258 ctx.config.current_secret(),
259 email,
260 None,
261 self.config.verification_token_expiry,
262 None,
263 )?;
264 let verification_url = verification_url(&ctx.config, &verification_token, callback_url);
265
266 if let Some(ref custom_sender) = self.config.send_verification_email {
268 let user = ctx.trusted_user_view(user);
269 send_custom_verification_email(
270 ctx,
271 custom_sender,
272 user,
273 verification_url,
274 verification_token,
275 ctx.config.awaited_notification_errors,
276 )
277 .await?;
278 } else if self.config.send_email_notifications {
279 if let Some(provider) = ctx.email_provider.clone() {
281 let subject = "Verify your email address";
282 let html = format!(
283 "<p>Click the link below to verify your email address:</p>\
284 <p><a href=\"{verification_url}\">Verify Email</a></p>"
285 );
286 let text = format!("Verify your email address: {verification_url}");
287
288 let email = email.to_owned();
289 super::authentication_helpers::run_owned_notification(
290 ctx,
291 async move { provider.send(&email, subject, &html, &text).await },
292 ctx.config.awaited_notification_errors,
293 )
294 .await?;
295 } else {
296 tracing::warn!("No email provider configured, skipping verification email");
297 }
298 }
299
300 Ok(())
301 }
302
303 pub async fn send_verification_on_sign_in(
313 &self,
314 user: &impl AuthUser,
315 callback_url: Option<&str>,
316 ctx: &AuthContext<impl alibi_core::AuthSchema>,
317 ) -> AuthResult<()> {
318 if !self.config.send_on_sign_in {
319 return Ok(());
320 }
321
322 if user.email_verified() {
323 return Ok(());
324 }
325
326 if let Some(email) = user.email() {
327 self.send_verification_email_for_user(user, email, callback_url, ctx)
328 .await?;
329 }
330
331 Ok(())
332 }
333
334 #[must_use]
336 pub const fn should_send_on_sign_in(&self) -> bool {
337 self.config.send_on_sign_in
338 }
339
340 #[must_use]
342 pub const fn is_verification_required(&self) -> bool {
343 self.config.require_verification_for_signin
344 }
345
346 pub fn is_user_verified_or_not_required(&self, user: &impl AuthUser) -> bool {
348 user.email_verified() || !self.config.require_verification_for_signin
349 }
350}
351
352pub(in crate::plugins) async fn send_custom_verification_email(
354 ctx: &AuthContext<impl alibi_core::AuthSchema>,
355 sender: &Arc<dyn SendVerificationEmail>,
356 user: alibi_core::wire::UserView,
357 url: String,
358 token: String,
359 error_policy: alibi_core::AwaitedNotificationErrorPolicy,
360) -> AuthResult<()> {
361 let sender = Arc::clone(sender);
362 super::authentication_helpers::run_owned_notification(
363 ctx,
364 async move { sender.send(&user, &url, &token).await },
365 error_policy,
366 )
367 .await
368}
369
370pub(in crate::plugins) async fn send_signup_verification<S: alibi_core::AuthSchema>(
376 user: &impl AuthUser,
377 callback_url: Option<&str>,
378 required: bool,
379 ctx: &AuthContext<S>,
380 tx: &dyn alibi_core::store::AuthTransaction<S>,
381) -> AuthResult<()> {
382 let config = ctx.extensions.get::<EmailVerificationConfig>();
383 if !config
384 .as_ref()
385 .and_then(|config| config.send_on_sign_up)
386 .unwrap_or(required)
387 {
388 return Ok(());
389 }
390 let user = ctx.trusted_user_view(user);
391 if config
392 .as_ref()
393 .is_none_or(|config| config.send_verification_email.is_none())
394 && let Some(sender) = ctx.email_verification_override()
395 {
396 return sender.0.send_in_transaction(&user, None, ctx, tx).await;
397 }
398 let Some(config) = config else {
399 return Ok(());
400 };
401 let Some(sender) = &config.send_verification_email else {
402 return Ok(());
403 };
404 let Some(email) = user.email.as_deref() else {
405 return Ok(());
406 };
407 let token = token::create_email_verification_token(
408 ctx.config.current_secret(),
409 email,
410 None,
411 config.verification_token_expiry,
412 None,
413 )?;
414 let url = verification_url(&ctx.config, &token, callback_url);
415 send_custom_verification_email(
416 ctx,
417 sender,
418 user,
419 url,
420 token,
421 ctx.config.awaited_notification_errors,
422 )
423 .await
424}
425
426#[cfg(test)]
432mod tests {
433 use super::token::create_email_verification_token;
434 use super::*;
435 use crate::plugins::test_helpers;
436 use alibi_core::utils::cookie_utils::create_session_cookie;
437 use alibi_core::wire::UserView;
438 use alibi_core::{AuthPlugin, HttpMethod};
439 use alibi_core::{AuthResult, AuthSession, CreateUser, UpdateUser};
440 use async_trait::async_trait;
441 use chrono::{Duration, Utc};
442 use std::collections::HashMap;
443 use std::sync::Arc;
444 use std::sync::atomic::{AtomicU32, Ordering};
445
446 struct DummySender;
451
452 #[async_trait]
453 impl SendVerificationEmail for DummySender {
454 async fn send(&self, _user: &UserView, _url: &str, _token: &str) -> AuthResult<()> {
455 Ok(())
456 }
457 }
458
459 #[test]
466 fn test_default_config() {
467 let config = EmailVerificationConfig::default();
468 assert_eq!(config.verification_token_expiry, Duration::hours(1));
469 assert!(config.send_email_notifications);
470 assert!(!config.require_verification_for_signin);
471 assert!(!config.auto_verify_new_users);
472 assert!(!config.send_on_sign_in);
473 assert!(!config.auto_sign_in_after_verification);
474 assert!(config.send_verification_email.is_none());
475 assert!(config.before_email_verification.is_none());
476 assert!(config.after_email_verification.is_none());
477 }
478
479 #[test]
482 fn test_builder_verification_token_expiry() {
483 let plugin =
484 EmailVerificationPlugin::new().verification_token_expiry(Duration::minutes(30));
485 assert_eq!(
486 plugin.config.verification_token_expiry,
487 Duration::minutes(30)
488 );
489 }
490
491 #[test]
494 fn test_builder_send_on_sign_in() {
495 let plugin = EmailVerificationPlugin::new().send_on_sign_in(true);
496 assert!(plugin.config.send_on_sign_in);
497 }
498
499 #[test]
502 fn test_builder_auto_sign_in_after_verification() {
503 let plugin = EmailVerificationPlugin::new().auto_sign_in_after_verification(true);
504 assert!(plugin.config.auto_sign_in_after_verification);
505 }
506
507 #[test]
510 fn test_builder_send_email_notifications() {
511 let plugin = EmailVerificationPlugin::new().send_email_notifications(false);
512 assert!(!plugin.config.send_email_notifications);
513 }
514
515 #[test]
518 fn test_builder_require_verification_for_signin() {
519 let plugin = EmailVerificationPlugin::new().require_verification_for_signin(true);
520 assert!(plugin.config.require_verification_for_signin);
521 }
522
523 #[test]
526 fn test_builder_auto_verify_new_users() {
527 let plugin = EmailVerificationPlugin::new().auto_verify_new_users(true);
528 assert!(plugin.config.auto_verify_new_users);
529 }
530
531 #[test]
534 fn test_builder_chaining() {
535 let plugin = EmailVerificationPlugin::new()
536 .verification_token_expiry(Duration::hours(2))
537 .send_on_sign_in(true)
538 .auto_sign_in_after_verification(true)
539 .send_email_notifications(false)
540 .require_verification_for_signin(true);
541 assert_eq!(plugin.config.verification_token_expiry, Duration::hours(2));
542 assert!(plugin.config.send_on_sign_in);
543 assert!(plugin.config.auto_sign_in_after_verification);
544 assert!(!plugin.config.send_email_notifications);
545 assert!(plugin.config.require_verification_for_signin);
546 }
547
548 #[test]
551 fn test_builder_custom_send_verification_email() {
552 let plugin =
553 EmailVerificationPlugin::new().custom_send_verification_email(Arc::new(DummySender));
554 assert!(plugin.config.send_verification_email.is_some());
555 }
556
557 #[test]
560 fn test_builder_before_email_verification_hook() {
561 let hook: EmailVerificationHook = Arc::new(|_user: &UserView| Box::pin(async { Ok(()) }));
562 let plugin = EmailVerificationPlugin::new().before_email_verification(hook);
563 assert!(plugin.config.before_email_verification.is_some());
564 }
565
566 #[test]
569 fn test_builder_after_email_verification_hook() {
570 let hook: EmailVerificationHook = Arc::new(|_user: &UserView| Box::pin(async { Ok(()) }));
571 let plugin = EmailVerificationPlugin::new().after_email_verification(hook);
572 assert!(plugin.config.after_email_verification.is_some());
573 }
574
575 fn make_test_user(email: &str, verified: bool) -> UserView {
577 UserView {
578 omitted_fields: std::collections::BTreeSet::default(),
579 id: "test-id".into(),
580 name: Some("Test".into()),
581 email: Some(email.into()),
582 email_verified: verified,
583 image: None,
584 created_at: Utc::now(),
585 updated_at: Utc::now(),
586 username: None,
587 display_username: None,
588 two_factor_enabled: Some(false),
589 role: None,
590 banned: Some(false),
591 ban_reason: None,
592 ban_expires: None,
593 metadata: serde_json::Value::Null,
594 is_anonymous: None,
595 phone_number: None,
596 phone_number_verified: None,
597 last_login_method: None,
598 extension_fields: std::collections::BTreeMap::default(),
599 }
600 }
601
602 fn jwt_token(
603 ctx: &AuthContext<impl alibi_core::AuthSchema>,
604 email: &str,
605 update_to: Option<&str>,
606 request_type: Option<&str>,
607 ) -> String {
608 create_email_verification_token(
609 &ctx.config.secret,
610 email,
611 update_to,
612 Duration::hours(1),
613 request_type,
614 )
615 .unwrap()
616 }
617
618 #[tokio::test]
621 #[expect(
622 clippy::too_many_lines,
623 reason = "Keep this ordered integration scenario and its assertions together; Result propagates setup failures"
624 )]
625 async fn legacy_email_change_reuses_or_issues_session_and_default_lifetime_followup() {
626 use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
627
628 struct Sender {
629 calls: Arc<std::sync::Mutex<Vec<(UserView, String)>>>,
630 fail: bool,
631 }
632 #[derive(Default)]
633 struct Observer(std::sync::Mutex<Option<alibi_core::BackgroundTaskCompletion>>);
634 impl alibi_core::BackgroundTaskHandler for Observer {
635 fn handle(&self, completion: alibi_core::BackgroundTaskCompletion) -> AuthResult<()> {
636 *self.0.lock().unwrap() = Some(completion);
637 Ok(())
638 }
639 }
640 #[async_trait]
641 impl SendVerificationEmail for Sender {
642 async fn send(&self, user: &UserView, _: &str, token: &str) -> AuthResult<()> {
643 self.calls
644 .lock()
645 .unwrap()
646 .push((user.clone(), token.into()));
647 if self.fail {
648 Err(AuthError::bad_request("fixture delivery failed"))
649 } else {
650 Ok(())
651 }
652 }
653 }
654
655 for (authenticated, fail, background) in [
656 (false, false, false),
657 (true, false, false),
658 (false, true, false),
659 (true, true, false),
660 (false, true, true),
661 (true, true, true),
662 ] {
663 let mut ctx = test_helpers::create_test_context().await;
664 let mut init = alibi_core::AuthInitContext::new(
667 Arc::clone(&ctx.config),
668 Arc::clone(&ctx.database),
669 );
670 init.extensions
671 .insert(alibi_core::field_policy::SessionFields::default());
672 ctx.database = init.database_with_registered_transforms();
673 ctx.extensions = init.into_parts().extensions;
674 let observer = Arc::new(Observer::default());
675 if background {
676 Arc::make_mut(&mut ctx.config).background_tasks = Some(observer.clone());
677 }
678 let calls = Arc::new(std::sync::Mutex::new(Vec::new()));
679 let plugin = EmailVerificationPlugin::new()
680 .verification_token_expiry(Duration::seconds(90))
681 .custom_send_verification_email(Arc::new(Sender {
682 calls: Arc::clone(&calls),
683 fail,
684 }));
685 let user = ctx
686 .database
687 .create_user(CreateUser::new().with_email("before@legacy.fixture.test"))
688 .await
689 .unwrap();
690 let current = if authenticated {
691 Some(
692 ctx.database
693 .create_session(alibi_core::CreateSession {
694 additional_fields: alibi_core::field_policy::FieldValues::default(),
695 token: None,
696 user_id: user.id().to_string(),
697 expires_at: Utc::now() + ctx.config.session.expires_in,
698 ip_address: None,
699 user_agent: None,
700 impersonated_by: None,
701 active_organization_id: None,
702 active_team_id: None,
703 })
704 .await
705 .unwrap(),
706 )
707 } else {
708 None
709 };
710 let mut query = HashMap::new();
711 query.insert(
712 "token".into(),
713 jwt_token(
714 &ctx,
715 "before@legacy.fixture.test",
716 Some("After@legacy.fixture.test"),
717 None,
718 ),
719 );
720 let req = test_helpers::create_auth_request(
721 HttpMethod::Get,
722 "/verify-email",
723 current.as_ref().map(|session| session.token.as_str()),
724 None,
725 query,
726 );
727 let result = plugin.handle_verify_email(&req, &ctx).await;
728 let response = if fail && !background {
729 assert_eq!(result.unwrap_err().status_code(), 400);
730 None
731 } else {
732 let response = result.unwrap();
733 assert_eq!(response.status, 200);
734 let body: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
735 assert_eq!(
736 (*(*(body).get("user").unwrap_or(&serde_json::Value::Null))
737 .get("id")
738 .unwrap_or(&serde_json::Value::Null))
739 .as_str(),
740 Some(user.id().as_ref())
741 );
742 assert_eq!(
743 (*(*(body).get("user").unwrap_or(&serde_json::Value::Null))
744 .get("email")
745 .unwrap_or(&serde_json::Value::Null)),
746 "after@legacy.fixture.test"
747 );
748 assert_eq!(
749 (*(*(body).get("user").unwrap_or(&serde_json::Value::Null))
750 .get("emailVerified")
751 .unwrap_or(&serde_json::Value::Null)),
752 false
753 );
754 Some(response)
755 };
756 if background {
757 let completion = observer.0.lock().unwrap().take().unwrap();
758 completion.await.unwrap();
759 }
760 let sessions = ctx.database.get_user_sessions(&user.id()).await.unwrap();
761 assert_eq!(sessions.len(), 1, "Anonymous proof must issue a session");
762 let session = sessions.first().unwrap();
763 if let Some(previous) = current {
764 assert_eq!(session.id(), previous.id());
765 assert_eq!(session.token(), previous.token());
766 }
767 if let Some(response) = response {
768 assert_eq!(
769 response.headers.get("set-cookie"),
770 Some(&create_session_cookie(session.token(), &ctx.config).unwrap())
771 );
772 }
773 let updated = ctx
774 .database
775 .get_user_by_email("after@legacy.fixture.test")
776 .await
777 .unwrap()
778 .unwrap();
779 assert_eq!(updated.id(), user.id());
780 assert!(!updated.email_verified());
781 let calls = calls.lock().unwrap();
782 assert_eq!(calls.len(), 1);
783 let (recipient, token) = calls.first().unwrap();
784 assert_eq!(recipient.id, user.id());
785 assert_eq!(
786 recipient.email.as_deref(),
787 Some("after@legacy.fixture.test")
788 );
789 let encoded = token.split('.').nth(1).unwrap();
790 let claims: serde_json::Value =
791 serde_json::from_slice(&URL_SAFE_NO_PAD.decode(encoded).unwrap()).unwrap();
792 assert_eq!(
793 (*(claims).get("email").unwrap_or(&serde_json::Value::Null)),
794 "after@legacy.fixture.test"
795 );
796 assert_eq!(
797 (*(claims).get("exp").unwrap_or(&serde_json::Value::Null))
798 .as_i64()
799 .unwrap()
800 - (*(claims).get("iat").unwrap_or(&serde_json::Value::Null))
801 .as_i64()
802 .unwrap(),
803 3600
804 );
805 assert!(claims.get("updateTo").is_none());
806 drop(calls);
807 }
808 }
809
810 fn external_verification_token(
811 secret: &str,
812 algorithm: jsonwebtoken::Algorithm,
813 payload: &serde_json::Value,
814 ) -> String {
815 let mut header = jsonwebtoken::Header::new(algorithm);
816 header.typ = None;
817 jsonwebtoken::encode(
818 &header,
819 payload,
820 &jsonwebtoken::EncodingKey::from_secret(secret.as_bytes()),
821 )
822 .unwrap()
823 }
824
825 #[tokio::test]
828 async fn external_verification_proofs_enforce_signature_algorithm_and_numeric_dates() {
829 use alibi_core::AuthUser;
830 let ctx = test_helpers::create_test_context().await;
831 let plugin = EmailVerificationPlugin::new().auto_sign_in_after_verification(true);
832 let user = ctx
833 .database
834 .create_user(CreateUser::new().with_email("proof-owner@fixture.test"))
835 .await
836 .unwrap();
837 let now = Utc::now().timestamp();
838 for (secret, algorithm, dates, code) in [
839 (
840 ctx.config.secret.as_str(),
841 jsonwebtoken::Algorithm::HS256,
842 serde_json::json!({"exp":now-30}),
843 "TOKEN_EXPIRED",
844 ),
845 (
846 ctx.config.secret.as_str(),
847 jsonwebtoken::Algorithm::HS256,
848 serde_json::json!({"exp":now}),
849 "TOKEN_EXPIRED",
850 ),
851 (
852 ctx.config.secret.as_str(),
853 jsonwebtoken::Algorithm::HS256,
854 serde_json::json!({"exp":now+300,"nbf":now+30}),
855 "INVALID_TOKEN",
856 ),
857 (
858 ctx.config.secret.as_str(),
859 jsonwebtoken::Algorithm::HS256,
860 serde_json::json!({"exp":now+300,"iat":"invalid"}),
861 "INVALID_TOKEN",
862 ),
863 (
864 ctx.config.secret.as_str(),
865 jsonwebtoken::Algorithm::HS384,
866 serde_json::json!({"exp":now+300}),
867 "INVALID_TOKEN",
868 ),
869 (
870 "different-signing-key-for-fixture-only",
871 jsonwebtoken::Algorithm::HS256,
872 serde_json::json!({"exp":now+300}),
873 "INVALID_TOKEN",
874 ),
875 ] {
876 let mut payload = dates;
877 drop(
878 payload
879 .as_object_mut()
880 .expect("token payload is an object")
881 .insert(
882 "email".to_owned(),
883 serde_json::json!("proof-owner@fixture.test"),
884 ),
885 );
886 let token = external_verification_token(secret, algorithm, &payload);
887 let query = HashMap::from([("token".to_owned(), token.clone())]);
888 let req = test_helpers::create_auth_request(
889 HttpMethod::Get,
890 "/verify-email",
891 None,
892 None,
893 query,
894 );
895 let error = plugin.handle_verify_email(&req, &ctx).await.unwrap_err();
896 assert_eq!(error.status_code(), 401);
897 assert_eq!(error.error_payload().1.as_deref(), Some(code));
898 let callback = "/verification-error?source=caller&error=existing#details";
899 let query_2 = HashMap::from([
900 ("token".to_owned(), token),
901 ("callbackURL".to_owned(), callback.to_owned()),
902 ]);
903 let req_2 = test_helpers::create_auth_request(
904 HttpMethod::Get,
905 "/verify-email",
906 None,
907 None,
908 query_2,
909 );
910 let response = plugin.handle_verify_email(&req_2, &ctx).await.unwrap();
911 assert_eq!(response.status, 302);
912 assert_eq!(
913 response.headers.get("Location"),
914 Some(&format!(
915 "/verification-error?source=caller&error=existing&error={code}#details"
916 ))
917 );
918 let persisted = ctx
919 .database
920 .get_user_by_id(&user.id())
921 .await
922 .unwrap()
923 .unwrap();
924 assert!(!persisted.email_verified());
925 assert_eq!(
926 ctx.database
927 .get_user_sessions(&user.id())
928 .await
929 .unwrap()
930 .len(),
931 0
932 );
933 }
934 }
935
936 #[tokio::test]
939 async fn externally_signed_verification_without_dates_or_matching_audience_is_accepted() {
940 use alibi_core::AuthUser;
941 let ctx = test_helpers::create_test_context().await;
942 let plugin = EmailVerificationPlugin::new();
943 let user = ctx
944 .database
945 .create_user(CreateUser::new().with_email("legacy-proof@fixture.test"))
946 .await
947 .unwrap();
948 let token = external_verification_token(
949 &ctx.config.secret,
950 jsonwebtoken::Algorithm::HS256,
951 &serde_json::json!({"email":"legacy-proof@fixture.test","aud":"embedding-app","iss":"embedding-issuer"}),
952 );
953 let query = HashMap::from([("token".to_owned(), token)]);
954 let req =
955 test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
956 let response = plugin.handle_verify_email(&req, &ctx).await.unwrap();
957 assert_eq!(response.status, 200);
958 let persisted = ctx
959 .database
960 .get_user_by_id(&user.id())
961 .await
962 .unwrap()
963 .unwrap();
964 assert!(persisted.email_verified());
965 assert_eq!(
966 ctx.database
967 .get_user_sessions(&user.id())
968 .await
969 .unwrap()
970 .len(),
971 0
972 );
973 }
974
975 #[test]
978 fn test_should_send_on_sign_in() {
979 let plugin = EmailVerificationPlugin::new();
980 assert!(!plugin.should_send_on_sign_in());
981
982 let plugin_2 = EmailVerificationPlugin::new().send_on_sign_in(true);
983 assert!(plugin_2.should_send_on_sign_in());
984 }
985
986 #[test]
989 fn test_is_verification_required() {
990 let plugin = EmailVerificationPlugin::new();
991 assert!(!plugin.is_verification_required());
992
993 let plugin_2 = EmailVerificationPlugin::new().require_verification_for_signin(true);
994 assert!(plugin_2.is_verification_required());
995 }
996
997 #[tokio::test]
1000 async fn test_is_user_verified_or_not_required() {
1001 let plugin = EmailVerificationPlugin::new();
1002 let user = make_test_user("a@b.com", false);
1003 assert!(plugin.is_user_verified_or_not_required(&user));
1005
1006 let plugin_2 = EmailVerificationPlugin::new().require_verification_for_signin(true);
1007 assert!(!plugin_2.is_user_verified_or_not_required(&user));
1009
1010 let verified_user = make_test_user("a@b.com", true);
1011 assert!(plugin_2.is_user_verified_or_not_required(&verified_user));
1013 }
1014
1015 #[test]
1021 fn test_to_user_preserves_fields() {
1022 let user = UserView {
1023 omitted_fields: std::collections::BTreeSet::default(),
1024 id: "test-id".into(),
1025 name: Some("Test User".into()),
1026 email: Some("test@example.com".into()),
1027 email_verified: true,
1028 image: Some("https://img.example.com/a.png".into()),
1029 created_at: Utc::now(),
1030 updated_at: Utc::now(),
1031 username: Some("testuser".into()),
1032 display_username: Some("TestUser".into()),
1033 two_factor_enabled: Some(true),
1034 role: Some("admin".into()),
1035 banned: Some(true),
1036 ban_reason: Some("spam".into()),
1037 ban_expires: None,
1038 metadata: serde_json::Value::Null,
1039 is_anonymous: None,
1040 phone_number: None,
1041 phone_number_verified: None,
1042 last_login_method: None,
1043 extension_fields: std::collections::BTreeMap::default(),
1044 };
1045 let converted = UserView::from(&user);
1046 assert_eq!(converted.id, "test-id");
1047 assert_eq!(converted.name.as_deref(), Some("Test User"));
1048 assert_eq!(converted.email.as_deref(), Some("test@example.com"));
1049 assert!(converted.email_verified);
1050 assert_eq!(
1051 converted.image.as_deref(),
1052 Some("https://img.example.com/a.png")
1053 );
1054 assert_eq!(converted.username.as_deref(), Some("testuser"));
1055 assert_eq!(converted.display_username.as_deref(), Some("TestUser"));
1056 assert_eq!(converted.two_factor_enabled, Some(true));
1057 assert_eq!(converted.role.as_deref(), Some("admin"));
1058 assert_eq!(converted.banned, Some(true));
1059 assert_eq!(converted.ban_reason.as_deref(), Some("spam"));
1060 }
1061
1062 #[test]
1068 fn test_plugin_name() {
1069 let plugin = EmailVerificationPlugin::new();
1070 assert_eq!(
1071 AuthPlugin::<
1072 alibi_seaorm::store::__private_test_support::bundled_schema::BundledSchema,
1073 >::name(&plugin,),
1074 "email-verification"
1075 );
1076 }
1077
1078 #[test]
1080 fn test_plugin_routes() {
1081 let plugin = EmailVerificationPlugin::new();
1082 let routes = AuthPlugin::<
1083 alibi_seaorm::store::__private_test_support::bundled_schema::BundledSchema,
1084 >::routes(&plugin);
1085 assert_eq!(routes.len(), 2);
1086 assert!(
1087 routes
1088 .iter()
1089 .any(|r| r.path == "/send-verification-email" && r.method == HttpMethod::Post)
1090 );
1091 assert!(
1092 routes
1093 .iter()
1094 .any(|r| r.path == "/verify-email" && r.method == HttpMethod::Get)
1095 );
1096 }
1097
1098 #[tokio::test]
1100 async fn test_on_request_unknown_route_returns_none() {
1101 let plugin = EmailVerificationPlugin::new();
1102 let ctx = test_helpers::create_test_context().await;
1103 let req = test_helpers::create_auth_request(
1104 HttpMethod::Get,
1105 "/unknown",
1106 None,
1107 None,
1108 HashMap::new(),
1109 );
1110 let result = plugin.on_request(&req, &ctx).await.unwrap();
1111 assert!(result.is_none());
1112 }
1113
1114 #[tokio::test]
1120 async fn test_send_verification_on_sign_in_disabled() {
1121 let plugin = EmailVerificationPlugin::new().send_on_sign_in(false);
1122 let ctx = test_helpers::create_test_context().await;
1123 let user = ctx
1124 .database
1125 .create_user(
1126 CreateUser::new()
1127 .with_email("unverified@test.com")
1128 .with_name("Test"),
1129 )
1130 .await
1131 .unwrap();
1132 plugin
1134 .send_verification_on_sign_in(&user, None, &ctx)
1135 .await
1136 .unwrap();
1137 }
1138
1139 #[tokio::test]
1141 async fn test_send_verification_on_sign_in_verified_user() {
1142 let plugin = EmailVerificationPlugin::new().send_on_sign_in(true);
1143 let ctx = test_helpers::create_test_context().await;
1144 let user = ctx
1145 .database
1146 .create_user(
1147 CreateUser::new()
1148 .with_email("verified@test.com")
1149 .with_name("Test"),
1150 )
1151 .await
1152 .unwrap();
1153 let update = UpdateUser {
1155 email_verified: Some(true),
1156 ..Default::default()
1157 };
1158 let verified = ctx.database.update_user(&user.id, update).await.unwrap();
1159 plugin
1161 .send_verification_on_sign_in(&verified, None, &ctx)
1162 .await
1163 .unwrap();
1164 }
1165
1166 #[tokio::test]
1168 async fn test_send_verification_on_sign_in_creates_token() {
1169 struct CountingSender(Arc<AtomicU32>);
1170
1171 #[async_trait]
1172 impl SendVerificationEmail for CountingSender {
1173 async fn send(&self, _user: &UserView, _url: &str, _token: &str) -> AuthResult<()> {
1174 self.0.fetch_add(1, Ordering::Relaxed);
1175 Ok(())
1176 }
1177 }
1178
1179 let call_count = Arc::new(AtomicU32::new(0));
1182 let counter = std::sync::Arc::clone(&call_count);
1183
1184 let plugin = EmailVerificationPlugin::new()
1185 .send_on_sign_in(true)
1186 .send_email_notifications(false) .custom_send_verification_email(Arc::new(CountingSender(counter)));
1188
1189 let ctx = test_helpers::create_test_context().await;
1190 let user = ctx
1191 .database
1192 .create_user(
1193 CreateUser::new()
1194 .with_email("unverified@test.com")
1195 .with_name("Test"),
1196 )
1197 .await
1198 .unwrap();
1199
1200 plugin
1201 .send_verification_on_sign_in(&user, None, &ctx)
1202 .await
1203 .unwrap();
1204
1205 assert_eq!(call_count.load(Ordering::Relaxed), 1);
1206 }
1207
1208 #[tokio::test]
1215 async fn test_on_user_created_custom_sender_fires_without_notifications() {
1216 struct CountingSender(Arc<AtomicU32>);
1217
1218 #[async_trait]
1219 impl SendVerificationEmail for CountingSender {
1220 async fn send(&self, _user: &UserView, _url: &str, _token: &str) -> AuthResult<()> {
1221 self.0.fetch_add(1, Ordering::Relaxed);
1222 Ok(())
1223 }
1224 }
1225
1226 let call_count = Arc::new(AtomicU32::new(0));
1227 let counter = std::sync::Arc::clone(&call_count);
1228
1229 let plugin = EmailVerificationPlugin::new()
1230 .send_email_notifications(false)
1231 .custom_send_verification_email(Arc::new(CountingSender(counter)));
1232
1233 let ctx = test_helpers::create_test_context().await;
1234 let user = ctx
1235 .database
1236 .create_user(
1237 CreateUser::new()
1238 .with_email("newuser@test.com")
1239 .with_name("New"),
1240 )
1241 .await
1242 .unwrap();
1243
1244 plugin.on_user_created(&user, &ctx).await.unwrap();
1245
1246 assert_eq!(call_count.load(Ordering::Relaxed), 1);
1249 }
1250
1251 #[tokio::test]
1253 async fn test_on_user_created_verified_user_skips_email() {
1254 struct CountingSender(Arc<AtomicU32>);
1255
1256 #[async_trait]
1257 impl SendVerificationEmail for CountingSender {
1258 async fn send(&self, _user: &UserView, _url: &str, _token: &str) -> AuthResult<()> {
1259 self.0.fetch_add(1, Ordering::Relaxed);
1260 Ok(())
1261 }
1262 }
1263
1264 let call_count = Arc::new(AtomicU32::new(0));
1265 let counter = std::sync::Arc::clone(&call_count);
1266
1267 let plugin = EmailVerificationPlugin::new()
1268 .custom_send_verification_email(Arc::new(CountingSender(counter)));
1269
1270 let ctx = test_helpers::create_test_context().await;
1271 let user = ctx
1272 .database
1273 .create_user(
1274 CreateUser::new()
1275 .with_email("newuser@test.com")
1276 .with_name("New"),
1277 )
1278 .await
1279 .unwrap();
1280 let update = UpdateUser {
1282 email_verified: Some(true),
1283 ..Default::default()
1284 };
1285 let verified = ctx.database.update_user(&user.id, update).await.unwrap();
1286
1287 plugin.on_user_created(&verified, &ctx).await.unwrap();
1288
1289 assert_eq!(call_count.load(Ordering::Relaxed), 0);
1291 }
1292
1293 #[tokio::test]
1299 async fn test_verify_email_basic_flow() {
1300 let plugin = EmailVerificationPlugin::new();
1301 let ctx = test_helpers::create_test_context().await;
1302
1303 let _user = ctx
1305 .database
1306 .create_user(
1307 CreateUser::new()
1308 .with_email("verify@test.com")
1309 .with_name("Verify Me"),
1310 )
1311 .await
1312 .unwrap();
1313
1314 let token_value = jwt_token(&ctx, "verify@test.com", None, None);
1315
1316 let mut query = HashMap::new();
1318 query.insert("token".to_owned(), token_value.clone());
1319 let req =
1320 test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
1321 let response = plugin.handle_verify_email(&req, &ctx).await.unwrap();
1322
1323 assert_eq!(response.status, 200);
1324 let body: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
1325 assert_eq!(
1326 (*(body).get("status").unwrap_or(&serde_json::Value::Null)),
1327 true
1328 );
1329 assert!((*(body).get("user").unwrap_or(&serde_json::Value::Null)).is_null());
1330
1331 let updated = ctx
1333 .database
1334 .get_user_by_email("verify@test.com")
1335 .await
1336 .unwrap()
1337 .unwrap();
1338 assert!(updated.email_verified);
1339 }
1340
1341 #[tokio::test]
1347 async fn test_verify_email_calls_before_and_after_hooks() {
1348 let before_count = Arc::new(AtomicU32::new(0));
1349 let after_count = Arc::new(AtomicU32::new(0));
1350 let bc = std::sync::Arc::clone(&before_count);
1351 let ac = std::sync::Arc::clone(&after_count);
1352
1353 let before_hook: EmailVerificationHook = Arc::new(move |_user: &UserView| {
1354 let c = std::sync::Arc::clone(&bc);
1355 Box::pin(async move {
1356 c.fetch_add(1, Ordering::Relaxed);
1357 Ok(())
1358 })
1359 });
1360 let after_hook: EmailVerificationHook = Arc::new(move |_user: &UserView| {
1361 let c = std::sync::Arc::clone(&ac);
1362 Box::pin(async move {
1363 c.fetch_add(1, Ordering::Relaxed);
1364 Ok(())
1365 })
1366 });
1367
1368 let plugin = EmailVerificationPlugin::new()
1369 .before_email_verification(before_hook)
1370 .after_email_verification(after_hook);
1371
1372 let ctx = test_helpers::create_test_context().await;
1373 let _user = ctx
1374 .database
1375 .create_user(
1376 CreateUser::new()
1377 .with_email("hooks@test.com")
1378 .with_name("Hooks"),
1379 )
1380 .await
1381 .unwrap();
1382
1383 let token_value = jwt_token(&ctx, "hooks@test.com", None, None);
1384
1385 let mut query = HashMap::new();
1386 query.insert("token".to_owned(), token_value);
1387 let req =
1388 test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
1389 let response = plugin.handle_verify_email(&req, &ctx).await.unwrap();
1390 assert_eq!(response.status, 200);
1391
1392 assert_eq!(before_count.load(Ordering::Relaxed), 1);
1393 assert_eq!(after_count.load(Ordering::Relaxed), 1);
1394 }
1395
1396 #[tokio::test]
1398 async fn test_change_email_verification_after_hook_observes_updated_user() {
1399 let captured = Arc::new(std::sync::Mutex::new(Vec::<(Option<String>, bool)>::new()));
1400 let hook_state = std::sync::Arc::clone(&captured);
1401 let after_hook: EmailVerificationHook = Arc::new(move |user: &UserView| {
1402 let hook_state = std::sync::Arc::clone(&hook_state);
1403 let email = user.email.clone();
1404 let verified = user.email_verified;
1405 Box::pin(async move {
1406 hook_state.lock().unwrap().push((email, verified));
1407 Ok(())
1408 })
1409 });
1410
1411 let plugin = EmailVerificationPlugin::new().after_email_verification(after_hook);
1412
1413 let ctx = test_helpers::create_test_context().await;
1414 let _user = ctx
1415 .database
1416 .create_user(
1417 CreateUser::new()
1418 .with_email("change-email@test.com")
1419 .with_name("ChangeEmail"),
1420 )
1421 .await
1422 .unwrap();
1423
1424 let token_value = jwt_token(
1425 &ctx,
1426 "change-email@test.com",
1427 Some("updated-email@test.com"),
1428 Some("change-email-verification"),
1429 );
1430
1431 let mut query = HashMap::new();
1432 query.insert("token".to_owned(), token_value);
1433 let req =
1434 test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
1435 let response = plugin.handle_verify_email(&req, &ctx).await.unwrap();
1436
1437 assert_eq!(response.status, 200);
1438 assert_eq!(
1439 *captured.lock().unwrap(),
1440 vec![(Some("updated-email@test.com".to_owned()), true)]
1441 );
1442
1443 let updated_user = ctx
1444 .database
1445 .get_user_by_email("updated-email@test.com")
1446 .await
1447 .unwrap()
1448 .unwrap();
1449 assert!(updated_user.email_verified);
1450 }
1451
1452 #[tokio::test]
1454 async fn test_change_email_verification_does_not_fire_after_hook_when_update_fails() {
1455 let after_count = Arc::new(AtomicU32::new(0));
1456 let counter = std::sync::Arc::clone(&after_count);
1457 let after_hook: EmailVerificationHook = Arc::new(move |_user: &UserView| {
1458 let counter = std::sync::Arc::clone(&counter);
1459 Box::pin(async move {
1460 counter.fetch_add(1, Ordering::Relaxed);
1461 Ok(())
1462 })
1463 });
1464
1465 let plugin = EmailVerificationPlugin::new().after_email_verification(after_hook);
1466
1467 let ctx = test_helpers::create_test_context().await;
1468 let _source_user = ctx
1469 .database
1470 .create_user(
1471 CreateUser::new()
1472 .with_email("duplicate-source@test.com")
1473 .with_name("DuplicateSource"),
1474 )
1475 .await
1476 .unwrap();
1477 let _existing_user = ctx
1478 .database
1479 .create_user(
1480 CreateUser::new()
1481 .with_email("duplicate-target@test.com")
1482 .with_name("DuplicateTarget"),
1483 )
1484 .await
1485 .unwrap();
1486
1487 let token_value = jwt_token(
1488 &ctx,
1489 "duplicate-source@test.com",
1490 Some("duplicate-target@test.com"),
1491 Some("change-email-verification"),
1492 );
1493
1494 let mut query = HashMap::new();
1495 query.insert("token".to_owned(), token_value);
1496 let req =
1497 test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
1498 let err = plugin.handle_verify_email(&req, &ctx).await.unwrap_err();
1499
1500 assert_ne!(err.status_code(), 200);
1501 assert_eq!(after_count.load(Ordering::Relaxed), 0);
1502
1503 let source_user = ctx
1504 .database
1505 .get_user_by_email("duplicate-source@test.com")
1506 .await
1507 .unwrap()
1508 .unwrap();
1509 assert_eq!(
1510 source_user.email.as_deref(),
1511 Some("duplicate-source@test.com")
1512 );
1513 assert!(!source_user.email_verified);
1514 }
1515
1516 #[tokio::test]
1518 async fn test_verify_email_before_hook_error_aborts() {
1519 let before_hook: EmailVerificationHook = Arc::new(|_user: &UserView| {
1520 Box::pin(async { Err(AuthError::forbidden("hook rejected")) })
1521 });
1522
1523 let plugin = EmailVerificationPlugin::new().before_email_verification(before_hook);
1524
1525 let ctx = test_helpers::create_test_context().await;
1526 let _user = ctx
1527 .database
1528 .create_user(
1529 CreateUser::new()
1530 .with_email("hook-err@test.com")
1531 .with_name("HookErr"),
1532 )
1533 .await
1534 .unwrap();
1535
1536 let token_value = jwt_token(&ctx, "hook-err@test.com", None, None);
1537
1538 let mut query = HashMap::new();
1539 query.insert("token".to_owned(), token_value.clone());
1540 let req =
1541 test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
1542 let err = plugin.handle_verify_email(&req, &ctx).await.unwrap_err();
1543 assert_eq!(err.status_code(), 403);
1544
1545 let u = ctx
1547 .database
1548 .get_user_by_email("hook-err@test.com")
1549 .await
1550 .unwrap()
1551 .unwrap();
1552 assert!(!u.email_verified);
1553 }
1554
1555 #[tokio::test]
1561 async fn test_verify_email_auto_sign_in_creates_session() {
1562 struct CapturingSender(Arc<std::sync::Mutex<String>>);
1563
1564 #[async_trait]
1565 impl SendVerificationEmail for CapturingSender {
1566 async fn send(&self, _user: &UserView, _url: &str, token: &str) -> AuthResult<()> {
1567 *self.0.lock().unwrap() = token.to_owned();
1568 Ok(())
1569 }
1570 }
1571
1572 let captured = Arc::new(std::sync::Mutex::new(String::new()));
1573
1574 let plugin = EmailVerificationPlugin::new()
1575 .auto_sign_in_after_verification(true)
1576 .custom_send_verification_email(Arc::new(CapturingSender(std::sync::Arc::clone(
1577 &captured,
1578 ))));
1579
1580 let ctx = test_helpers::create_test_context().await;
1581 let _user = ctx
1582 .database
1583 .create_user(
1584 CreateUser::new()
1585 .with_email("autosign@test.com")
1586 .with_name("AutoSign"),
1587 )
1588 .await
1589 .unwrap();
1590
1591 let body = serde_json::json!({ "email": "autosign@test.com" });
1592 let mut headers = HashMap::new();
1593 headers.insert("content-type".to_owned(), "application/json".to_owned());
1594 let send_req = AuthRequest::from_parts(
1595 HttpMethod::Post,
1596 "/send-verification-email".to_owned(),
1597 headers,
1598 Some(body.to_string().into_bytes()),
1599 HashMap::new(),
1600 );
1601 let send_response = plugin
1602 .handle_send_verification_email(&send_req, &ctx)
1603 .await
1604 .unwrap();
1605 assert_eq!(send_response.status, 200);
1606
1607 let mut query = HashMap::new();
1608 query.insert("token".to_owned(), captured.lock().unwrap().clone());
1609 let req =
1610 test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
1611 let response = plugin.handle_verify_email(&req, &ctx).await.unwrap();
1612
1613 assert_eq!(response.status, 200);
1614 let body_2: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
1615 assert_eq!(
1616 (*(body_2).get("status").unwrap_or(&serde_json::Value::Null)),
1617 true
1618 );
1619 assert!((*(body_2).get("user").unwrap_or(&serde_json::Value::Null)).is_null());
1620
1621 assert!(response.headers.contains_key("Set-Cookie"));
1623 let cookie_header = (response.headers)
1624 .get("Set-Cookie")
1625 .expect("fixture contains the requested index");
1626 assert!(cookie_header.contains("better-auth.session"));
1627 }
1628
1629 #[tokio::test]
1631 async fn test_verify_email_no_auto_sign_in_no_session() {
1632 let plugin = EmailVerificationPlugin::new().auto_sign_in_after_verification(false);
1633
1634 let ctx = test_helpers::create_test_context().await;
1635 let _user = ctx
1636 .database
1637 .create_user(
1638 CreateUser::new()
1639 .with_email("noautosign@test.com")
1640 .with_name("NoAutoSign"),
1641 )
1642 .await
1643 .unwrap();
1644
1645 let token_value = jwt_token(&ctx, "noautosign@test.com", None, None);
1646
1647 let mut query = HashMap::new();
1648 query.insert("token".to_owned(), token_value);
1649 let req =
1650 test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
1651 let response = plugin.handle_verify_email(&req, &ctx).await.unwrap();
1652
1653 assert_eq!(response.status, 200);
1654 let body: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
1655 assert_eq!(
1656 (*(body).get("status").unwrap_or(&serde_json::Value::Null)),
1657 true
1658 );
1659 assert!(body.get("session").is_none());
1661 assert!(!response.headers.contains_key("Set-Cookie"));
1663 }
1664
1665 #[tokio::test]
1671 async fn test_verify_email_auto_sign_in_redirect_includes_cookie() {
1672 let plugin = EmailVerificationPlugin::new().auto_sign_in_after_verification(true);
1673
1674 let ctx = test_helpers::create_test_context().await;
1675 let _user = ctx
1676 .database
1677 .create_user(
1678 CreateUser::new()
1679 .with_email("redirect@test.com")
1680 .with_name("Redirect"),
1681 )
1682 .await
1683 .unwrap();
1684
1685 let token_value = jwt_token(&ctx, "redirect@test.com", None, None);
1686
1687 let mut query = HashMap::new();
1688 query.insert("token".to_owned(), token_value);
1689 query.insert("callbackURL".to_owned(), "/verified".to_owned());
1690 let req =
1691 test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
1692 let response = plugin.handle_verify_email(&req, &ctx).await.unwrap();
1693
1694 assert_eq!(response.status, 302);
1695 assert_eq!(
1696 (*(response.headers)
1697 .get("Location")
1698 .expect("fixture contains the requested index")),
1699 "/verified"
1700 );
1701 assert!(response.headers.contains_key("Set-Cookie"));
1703 assert!(
1704 (*(response.headers)
1705 .get("Set-Cookie")
1706 .expect("fixture contains the requested index"))
1707 .contains("better-auth.session")
1708 );
1709 }
1710
1711 #[tokio::test]
1713 async fn test_verify_email_redirect_without_auto_sign_in_no_cookie() {
1714 let plugin = EmailVerificationPlugin::new().auto_sign_in_after_verification(false);
1715
1716 let ctx = test_helpers::create_test_context().await;
1717 let _user = ctx
1718 .database
1719 .create_user(
1720 CreateUser::new()
1721 .with_email("redir-nocookie@test.com")
1722 .with_name("Redir"),
1723 )
1724 .await
1725 .unwrap();
1726
1727 let token_value = jwt_token(&ctx, "redir-nocookie@test.com", None, None);
1728
1729 let mut query = HashMap::new();
1730 query.insert("token".to_owned(), token_value);
1731 query.insert("callbackURL".to_owned(), "/verified".to_owned());
1732 let req =
1733 test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
1734 let response = plugin.handle_verify_email(&req, &ctx).await.unwrap();
1735
1736 assert_eq!(response.status, 302);
1737 assert!(!response.headers.contains_key("Set-Cookie"));
1738 }
1739
1740 #[tokio::test]
1746 async fn test_verify_email_invalid_token() {
1747 let plugin = EmailVerificationPlugin::new();
1748 let ctx = test_helpers::create_test_context().await;
1749
1750 let mut query = HashMap::new();
1751 query.insert("token".to_owned(), "bogus-token".to_owned());
1752 let req =
1753 test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
1754 let err = plugin.handle_verify_email(&req, &ctx).await.unwrap_err();
1755 assert_eq!(err.status_code(), 401);
1756 assert_eq!(err.error_payload().1.as_deref(), Some("INVALID_TOKEN"));
1757 }
1758
1759 #[tokio::test]
1761 async fn test_verify_email_missing_token() {
1762 let plugin = EmailVerificationPlugin::new();
1763 let ctx = test_helpers::create_test_context().await;
1764
1765 let req = test_helpers::create_auth_request(
1766 HttpMethod::Get,
1767 "/verify-email",
1768 None,
1769 None,
1770 HashMap::new(),
1771 );
1772 let err = plugin.handle_verify_email(&req, &ctx).await.unwrap_err();
1773 assert_eq!(err.status_code(), 400);
1774 }
1775
1776 #[tokio::test]
1782 async fn test_verify_email_already_verified_returns_ok() {
1783 let plugin = EmailVerificationPlugin::new();
1784 let ctx = test_helpers::create_test_context().await;
1785
1786 let user = ctx
1787 .database
1788 .create_user(
1789 CreateUser::new()
1790 .with_email("already@test.com")
1791 .with_name("Already"),
1792 )
1793 .await
1794 .unwrap();
1795 ctx.database
1797 .update_user(
1798 &user.id,
1799 UpdateUser {
1800 email_verified: Some(true),
1801 ..Default::default()
1802 },
1803 )
1804 .await
1805 .unwrap();
1806
1807 let token_value = jwt_token(&ctx, "already@test.com", None, None);
1808
1809 let mut query = HashMap::new();
1810 query.insert("token".to_owned(), token_value);
1811 let req =
1812 test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
1813 let response = plugin.handle_verify_email(&req, &ctx).await.unwrap();
1814 assert_eq!(response.status, 200);
1815 let body: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
1816 assert_eq!(
1817 (*(body).get("status").unwrap_or(&serde_json::Value::Null)),
1818 true
1819 );
1820 }
1821
1822 #[tokio::test]
1828 async fn test_send_verification_email_already_verified_returns_error() {
1829 let plugin = EmailVerificationPlugin::new();
1830 let ctx = test_helpers::create_test_context().await;
1831
1832 let user = ctx
1833 .database
1834 .create_user(
1835 CreateUser::new()
1836 .with_email("verified@test.com")
1837 .with_name("Verified"),
1838 )
1839 .await
1840 .unwrap();
1841 ctx.database
1842 .update_user(
1843 &user.id,
1844 UpdateUser {
1845 email_verified: Some(true),
1846 ..Default::default()
1847 },
1848 )
1849 .await
1850 .unwrap();
1851
1852 let body = serde_json::json!({ "email": "verified@test.com" });
1853 let mut headers = HashMap::new();
1854 headers.insert("content-type".to_owned(), "application/json".to_owned());
1855 let req = AuthRequest::from_parts(
1856 HttpMethod::Post,
1857 "/send-verification-email".to_owned(),
1858 headers,
1859 Some(body.to_string().into_bytes()),
1860 HashMap::new(),
1861 );
1862 let err = plugin
1863 .handle_send_verification_email(&req, &ctx)
1864 .await
1865 .unwrap_err();
1866 assert_eq!(err.status_code(), 400);
1867 }
1868
1869 #[tokio::test]
1871 async fn unauthenticated_verification_email_has_fixed_floor_for_all_mailbox_states() {
1872 struct Sender {
1873 calls: Arc<AtomicU32>,
1874 fail: bool,
1875 }
1876 #[async_trait]
1877 impl SendVerificationEmail for Sender {
1878 async fn send(&self, _: &UserView, _: &str, _: &str) -> AuthResult<()> {
1879 self.calls.fetch_add(1, Ordering::SeqCst);
1880 if self.fail {
1881 Err(AuthError::bad_request("fixture delivery failed"))
1882 } else {
1883 Ok(())
1884 }
1885 }
1886 }
1887 for (existing, fail) in [
1888 (None, false),
1889 (Some(true), false),
1890 (Some(false), false),
1891 (Some(false), true),
1892 ] {
1893 let ctx = test_helpers::create_test_context().await;
1894 let email = "enumeration@fixture.test";
1895 let user = if let Some(verified) = existing {
1896 Some(
1897 ctx.database
1898 .create_user(
1899 CreateUser::new()
1900 .with_email(email)
1901 .with_email_verified(verified),
1902 )
1903 .await
1904 .unwrap(),
1905 )
1906 } else {
1907 None
1908 };
1909 let calls = Arc::new(AtomicU32::new(0));
1910 let plugin =
1911 EmailVerificationPlugin::new().custom_send_verification_email(Arc::new(Sender {
1912 calls: std::sync::Arc::clone(&calls),
1913 fail,
1914 }));
1915 let req = test_helpers::create_auth_json_request_no_query(
1916 HttpMethod::Post,
1917 "/send-verification-email",
1918 None,
1919 Some(serde_json::json!({"email":email})),
1920 );
1921 let start = std::time::Instant::now();
1922 let response = plugin.handle_send_verification_email(&req, &ctx).await;
1923 assert!(
1924 start.elapsed() >= std::time::Duration::from_millis(500),
1925 "Every unauthenticated mailbox result must retain the timing floor"
1926 );
1927 if fail {
1928 assert_eq!(response.unwrap_err().status_code(), 400);
1929 } else {
1930 assert_eq!(response.unwrap().status, 200);
1931 }
1932 assert_eq!(
1933 calls.load(Ordering::SeqCst),
1934 u32::from(existing == Some(false))
1935 );
1936 let after = ctx.database.get_user_by_email(email).await.unwrap();
1937 assert_eq!(after.as_ref().map(AuthUser::email_verified), existing);
1938 if let Some(user) = user {
1939 assert_eq!(after.as_ref().unwrap().id(), user.id());
1940 assert_eq!(
1941 ctx.database
1942 .get_user_sessions(&user.id())
1943 .await
1944 .unwrap()
1945 .len(),
1946 0
1947 );
1948 }
1949 }
1950 }
1951
1952 #[test]
1958 fn test_create_session_cookie_format() {
1959 use alibi_core::utils::cookie_utils::create_session_cookie;
1960
1961 let ctx = test_helpers::create_test_context_blocking();
1962 let cookie_str = create_session_cookie("my-token-123", &ctx.config).unwrap();
1963 assert!(cookie_str.contains("better-auth.session_token=my-token-123"));
1965 assert!(cookie_str.contains("Path=/"));
1967 assert!(cookie_str.contains("HttpOnly"));
1969 assert!(cookie_str.contains("SameSite=Lax"));
1971 }
1972
1973 #[test]
1975 fn test_create_session_cookie_special_characters_in_token() {
1976 use alibi_core::utils::cookie_utils::create_session_cookie;
1977
1978 let ctx = test_helpers::create_test_context_blocking();
1979 let token = "token+with/special=chars&more";
1980 let cookie_str = create_session_cookie(token, &ctx.config).unwrap();
1981 assert!(cookie_str.contains("better-auth.session_token="));
1983 }
1984
1985 #[tokio::test]
1991 async fn test_verify_email_rejects_untrusted_callback_url() {
1992 let plugin = EmailVerificationPlugin::new();
1993 let ctx = test_helpers::create_test_context().await;
1994
1995 let _user = ctx
1996 .database
1997 .create_user(
1998 CreateUser::new()
1999 .with_email("redirect-guard@test.com")
2000 .with_name("Redirect Guard"),
2001 )
2002 .await
2003 .unwrap();
2004
2005 let token_value = jwt_token(&ctx, "redirect-guard@test.com", None, None);
2006
2007 let mut query = HashMap::new();
2008 query.insert("token".to_owned(), token_value);
2009 query.insert(
2010 "callbackURL".to_owned(),
2011 "https://evil.com/phish".to_owned(),
2012 );
2013 let req =
2014 test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
2015 let response = plugin.handle_verify_email(&req, &ctx).await.unwrap();
2016
2017 assert_eq!(response.status, 403);
2018 }
2019
2020 #[tokio::test]
2022 async fn test_verify_email_allows_relative_callback_url() {
2023 let plugin = EmailVerificationPlugin::new();
2024 let ctx = test_helpers::create_test_context().await;
2025
2026 let _user = ctx
2027 .database
2028 .create_user(
2029 CreateUser::new()
2030 .with_email("redirect-rel@test.com")
2031 .with_name("Redirect Rel"),
2032 )
2033 .await
2034 .unwrap();
2035
2036 let token_value = jwt_token(&ctx, "redirect-rel@test.com", None, None);
2037
2038 let mut query = HashMap::new();
2039 query.insert("token".to_owned(), token_value);
2040 query.insert("callbackURL".to_owned(), "/dashboard".to_owned());
2041 let req =
2042 test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
2043 let response = plugin.handle_verify_email(&req, &ctx).await.unwrap();
2044
2045 assert_eq!(response.status, 302);
2046 assert_eq!(
2047 (*(response.headers)
2048 .get("Location")
2049 .expect("fixture contains the requested index")),
2050 "/dashboard"
2051 );
2052 }
2053
2054 #[tokio::test]
2056 async fn test_verify_email_allows_trusted_origin_callback_url() {
2057 let plugin = EmailVerificationPlugin::new();
2058 let config = test_helpers::create_test_config().trusted_origin("https://trusted.com");
2059 let ctx = test_helpers::create_test_context_with_config(config).await;
2060
2061 let _user = ctx
2062 .database
2063 .create_user(
2064 CreateUser::new()
2065 .with_email("redirect-trusted@test.com")
2066 .with_name("Redirect Trusted"),
2067 )
2068 .await
2069 .unwrap();
2070
2071 let token_value = jwt_token(&ctx, "redirect-trusted@test.com", None, None);
2072
2073 let mut query = HashMap::new();
2074 query.insert("token".to_owned(), token_value);
2075 query.insert(
2076 "callbackURL".to_owned(),
2077 "https://trusted.com/verified".to_owned(),
2078 );
2079 let req =
2080 test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
2081 let response = plugin.handle_verify_email(&req, &ctx).await.unwrap();
2082
2083 assert_eq!(response.status, 302);
2084 assert_eq!(
2085 (*(response.headers)
2086 .get("Location")
2087 .expect("fixture contains the requested index")),
2088 "https://trusted.com/verified"
2089 );
2090 }
2091
2092 #[tokio::test]
2094 async fn test_verify_email_skips_origin_check_when_disabled() {
2095 let plugin = EmailVerificationPlugin::new();
2096 let config = test_helpers::create_test_config().disable_origin_check(true);
2097 let ctx = test_helpers::create_test_context_with_config(config).await;
2098
2099 let _user = ctx
2100 .database
2101 .create_user(
2102 CreateUser::new()
2103 .with_email("redirect-disabled@test.com")
2104 .with_name("Redirect Disabled"),
2105 )
2106 .await
2107 .unwrap();
2108
2109 let token_value = jwt_token(&ctx, "redirect-disabled@test.com", None, None);
2110
2111 let mut query = HashMap::new();
2112 query.insert("token".to_owned(), token_value);
2113 query.insert(
2114 "callbackURL".to_owned(),
2115 "https://evil.com/anywhere".to_owned(),
2116 );
2117 let req =
2118 test_helpers::create_auth_request(HttpMethod::Get, "/verify-email", None, None, query);
2119 let response = plugin.handle_verify_email(&req, &ctx).await.unwrap();
2120
2121 assert_eq!(response.status, 302);
2123 }
2124}
2125