Skip to main content

alibi_plugins/email_otp/
mod.rs

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