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