Skip to main content

alibi_api/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::{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/// Delivers a code to its intended mailbox with the real native callback context.
24/// Delivery awaits and propagates errors by default; configured background work
25/// owns its context and logs failures while retaining the issued verification.
26#[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/// Optional application code generator. Returning `None` selects the default
36/// cryptographically random numeric generator.
37#[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/// Configuration for email OTP verification, login, password reset and email change.
48#[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    /// Raw numeric length. Safe positive fractions round up; NaN generates an
57    /// empty code. Nonpositive and resource-unsafe lengths fail generation.
58    pub otp_length: f64,
59    /// Lifetime in seconds. Fractions retain JavaScript millisecond rounding;
60    /// invalid dates fail before persistence.
61    pub expires_in: f64,
62    /// Raw attempt budget compared with the persisted integer counter. Zero and
63    /// NaN use three; negative values reject even an unused proof.
64    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    /// Create and persist a code without delivering it or checking whether the
190    /// mailbox has an account. This operation is available only to server code.
191    ///
192    /// # Errors
193    ///
194    /// Returns an error if OTP generation, encoding, or persistence fails.
195    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    /// Retrieve a live plaintext/decryptable code without consuming it.
206    /// Hashed storage rejects this operation, since a hash cannot reveal a code.
207    ///
208    /// # Errors
209    ///
210    /// Returns an error if storage fails or the configured OTP representation cannot be recovered.
211    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// LCOV_EXCL_START
353#[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    // The pinned endpoint awaits runInBackgroundOrAwait: a rejected notification
467    // leaves its real proof available and does not replace the successful response.
468    #[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    // Upstream checkVerificationOTP rejects once when a database update hook
579    // returns false. Hook cancellation must not be retried as CAS contention.
580    #[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    // Upstream: email-otp/routes.ts :: sendVerificationOTP anti-enumeration branch.
656    #[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    // Upstream: email-otp/routes.ts :: signInEmailOTP + atomicVerifyOTP.
688    #[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    // Upstream: atomicVerifyOTP attempt count is enforced before code validation.
813    #[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    // Upstream: checkVerificationOTP is non-consuming; verifyEmailOTP consumes.
872    #[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    // Upstream: expired code has a distinct error and is removed before consuming.
940    #[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    // Upstream: a mailbox proof promotes an unverified row only after deleting
1014    // every standing account/session that predates proof of ownership.
1015    #[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    // Upstream: atomicVerifyOTP has exactly one successful concurrent consumer.
1090    #[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    // Upstream: parseUserInput ignores fields contributed by an absent plugin.
1110    // Persisted fields are checked because response projection alone can hide writes.
1111    #[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    // Upstream: reusable plain OTPs keep attempts and extend expiry; hashes cannot
1166    // recover a plaintext for delivery, and must therefore rotate even in reuse mode.
1167    #[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    // Upstream: change-email identifier binds both current and target mailboxes.
1257    #[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    // Ciphertext produced by the installed 1.7.6 symmetricEncrypt runtime. This
1361    // proves the persistence encoding independently of our encryption round-trip.
1362    #[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    // Upstream: password policy runs before consumption; reset callbacks see the
1417    // pre-verification user and configured session revocation applies afterward.
1418    #[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    // Upstream: an OTP reset can create the credential for a passwordless account;
1549    // unknown mailboxes do not receive reset codes.
1550    #[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    // Upstream: checks preserve a correct code even if the mailbox has no user,
1605    // while wrong checks count toward the same budget and expired checks delete it.
1606    #[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    // 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        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    // Golden error vectors from the 1.7.6 runtime. Optional nulls are invalid;
1910    // the schema reports every failed field before authentication middleware.
1911    #[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    // Upstream: sendVerificationOnSignUp runs after a successful sign-up response,
1974    // and disabled signup never issues a delivered login for an unknown mailbox.
1975    #[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    /// Distinct delivery lifecycle contract: an abandoned completion observation
2038    /// still owns the real request/context and can read the issued proof after the
2039    /// request returns. No fixture supplies the proof or completes delivery.
2040    #[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// LCOV_EXCL_STOP