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