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