1mod endpoint;
8mod handlers;
9
10mod helpers;
11
12mod storage;
13
14mod types;
15
16use alibi_core::AuthError;
17use alibi_core::{AuthContext, AuthRequest, AuthResponse, AuthResult};
18use async_trait::async_trait;
19pub use endpoint::EmailOtpRead;
20use std::sync::Arc;
21pub use storage::{EmailOtpCodec, EmailOtpStorage};
22pub use types::{EmailOtpDelivery, EmailOtpType, OtpResendStrategy};
23
24#[async_trait]
28pub trait SendEmailOtp: Send + Sync {
29 async fn send(
30 &self,
31 delivery: &EmailOtpDelivery,
32 context: &alibi_core::CallbackContext,
33 ) -> AuthResult<()>;
34}
35
36#[async_trait]
39pub trait EmailOtpGenerator: Send + Sync {
40 async fn generate(
41 &self,
42 email: &str,
43 otp_type: EmailOtpType,
44 context: &alibi_core::CallbackContext,
45 ) -> AuthResult<Option<String>>;
46}
47
48#[derive(Clone)]
50#[expect(
51 clippy::struct_excessive_bools,
52 reason = "Independent configuration switches model distinct upstream behavior, rather than mutually exclusive states"
53)]
54pub struct EmailOtpConfig {
55 pub send_verification_otp: Option<Arc<dyn SendEmailOtp>>,
56 pub generate_otp: Option<Arc<dyn EmailOtpGenerator>>,
57 pub otp_length: f64,
60 pub expires_in: f64,
63 pub allowed_attempts: f64,
66 pub rate_limit: alibi_core::EndpointRateLimit,
67 pub storage: EmailOtpStorage,
68 pub resend_strategy: OtpResendStrategy,
69 pub disable_sign_up: bool,
70 pub send_verification_on_sign_up: bool,
71 pub override_default_email_verification: bool,
72 pub change_email_enabled: bool,
73 pub verify_current_email: bool,
74 pub auto_sign_in_after_verification: bool,
75 pub before_email_verification: Option<super::email_verification::EmailVerificationHook>,
76 pub after_email_verification: Option<super::email_verification::EmailVerificationHook>,
77 pub password_hasher: Option<Arc<dyn alibi_core::PasswordHasher>>,
78 pub revoke_sessions_on_password_reset: bool,
79 pub on_password_reset: Option<Arc<super::password_management::OnPasswordResetCallback>>,
80}
81
82impl std::fmt::Debug for EmailOtpConfig {
83 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84 f.debug_struct("EmailOtpConfig").finish_non_exhaustive()
85 }
86}
87
88impl Default for EmailOtpConfig {
89 fn default() -> Self {
90 Self {
91 send_verification_otp: None,
92 generate_otp: None,
93 otp_length: 6.0,
94 expires_in: 300.0,
95 allowed_attempts: 3.0,
96 rate_limit: alibi_core::EndpointRateLimit {
97 window_seconds: 60.0,
98 max_requests: 3.0,
99 },
100 storage: EmailOtpStorage::Plain,
101 resend_strategy: OtpResendStrategy::Rotate,
102 disable_sign_up: false,
103 send_verification_on_sign_up: false,
104 override_default_email_verification: false,
105 change_email_enabled: false,
106 verify_current_email: false,
107 auto_sign_in_after_verification: false,
108 before_email_verification: None,
109 after_email_verification: None,
110 password_hasher: None,
111 revoke_sessions_on_password_reset: false,
112 on_password_reset: None,
113 }
114 }
115}
116
117#[derive(Clone)]
118pub struct EmailOtpPlugin {
119 config: EmailOtpConfig,
120}
121
122impl std::fmt::Debug for EmailOtpPlugin {
123 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124 f.debug_struct("EmailOtpPlugin").finish_non_exhaustive()
125 }
126}
127
128impl EmailOtpPlugin {
129 #[must_use]
130 pub const fn new(config: EmailOtpConfig) -> Self {
131 Self { config }
132 }
133
134 fn verification_settings(
135 &self,
136 ctx: &AuthContext<impl alibi_core::AuthSchema>,
137 ) -> VerificationSettings {
138 let inherited = ctx
139 .extensions
140 .get::<super::email_verification::EmailVerificationConfig>();
141 VerificationSettings {
142 before: inherited
143 .as_ref()
144 .and_then(|config| config.before_email_verification.clone())
145 .or_else(|| self.config.before_email_verification.clone()),
146 after: inherited
147 .as_ref()
148 .and_then(|config| config.after_email_verification.clone())
149 .or_else(|| self.config.after_email_verification.clone()),
150 auto_sign_in: inherited
151 .as_ref()
152 .is_some_and(|config| config.auto_sign_in_after_verification)
153 || self.config.auto_sign_in_after_verification,
154 }
155 }
156
157 fn password_settings(
158 &self,
159 ctx: &AuthContext<impl alibi_core::AuthSchema>,
160 ) -> PasswordSettings {
161 let passwords = ctx
162 .extensions
163 .get::<super::email_password::EmailPasswordConfig>();
164 let resets = ctx
165 .extensions
166 .get::<super::password_management::PasswordManagementConfig>();
167 let (minimum, maximum) = super::email_password::password_length_limits(ctx);
168 PasswordSettings {
169 minimum,
170 maximum,
171 hasher: resets
172 .as_ref()
173 .and_then(|config| config.password_hasher.clone())
174 .or_else(|| {
175 let config = passwords.as_ref()?;
176 config.password_hasher.clone()
177 })
178 .or_else(|| self.config.password_hasher.clone()),
179 on_reset: resets
180 .as_ref()
181 .and_then(|config| config.on_password_reset.clone())
182 .or_else(|| self.config.on_password_reset.clone()),
183 revoke_sessions: resets
184 .as_ref()
185 .is_some_and(|config| config.revoke_sessions_on_password_reset)
186 || self.config.revoke_sessions_on_password_reset,
187 }
188 }
189
190 pub async fn create_verification_otp(
197 &self,
198 ctx: &AuthContext<impl alibi_core::AuthSchema>,
199 email: &str,
200 otp_type: EmailOtpType,
201 ) -> AuthResult<String> {
202 self.issue_code(ctx, None, &email.to_lowercase(), otp_type, None)
203 .await
204 }
205
206 pub async fn get_verification_otp(
213 &self,
214 ctx: &AuthContext<impl alibi_core::AuthSchema>,
215 email: &str,
216 otp_type: EmailOtpType,
217 ) -> AuthResult<Option<String>> {
218 let identifier = types::identifier(otp_type, &email.to_lowercase());
219 let Some(value) =
220 super::authentication_helpers::find_verification(ctx, &identifier).await?
221 else {
222 return Ok(None);
223 };
224 if value.is_expired() {
225 return Ok(None);
226 }
227 let (stored, _) = types::split_value(value.value()?);
228 self.config
229 .storage
230 .retrieve(stored, &ctx.config)
231 .await
232 .map_err(|error| match error {
233 error if error.status_code() != 500 => error,
234 AuthError::Api { .. }
235 | AuthError::Upstream { .. }
236 | AuthError::CallbackFailure(_) => error,
237 error => AuthError::CallbackFailure(Box::new(error)),
238 })
239 }
240}
241
242struct VerificationSettings {
243 before: Option<super::email_verification::EmailVerificationHook>,
244 after: Option<super::email_verification::EmailVerificationHook>,
245 auto_sign_in: bool,
246}
247
248struct PasswordSettings {
249 minimum: usize,
250 maximum: usize,
251 hasher: Option<Arc<dyn alibi_core::PasswordHasher>>,
252 on_reset: Option<Arc<super::password_management::OnPasswordResetCallback>>,
253 revoke_sessions: bool,
254}
255
256alibi_core::impl_auth_plugin! {
257 EmailOtpPlugin, "email-otp";
258 routes {
259 post "/email-otp/send-verification-otp" => send_verification, "sendEmailVerificationOTP";
260 post "/email-otp/check-verification-otp" => check_verification, "verifyEmailWithOTP";
261 post "/email-otp/verify-email" => verify_email, "verifyEmailOTP";
262 post "/sign-in/email-otp" => sign_in, "signInWithEmailOTP";
263 post "/email-otp/request-password-reset" => request_password_reset, "requestPasswordResetWithEmailOTP";
264 post "/forget-password/email-otp" => request_password_reset, "forgetPasswordWithEmailOTP";
265 post "/email-otp/reset-password" => reset_password, "resetPasswordWithEmailOTP";
266 post "/email-otp/request-email-change" => request_email_change, "requestEmailChangeWithEmailOTP";
267 post "/email-otp/change-email" => change_email, "changeEmailWithEmailOTP";
268 }
269 extra {
270 route_openapi_metadata!(S);
271
272 fn rate_limits(&self) -> Vec<alibi_core::PluginRateLimit> {
273 vec![alibi_core::PluginRateLimit { matches: |path| matches!(path, "/email-otp/send-verification-otp" | "/email-otp/check-verification-otp" | "/email-otp/verify-email" | "/sign-in/email-otp" | "/email-otp/request-password-reset" | "/email-otp/reset-password" | "/forget-password/email-otp" | "/email-otp/request-email-change" | "/email-otp/change-email"), limit: alibi_core::EndpointRateLimit {
274 window_seconds: if self.config.rate_limit.window_seconds == 0.0 || self.config.rate_limit.window_seconds.is_nan() { 60.0 } else { self.config.rate_limit.window_seconds },
275 max_requests: if self.config.rate_limit.max_requests == 0.0 || self.config.rate_limit.max_requests.is_nan() { 3.0 } else { self.config.rate_limit.max_requests },
276 } }]
277 }
278 fn server_endpoints(&self) -> Vec<alibi_core::endpoint::EndpointDefinition> { endpoint::definitions() }
279
280 fn validate_endpoint(&self, call: &alibi_core::endpoint::EndpointCall, _ctx: &AuthContext<S>) -> AuthResult<alibi_core::endpoint::EndpointInput> { endpoint::validate(call) }
281
282 async fn on_endpoint(&self, call: &alibi_core::endpoint::EndpointCall, ctx: &AuthContext<S>) -> AuthResult<alibi_core::endpoint::EndpointResponse> { self.call_endpoint(call, ctx).await }
283
284 async fn on_init(&self, ctx: &mut alibi_core::AuthInitContext<S>) -> AuthResult<()> {
285 ctx.set_metadata("email-otp.enabled", serde_json::json!(true));
286 if self.config.override_default_email_verification {
287 ctx.set_email_verification_override(Arc::new(self.clone()));
288 }
289 Ok(())
290 }
291
292 async fn after_request(
293 &self,
294 req: &AuthRequest,
295 ctx: &AuthContext<S>,
296 response: AuthResponse,
297 ) -> AuthResult<AuthResponse> {
298 if response.status < 400 && req.path().starts_with("/sign-up")
299 && self.config.send_verification_on_sign_up
300 && !self.config.override_default_email_verification
301 {
302 let value: serde_json::Value = serde_json::from_slice(&response.body)?;
303 if let Some(email) = value.get("user").and_then(|user| user.get("email")).and_then(serde_json::Value::as_str) {
304 let otp = self.issue_code(ctx, Some(req), email, EmailOtpType::EmailVerification, None).await?;
305 self.deliver(ctx, Some(req), email, otp, EmailOtpType::EmailVerification).await?;
306 }
307 }
308 Ok(response)
309 }
310 }
311}
312
313#[async_trait]
314impl<S: alibi_core::AuthSchema> alibi_core::VerificationEmailOverride<S> for EmailOtpPlugin {
315 async fn send(
316 &self,
317 user: &alibi_core::wire::UserView,
318 request: Option<&AuthRequest>,
319 ctx: &AuthContext<S>,
320 ) -> AuthResult<()> {
321 let Some(email) = user.email.as_deref() else {
322 return Ok(());
323 };
324 let body = serde_json::json!({"email":email,"type":"email-verification"});
325 let mut req = AuthRequest::new(
326 alibi_core::HttpMethod::Post,
327 "/email-otp/send-verification-otp",
328 );
329 req.body = Some(serde_json::to_vec(&body)?);
330 _ = self
331 .send_verification_with_request(&req, request, ctx)
332 .await?;
333 Ok(())
334 }
335
336 async fn send_in_transaction(
337 &self,
338 user: &alibi_core::wire::UserView,
339 request: Option<&AuthRequest>,
340 ctx: &AuthContext<S>,
341 tx: &dyn alibi_core::store::AuthTransaction<S>,
342 ) -> AuthResult<()> {
343 let Some(email) = user.email.as_deref() else {
344 return Ok(());
345 };
346 let email = crate::authentication_helpers::parse_email(email)?;
347 let (otp, value) = self
348 .prepare_code(ctx, request, &email, EmailOtpType::EmailVerification, None)
349 .await?;
350 _ = ctx.verifications().create_in_transaction(tx, value).await?;
351 self.deliver(ctx, request, &email, otp, EmailOtpType::EmailVerification)
352 .await
353 }
354}
355
356#[cfg(test)]
358mod tests {
359 use super::*;
360 use crate::test_helpers::{self, create_auth_json_request_no_query};
361 use alibi_core::{
362 AuthError, AuthPlugin, AuthSession, AuthUser, AuthVerification, CreateAccount, CreateUser,
363 CreateVerification, HttpMethod,
364 };
365 use chrono::Duration;
366 use serde_json::{Value, json};
367 use std::sync::Mutex;
368 use std::sync::atomic::{AtomicUsize, Ordering};
369
370 #[derive(Default)]
371 struct Outbox(Mutex<Vec<EmailOtpDelivery>>);
372
373 #[async_trait]
374 impl SendEmailOtp for Outbox {
375 async fn send(
376 &self,
377 delivery: &EmailOtpDelivery,
378 _context: &alibi_core::CallbackContext,
379 ) -> AuthResult<()> {
380 self.0.lock().unwrap().push(delivery.clone());
381 Ok(())
382 }
383 }
384
385 struct RejectingSender(Arc<Outbox>, bool);
386
387 #[async_trait]
388 impl SendEmailOtp for RejectingSender {
389 async fn send(
390 &self,
391 delivery: &EmailOtpDelivery,
392 _context: &alibi_core::CallbackContext,
393 ) -> AuthResult<()> {
394 self.0.0.lock().unwrap().push(delivery.clone());
395 if self.1 {
396 Err(AuthError::Upstream {
397 status: 409,
398 code: "DELIVERY_REJECTED",
399 message: "fixture delivery failed",
400 })
401 } else {
402 Err(AuthError::bad_request("fixture delivery failed"))
403 }
404 }
405 }
406
407 struct CounterGenerator(AtomicUsize);
408
409 #[async_trait]
410 impl EmailOtpGenerator for CounterGenerator {
411 async fn generate(
412 &self,
413 _: &str,
414 _: EmailOtpType,
415 _: &alibi_core::CallbackContext,
416 ) -> AuthResult<Option<String>> {
417 Ok(Some(format!(
418 "{:06}",
419 self.0.fetch_add(1, Ordering::SeqCst) + 100_000
420 )))
421 }
422 }
423
424 struct CancelVerificationUpdate(Arc<AtomicUsize>);
425
426 #[async_trait]
427 impl
428 alibi_seaorm::DatabaseHooks<
429 alibi_seaorm::store::__private_test_support::bundled_schema::BundledSchema,
430 alibi_seaorm::SeaOrmBackend,
431 > for CancelVerificationUpdate
432 {
433 async fn before_update_verification(
434 &self,
435 _: &str,
436 _: &mut alibi_core::UpdateVerification,
437 _: &alibi_seaorm::SeaOrmHookContext<'_>,
438 ) -> AuthResult<alibi_seaorm::HookControl> {
439 self.0.fetch_add(1, Ordering::SeqCst);
440 Ok(alibi_seaorm::HookControl::Cancel)
441 }
442 }
443
444 fn configured() -> (EmailOtpConfig, Arc<Outbox>) {
445 let outbox = Arc::new(Outbox::default());
446 (
447 EmailOtpConfig {
448 send_verification_otp: Some(Arc::<Outbox>::clone(&outbox)),
449 generate_otp: Some(Arc::new(CounterGenerator(AtomicUsize::new(0)))),
450 ..Default::default()
451 },
452 outbox,
453 )
454 }
455
456 async fn post(
457 plugin: &EmailOtpPlugin,
458 ctx: &AuthContext<impl alibi_core::AuthSchema>,
459 path: &str,
460 body: Value,
461 ) -> AuthResponse {
462 let req = create_auth_json_request_no_query(HttpMethod::Post, path, None, Some(body));
463 match plugin.on_request(&req, ctx).await {
464 Ok(Some(response)) => response,
465 Ok(None) => AuthResponse::text(404, "missing test route"),
466 Err(error) => error.to_auth_response(),
467 }
468 }
469
470 #[tokio::test]
473 async fn notification_failure_retains_the_issued_otp_for_single_use_signin() {
474 for (policy, coded) in [
475 (alibi_core::AwaitedNotificationErrorPolicy::Propagate, false),
476 (alibi_core::AwaitedNotificationErrorPolicy::Propagate, true),
477 (
478 alibi_core::AwaitedNotificationErrorPolicy::LogAndContinue,
479 false,
480 ),
481 (
482 alibi_core::AwaitedNotificationErrorPolicy::LogAndContinue,
483 true,
484 ),
485 ] {
486 let mut ctx = test_helpers::create_test_context().await;
487 ctx.config = Arc::new((*ctx.config).clone().awaited_notification_errors(policy));
488 let (mut config, outbox) = configured();
489 config.send_verification_otp = Some(Arc::new(RejectingSender(
490 Arc::<Outbox>::clone(&outbox),
491 coded,
492 )));
493 let plugin = EmailOtpPlugin::new(config);
494 let email = "delivery-failure@fixture.test";
495 let issued = post(
496 &plugin,
497 &ctx,
498 "/email-otp/send-verification-otp",
499 json!({"email":email,"type":"sign-in"}),
500 )
501 .await;
502 if policy == alibi_core::AwaitedNotificationErrorPolicy::Propagate {
503 assert_eq!(issued.status, if coded { 409 } else { 400 });
504 if coded {
505 assert_eq!(
506 serde_json::from_slice::<Value>(&issued.body)
507 .unwrap()
508 .get("code")
509 .and_then(Value::as_str),
510 Some("DELIVERY_REJECTED")
511 );
512 }
513 } else {
514 assert_eq!(issued.status, 200);
515 assert_eq!(
516 serde_json::from_slice::<Value>(&issued.body).unwrap(),
517 json!({"success":true})
518 );
519 }
520 let delivery = outbox.0.lock().unwrap().first().unwrap().clone();
521 assert_eq!(delivery.email, email);
522 let identifier = format!("sign-in-otp-{email}");
523 let proof = ctx
524 .database
525 .get_latest_verification_by_identifier(&identifier)
526 .await
527 .unwrap()
528 .unwrap();
529 assert_eq!(proof.value(), format!("{}:0", delivery.otp));
530 assert!(
531 ctx.database
532 .get_user_by_email(email)
533 .await
534 .unwrap()
535 .is_none()
536 );
537 let received = post(
538 &plugin,
539 &ctx,
540 "/sign-in/email-otp",
541 json!({"email":email,"otp":delivery.otp}),
542 )
543 .await;
544 assert_eq!(received.status, 200);
545 let user = ctx
546 .database
547 .get_user_by_email(email)
548 .await
549 .unwrap()
550 .unwrap();
551 assert!(user.email_verified());
552 assert_eq!(
553 ctx.database
554 .get_user_sessions(&user.id())
555 .await
556 .unwrap()
557 .len(),
558 1
559 );
560 assert!(
561 ctx.database
562 .get_latest_verification_by_identifier(&identifier)
563 .await
564 .unwrap()
565 .is_none()
566 );
567 assert_eq!(
568 post(
569 &plugin,
570 &ctx,
571 "/sign-in/email-otp",
572 json!({"email":email,"otp":delivery.otp})
573 )
574 .await
575 .status,
576 400
577 );
578 assert_eq!(outbox.0.lock().unwrap().len(), 1);
579 }
580 }
581
582 #[tokio::test]
585 async fn cancelled_attempt_update_rejects_once_without_hanging_or_consuming_proof() {
586 use alibi_seaorm::store::__private_test_support::{
587 bundled_schema::BundledSchema, migrator,
588 };
589 use alibi_seaorm::{Database, SeaOrmStore};
590 let config = Arc::new(test_helpers::create_test_config());
591 let database = Database::connect("sqlite::memory:").await.unwrap();
592 migrator::run_migrations(&database).await.unwrap();
593 let calls = Arc::new(AtomicUsize::new(0));
594 let store = Arc::new(
595 SeaOrmStore::<BundledSchema>::new(Arc::clone(&config), database)
596 .hook(CancelVerificationUpdate(Arc::clone(&calls))),
597 );
598 let ctx = AuthContext::new(config, store);
599 _ = ctx
600 .database
601 .create_user(CreateUser::new().with_email("veto@example.com"))
602 .await
603 .unwrap();
604 let (config_2, _) = configured();
605 let plugin = EmailOtpPlugin::new(config_2);
606 let otp = plugin
607 .create_verification_otp(&ctx, "veto@example.com", EmailOtpType::EmailVerification)
608 .await
609 .unwrap();
610 let identifier = "email-verification-otp-veto@example.com";
611 let before = ctx
612 .database
613 .get_latest_verification_by_identifier(identifier)
614 .await
615 .unwrap()
616 .unwrap();
617 let rejected = tokio::time::timeout(
618 std::time::Duration::from_millis(500),
619 post(
620 &plugin,
621 &ctx,
622 "/email-otp/check-verification-otp",
623 json!({"email":"veto@example.com","type":"email-verification","otp":"incorrect"}),
624 ),
625 )
626 .await
627 .expect("A cancelled attempt update must return rather than retry forever");
628 assert_eq!(rejected.status, 400);
629 assert_eq!(
630 serde_json::from_slice::<Value>(&rejected.body).unwrap(),
631 json!({"code":"INVALID_OTP","message":"Invalid OTP"})
632 );
633 assert_eq!(calls.load(Ordering::SeqCst), 1);
634 let after = ctx
635 .database
636 .get_latest_verification_by_identifier(identifier)
637 .await
638 .unwrap()
639 .unwrap();
640 assert_eq!(after.id(), before.id());
641 assert_eq!(after.value(), before.value());
642 assert_eq!(after.expires_at(), before.expires_at());
643 let accepted = post(
644 &plugin,
645 &ctx,
646 "/email-otp/check-verification-otp",
647 json!({"email":"veto@example.com","type":"email-verification","otp":otp}),
648 )
649 .await;
650 assert_eq!(accepted.status, 200);
651 assert_eq!(
652 serde_json::from_slice::<Value>(&accepted.body).unwrap(),
653 json!({"success":true})
654 );
655 assert_eq!(calls.load(Ordering::SeqCst), 1);
656 }
657
658 #[tokio::test]
660 async fn unknown_verification_and_reset_mailboxes_leave_no_code_or_delivery() {
661 let ctx = test_helpers::create_test_context().await;
662 let (config, outbox) = configured();
663 let plugin = EmailOtpPlugin::new(config);
664 for otp_type in [
665 EmailOtpType::EmailVerification,
666 EmailOtpType::ForgetPassword,
667 ] {
668 let response = post(
669 &plugin,
670 &ctx,
671 "/email-otp/send-verification-otp",
672 json!({"email":"missing@example.com","type":otp_type}),
673 )
674 .await;
675 assert_eq!(response.status, 200);
676 assert!(
677 ctx.database
678 .get_latest_verification_by_identifier(&types::identifier(
679 otp_type,
680 "missing@example.com"
681 ))
682 .await
683 .unwrap()
684 .is_none()
685 );
686 }
687 assert!(outbox.0.lock().unwrap().is_empty());
688 }
689
690 #[tokio::test]
692 #[expect(
693 clippy::too_many_lines,
694 reason = "Keep this ordered integration scenario and its assertions together; Result propagates setup failures"
695 )]
696 async fn sign_in_creates_verified_user_and_owned_session_then_rejects_replay() {
697 use alibi_core::utils::cookie_utils::{
698 related_cookie_name, sign_cookie_value, verify_cookie_value,
699 };
700
701 for lifetime in [604_800, 90] {
702 for (preference, persistent) in [
703 (None, true),
704 (Some(""), true),
705 (Some("false"), false),
706 (Some("true"), false),
707 ] {
708 let mut auth_config = test_helpers::create_test_config();
709 auth_config.session.expires_in = Duration::seconds(lifetime);
710 auth_config.session.cookie_name = "otp-fixture.session_token".to_owned();
711 let ctx = test_helpers::create_test_context_with_config(auth_config).await;
712 let (config, outbox) = configured();
713 let plugin = EmailOtpPlugin::new(config);
714 assert_eq!(
715 post(
716 &plugin,
717 &ctx,
718 "/email-otp/send-verification-otp",
719 json!({"email":"Owner@Example.com","type":"sign-in"})
720 )
721 .await
722 .status,
723 200
724 );
725 let delivery = outbox.0.lock().unwrap().last().unwrap().clone();
726 assert_eq!(delivery.email, "owner@example.com");
727 let body =
728 json!({"email":"OWNER@example.com","otp":delivery.otp,"name":"Mailbox Owner"});
729 let mut request = create_auth_json_request_no_query(
730 HttpMethod::Post,
731 "/sign-in/email-otp",
732 None,
733 Some(body.clone()),
734 );
735 if let Some(value) = preference {
736 _ = request.headers.insert(
737 "cookie".to_owned(),
738 format!(
739 "{}={}",
740 related_cookie_name(&ctx.config, "dont_remember"),
741 sign_cookie_value(value, &ctx.config.secret)
742 ),
743 );
744 }
745 let response = plugin.on_request(&request, &ctx).await.unwrap().unwrap();
746 assert_eq!(response.status, 200);
747 let payload: Value = serde_json::from_slice(&response.body).unwrap();
748 let token = payload.get("token").and_then(Value::as_str).unwrap();
749 let user = ctx
750 .database
751 .get_user_by_email("owner@example.com")
752 .await
753 .unwrap()
754 .unwrap();
755 assert!(user.email_verified());
756 assert_eq!(user.name(), Some("Mailbox Owner"));
757 let session = ctx.database.get_session(token).await.unwrap().unwrap();
758 assert_eq!(session.user_id(), user.id());
759 assert!(
760 ((session.expires_at() - session.created_at()).num_milliseconds()
761 - lifetime * 1_000)
762 .abs()
763 < 1_000,
764 "preference {preference:?} must preserve the configured persisted session lifetime",
765 );
766 let cookies = response
767 .headers
768 .get_all("set-cookie")
769 .map(|header| cookie::Cookie::parse(header.clone()).unwrap())
770 .collect::<Vec<_>>();
771 let session_cookie = cookies
772 .iter()
773 .find(|cookie| cookie.name() == "otp-fixture.session_token")
774 .unwrap();
775 assert_eq!(
776 session_cookie
777 .max_age()
778 .map(cookie::time::Duration::whole_seconds),
779 persistent.then_some(lifetime),
780 "signed preference {preference:?}, configured lifetime {lifetime}",
781 );
782 assert_eq!(session_cookie.http_only(), Some(true));
783 assert_eq!(session_cookie.path(), Some("/"));
784 assert_eq!(
785 verify_cookie_value(session_cookie.value(), &ctx.config.secret).as_deref(),
786 Some(token)
787 );
788 let preference_cookie = cookies
789 .iter()
790 .find(|cookie| cookie.name() == "otp-fixture.dont_remember");
791 assert_eq!(preference_cookie.is_some(), !persistent);
792 if let Some(cookie) = preference_cookie {
793 assert_eq!(
794 verify_cookie_value(cookie.value(), &ctx.config.secret).as_deref(),
795 Some("true")
796 );
797 assert_eq!(cookie.max_age(), None);
798 assert_eq!(cookie.http_only(), Some(true));
799 }
800 assert!(
801 ctx.database
802 .get_latest_verification_by_identifier("sign-in-otp-owner@example.com")
803 .await
804 .unwrap()
805 .is_none()
806 );
807 assert_eq!(
808 post(&plugin, &ctx, "/sign-in/email-otp", body).await.status,
809 400
810 );
811 }
812 }
813 }
814
815 #[tokio::test]
817 async fn invalid_attempts_preserve_deadline_and_exhaust_the_budget() {
818 let ctx = test_helpers::create_test_context().await;
819 let (config, _) = configured();
820 let plugin = EmailOtpPlugin::new(config);
821 let code = plugin
822 .create_verification_otp(&ctx, "attempts@example.com", EmailOtpType::SignIn)
823 .await
824 .unwrap();
825 let key = "sign-in-otp-attempts@example.com";
826 let original = ctx
827 .database
828 .get_latest_verification_by_identifier(key)
829 .await
830 .unwrap()
831 .unwrap();
832 for attempts in 1..=3 {
833 let response = post(
834 &plugin,
835 &ctx,
836 "/sign-in/email-otp",
837 json!({"email":"attempts@example.com","otp":"wrong"}),
838 )
839 .await;
840 assert_eq!(response.status, 400);
841 let value = ctx
842 .database
843 .get_latest_verification_by_identifier(key)
844 .await
845 .unwrap()
846 .unwrap();
847 assert_eq!(value.expires_at(), original.expires_at());
848 assert_eq!(types::split_value(value.value()).1, attempts);
849 }
850 let response = post(
851 &plugin,
852 &ctx,
853 "/sign-in/email-otp",
854 json!({"email":"attempts@example.com","otp":code}),
855 )
856 .await;
857 assert_eq!(response.status, 403);
858 assert!(
859 ctx.database
860 .get_latest_verification_by_identifier(key)
861 .await
862 .unwrap()
863 .is_none()
864 );
865 assert!(
866 ctx.database
867 .get_user_by_email("attempts@example.com")
868 .await
869 .unwrap()
870 .is_none()
871 );
872 }
873
874 #[tokio::test]
876 async fn checking_does_not_verify_or_consume_then_verification_persists_state() {
877 let ctx = test_helpers::create_test_context().await;
878 let user = ctx
879 .database
880 .create_user(CreateUser::new().with_email("verify@example.com"))
881 .await
882 .unwrap();
883 let (config, _) = configured();
884 let plugin = EmailOtpPlugin::new(config);
885 let otp = plugin
886 .create_verification_otp(&ctx, "verify@example.com", EmailOtpType::EmailVerification)
887 .await
888 .unwrap();
889 assert_eq!(
890 post(
891 &plugin,
892 &ctx,
893 "/email-otp/check-verification-otp",
894 json!({"email":"verify@example.com","type":"email-verification","otp":otp})
895 )
896 .await
897 .status,
898 200
899 );
900 assert!(
901 !ctx.database
902 .get_user_by_id(&user.id())
903 .await
904 .unwrap()
905 .unwrap()
906 .email_verified()
907 );
908 assert!(
909 plugin
910 .get_verification_otp(&ctx, "verify@example.com", EmailOtpType::EmailVerification)
911 .await
912 .unwrap()
913 .is_some()
914 );
915 let response = post(
916 &plugin,
917 &ctx,
918 "/email-otp/verify-email",
919 json!({"email":"verify@example.com","otp":otp}),
920 )
921 .await;
922 assert_eq!(response.status, 200);
923 let payload: Value = serde_json::from_slice(&response.body).unwrap();
924 assert_eq!(payload.get("token"), Some(&Value::Null));
925 assert!(
926 ctx.database
927 .get_user_by_id(&user.id())
928 .await
929 .unwrap()
930 .unwrap()
931 .email_verified()
932 );
933 assert!(
934 plugin
935 .get_verification_otp(&ctx, "verify@example.com", EmailOtpType::EmailVerification)
936 .await
937 .unwrap()
938 .is_none()
939 );
940 }
941
942 #[tokio::test]
944 async fn expired_and_cross_scope_codes_cannot_authenticate() {
945 let ctx = test_helpers::create_test_context().await;
946 let (config, _) = configured();
947 let plugin = EmailOtpPlugin::new(config);
948 _ = ctx
949 .database
950 .create_verification(CreateVerification {
951 identifier: "sign-in-otp-expired@example.com".into(),
952 value: "654321:0".into(),
953 expires_at: chrono::Utc::now() - Duration::seconds(1),
954 })
955 .await
956 .unwrap();
957 let expired = post(
958 &plugin,
959 &ctx,
960 "/sign-in/email-otp",
961 json!({"email":"expired@example.com","otp":"654321"}),
962 )
963 .await;
964 let payload: Value = serde_json::from_slice(&expired.body).unwrap();
965 assert_eq!(
966 payload.get("code").and_then(Value::as_str),
967 Some("OTP_EXPIRED")
968 );
969 assert!(
970 ctx.database
971 .get_latest_verification_by_identifier("sign-in-otp-expired@example.com")
972 .await
973 .unwrap()
974 .is_none()
975 );
976 let code = plugin
977 .create_verification_otp(
978 &ctx,
979 "wrong-scope@example.com",
980 EmailOtpType::EmailVerification,
981 )
982 .await
983 .unwrap();
984 assert_eq!(
985 post(
986 &plugin,
987 &ctx,
988 "/sign-in/email-otp",
989 json!({"email":"wrong-scope@example.com","otp":code})
990 )
991 .await
992 .status,
993 400
994 );
995 assert_eq!(
996 post(
997 &plugin,
998 &ctx,
999 "/sign-in/email-otp",
1000 json!({"email":"foreign@example.com","otp":code})
1001 )
1002 .await
1003 .status,
1004 400
1005 );
1006 assert!(
1007 ctx.database
1008 .get_user_by_email("wrong-scope@example.com")
1009 .await
1010 .unwrap()
1011 .is_none()
1012 );
1013 }
1014
1015 #[tokio::test]
1018 async fn existing_unverified_account_loses_password_oauth_and_old_sessions() {
1019 let ctx = test_helpers::create_test_context().await;
1020 let user = ctx
1021 .database
1022 .create_user(CreateUser::new().with_email("promote@example.com"))
1023 .await
1024 .unwrap();
1025 for provider in ["credential", "google"] {
1026 _ = ctx
1027 .database
1028 .create_account(CreateAccount {
1029 additional_fields: alibi_core::field_policy::FieldValues::default(),
1030 user_id: user.id().to_string(),
1031 account_id: format!("{provider}-identity"),
1032 provider_id: provider.into(),
1033 access_token: None,
1034 refresh_token: None,
1035 id_token: None,
1036 access_token_expires_at: None,
1037 refresh_token_expires_at: None,
1038 scope: None,
1039 password: (provider == "credential").then_some("old-password-hash".into()),
1040 })
1041 .await
1042 .unwrap();
1043 }
1044 let old_session = ctx
1045 .session_manager()
1046 .create_session(&user, None, None)
1047 .await
1048 .unwrap();
1049 let (config, _) = configured();
1050 let plugin = EmailOtpPlugin::new(config);
1051 let otp = plugin
1052 .create_verification_otp(&ctx, "promote@example.com", EmailOtpType::SignIn)
1053 .await
1054 .unwrap();
1055 assert_eq!(
1056 post(
1057 &plugin,
1058 &ctx,
1059 "/sign-in/email-otp",
1060 json!({"email":"promote@example.com","otp":otp})
1061 )
1062 .await
1063 .status,
1064 200
1065 );
1066 let promoted = ctx
1067 .database
1068 .get_user_by_id(&user.id())
1069 .await
1070 .unwrap()
1071 .unwrap();
1072 assert!(promoted.email_verified());
1073 assert_eq!(
1074 ctx.database
1075 .get_user_accounts(&user.id())
1076 .await
1077 .unwrap()
1078 .len(),
1079 0
1080 );
1081 assert!(
1082 ctx.database
1083 .get_session(old_session.token())
1084 .await
1085 .unwrap()
1086 .is_none()
1087 );
1088 }
1089
1090 #[tokio::test]
1092 async fn concurrent_sign_in_cannot_reuse_a_code() {
1093 let ctx = test_helpers::create_test_context().await;
1094 let (config, _) = configured();
1095 let plugin = EmailOtpPlugin::new(config);
1096 let otp = plugin
1097 .create_verification_otp(&ctx, "race@example.com", EmailOtpType::SignIn)
1098 .await
1099 .unwrap();
1100 let body = json!({"email":"race@example.com","otp":otp});
1101 let (first, second) = tokio::join!(
1102 post(&plugin, &ctx, "/sign-in/email-otp", body.clone()),
1103 post(&plugin, &ctx, "/sign-in/email-otp", body)
1104 );
1105 let mut statuses = [first.status, second.status];
1106 statuses.sort_unstable();
1107 assert_eq!(statuses, [200, 400]);
1108 }
1109
1110 #[tokio::test]
1113 async fn signup_ignores_unregistered_username_fields() {
1114 let ctx = test_helpers::create_test_context().await;
1115 let (config, _) = configured();
1116 let plugin = EmailOtpPlugin::new(config);
1117 for (index, username, display) in [
1118 (0, json!("ab"), json!("Ignored Display")),
1119 (1, json!(7), json!({"unexpected":true})),
1120 ] {
1121 let email = format!("unregistered-{index}@example.com");
1122 let otp = plugin
1123 .create_verification_otp(&ctx, &email, EmailOtpType::SignIn)
1124 .await
1125 .unwrap();
1126 let response = post(
1127 &plugin,
1128 &ctx,
1129 "/sign-in/email-otp",
1130 json!({"email":email,"otp":otp,"username":username,"displayUsername":display}),
1131 )
1132 .await;
1133 assert_eq!(
1134 response.status,
1135 200,
1136 "{}",
1137 String::from_utf8_lossy(&response.body)
1138 );
1139 let user = ctx
1140 .database
1141 .get_user_by_email(&email)
1142 .await
1143 .unwrap()
1144 .unwrap();
1145 assert_eq!(user.username(), None);
1146 assert_eq!(user.display_username(), None);
1147 assert!(user.email_verified());
1148 assert_eq!(
1149 ctx.database
1150 .get_user_sessions(&user.id())
1151 .await
1152 .unwrap()
1153 .len(),
1154 1
1155 );
1156 assert!(
1157 ctx.database
1158 .get_latest_verification_by_identifier(&format!("sign-in-otp-{email}"))
1159 .await
1160 .unwrap()
1161 .is_none()
1162 );
1163 }
1164 }
1165
1166 #[tokio::test]
1169 async fn reuse_extends_existing_code_and_hashed_storage_rotates() {
1170 let ctx = test_helpers::create_test_context().await;
1171 let (mut config, outbox) = configured();
1172 config.resend_strategy = OtpResendStrategy::Reuse;
1173 let mut plugin = EmailOtpPlugin::new(config);
1174 let request = json!({"email":"reuse@example.com","type":"sign-in"});
1175 assert_eq!(
1176 post(
1177 &plugin,
1178 &ctx,
1179 "/email-otp/send-verification-otp",
1180 request.clone()
1181 )
1182 .await
1183 .status,
1184 200
1185 );
1186 assert_eq!(
1187 post(
1188 &plugin,
1189 &ctx,
1190 "/email-otp/send-verification-otp",
1191 request.clone()
1192 )
1193 .await
1194 .status,
1195 200
1196 );
1197 let codes = outbox
1198 .0
1199 .lock()
1200 .unwrap()
1201 .iter()
1202 .map(|delivery| delivery.otp.clone())
1203 .collect::<Vec<_>>();
1204 assert_eq!(codes.first(), codes.last());
1205 plugin.config.storage = EmailOtpStorage::Hashed;
1206 assert_eq!(
1207 post(
1208 &plugin,
1209 &ctx,
1210 "/email-otp/send-verification-otp",
1211 request.clone()
1212 )
1213 .await
1214 .status,
1215 200
1216 );
1217 assert_eq!(
1218 post(&plugin, &ctx, "/email-otp/send-verification-otp", request)
1219 .await
1220 .status,
1221 200
1222 );
1223 let codes_2 = outbox
1224 .0
1225 .lock()
1226 .unwrap()
1227 .iter()
1228 .map(|delivery| delivery.otp.clone())
1229 .collect::<Vec<_>>();
1230 assert_ne!(codes_2.get(2), codes_2.get(3));
1231 assert!(
1232 plugin
1233 .get_verification_otp(&ctx, "reuse@example.com", EmailOtpType::SignIn)
1234 .await
1235 .is_err()
1236 );
1237 let raw = ctx
1238 .database
1239 .get_latest_verification_by_identifier("sign-in-otp-reuse@example.com")
1240 .await
1241 .unwrap()
1242 .unwrap();
1243 assert!(!codes_2.iter().any(|code| raw.value().contains(code)));
1244 assert_eq!(
1245 post(
1246 &plugin,
1247 &ctx,
1248 "/sign-in/email-otp",
1249 json!({"email":"reuse@example.com","otp":codes_2.last().unwrap()})
1250 )
1251 .await
1252 .status,
1253 200
1254 );
1255 }
1256
1257 #[tokio::test]
1259 async fn changing_email_rejects_another_users_code_and_keeps_current_session() {
1260 let ctx = test_helpers::create_test_context().await;
1261 let owner = ctx
1262 .database
1263 .create_user(
1264 CreateUser::new()
1265 .with_email("owner@example.com")
1266 .with_email_verified(true),
1267 )
1268 .await
1269 .unwrap();
1270 let stranger = ctx
1271 .database
1272 .create_user(
1273 CreateUser::new()
1274 .with_email("stranger@example.com")
1275 .with_email_verified(true),
1276 )
1277 .await
1278 .unwrap();
1279 let owner_session = ctx
1280 .session_manager()
1281 .create_session(&owner, None, None)
1282 .await
1283 .unwrap();
1284 let stranger_session = ctx
1285 .session_manager()
1286 .create_session(&stranger, None, None)
1287 .await
1288 .unwrap();
1289 let (mut config, outbox) = configured();
1290 config.change_email_enabled = true;
1291 let plugin = EmailOtpPlugin::new(config);
1292 let req = create_auth_json_request_no_query(
1293 HttpMethod::Post,
1294 "/email-otp/request-email-change",
1295 Some(owner_session.token()),
1296 Some(json!({"newEmail":"target@example.com"})),
1297 );
1298 assert_eq!(
1299 plugin.on_request(&req, &ctx).await.unwrap().unwrap().status,
1300 200
1301 );
1302 let code = outbox.0.lock().unwrap().last().unwrap().otp.clone();
1303 let req_2 = create_auth_json_request_no_query(
1304 HttpMethod::Post,
1305 "/email-otp/change-email",
1306 Some(stranger_session.token()),
1307 Some(json!({"newEmail":"target@example.com","otp":code})),
1308 );
1309 assert_eq!(
1310 plugin
1311 .on_request(&req_2, &ctx)
1312 .await
1313 .unwrap_err()
1314 .status_code(),
1315 400
1316 );
1317 assert_eq!(
1318 ctx.database
1319 .get_user_by_id(&stranger.id())
1320 .await
1321 .unwrap()
1322 .unwrap()
1323 .email(),
1324 Some("stranger@example.com")
1325 );
1326 let req_3 = create_auth_json_request_no_query(
1327 HttpMethod::Post,
1328 "/email-otp/change-email",
1329 Some(owner_session.token()),
1330 Some(json!({"newEmail":"target@example.com","otp":code})),
1331 );
1332 assert_eq!(
1333 plugin
1334 .on_request(&req_3, &ctx)
1335 .await
1336 .unwrap()
1337 .unwrap()
1338 .status,
1339 200
1340 );
1341 assert_eq!(
1342 ctx.database
1343 .get_user_by_id(&owner.id())
1344 .await
1345 .unwrap()
1346 .unwrap()
1347 .email(),
1348 Some("target@example.com")
1349 );
1350 assert_eq!(
1351 ctx.database
1352 .get_session(owner_session.token())
1353 .await
1354 .unwrap()
1355 .unwrap()
1356 .user_id(),
1357 owner.id()
1358 );
1359 }
1360
1361 #[tokio::test]
1364 async fn encrypted_codec_reads_upstream_ciphertext_and_hides_plaintext() {
1365 let fixture = "89c516b9ba08b2f347ecf25375ca9524bca3e8c57ec3982eaf21996f87742a4a739b47adfce936e33fe71e424a45";
1366 let storage = EmailOtpStorage::Encrypted;
1367 assert_eq!(
1368 storage
1369 .retrieve(
1370 fixture,
1371 &alibi_core::AuthConfig::new("upstream-otp-codec-fixture-secret")
1372 )
1373 .await
1374 .unwrap(),
1375 Some("654321".into())
1376 );
1377 let encrypted = storage
1378 .store(
1379 "654321",
1380 &alibi_core::AuthConfig::new("upstream-otp-codec-fixture-secret"),
1381 )
1382 .await
1383 .unwrap();
1384 assert_ne!(encrypted, "654321");
1385 assert!(
1386 storage
1387 .verify(
1388 &encrypted,
1389 "654321",
1390 &alibi_core::AuthConfig::new("upstream-otp-codec-fixture-secret")
1391 )
1392 .await
1393 .unwrap()
1394 );
1395 assert!(
1396 !storage
1397 .verify(
1398 &encrypted,
1399 "000000",
1400 &alibi_core::AuthConfig::new("upstream-otp-codec-fixture-secret")
1401 )
1402 .await
1403 .unwrap()
1404 );
1405 assert!(
1406 storage
1407 .verify(
1408 &encrypted,
1409 "654321",
1410 &alibi_core::AuthConfig::new("wrong-secret")
1411 )
1412 .await
1413 .is_err()
1414 );
1415 }
1416
1417 #[tokio::test]
1420 #[expect(
1421 clippy::too_many_lines,
1422 reason = "Keep this ordered integration scenario and its assertions together; Result propagates setup failures"
1423 )]
1424 async fn reset_updates_password_runs_hook_and_revokes_owned_sessions() {
1425 use alibi_core::AuthAccount;
1426 let ctx = test_helpers::create_test_context().await;
1427 let user = ctx
1428 .database
1429 .create_user(CreateUser::new().with_email("reset@example.com"))
1430 .await
1431 .unwrap();
1432 let session = ctx
1433 .session_manager()
1434 .create_session(&user, None, None)
1435 .await
1436 .unwrap();
1437 let account = ctx
1438 .database
1439 .create_account(CreateAccount {
1440 additional_fields: alibi_core::field_policy::FieldValues::default(),
1441 user_id: user.id().to_string(),
1442 account_id: user.id().to_string(),
1443 provider_id: "credential".into(),
1444 access_token: None,
1445 refresh_token: None,
1446 id_token: None,
1447 access_token_expires_at: None,
1448 refresh_token_expires_at: None,
1449 scope: None,
1450 password: Some("old-hash".into()),
1451 })
1452 .await
1453 .unwrap();
1454 let calls = Arc::new(Mutex::new(Vec::new()));
1455 let (mut config, outbox) = configured();
1456 let calls_for_hook = Arc::clone(&calls);
1457 config.on_password_reset = Some(Arc::new(move |user_2| {
1458 let calls_2 = Arc::clone(&calls_for_hook);
1459 Box::pin(async move {
1460 calls_2.lock().unwrap().push(user_2);
1461 Ok(())
1462 })
1463 }));
1464 config.revoke_sessions_on_password_reset = true;
1465 let plugin = EmailOtpPlugin::new(config);
1466 for path in [
1467 "/email-otp/request-password-reset",
1468 "/forget-password/email-otp",
1469 ] {
1470 assert_eq!(
1471 post(&plugin, &ctx, path, json!({"email":"RESET@example.com"}))
1472 .await
1473 .status,
1474 200
1475 );
1476 }
1477 let otp = outbox.0.lock().unwrap().last().unwrap().otp.clone();
1478 assert_eq!(
1479 post(
1480 &plugin,
1481 &ctx,
1482 "/email-otp/reset-password",
1483 json!({"email":"reset@example.com","otp":otp,"password":"short"})
1484 )
1485 .await
1486 .status,
1487 400
1488 );
1489 assert!(
1490 plugin
1491 .get_verification_otp(&ctx, "reset@example.com", EmailOtpType::ForgetPassword)
1492 .await
1493 .unwrap()
1494 .is_some()
1495 );
1496 assert_eq!(
1497 post(
1498 &plugin,
1499 &ctx,
1500 "/email-otp/reset-password",
1501 json!({"email":"reset@example.com","otp":otp,"password":"replacement-password"})
1502 )
1503 .await
1504 .status,
1505 200
1506 );
1507 let updated = ctx
1508 .database
1509 .get_account("credential", account.account_id())
1510 .await
1511 .unwrap()
1512 .unwrap();
1513 alibi_core::utils::password::verify_password(
1514 None,
1515 "replacement-password",
1516 updated.password().unwrap(),
1517 )
1518 .await
1519 .unwrap();
1520 assert!(
1521 ctx.database
1522 .get_session(session.token())
1523 .await
1524 .unwrap()
1525 .is_none()
1526 );
1527 assert!(
1528 ctx.database
1529 .get_user_by_id(&user.id())
1530 .await
1531 .unwrap()
1532 .unwrap()
1533 .email_verified()
1534 );
1535 assert_eq!(calls.lock().unwrap().len(), 1);
1536 assert_eq!(
1537 post(
1538 &plugin,
1539 &ctx,
1540 "/email-otp/reset-password",
1541 json!({"email":"reset@example.com","otp":otp,"password":"replacement-password"})
1542 )
1543 .await
1544 .status,
1545 400
1546 );
1547 }
1548
1549 #[tokio::test]
1552 async fn reset_creates_missing_credential_and_unknown_reset_is_indistinguishable() {
1553 use alibi_core::AuthAccount;
1554
1555 let ctx = test_helpers::create_test_context().await;
1556 let user = ctx
1557 .database
1558 .create_user(
1559 CreateUser::new()
1560 .with_email("passwordless@example.com")
1561 .with_email_verified(true),
1562 )
1563 .await
1564 .unwrap();
1565 let (config, outbox) = configured();
1566 let plugin = EmailOtpPlugin::new(config);
1567 assert_eq!(
1568 post(
1569 &plugin,
1570 &ctx,
1571 "/email-otp/request-password-reset",
1572 json!({"email":"absent@example.com"})
1573 )
1574 .await
1575 .status,
1576 200
1577 );
1578 assert!(outbox.0.lock().unwrap().is_empty());
1579 let otp = plugin
1580 .create_verification_otp(
1581 &ctx,
1582 "passwordless@example.com",
1583 EmailOtpType::ForgetPassword,
1584 )
1585 .await
1586 .unwrap();
1587 assert_eq!(
1588 post(
1589 &plugin,
1590 &ctx,
1591 "/email-otp/reset-password",
1592 json!({"email":"passwordless@example.com","otp":otp,"password":"new-password123"})
1593 )
1594 .await
1595 .status,
1596 200
1597 );
1598 let accounts = ctx.database.get_user_accounts(&user.id()).await.unwrap();
1599 assert_eq!(accounts.len(), 1);
1600
1601 assert_eq!(accounts.first().unwrap().account_id(), user.id());
1602 assert_eq!(accounts.first().unwrap().provider_id(), "credential");
1603 }
1604
1605 #[tokio::test]
1608 async fn nonconsuming_checks_count_attempts_and_reject_unowned_and_expired_mailboxes() {
1609 let ctx = test_helpers::create_test_context().await;
1610 let (mut config, _) = configured();
1611 config.allowed_attempts = 1.0;
1612 let plugin = EmailOtpPlugin::new(config);
1613 let otp = plugin
1614 .create_verification_otp(&ctx, "unknown@example.com", EmailOtpType::EmailVerification)
1615 .await
1616 .unwrap();
1617 let checked = post(
1618 &plugin,
1619 &ctx,
1620 "/email-otp/check-verification-otp",
1621 json!({"email":"unknown@example.com","type":"email-verification","otp":otp}),
1622 )
1623 .await;
1624 let payload: Value = serde_json::from_slice(&checked.body).unwrap();
1625 assert_eq!(payload.get("code"), Some(&json!("USER_NOT_FOUND")));
1626 assert_eq!(
1627 plugin
1628 .get_verification_otp(&ctx, "unknown@example.com", EmailOtpType::EmailVerification)
1629 .await
1630 .unwrap(),
1631 Some(otp)
1632 );
1633 assert_eq!(
1634 post(
1635 &plugin,
1636 &ctx,
1637 "/email-otp/check-verification-otp",
1638 json!({"email":"unknown@example.com","type":"email-verification","otp":"wrong"})
1639 )
1640 .await
1641 .status,
1642 400
1643 );
1644 assert_eq!(
1645 post(
1646 &plugin,
1647 &ctx,
1648 "/email-otp/check-verification-otp",
1649 json!({"email":"unknown@example.com","type":"email-verification","otp":"wrong"})
1650 )
1651 .await
1652 .status,
1653 403
1654 );
1655 assert!(
1656 plugin
1657 .get_verification_otp(&ctx, "unknown@example.com", EmailOtpType::EmailVerification)
1658 .await
1659 .unwrap()
1660 .is_none()
1661 );
1662 _ = ctx
1663 .database
1664 .create_verification(CreateVerification {
1665 identifier: "email-verification-otp-expired-check@example.com".into(),
1666 value: "654321:0".into(),
1667 expires_at: chrono::Utc::now() - Duration::seconds(1),
1668 })
1669 .await
1670 .unwrap();
1671 assert!(
1672 plugin
1673 .get_verification_otp(
1674 &ctx,
1675 "expired-check@example.com",
1676 EmailOtpType::EmailVerification
1677 )
1678 .await
1679 .unwrap()
1680 .is_none()
1681 );
1682 assert_eq!(
1683 post(
1684 &plugin,
1685 &ctx,
1686 "/email-otp/check-verification-otp",
1687 json!({"email":"expired-check@example.com","type":"email-verification","otp":"654321"})
1688 )
1689 .await
1690 .status,
1691 400
1692 );
1693 assert!(
1694 ctx.database
1695 .get_latest_verification_by_identifier(
1696 "email-verification-otp-expired-check@example.com"
1697 )
1698 .await
1699 .unwrap()
1700 .is_none()
1701 );
1702 }
1703
1704 #[tokio::test]
1707 async fn verification_hooks_and_auto_signin_observe_order_and_ownership() {
1708 let ctx = test_helpers::create_test_context().await;
1709 let user = ctx
1710 .database
1711 .create_user(CreateUser::new().with_email("hooks@example.com"))
1712 .await
1713 .unwrap();
1714 let seen = Arc::new(Mutex::new(Vec::new()));
1715 let (mut config, _) = configured();
1716 config.auto_sign_in_after_verification = true;
1717 let before = Arc::clone(&seen);
1718 config.before_email_verification = Some(Arc::new(move |user_2| {
1719 let before = Arc::clone(&before);
1720 let verified = user_2.email_verified;
1721 Box::pin(async move {
1722 before.lock().unwrap().push(("before", verified));
1723 Ok(())
1724 })
1725 }));
1726 let after = Arc::clone(&seen);
1727 config.after_email_verification = Some(Arc::new(move |user_3| {
1728 let after = Arc::clone(&after);
1729 let verified = user_3.email_verified;
1730 Box::pin(async move {
1731 after.lock().unwrap().push(("after", verified));
1732 Ok(())
1733 })
1734 }));
1735 let mut plugin = EmailOtpPlugin::new(config);
1736 let otp = plugin
1737 .create_verification_otp(&ctx, "hooks@example.com", EmailOtpType::EmailVerification)
1738 .await
1739 .unwrap();
1740 let response = post(
1741 &plugin,
1742 &ctx,
1743 "/email-otp/verify-email",
1744 json!({"email":"hooks@example.com","otp":otp}),
1745 )
1746 .await;
1747 assert_eq!(response.status, 200);
1748 let payload: Value = serde_json::from_slice(&response.body).unwrap();
1749 let token = payload.get("token").and_then(Value::as_str).unwrap();
1750 assert_eq!(
1751 ctx.database
1752 .get_session(token)
1753 .await
1754 .unwrap()
1755 .unwrap()
1756 .user_id(),
1757 user.id()
1758 );
1759 assert_eq!(*seen.lock().unwrap(), [("before", false), ("after", true)]);
1760 plugin.config.before_email_verification = Some(Arc::new(|_| {
1761 Box::pin(async { Err(AuthError::forbidden("blocked by verification policy")) })
1762 }));
1763 let otp_2 = plugin
1764 .create_verification_otp(&ctx, "hooks@example.com", EmailOtpType::EmailVerification)
1765 .await
1766 .unwrap();
1767 assert_eq!(
1768 post(
1769 &plugin,
1770 &ctx,
1771 "/email-otp/verify-email",
1772 json!({"email":"hooks@example.com","otp":otp_2})
1773 )
1774 .await
1775 .status,
1776 403
1777 );
1778 assert!(
1779 plugin
1780 .get_verification_otp(&ctx, "hooks@example.com", EmailOtpType::EmailVerification)
1781 .await
1782 .unwrap()
1783 .is_none()
1784 );
1785 }
1786
1787 #[tokio::test]
1790 #[expect(
1791 clippy::too_many_lines,
1792 reason = "Keep this ordered integration scenario and its assertions together; Result propagates setup failures"
1793 )]
1794 async fn email_change_requires_current_proof_and_hides_existing_target() {
1795 let ctx = test_helpers::create_test_context().await;
1796 let user = ctx
1797 .database
1798 .create_user(CreateUser::new().with_email("proof@example.com"))
1799 .await
1800 .unwrap();
1801 _ = ctx
1802 .database
1803 .create_user(CreateUser::new().with_email("occupied@example.com"))
1804 .await
1805 .unwrap();
1806 let session = ctx
1807 .session_manager()
1808 .create_session(&user, None, None)
1809 .await
1810 .unwrap();
1811 let (mut config, outbox) = configured();
1812 config.change_email_enabled = true;
1813 config.verify_current_email = true;
1814 let plugin = EmailOtpPlugin::new(config);
1815 let request = |body| {
1816 create_auth_json_request_no_query(
1817 HttpMethod::Post,
1818 "/email-otp/request-email-change",
1819 Some(session.token()),
1820 Some(body),
1821 )
1822 };
1823 assert_eq!(
1824 plugin
1825 .on_request(&request(json!({"newEmail":"target@example.com"})), &ctx)
1826 .await
1827 .unwrap_err()
1828 .status_code(),
1829 400
1830 );
1831 assert!(outbox.0.lock().unwrap().is_empty());
1832 let current = plugin
1833 .create_verification_otp(&ctx, "proof@example.com", EmailOtpType::EmailVerification)
1834 .await
1835 .unwrap();
1836 assert_eq!(
1837 plugin
1838 .on_request(
1839 &request(json!({"newEmail":"occupied@example.com","otp":current})),
1840 &ctx
1841 )
1842 .await
1843 .unwrap()
1844 .unwrap()
1845 .status,
1846 200
1847 );
1848 assert!(outbox.0.lock().unwrap().is_empty());
1849 assert!(
1850 plugin
1851 .get_verification_otp(&ctx, "proof@example.com", EmailOtpType::EmailVerification)
1852 .await
1853 .unwrap()
1854 .is_none()
1855 );
1856 assert!(
1857 ctx.database
1858 .get_latest_verification_by_identifier(
1859 "change-email-otp-proof@example.com-occupied@example.com"
1860 )
1861 .await
1862 .unwrap()
1863 .is_none()
1864 );
1865 let current_2 = plugin
1866 .create_verification_otp(&ctx, "proof@example.com", EmailOtpType::EmailVerification)
1867 .await
1868 .unwrap();
1869 assert_eq!(
1870 plugin
1871 .on_request(
1872 &request(json!({"newEmail":"target@example.com","otp":current_2})),
1873 &ctx
1874 )
1875 .await
1876 .unwrap()
1877 .unwrap()
1878 .status,
1879 200
1880 );
1881 let code = outbox.0.lock().unwrap().last().unwrap().otp.clone();
1882 let confirm = create_auth_json_request_no_query(
1883 HttpMethod::Post,
1884 "/email-otp/change-email",
1885 Some(session.token()),
1886 Some(json!({"newEmail":"target@example.com","otp":code})),
1887 );
1888 assert_eq!(
1889 plugin
1890 .on_request(&confirm, &ctx)
1891 .await
1892 .unwrap()
1893 .unwrap()
1894 .status,
1895 200
1896 );
1897 assert_eq!(
1898 ctx.database
1899 .get_user_by_id(&user.id())
1900 .await
1901 .unwrap()
1902 .unwrap()
1903 .email(),
1904 Some("target@example.com")
1905 );
1906 }
1907
1908 #[tokio::test]
1911 async fn request_validation_matches_pinned_runtime_error_vectors() {
1912 let ctx = test_helpers::create_test_context().await;
1913 let (config, _) = configured();
1914 let plugin = EmailOtpPlugin::new(config);
1915 for (path, body, message) in [
1916 (
1917 "/email-otp/send-verification-otp",
1918 json!({}),
1919 "[body.email] Invalid input: expected string, received undefined; [body.type] Invalid option: expected one of \"email-verification\"|\"sign-in\"|\"forget-password\"|\"change-email\"",
1920 ),
1921 (
1922 "/sign-in/email-otp",
1923 json!({"email":5,"otp":false,"name":null,"image":1}),
1924 "[body.email] Invalid input: expected string, received number; [body.otp] Invalid input: expected string, received boolean; [body.name] Invalid input: expected string, received null; [body.image] Invalid input: expected string, received number",
1925 ),
1926 (
1927 "/email-otp/change-email",
1928 json!({}),
1929 "[body.newEmail] Invalid input: expected string, received undefined; [body.otp] Invalid input: expected string, received undefined",
1930 ),
1931 ] {
1932 let response = post(&plugin, &ctx, path, body).await;
1933 assert_eq!(response.status, 400);
1934 let payload: Value = serde_json::from_slice(&response.body).unwrap();
1935 assert_eq!(
1936 payload,
1937 json!({"message":message,"code":"VALIDATION_ERROR"})
1938 );
1939 }
1940 for email in [
1941 "a@b.c",
1942 ".a@example.com",
1943 "a..b@example.com",
1944 "a!b@example.com",
1945 "a'@example.com",
1946 ] {
1947 assert_eq!(
1948 post(
1949 &plugin,
1950 &ctx,
1951 "/email-otp/send-verification-otp",
1952 json!({"email":email,"type":"sign-in"})
1953 )
1954 .await
1955 .status,
1956 400
1957 );
1958 }
1959 assert_eq!(
1960 post(
1961 &plugin,
1962 &ctx,
1963 "/email-otp/send-verification-otp",
1964 json!({"email":"a'b@example.com","type":"change-email"})
1965 )
1966 .await
1967 .status,
1968 400
1969 );
1970 }
1971
1972 #[tokio::test]
1975 async fn signup_hook_and_disabled_signup_preserve_delivery_and_state_contracts() {
1976 let ctx = test_helpers::create_test_context().await;
1977 let (mut config, outbox) = configured();
1978 config.send_verification_on_sign_up = true;
1979 config.disable_sign_up = true;
1980 let plugin = EmailOtpPlugin::new(config);
1981 let request = AuthRequest::new(HttpMethod::Post, "/sign-up/email");
1982 let response =
1983 AuthResponse::json(200, &json!({"user":{"email":"signup@example.com"}})).unwrap();
1984 _ = plugin
1985 .after_request(&request, &ctx, response)
1986 .await
1987 .unwrap();
1988 assert_eq!(
1989 outbox.0.lock().unwrap().last().unwrap().otp_type,
1990 EmailOtpType::EmailVerification
1991 );
1992 assert_eq!(
1993 post(
1994 &plugin,
1995 &ctx,
1996 "/email-otp/send-verification-otp",
1997 json!({"email":"missing@example.com","type":"sign-in"})
1998 )
1999 .await
2000 .status,
2001 200
2002 );
2003 assert!(
2004 ctx.database
2005 .get_latest_verification_by_identifier("sign-in-otp-missing@example.com")
2006 .await
2007 .unwrap()
2008 .is_none()
2009 );
2010 let otp = plugin
2011 .create_verification_otp(&ctx, "missing@example.com", EmailOtpType::SignIn)
2012 .await
2013 .unwrap();
2014 assert_eq!(
2015 post(
2016 &plugin,
2017 &ctx,
2018 "/sign-in/email-otp",
2019 json!({"email":"missing@example.com","otp":otp})
2020 )
2021 .await
2022 .status,
2023 400
2024 );
2025 assert!(
2026 ctx.database
2027 .get_user_by_email("missing@example.com")
2028 .await
2029 .unwrap()
2030 .is_none()
2031 );
2032 }
2033
2034 #[tokio::test]
2038 async fn background_delivery_retains_request_store_and_proof_after_observer_rejection() {
2039 type Schema = alibi_seaorm::store::__private_test_support::bundled_schema::BundledSchema;
2040 struct RejectObservation;
2041 impl alibi_core::BackgroundTaskHandler for RejectObservation {
2042 fn handle(&self, completion: alibi_core::BackgroundTaskCompletion) -> AuthResult<()> {
2043 drop(completion);
2044 Err(AuthError::internal("observer rejected"))
2045 }
2046 }
2047 struct Deferred {
2048 resume: tokio::sync::Notify,
2049 result: tokio::sync::Mutex<Option<(EmailOtpDelivery, String, bool)>>,
2050 done: tokio::sync::Notify,
2051 }
2052 #[async_trait]
2053 impl SendEmailOtp for Deferred {
2054 async fn send(
2055 &self,
2056 delivery: &EmailOtpDelivery,
2057 callback: &alibi_core::CallbackContext,
2058 ) -> AuthResult<()> {
2059 self.resume.notified().await;
2060 let ctx = callback.context::<Schema>().unwrap();
2061 let proof = ctx
2062 .database
2063 .get_verification_by_identifier(&format!("sign-in-otp-{}", delivery.email))
2064 .await?;
2065 let marker = callback
2066 .request
2067 .as_ref()
2068 .unwrap()
2069 .headers
2070 .get("x-callback-marker")
2071 .unwrap()
2072 .clone();
2073 *self.result.lock().await = Some((delivery.clone(), marker, proof.is_some()));
2074 self.done.notify_one();
2075 Err(AuthError::Upstream {
2076 status: 409,
2077 code: "DELIVERY_REJECTED",
2078 message: "delivery rejected",
2079 })
2080 }
2081 }
2082 let mut ctx = test_helpers::create_test_context().await;
2083 ctx.config = Arc::new(
2084 (*ctx.config)
2085 .clone()
2086 .background_tasks(Arc::new(RejectObservation)),
2087 );
2088 let sender = Arc::new(Deferred {
2089 resume: tokio::sync::Notify::new(),
2090 result: tokio::sync::Mutex::new(None),
2091 done: tokio::sync::Notify::new(),
2092 });
2093 let plugin = EmailOtpPlugin::new(EmailOtpConfig {
2094 send_verification_otp: Some(sender.clone()),
2095 ..Default::default()
2096 });
2097 let email = "background-context@fixture.test";
2098 let mut request = create_auth_json_request_no_query(
2099 HttpMethod::Post,
2100 "/email-otp/send-verification-otp",
2101 None,
2102 Some(json!({"email":email,"type":"sign-in"})),
2103 );
2104 request
2105 .headers
2106 .insert("x-callback-marker".into(), "real-request".into());
2107 let response = plugin.on_request(&request, &ctx).await.unwrap().unwrap();
2108 assert_eq!(response.status, 200);
2109 assert!(sender.result.lock().await.is_none());
2110 drop(request);
2111 sender.resume.notify_one();
2112 tokio::time::timeout(std::time::Duration::from_secs(5), sender.done.notified())
2113 .await
2114 .unwrap();
2115 let (delivery, marker, proof_exists) = sender.result.lock().await.clone().unwrap();
2116 assert_eq!(marker, "real-request");
2117 assert!(proof_exists);
2118 let consumed = post(
2119 &plugin,
2120 &ctx,
2121 "/sign-in/email-otp",
2122 json!({"email":email,"otp":delivery.otp}),
2123 )
2124 .await;
2125 assert_eq!(consumed.status, 200);
2126 let replay = post(
2127 &plugin,
2128 &ctx,
2129 "/sign-in/email-otp",
2130 json!({"email":email,"otp":delivery.otp}),
2131 )
2132 .await;
2133 assert_eq!(replay.status, 400);
2134 assert_eq!(
2135 ctx.database
2136 .get_user_sessions(
2137 serde_json::from_slice::<Value>(&consumed.body)
2138 .unwrap()
2139 .get("user")
2140 .and_then(|user| user.get("id"))
2141 .and_then(Value::as_str)
2142 .unwrap()
2143 )
2144 .await
2145 .unwrap()
2146 .len(),
2147 1
2148 );
2149 }
2150}
2151