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