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