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