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