Skip to main content

alibi_plugins/two_factor/
mod.rs

1mod backup_codes;
2mod challenge;
3mod codec;
4mod config;
5mod cookies;
6mod enrollment;
7mod lockout;
8mod otp_flow;
9mod totp;
10
11use alibi_core::error::codes;
12use backup_codes::generate_backup_codes;
13use backup_codes::generate_backup_codes_core;
14use backup_codes::generate_numeric_string;
15use backup_codes::verify_backup_code_core;
16use backup_codes::view_backup_codes_core;
17use challenge::begin_factor_attempt;
18pub(crate) use challenge::begin_sign_in_challenge;
19use challenge::finalize_pending_two_factor;
20pub(crate) use challenge::inspect_trusted_device;
21use challenge::load_two_factor_record;
22use challenge::rearm_factor_attempt;
23use challenge::resolve_two_factor_state;
24use challenge::verification_error_response;
25use challenge::verify_existing_session_factor;
26use codec::decrypt_value;
27use codec::encrypt_value;
28pub use config::AccountLockoutConfig;
29pub use config::SendTwoFactorOtp;
30pub use config::TwoFactorConfig;
31use cookies::clear_cookie_header;
32use cookies::cookie_expiry;
33use cookies::create_session_cookie_for_dont_remember;
34use cookies::create_signed_cookie_header;
35use cookies::create_trust_device_cookie_header;
36use cookies::get_factor_cookie;
37use cookies::read_signed_cookie;
38use cookies::sign_value;
39use cookies::two_factor_cookie_max_age;
40use cookies::verify_factor_cookie_value;
41use enrollment::disable_core;
42use enrollment::enable_core;
43use enrollment::mark_factor_verified;
44use enrollment::parse_password_body;
45use enrollment::verify_user_password;
46use lockout::assert_account_not_locked;
47use lockout::record_account_failure;
48use lockout::reset_account_failures;
49use otp_flow::send_otp_core;
50use otp_flow::verify_otp_core;
51use totp::generate_secret;
52use totp::generate_totp_at;
53use totp::get_totp_uri_core;
54use totp::totp_counter;
55use totp::totp_uri;
56use totp::verify_totp_core;
57mod backup_storage;
58mod endpoint;
59
60mod otp_storage;
61
62mod otp;
63
64use super::StatusResponse;
65use crate::helpers::{
66    SessionIssueError, get_credential_password_hash, issue_user_session_record,
67    issue_user_session_with_overrides_record,
68};
69use alibi_core::entity::{AuthSession, AuthTwoFactor, AuthUser};
70use alibi_core::utils::cookie_utils::{
71    create_clear_cookie, create_session_cookie, create_session_cookie_with_max_age,
72    related_cookie_name,
73};
74use alibi_core::wire::UserView;
75use alibi_core::{
76    AuthContext, AuthError, AuthRequest, AuthResponse, AuthResult, CreateTwoFactor,
77    CreateVerification, RequestMeta, TwoFactor, UpdateTwoFactor, UpdateUser,
78};
79use async_trait::async_trait;
80pub use backup_storage::{TwoFactorBackupCipher, TwoFactorBackupStorage};
81use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
82use chrono::{Duration, Utc};
83pub use endpoint::{
84    BackupCodeVerified, BackupCodesOutput, TotpOutput, TotpVerified, TwoFactorDisabled,
85    TwoFactorOtpSent,
86};
87use hmac::{Hmac, KeyInit, Mac};
88pub use otp_storage::{TwoFactorOtpCipher, TwoFactorOtpHasher, TwoFactorOtpStorage};
89use rand::RngExt;
90use rand::distr::Alphanumeric;
91use serde::{Deserialize, Serialize};
92use std::sync::Arc;
93use validator::Validate;
94
95const TWO_FACTOR_COOKIE_SUFFIX: &str = "two_factor";
96
97const TRUST_DEVICE_COOKIE_SUFFIX: &str = "trust_device";
98
99const DONT_REMEMBER_COOKIE_SUFFIX: &str = "dont_remember";
100
101const METADATA_ENABLED: &str = "two_factor.enabled";
102
103const METADATA_OTP_ENABLED: &str = "two_factor.otp_enabled";
104
105const METADATA_TWO_FACTOR_COOKIE_MAX_AGE: &str = "two_factor.two_factor_cookie_max_age";
106
107const METADATA_TRUST_DEVICE_MAX_AGE: &str = "two_factor.trust_device_max_age";
108
109const METADATA_TOTP_DISABLED: &str = "two_factor.totp_disabled";
110
111const DEFAULT_TWO_FACTOR_COOKIE_MAX_AGE_SECS: f64 = 600.0;
112
113const DEFAULT_TRUST_DEVICE_MAX_AGE_SECS: f64 = 2_592_000.0;
114
115#[derive(Clone, Copy)]
116struct TwoFactorCookiePolicy {
117    challenge_max_age: f64,
118    trust_max_age: f64,
119}
120
121const DEFAULT_TOTP_PERIOD_SECS: f64 = 30.0;
122
123const DEFAULT_TOTP_DIGITS: f64 = 6.0;
124
125/// Two-factor authentication plugin providing TOTP, OTP, and backup code flows.
126#[derive(Clone)]
127pub struct TwoFactorPlugin {
128    config: TwoFactorConfig,
129}
130
131/// Password authority and enrollment method for enabling the current user's factor.
132#[derive(Debug, Default, Deserialize, Serialize, Validate)]
133pub struct EnableTwoFactorInput {
134    #[serde(skip_serializing_if = "Option::is_none")]
135    pub password: Option<String>,
136    #[serde(default)]
137    pub method: TwoFactorMethod,
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub issuer: Option<String>,
140}
141
142/// The supported factor enrollment methods.
143#[derive(Debug, Default, Deserialize, Serialize, PartialEq)]
144#[serde(rename_all = "lowercase")]
145pub enum TwoFactorMethod {
146    Otp,
147    #[default]
148    Totp,
149}
150
151/// Password authority for disabling the current user's factor.
152#[derive(Debug, Default, Deserialize, Serialize, Validate)]
153pub struct DisableTwoFactorInput {
154    #[serde(skip_serializing_if = "Option::is_none")]
155    pub password: Option<String>,
156}
157
158/// Password authority for retrieving the current user's stored authenticator URI.
159#[derive(Debug, Default, Deserialize, Serialize, Validate)]
160pub struct GetTotpUriInput {
161    #[serde(skip_serializing_if = "Option::is_none")]
162    pub password: Option<String>,
163}
164
165/// Authenticator code and optional trusted-device preference.
166#[derive(Debug, Deserialize, Serialize, Validate)]
167pub struct VerifyTotpRequest {
168    pub code: String,
169    #[serde(rename = "trustDevice", skip_serializing_if = "Option::is_none")]
170    pub trust_device: Option<bool>,
171}
172
173/// Public input for direct authenticator verification.
174pub type VerifyTotpInput = VerifyTotpRequest;
175
176#[derive(Deserialize)]
177struct SendOtpRequest {
178    #[serde(rename = "trustDevice")]
179    _trust_device: Option<bool>,
180}
181
182impl crate::authentication_helpers::RequestBody for SendOtpRequest {
183    const FIELDS: &'static [crate::authentication_helpers::JsonField] =
184        &[crate::authentication_helpers::JsonField {
185            name: "trustDevice",
186            kind: crate::authentication_helpers::JsonFieldKind::Boolean,
187            required: false,
188        }];
189}
190
191/// Delivered code and optional trusted-device preference.
192#[derive(Debug, Deserialize, Serialize, Validate)]
193pub struct VerifyOtpRequest {
194    pub code: String,
195    #[serde(rename = "trustDevice", skip_serializing_if = "Option::is_none")]
196    pub trust_device: Option<bool>,
197}
198/// Public input for direct delivered-code verification.
199pub type VerifyOtpInput = VerifyOtpRequest;
200
201/// Password admission for regenerating the current owner's recovery codes.
202#[derive(Debug, Deserialize, Serialize, Validate)]
203pub struct GenerateBackupCodesRequest {
204    #[serde(skip_serializing_if = "Option::is_none")]
205    pub password: Option<String>,
206}
207/// Public input for direct backup-code regeneration.
208pub type GenerateBackupCodesInput = GenerateBackupCodesRequest;
209
210/// Recovery code and optional session/device publication preferences.
211#[derive(Debug, Deserialize, Serialize, Validate)]
212pub struct VerifyBackupCodeRequest {
213    pub code: String,
214    #[serde(rename = "disableSession", skip_serializing_if = "Option::is_none")]
215    pub disable_session: Option<bool>,
216    #[serde(rename = "trustDevice", skip_serializing_if = "Option::is_none")]
217    pub trust_device: Option<bool>,
218}
219/// Public input for direct recovery-code verification.
220pub type VerifyBackupCodeInput = VerifyBackupCodeRequest;
221
222/// A generated authenticator enrollment or an immediately enabled OTP factor.
223#[derive(Debug, Serialize, Deserialize)]
224#[serde(tag = "method", rename_all = "lowercase")]
225pub enum TwoFactorEnabled {
226    Otp,
227    Totp {
228        #[serde(rename = "totpURI")]
229        totp_uri: String,
230        #[serde(rename = "backupCodes")]
231        backup_codes: Vec<String>,
232    },
233}
234
235/// An authenticator URI recovered from the caller's stored factor.
236#[derive(Debug, Serialize, Deserialize)]
237pub struct TotpUriOutput {
238    #[serde(rename = "totpURI")]
239    pub totp_uri: String,
240}
241
242#[derive(Debug, Serialize)]
243pub(crate) struct SessionTokenResponse<U> {
244    token: String,
245    user: U,
246}
247
248#[derive(Debug, Serialize)]
249struct BackupVerificationResponse {
250    #[serde(skip_serializing_if = "Option::is_none")]
251    token: Option<String>,
252    user: UserView,
253}
254
255impl From<SessionTokenResponse<UserView>> for BackupVerificationResponse {
256    fn from(response: SessionTokenResponse<UserView>) -> Self {
257        Self {
258            token: Some(response.token),
259            user: response.user,
260        }
261    }
262}
263
264#[derive(Debug, Serialize)]
265pub(crate) struct BackupCodesResponse {
266    status: bool,
267    #[serde(rename = "backupCodes")]
268    backup_codes: Vec<String>,
269}
270
271#[derive(Debug, Serialize)]
272pub(crate) struct TwoFactorRedirectResponse {
273    #[serde(rename = "twoFactorRedirect")]
274    two_factor_redirect: bool,
275    /// Second factors this user can actually complete, so the client knows
276    /// which challenge to present.
277    #[serde(rename = "twoFactorMethods")]
278    two_factor_methods: Vec<&'static str>,
279}
280
281struct PendingTwoFactorState<S: alibi_core::AuthSchema> {
282    user: alibi_core::AdapterRecord<S::User>,
283    verification: alibi_core::verification::VerificationSnapshot,
284    key: String,
285    dont_remember: bool,
286}
287
288enum ResolvedTwoFactorState<S: alibi_core::AuthSchema> {
289    Session {
290        user: alibi_core::AuthenticatedUser<S>,
291        session: Box<alibi_core::wire::SessionView>,
292        key: String,
293    },
294    Pending(PendingTwoFactorState<S>),
295}
296
297pub(crate) struct SignInTwoFactorRedirect {
298    pub response: TwoFactorRedirectResponse,
299    pub set_cookie_headers: Vec<String>,
300}
301
302pub(crate) struct TrustedDeviceCheck {
303    pub trusted: bool,
304    pub set_cookie_headers: Vec<String>,
305}
306
307impl TwoFactorPlugin {
308    /// Install a custom OTP sender.
309    #[must_use]
310    pub fn custom_send_otp(mut self, sender: Arc<dyn SendTwoFactorOtp>) -> Self {
311        self.config.send_otp = Some(sender);
312        self
313    }
314
315    /// Generate a current TOTP from an application-owned UTF-8 secret.
316    ///
317    /// This corresponds to `auth.api.generateTOTP`; it has no public HTTP route.
318    ///
319    /// # Errors
320    ///
321    /// Returns an error if the secret cannot be decoded or the TOTP configuration is invalid.
322    pub fn generate_totp(&self, secret: &str) -> AuthResult<String> {
323        require_totp_enabled(&self.config)?;
324        generate_totp_at(&self.config, secret, totp_counter(&self.config), 0.0)
325    }
326
327    /// Read the currently stored backup JSON for a user.
328    ///
329    /// Installed storage may contain any truthy JSON value. Dates are revived
330    /// with the pinned grammar and numbers use JavaScript JSON serialization;
331    /// generated code arrays retain their ordinary array-of-strings shape.
332    ///
333    /// This is the Rust server-side equivalent of the TypeScript
334    /// `auth.api.viewBackupCodes` capability. It is intentionally not exposed
335    /// as a public HTTP route.
336    ///
337    /// # Errors
338    ///
339    /// Returns an error if backup codes are unavailable, cannot be decrypted, or cannot be loaded.
340    pub async fn view_backup_codes<S: alibi_core::AuthSchema>(
341        &self,
342        user_id: &str,
343        ctx: &AuthContext<S>,
344    ) -> AuthResult<serde_json::Value> {
345        view_backup_codes_core(user_id, &self.config, ctx).await
346    }
347}
348
349alibi_core::impl_auth_plugin! {
350    TwoFactorPlugin, "two-factor";
351    routes {
352        post "/two-factor/enable" => handle_enable, "enable_two_factor";
353        post "/two-factor/disable" => handle_disable, "disable_two_factor";
354        post "/two-factor/get-totp-uri" => handle_get_totp_uri, "get_totp_uri";
355        post "/two-factor/verify-totp" => handle_verify_totp, "verify_totp";
356        post "/two-factor/send-otp" => handle_send_otp, "send_otp";
357        post "/two-factor/verify-otp" => handle_verify_otp, "verify_otp";
358        post "/two-factor/generate-backup-codes" => handle_generate_backup_codes, "generate_backup_codes";
359        post "/two-factor/verify-backup-code" => handle_verify_backup_code, "verify_backup_code";
360    }
361    extra {
362    route_openapi_metadata!(S);
363
364        fn rate_limits(&self) -> Vec<alibi_core::PluginRateLimit> {
365            vec![alibi_core::PluginRateLimit { matches: |path| path.starts_with("/two-factor/"), limit: alibi_core::EndpointRateLimit { window_seconds: 10.0, max_requests: 3.0 } }]
366        }
367        fn server_endpoints(&self) -> Vec<alibi_core::endpoint::EndpointDefinition> { endpoint::definitions() }
368
369        fn validate_endpoint(&self, call: &alibi_core::endpoint::EndpointCall, _ctx: &AuthContext<S>) -> AuthResult<alibi_core::endpoint::EndpointInput> { endpoint::validate(call, &self.config) }
370
371        async fn on_endpoint(&self, call: &alibi_core::endpoint::EndpointCall, ctx: &AuthContext<S>) -> AuthResult<alibi_core::endpoint::EndpointResponse> { self.call_endpoint(call, ctx).await }
372
373        async fn on_init(
374            &self,
375            ctx: &mut alibi_core::AuthInitContext<S>,
376        ) -> AuthResult<()> {
377            let default = |mut input: alibi_core::CreateUser| {
378                _ = input.two_factor_enabled.get_or_insert(false);
379                Ok(input)
380            };
381            if ctx.config.user_validation.is_some() {ctx.register_user_creation_adapter_default(default);}
382            else {ctx.register_user_create_transform(default);}
383            ctx.set_metadata(METADATA_ENABLED, serde_json::Value::Bool(true));
384            ctx.set_metadata(METADATA_TOTP_DISABLED, serde_json::Value::Bool(self.config.totp_disabled));
385            ctx.set_metadata(
386                METADATA_OTP_ENABLED,
387                serde_json::Value::Bool(self.config.send_otp.is_some()),
388            );
389            ctx.set_metadata(
390                METADATA_TWO_FACTOR_COOKIE_MAX_AGE,
391                serde_json::Number::from_f64(self.config.two_factor_cookie_max_age)
392                    .map_or(serde_json::Value::Null, serde_json::Value::Number),
393            );
394            ctx.set_metadata(
395                METADATA_TRUST_DEVICE_MAX_AGE,
396                serde_json::Number::from_f64(self.config.trust_device_max_age)
397                    .map_or(serde_json::Value::Null, serde_json::Value::Number),
398            );
399            ctx.extensions.insert(TwoFactorCookiePolicy {
400                challenge_max_age: self.config.two_factor_cookie_max_age,
401                trust_max_age: self.config.trust_device_max_age,
402            });
403            Ok(())
404        }
405    }
406}
407
408impl TwoFactorPlugin {
409    async fn handle_enable(
410        &self,
411        req: &AuthRequest,
412        ctx: &AuthContext<impl alibi_core::AuthSchema>,
413    ) -> AuthResult<AuthResponse> {
414        let body: EnableTwoFactorInput =
415            match parse_password_body(req, self.config.allow_passwordless, true) {
416                Ok(body) => body,
417                Err(response) => return Ok(response),
418            };
419        let (user, session) = super::helpers::ordinary_session(req, ctx).await?;
420
421        let (response, set_cookie_headers) =
422            match enable_core(&body, &user, &session, &self.config, ctx).await {
423                Ok(result) => result,
424                Err(
425                    BackupOperationError::Auth(AuthError::SessionCreationCancelled)
426                    | BackupOperationError::InvalidGeneration,
427                ) => {
428                    return Ok(AuthResponse::new(500));
429                }
430                Err(BackupOperationError::Auth(error)) => return Err(error),
431            };
432        let mut auth_response = AuthResponse::json(200, &response)?;
433        for cookie in set_cookie_headers {
434            auth_response = auth_response.with_appended_header("Set-Cookie", cookie);
435        }
436        Ok(auth_response)
437    }
438
439    async fn handle_disable(
440        &self,
441        req: &AuthRequest,
442        ctx: &AuthContext<impl alibi_core::AuthSchema>,
443    ) -> AuthResult<AuthResponse> {
444        let body: DisableTwoFactorInput =
445            match parse_password_body(req, self.config.allow_passwordless, false) {
446                Ok(v) => v,
447                Err(resp) => return Ok(resp),
448            };
449        self.disable_factor(&body, req, ctx).await
450    }
451
452    async fn disable_factor<S: alibi_core::AuthSchema>(
453        &self,
454        body: &DisableTwoFactorInput,
455        req: &impl alibi_core::session::SessionRequest,
456        ctx: &AuthContext<S>,
457    ) -> AuthResult<AuthResponse> {
458        let (user, session) = ctx
459            .require_authoritative_session(req)
460            .await
461            .map_err(|error| {
462                if matches!(
463                    error,
464                    AuthError::Unauthenticated | AuthError::SessionNotFound
465                ) {
466                    crate::helpers::unauthorized()
467                } else {
468                    error
469                }
470            })?;
471
472        let (response, set_cookie_headers) =
473            disable_core(body, &user, &session, req, &self.config, ctx).await?;
474        let mut auth_response = AuthResponse::json(200, &response)?;
475        for cookie in set_cookie_headers {
476            auth_response = auth_response.with_appended_header("Set-Cookie", cookie);
477        }
478        Ok(auth_response)
479    }
480
481    async fn handle_get_totp_uri(
482        &self,
483        req: &AuthRequest,
484        ctx: &AuthContext<impl alibi_core::AuthSchema>,
485    ) -> AuthResult<AuthResponse> {
486        let (user, _session) = super::helpers::ordinary_session(req, ctx).await?;
487        let allow_passwordless = self
488            .config
489            .totp_allow_passwordless
490            .unwrap_or(self.config.allow_passwordless);
491        let body: GetTotpUriInput = match parse_password_body(req, allow_passwordless, false) {
492            Ok(v) => v,
493            Err(resp) => return Ok(resp),
494        };
495
496        let response = get_totp_uri_core(&body, &user, &self.config, ctx).await?;
497        AuthResponse::json(200, &response).map_err(AuthError::from)
498    }
499
500    async fn handle_verify_totp(
501        &self,
502        req: &AuthRequest,
503        ctx: &AuthContext<impl alibi_core::AuthSchema>,
504    ) -> AuthResult<AuthResponse> {
505        let body: VerifyTotpInput = match alibi_core::validate_request_body(req) {
506            Ok(v) => v,
507            Err(resp) => return Ok(resp),
508        };
509
510        self.verify_totp(&body, req, ctx).await
511    }
512
513    async fn verify_totp<S: alibi_core::AuthSchema>(
514        &self,
515        body: &VerifyTotpInput,
516        req: &impl alibi_core::session::SessionRequest,
517        ctx: &AuthContext<S>,
518    ) -> AuthResult<AuthResponse> {
519        let (response, set_cookie_headers) =
520            match Box::pin(verify_totp_core(req, body, &self.config, ctx)).await {
521                Ok(result) => result,
522                Err(
523                    TotpVerificationError::InvalidGeneration
524                    | TotpVerificationError::SessionCreationCancelled,
525                ) => return Ok(AuthResponse::new(500)),
526                Err(TotpVerificationError::Auth(error)) => {
527                    return verification_error_response(error, ctx);
528                }
529            };
530        let mut auth_response = AuthResponse::json(200, &response)?;
531        for cookie in set_cookie_headers {
532            auth_response = auth_response.with_appended_header("Set-Cookie", cookie);
533        }
534        Ok(auth_response)
535    }
536
537    async fn handle_send_otp(
538        &self,
539        req: &AuthRequest,
540        ctx: &AuthContext<impl alibi_core::AuthSchema>,
541    ) -> AuthResult<AuthResponse> {
542        if let Err(response) = crate::authentication_helpers::parse_body::<SendOtpRequest>(req) {
543            return Ok(response);
544        }
545        self.send_otp(req, ctx).await
546    }
547
548    async fn send_otp<S: alibi_core::AuthSchema>(
549        &self,
550        req: &impl alibi_core::session::SessionRequest,
551        ctx: &AuthContext<S>,
552    ) -> AuthResult<AuthResponse> {
553        let response = match Box::pin(send_otp_core(req, &self.config, ctx)).await {
554            Ok(response) => response,
555            Err(SendOtpError::InvalidGeneration) => return Ok(AuthResponse::new(500)),
556            Err(SendOtpError::Auth(error)) => return Err(error),
557        };
558        AuthResponse::json(200, &response).map_err(AuthError::from)
559    }
560
561    async fn handle_verify_otp(
562        &self,
563        req: &AuthRequest,
564        ctx: &AuthContext<impl alibi_core::AuthSchema>,
565    ) -> AuthResult<AuthResponse> {
566        let body: VerifyOtpRequest = match alibi_core::validate_request_body(req) {
567            Ok(v) => v,
568            Err(resp) => return Ok(resp),
569        };
570
571        self.verify_otp(&body, req, ctx).await
572    }
573
574    async fn verify_otp<S: alibi_core::AuthSchema>(
575        &self,
576        body: &VerifyOtpInput,
577        req: &impl alibi_core::session::SessionRequest,
578        ctx: &AuthContext<S>,
579    ) -> AuthResult<AuthResponse> {
580        let (response, set_cookie_headers) =
581            match Box::pin(verify_otp_core(req, body, &self.config, ctx)).await {
582                Ok(result) => result,
583                Err(ExistingSessionFactorError::SessionCreationCancelled) => {
584                    return Ok(AuthResponse::new(500));
585                }
586                // OTP's own code budget does not expire the pending-factor cookie.
587                Err(ExistingSessionFactorError::Auth(
588                    error @ AuthError::Upstream {
589                        code: codes::TOO_MANY_ATTEMPTS_REQUEST_NEW_CODE,
590                        ..
591                    },
592                )) => return Err(error),
593                Err(ExistingSessionFactorError::Auth(error)) => {
594                    return verification_error_response(error, ctx);
595                }
596            };
597        let mut auth_response = AuthResponse::json(200, &response)?;
598        for cookie in set_cookie_headers {
599            auth_response = auth_response.with_appended_header("Set-Cookie", cookie);
600        }
601        Ok(auth_response)
602    }
603
604    async fn handle_generate_backup_codes(
605        &self,
606        req: &AuthRequest,
607        ctx: &AuthContext<impl alibi_core::AuthSchema>,
608    ) -> AuthResult<AuthResponse> {
609        let (user, _session) = super::helpers::ordinary_session(req, ctx).await?;
610        let allow_passwordless = self
611            .config
612            .backup_allow_passwordless
613            .unwrap_or(self.config.allow_passwordless);
614        let body: GenerateBackupCodesRequest =
615            match parse_password_body(req, allow_passwordless, false) {
616                Ok(v) => v,
617                Err(resp) => return Ok(resp),
618            };
619
620        let response =
621            match Box::pin(generate_backup_codes_core(&body, &user, &self.config, ctx)).await {
622                Ok(response) => response,
623                Err(BackupOperationError::InvalidGeneration) => return Ok(AuthResponse::new(500)),
624                Err(BackupOperationError::Auth(error)) => return Err(error),
625            };
626        AuthResponse::json(200, &response).map_err(AuthError::from)
627    }
628
629    async fn handle_verify_backup_code(
630        &self,
631        req: &AuthRequest,
632        ctx: &AuthContext<impl alibi_core::AuthSchema>,
633    ) -> AuthResult<AuthResponse> {
634        let body: VerifyBackupCodeRequest = match alibi_core::validate_request_body(req) {
635            Ok(v) => v,
636            Err(resp) => return Ok(resp),
637        };
638
639        self.verify_backup_code(&body, req, ctx).await
640    }
641
642    async fn verify_backup_code<S: alibi_core::AuthSchema>(
643        &self,
644        body: &VerifyBackupCodeInput,
645        req: &impl alibi_core::session::SessionRequest,
646        ctx: &AuthContext<S>,
647    ) -> AuthResult<AuthResponse> {
648        let (response, set_cookie_headers) =
649            match Box::pin(verify_backup_code_core(req, body, &self.config, ctx)).await {
650                Ok(result) => result,
651                Err(error) => return verification_error_response(error, ctx),
652            };
653        let mut auth_response = AuthResponse::json(200, &response)?;
654        for cookie in set_cookie_headers {
655            auth_response = auth_response.with_appended_header("Set-Cookie", cookie);
656        }
657        Ok(auth_response)
658    }
659}
660
661enum TotpVerificationError {
662    Auth(AuthError),
663    InvalidGeneration,
664    SessionCreationCancelled,
665}
666impl From<AuthError> for TotpVerificationError {
667    fn from(error: AuthError) -> Self {
668        Self::Auth(error)
669    }
670}
671
672enum SendOtpError {
673    Auth(AuthError),
674    // The pinned random-string generator throws before storage or delivery.
675    InvalidGeneration,
676}
677
678impl From<AuthError> for SendOtpError {
679    fn from(error: AuthError) -> Self {
680        Self::Auth(error)
681    }
682}
683
684struct FactorAttempt {
685    identifier: String,
686    count: f64,
687    expires_at: chrono::DateTime<Utc>,
688}
689
690// Only an explicitly cancelled session creation is distinguished. The same
691// AuthError from user updates or other operations retains its default response.
692enum ExistingSessionFactorError {
693    Auth(AuthError),
694    SessionCreationCancelled,
695}
696
697impl From<AuthError> for ExistingSessionFactorError {
698    fn from(error: AuthError) -> Self {
699        Self::Auth(error)
700    }
701}
702
703impl ExistingSessionFactorError {
704    fn into_auth_error(self) -> AuthError {
705        match self {
706            Self::Auth(error) => error,
707            Self::SessionCreationCancelled => AuthError::SessionCreationCancelled,
708        }
709    }
710}
711
712enum BackupOperationError {
713    Auth(AuthError),
714    InvalidGeneration,
715}
716
717impl From<AuthError> for BackupOperationError {
718    fn from(error: AuthError) -> Self {
719        Self::Auth(error)
720    }
721}
722
723impl<S: alibi_core::AuthSchema> ResolvedTwoFactorState<S> {
724    fn user(&self) -> alibi_core::AuthenticatedUser<S> {
725        match self {
726            Self::Session { user, .. } => user.clone(),
727            Self::Pending(pending) => alibi_core::AuthenticatedUser::Stored(pending.user.clone()),
728        }
729    }
730
731    fn key(&self) -> &str {
732        match self {
733            Self::Session { key, .. } => key,
734            Self::Pending(pending) => &pending.key,
735        }
736    }
737}
738
739impl std::fmt::Debug for TwoFactorPlugin {
740    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
741        f.debug_struct("TwoFactorPlugin").finish_non_exhaustive()
742    }
743}
744
745pub(crate) fn is_enabled(ctx: &AuthContext<impl alibi_core::AuthSchema>) -> bool {
746    ctx.get_metadata(METADATA_ENABLED)
747        .and_then(serde_json::Value::as_bool)
748        .unwrap_or(false)
749}
750
751const fn require_totp_enabled(config: &TwoFactorConfig) -> AuthResult<()> {
752    if config.totp_disabled {
753        return Err(AuthError::Upstream {
754            status: 400,
755            code: codes::TOTP_NOT_CONFIGURED,
756            message: "totp isn't configured",
757        });
758    }
759    Ok(())
760}
761
762// LCOV_EXCL_START
763#[cfg(test)]
764mod tests {
765    use super::cookies::sign_cookie_value;
766    use super::*;
767    use crate::test_helpers;
768    use alibi_core::wire::{SessionView, UserView};
769    use alibi_core::{AuthPlugin, AuthVerification};
770    use alibi_core::{CreateAccount, CreateUser, HttpMethod};
771    use chrono::Duration;
772    use cookie::Cookie;
773
774    type TestSchema = alibi_seaorm::store::__private_test_support::bundled_schema::BundledSchema;
775
776    #[tokio::test]
777    #[expect(
778        clippy::too_many_lines,
779        reason = "Keep this ordered integration scenario and its assertions together; Result propagates setup failures"
780    )]
781    async fn skip_enrollment_hooks_retain_factor_generation_and_current_token_on_rejection() {
782        use alibi_core::{AuthConfig, CreateSession};
783        use alibi_seaorm::{
784            Database, DatabaseHooks, HookControl, SeaOrmBackend, SeaOrmHookContext, SeaOrmStore,
785        };
786        struct PolicyHook {
787            cancel_session: bool,
788            session_forbidden: bool,
789            observed: Arc<std::sync::Mutex<Vec<(String, String)>>>,
790        }
791        #[async_trait]
792        impl DatabaseHooks<TestSchema, SeaOrmBackend> for PolicyHook {
793            async fn before_update_user(
794                &self,
795                id: &str,
796                update: &mut UpdateUser,
797                context: &SeaOrmHookContext<'_>,
798            ) -> AuthResult<HookControl> {
799                if context
800                    .request
801                    .as_ref()
802                    .is_some_and(|request| request.path.ends_with("/two-factor/enable"))
803                {
804                    self.observed
805                        .lock()
806                        .unwrap()
807                        .push(("user".into(), id.into()));
808                    assert_eq!(update.two_factor_enabled, Some(true));
809                    if !self.cancel_session {
810                        return Err(AuthError::Upstream {
811                            status: 400,
812                            code: "USER_UPDATE_DENIED",
813                            message: "Configured user update denied",
814                        });
815                    }
816                }
817                Ok(HookControl::Continue)
818            }
819            async fn before_create_session(
820                &self,
821                session: &mut CreateSession,
822                context: &SeaOrmHookContext<'_>,
823            ) -> AuthResult<HookControl> {
824                if context
825                    .request
826                    .as_ref()
827                    .is_some_and(|request| request.path.ends_with("/two-factor/enable"))
828                {
829                    self.observed
830                        .lock()
831                        .unwrap()
832                        .push(("session".into(), session.user_id.clone()));
833                    assert_eq!(
834                        session.active_organization_id.as_deref(),
835                        Some("retained-org")
836                    );
837                    assert_eq!(session.active_team_id.as_deref(), Some("retained-team"));
838                    assert_eq!(session.impersonated_by.as_deref(), Some("retained-admin"));
839                    assert_eq!(session.ip_address.as_deref(), Some("127.0.0.9"));
840                    assert_eq!(session.user_agent.as_deref(), Some("retained-agent"));
841                    return if self.session_forbidden {
842                        Err(AuthError::forbidden(
843                            "session creation cancelled by database hook",
844                        ))
845                    } else {
846                        Ok(HookControl::Cancel)
847                    };
848                }
849                Ok(HookControl::Continue)
850            }
851        }
852        let password_hash = alibi_core::hash_password(None, "password123")
853            .await
854            .unwrap();
855        for (cancel_session, session_forbidden) in [(false, false), (true, false), (true, true)] {
856            for existing in [false, true] {
857                let db = Database::connect("sqlite::memory:").await.unwrap();
858                alibi_seaorm::store::__private_test_support::migrator::run_migrations(&db)
859                    .await
860                    .unwrap();
861                let config = Arc::new(AuthConfig::new("skip-hook-secret-at-least-32-characters"));
862                let mut ctx = AuthContext::new(
863                    Arc::clone(&config),
864                    Arc::new(SeaOrmStore::<TestSchema>::new(
865                        Arc::clone(&config),
866                        db.clone(),
867                    )),
868                );
869                let user = ctx
870                    .database
871                    .create_user(
872                        CreateUser::new()
873                            .with_name("Hook Owner")
874                            .with_email("hook-owner@fixture.test"),
875                    )
876                    .await
877                    .unwrap();
878                _ = ctx
879                    .database
880                    .create_account(CreateAccount {
881                        additional_fields: alibi_core::field_policy::FieldValues::default(),
882                        user_id: user.id.clone(),
883                        account_id: user.id.clone(),
884                        provider_id: "credential".into(),
885                        password: Some(password_hash.clone()),
886                        access_token: None,
887                        refresh_token: None,
888                        id_token: None,
889                        access_token_expires_at: None,
890                        refresh_token_expires_at: None,
891                        scope: None,
892                    })
893                    .await
894                    .unwrap();
895                let session = ctx
896                    .database
897                    .create_session(CreateSession {
898                        token: None,
899                        user_id: user.id.clone(),
900                        expires_at: Utc::now() + ctx.config.session.expires_in,
901                        active_organization_id: Some("retained-org".into()),
902                        active_team_id: Some("retained-team".into()),
903                        impersonated_by: Some("retained-admin".into()),
904                        ip_address: Some("127.0.0.9".into()),
905                        user_agent: Some("retained-agent".into()),
906                        additional_fields: alibi_core::field_policy::FieldValues::default(),
907                    })
908                    .await
909                    .unwrap();
910                let factor = if existing {
911                    Some(
912                        ctx.database
913                            .create_two_factor(CreateTwoFactor {
914                                user_id: user.id.clone(),
915                                secret: encrypt_value(&ctx.config, "historical-secret").unwrap(),
916                                backup_codes: encrypt_value(&ctx.config, "[\"historical-backup\"]")
917                                    .unwrap(),
918                                verified: Some(false),
919                                failed_verification_count: Some(0.5),
920                                locked_until: Some(Utc::now() + Duration::minutes(1)),
921                            })
922                            .await
923                            .unwrap(),
924                    )
925                } else {
926                    None
927                };
928                let observed = Arc::new(std::sync::Mutex::new(Vec::new()));
929                ctx.database = Arc::new(
930                    SeaOrmStore::<TestSchema>::new(config, db.clone()).with_hooks(vec![Arc::new(
931                        PolicyHook {
932                            cancel_session,
933                            session_forbidden,
934                            observed: Arc::clone(&observed),
935                        },
936                    )]),
937                );
938                let plugin = TwoFactorPlugin::with_config(TwoFactorConfig {
939                    skip_verification_on_enable: true,
940                    ..Default::default()
941                });
942                let mut init = alibi_core::AuthInitContext::new(
943                    Arc::clone(&ctx.config),
944                    Arc::clone(&ctx.database),
945                );
946                plugin.on_init(&mut init).await.unwrap();
947                crate::OrganizationPlugin::with_config(crate::organization::OrganizationConfig {
948                    teams: crate::organization::TeamsConfig {
949                        enabled: true,
950                        ..Default::default()
951                    },
952                    ..Default::default()
953                })
954                .on_init(&mut init)
955                .await
956                .unwrap();
957                crate::AdminPlugin::new().on_init(&mut init).await.unwrap();
958                ctx.database = init.database_with_registered_transforms();
959                let parts = init.into_parts();
960                ctx.metadata = parts.metadata;
961                ctx.extensions = parts.extensions;
962                let cookie = create_session_cookie(&session.token, &ctx.config).unwrap();
963                let mut request = AuthRequest::new(HttpMethod::Post, "/two-factor/enable");
964                _ = request
965                    .headers
966                    .insert("cookie".into(), cookie.split(';').next().unwrap().into());
967                request.body = Some(br#"{"password":"password123"}"#.to_vec());
968                let result = alibi_core::with_request_hook_context(
969                    &request,
970                    plugin.on_request(&request, &ctx),
971                )
972                .await;
973                if cancel_session && !session_forbidden {
974                    let response = result.unwrap().unwrap();
975                    assert_eq!(response.status, 500);
976                    assert_eq!(response.body.len(), 0);
977                } else {
978                    let error = result.unwrap_err();
979                    assert_eq!(error.status_code(), if cancel_session { 403 } else { 400 });
980                    if session_forbidden {
981                        assert!(matches!(error, AuthError::Forbidden(_)));
982                    }
983                }
984                let expected = if cancel_session {
985                    vec![
986                        ("user".into(), user.id.clone()),
987                        ("session".into(), user.id.clone()),
988                    ]
989                } else {
990                    vec![("user".into(), user.id.clone())]
991                };
992                assert_eq!(*observed.lock().unwrap(), expected);
993                assert_eq!(
994                    serde_json::to_value(
995                        ctx.database
996                            .get_two_factor_by_user_id(&user.id)
997                            .await
998                            .unwrap()
999                    )
1000                    .unwrap(),
1001                    serde_json::to_value(factor).unwrap()
1002                );
1003                let stored_user = ctx
1004                    .database
1005                    .get_user_by_id(&user.id)
1006                    .await
1007                    .unwrap()
1008                    .unwrap();
1009                assert_eq!(stored_user.two_factor_enabled(), cancel_session);
1010                let sessions = ctx.database.get_user_sessions(&user.id).await.unwrap();
1011                assert_eq!(sessions.len(), 1);
1012                assert_eq!(
1013                    serde_json::to_value(
1014                        (sessions)
1015                            .first()
1016                            .expect("fixture contains the requested index")
1017                    )
1018                    .unwrap(),
1019                    serde_json::to_value(&session).unwrap()
1020                );
1021                let (current_user, current_session) = ctx.require_session(&request).await.unwrap();
1022                assert_eq!(current_user.id, user.id);
1023                assert_eq!(current_session.token, session.token);
1024                db.close().await.unwrap();
1025            }
1026        }
1027    }
1028
1029    #[tokio::test]
1030    #[expect(
1031        clippy::too_many_lines,
1032        reason = "Keep this ordered integration scenario and its assertions together; Result propagates setup failures"
1033    )]
1034    async fn two_factor_password_checks_use_configured_native_hasher_and_utf16_maximum() {
1035        use alibi_core::{PasswordHasher, ScryptHasher, UpdateAccount};
1036        struct PrefixedHasher {
1037            received: std::sync::Mutex<Vec<(String, String)>>,
1038        }
1039        #[async_trait]
1040        impl PasswordHasher for PrefixedHasher {
1041            async fn hash(&self, password: &str) -> AuthResult<String> {
1042                ScryptHasher
1043                    .hash(&format!("provider-prefix:{password}"))
1044                    .await
1045            }
1046            async fn verify(&self, hash: &str, password: &str) -> AuthResult<bool> {
1047                self.received
1048                    .lock()
1049                    .unwrap()
1050                    .push((hash.into(), password.into()));
1051                ScryptHasher
1052                    .verify(hash, &format!("provider-prefix:{password}"))
1053                    .await
1054            }
1055        }
1056        let (mut ctx, user, session) =
1057            create_test_context_with_credential_user("native-provider@fixture.test", false).await;
1058        let provider = Arc::new(PrefixedHasher {
1059            received: std::sync::Mutex::new(Vec::new()),
1060        });
1061        let hash = provider.hash("password123").await.unwrap();
1062        let account = ctx
1063            .database
1064            .get_user_accounts(&user.id)
1065            .await
1066            .unwrap()
1067            .into_iter()
1068            .find(|account| account.provider_id == "credential")
1069            .unwrap();
1070        _ = ctx
1071            .database
1072            .update_account(
1073                &account.id,
1074                UpdateAccount {
1075                    password: Some(hash.clone()),
1076                    ..Default::default()
1077                },
1078            )
1079            .await
1080            .unwrap();
1081        let plugin = TwoFactorPlugin::new();
1082        let mut init =
1083            alibi_core::AuthInitContext::new(Arc::clone(&ctx.config), Arc::clone(&ctx.database));
1084        crate::EmailPasswordPlugin::new()
1085            .password_max_length(13)
1086            .password_hasher(Arc::<PrefixedHasher>::clone(&provider))
1087            .on_init(&mut init)
1088            .await
1089            .unwrap();
1090        plugin.on_init(&mut init).await.unwrap();
1091        let parts = init.into_parts();
1092        ctx.extensions = parts.extensions;
1093        ctx.metadata = parts.metadata;
1094        let cookie = create_session_cookie(&session.token, &ctx.config).unwrap();
1095        let mut request = AuthRequest::new(HttpMethod::Post, "/two-factor/enable");
1096        _ = request
1097            .headers
1098            .insert("cookie".into(), cookie.split(';').next().unwrap().into());
1099        request.body = Some(br#"{"password":"password123"}"#.to_vec());
1100        let response = plugin.on_request(&request, &ctx).await.unwrap().unwrap();
1101        assert_eq!(response.status, 200);
1102        let factor = ctx
1103            .database
1104            .get_two_factor_by_user_id(&user.id)
1105            .await
1106            .unwrap()
1107            .unwrap();
1108        request.path = "/two-factor/get-totp-uri".into();
1109        request.body = Some(br#"{"password":"wrong-password"}"#.to_vec());
1110        let wrong = plugin.on_request(&request, &ctx).await.unwrap_err();
1111        assert_eq!(
1112            wrong.error_payload().1.as_deref(),
1113            Some("PASSWORD_TOO_LONG")
1114        );
1115        // A different valid-length password must reach the configured real verifier.
1116        request.body = Some(br#"{"password":"wrong-pass"}"#.to_vec());
1117        let wrong_2 = plugin.on_request(&request, &ctx).await.unwrap_err();
1118        assert_eq!(
1119            wrong_2.error_payload().1.as_deref(),
1120            Some("INVALID_PASSWORD")
1121        );
1122        request.body =
1123            Some(serde_json::to_vec(&serde_json::json!({"password":"🍵".repeat(7)})).unwrap());
1124        let long = plugin.on_request(&request, &ctx).await.unwrap_err();
1125        assert_eq!(long.error_payload().1.as_deref(), Some("PASSWORD_TOO_LONG"));
1126        assert_eq!(
1127            *provider.received.lock().unwrap(),
1128            vec![
1129                (hash.clone(), "password123".into()),
1130                (hash, "wrong-pass".into())
1131            ]
1132        );
1133        assert_eq!(
1134            serde_json::to_value(
1135                ctx.database
1136                    .get_two_factor_by_user_id(&user.id)
1137                    .await
1138                    .unwrap()
1139                    .unwrap()
1140            )
1141            .unwrap(),
1142            serde_json::to_value(factor).unwrap()
1143        );
1144        assert!(
1145            ctx.database
1146                .get_session(&session.token)
1147                .await
1148                .unwrap()
1149                .is_some()
1150        );
1151    }
1152
1153    #[tokio::test]
1154    async fn signed_empty_factor_challenge_cannot_read_a_seeded_empty_identifier() {
1155        let (ctx, user, _) =
1156            create_test_context_with_credential_user("empty-challenge@fixture.test", true).await;
1157        let seeded = ctx
1158            .database
1159            .create_verification(CreateVerification {
1160                identifier: String::new(),
1161                value: user.id.clone(),
1162                expires_at: Utc::now() + Duration::minutes(5),
1163            })
1164            .await
1165            .unwrap();
1166        let signed = alibi_core::utils::cookie_utils::sign_cookie_value("", &ctx.config.secret);
1167        assert_eq!(
1168            alibi_core::utils::cookie_utils::verify_cookie_value(&signed, &ctx.config.secret),
1169            Some(String::new())
1170        );
1171        let mut req = AuthRequest::new(HttpMethod::Post, "/two-factor/verify-otp");
1172        _ = req.headers.insert(
1173            "cookie".into(),
1174            format!(
1175                "{}={signed}",
1176                related_cookie_name(&ctx.config, TWO_FACTOR_COOKIE_SUFFIX)
1177            ),
1178        );
1179        let error = resolve_two_factor_state(&req, &ctx)
1180            .await
1181            .err()
1182            .expect("An empty signed challenge must be rejected");
1183        assert_eq!(error.status_code(), 401);
1184        let untouched = ctx
1185            .database
1186            .get_verification_by_identifier("")
1187            .await
1188            .unwrap()
1189            .unwrap();
1190        assert_eq!(untouched.id(), seeded.id());
1191        assert_eq!(untouched.value(), seeded.value());
1192        assert_eq!(untouched.expires_at(), seeded.expires_at());
1193        assert_eq!(
1194            ctx.database
1195                .get_user_sessions(&user.id)
1196                .await
1197                .unwrap()
1198                .len(),
1199            1
1200        );
1201    }
1202
1203    #[tokio::test]
1204    async fn pending_factor_preferences_authenticate_the_first_cookie_and_preserve_issued_expiry() {
1205        let (ctx, user, _) =
1206            create_test_context_with_credential_user("preferences@fixture.test", true).await;
1207        let preference_name = related_cookie_name(&ctx.config, DONT_REMEMBER_COOKIE_SUFFIX);
1208        let empty = alibi_core::utils::cookie_utils::sign_cookie_value("", &ctx.config.secret);
1209        let signed = alibi_core::utils::cookie_utils::sign_cookie_value("true", &ctx.config.secret);
1210        let foreign = alibi_core::utils::cookie_utils::sign_cookie_value("true", "foreign-secret");
1211        for (preference, temporary) in [
1212            (empty.clone(), false),
1213            (signed.clone(), true),
1214            (foreign, false),
1215            ("true".to_owned(), false),
1216            (format!("{empty}; {preference_name}={signed}"), false),
1217            (format!("{signed}; {preference_name}={empty}"), true),
1218        ] {
1219            let challenge = begin_sign_in_challenge(&user, None, &ctx).await.unwrap();
1220            let challenge_cookie = challenge
1221                .set_cookie_headers
1222                .iter()
1223                .find(|header| {
1224                    header.starts_with(&format!(
1225                        "{}=",
1226                        related_cookie_name(&ctx.config, TWO_FACTOR_COOKIE_SUFFIX)
1227                    ))
1228                })
1229                .unwrap()
1230                .split(';')
1231                .next()
1232                .unwrap();
1233            let mut req = AuthRequest::new(HttpMethod::Post, "/two-factor/verify-otp");
1234            _ = req.headers.insert(
1235                "cookie".into(),
1236                format!("{challenge_cookie}; {preference_name}={preference}"),
1237            );
1238            let ResolvedTwoFactorState::Pending(pending) =
1239                resolve_two_factor_state(&req, &ctx).await.unwrap()
1240            else {
1241                panic!("A signed pending challenge must resolve without a session cookie");
1242            };
1243            assert_eq!(pending.dont_remember, temporary);
1244            let (completed, headers) =
1245                finalize_pending_two_factor(pending, &req, false, true, &ctx)
1246                    .await
1247                    .unwrap();
1248            let before = ctx
1249                .database
1250                .get_session(&completed.token)
1251                .await
1252                .unwrap()
1253                .unwrap();
1254            let lifetime = before.expires_at() - before.created_at();
1255            assert!(
1256                (lifetime - Duration::days(if temporary { 1 } else { 7 }))
1257                    .num_milliseconds()
1258                    .abs()
1259                    < 1000
1260            );
1261            let mut read = AuthRequest::new(HttpMethod::Get, "/get-session");
1262            let cookies = headers
1263                .iter()
1264                .filter(|header| !header.contains("Max-Age=0"))
1265                .map(|header| header.split(';').next().unwrap())
1266                .collect::<Vec<_>>()
1267                .join("; ");
1268            _ = read.headers.insert("cookie".into(), cookies);
1269            let (authenticated_user, authenticated_session) =
1270                ctx.require_session(&read).await.unwrap();
1271            assert_eq!(authenticated_user.id(), user.id);
1272            assert_eq!(authenticated_session.token, completed.token);
1273            assert_eq!(authenticated_session.expires_at, before.expires_at());
1274            let after = ctx
1275                .database
1276                .get_session(&completed.token)
1277                .await
1278                .unwrap()
1279                .unwrap();
1280            assert_eq!(after.expires_at(), before.expires_at());
1281            assert_eq!(after.updated_at(), before.updated_at());
1282        }
1283    }
1284
1285    async fn create_test_context_with_credential_user(
1286        email: &str,
1287        two_factor_enabled: bool,
1288    ) -> (AuthContext<TestSchema>, UserView, SessionView) {
1289        let mut ctx = test_helpers::create_test_context().await;
1290        let mut init =
1291            alibi_core::AuthInitContext::new(Arc::clone(&ctx.config), Arc::clone(&ctx.database));
1292        TwoFactorPlugin::new().on_init(&mut init).await.unwrap();
1293        ctx.database = init.database_with_registered_transforms();
1294        let parts = init.into_parts();
1295        ctx.metadata = parts.metadata;
1296        ctx.extensions = parts.extensions;
1297        let user = test_helpers::create_user(
1298            &ctx,
1299            CreateUser::new()
1300                .with_email(email)
1301                .with_name("Two Factor Tester"),
1302        )
1303        .await;
1304
1305        let password_hash = alibi_core::hash_password(None, "password123")
1306            .await
1307            .unwrap();
1308        _ = ctx
1309            .database
1310            .create_account(CreateAccount {
1311                additional_fields: alibi_core::field_policy::FieldValues::default(),
1312                user_id: user.id.clone(),
1313                account_id: user.id.clone(),
1314                provider_id: "credential".to_owned(),
1315                access_token: None,
1316                refresh_token: None,
1317                id_token: None,
1318                access_token_expires_at: None,
1319                refresh_token_expires_at: None,
1320                scope: None,
1321                password: Some(password_hash),
1322            })
1323            .await
1324            .unwrap();
1325
1326        let user = if two_factor_enabled {
1327            UserView::from(
1328                &ctx.database
1329                    .update_user(
1330                        &user.id,
1331                        UpdateUser {
1332                            two_factor_enabled: Some(true),
1333                            ..Default::default()
1334                        },
1335                    )
1336                    .await
1337                    .unwrap(),
1338            )
1339        } else {
1340            user
1341        };
1342
1343        let session = test_helpers::create_session(&ctx, user.id.clone(), Duration::hours(1)).await;
1344        (ctx, user, session)
1345    }
1346
1347    fn cookie_value(header: &str) -> String {
1348        Cookie::parse(header)
1349            .expect("Set-Cookie header should parse")
1350            .value()
1351            .to_owned()
1352    }
1353
1354    #[test]
1355    fn test_signed_cookie_round_trip_and_tamper_rejection() {
1356        let signed = sign_cookie_value("secret-value", "payload-value");
1357        let verified = verify_factor_cookie_value("secret-value", &signed);
1358        assert_eq!(verified.as_deref(), Some("payload-value"));
1359
1360        let tampered = signed.replacen("payload-value", "other-value", 1);
1361        let tampered_verified = verify_factor_cookie_value("secret-value", &tampered);
1362        assert!(tampered_verified.is_none());
1363    }
1364
1365    #[tokio::test]
1366    async fn test_begin_sign_in_challenge_sets_pending_cookie_and_remember_choice() {
1367        let (ctx, user, _session) =
1368            create_test_context_with_credential_user("challenge@example.com", true).await;
1369
1370        let challenge = begin_sign_in_challenge(&user, Some(false), &ctx)
1371            .await
1372            .unwrap();
1373        assert!(challenge.response.two_factor_redirect);
1374
1375        let two_factor_cookie = challenge
1376            .set_cookie_headers
1377            .iter()
1378            .find(|header| header.starts_with("better-auth.two_factor="))
1379            .cloned()
1380            .expect("challenge should set the two-factor cookie");
1381        let dont_remember_cookie = challenge
1382            .set_cookie_headers
1383            .iter()
1384            .find(|header| header.starts_with("better-auth.dont_remember="))
1385            .cloned()
1386            .expect("challenge should set the remember-choice cookie");
1387
1388        let two_factor_req = test_helpers::create_auth_request_no_query(
1389            HttpMethod::Post,
1390            "/two-factor/verify-otp",
1391            None,
1392            None,
1393        );
1394        let mut req = two_factor_req;
1395        req.headers.insert(
1396            "cookie".to_owned(),
1397            format!(
1398                "better-auth.two_factor={}; better-auth.dont_remember={}",
1399                cookie_value(&two_factor_cookie),
1400                cookie_value(&dont_remember_cookie)
1401            ),
1402        );
1403
1404        let identifier = read_signed_cookie(&req, TWO_FACTOR_COOKIE_SUFFIX, &ctx)
1405            .expect("signed cookie should verify");
1406        let verification = ctx
1407            .database
1408            .get_verification_by_identifier(&identifier)
1409            .await
1410            .unwrap()
1411            .expect("challenge should persist a pending verification");
1412        assert_eq!(verification.value(), user.id);
1413    }
1414
1415    #[tokio::test]
1416    async fn test_inspect_trusted_device_rotates_server_state() {
1417        let (ctx, user, _session) =
1418            create_test_context_with_credential_user("trusted@example.com", true).await;
1419
1420        let trust_cookie = create_trust_device_cookie_header(&user, &ctx)
1421            .await
1422            .unwrap();
1423        let mut req = test_helpers::create_auth_request_no_query(
1424            HttpMethod::Post,
1425            "/sign-in/email",
1426            None,
1427            None,
1428        );
1429        req.headers.insert(
1430            "cookie".to_owned(),
1431            format!("better-auth.trust_device={}", cookie_value(&trust_cookie)),
1432        );
1433
1434        let original_cookie = read_signed_cookie(&req, TRUST_DEVICE_COOKIE_SUFFIX, &ctx)
1435            .expect("trust cookie should verify");
1436        let original_identifier = original_cookie
1437            .split_once('!')
1438            .expect("trust cookie should include the identifier")
1439            .1
1440            .to_owned();
1441
1442        let result = inspect_trusted_device(&req, &user, &ctx).await.unwrap();
1443        assert!(result.trusted);
1444        assert_eq!(result.set_cookie_headers.len(), 1);
1445
1446        let rotated_cookie = (*(result.set_cookie_headers)
1447            .first()
1448            .expect("fixture contains the requested index"))
1449        .clone();
1450        let mut rotated_req = test_helpers::create_auth_request_no_query(
1451            HttpMethod::Post,
1452            "/sign-in/email",
1453            None,
1454            None,
1455        );
1456        rotated_req.headers.insert(
1457            "cookie".to_owned(),
1458            format!("better-auth.trust_device={}", cookie_value(&rotated_cookie)),
1459        );
1460        let rotated_value = read_signed_cookie(&rotated_req, TRUST_DEVICE_COOKIE_SUFFIX, &ctx)
1461            .expect("rotated trust cookie should verify");
1462        let rotated_identifier = rotated_value
1463            .split_once('!')
1464            .expect("rotated cookie should include the identifier")
1465            .1
1466            .to_owned();
1467
1468        assert_ne!(original_identifier, rotated_identifier);
1469        assert!(
1470            ctx.database
1471                .get_verification_by_identifier(&original_identifier)
1472                .await
1473                .unwrap()
1474                .is_none(),
1475            "the previous trust-device record should be deleted during rotation",
1476        );
1477        assert!(
1478            ctx.database
1479                .get_verification_by_identifier(&rotated_identifier)
1480                .await
1481                .unwrap()
1482                .is_some(),
1483            "the rotated trust-device record should be persisted",
1484        );
1485    }
1486
1487    #[tokio::test]
1488    #[expect(
1489        clippy::too_many_lines,
1490        reason = "Keep this ordered integration scenario and its assertions together; Result propagates setup failures"
1491    )]
1492    async fn totp_enrollment_and_real_verification_apply_user_hooks_and_preserve_rotation_owner() {
1493        let (mut ctx, user, session) =
1494            create_test_context_with_credential_user("reissue@example.com", false).await;
1495        ctx.database.delete_session(&session.token).await.unwrap();
1496        let session = ctx
1497            .database
1498            .create_session(alibi_core::CreateSession {
1499                token: None,
1500                user_id: user.id.clone(),
1501                expires_at: session.expires_at,
1502                ip_address: Some("127.0.0.7".into()),
1503                user_agent: Some("configured-agent".into()),
1504                active_organization_id: Some("configured-organization".into()),
1505                active_team_id: Some("configured-team".into()),
1506                impersonated_by: Some("configured-admin".into()),
1507                additional_fields: alibi_core::field_policy::FieldValues::default(),
1508            })
1509            .await
1510            .unwrap();
1511        let plugin = TwoFactorPlugin::new();
1512        let mut configured =
1513            alibi_core::AuthInitContext::new(Arc::clone(&ctx.config), Arc::clone(&ctx.database));
1514        plugin.on_init(&mut configured).await.unwrap();
1515        crate::OrganizationPlugin::with_config(crate::organization::OrganizationConfig {
1516            teams: crate::organization::TeamsConfig {
1517                enabled: true,
1518                ..Default::default()
1519            },
1520            ..Default::default()
1521        })
1522        .on_init(&mut configured)
1523        .await
1524        .unwrap();
1525        crate::AdminPlugin::new()
1526            .on_init(&mut configured)
1527            .await
1528            .unwrap();
1529        let configured = configured.into_parts();
1530        ctx.metadata = configured.metadata;
1531        ctx.extensions = configured.extensions;
1532        let cookie = create_session_cookie(&session.token, &ctx.config).unwrap();
1533        let mut enrollment = AuthRequest::new(HttpMethod::Post, "/two-factor/enable");
1534        _ = enrollment
1535            .headers
1536            .insert("cookie".into(), cookie.split(';').next().unwrap().into());
1537        enrollment.body = Some(br#"{"password":"password123"}"#.to_vec());
1538        let enabled = plugin.on_request(&enrollment, &ctx).await.unwrap().unwrap();
1539        assert_eq!(enabled.status, 200);
1540        let factor = ctx
1541            .database
1542            .get_two_factor_by_user_id(&user.id)
1543            .await
1544            .unwrap()
1545            .unwrap();
1546        assert_eq!(factor.verified, Some(false));
1547
1548        let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
1549        let observed = Arc::clone(&captured);
1550        let mut init =
1551            alibi_core::AuthInitContext::new(Arc::clone(&ctx.config), Arc::clone(&ctx.database));
1552        // Keep the initialized plugin policy when adding the observer. The
1553        // wrapped adapter projects the old snapshot using this same context.
1554        init.metadata = ctx.metadata.clone();
1555        init.extensions = ctx.extensions.clone();
1556        init.register_user_update_transform(move |id, mut update| {
1557            observed
1558                .lock()
1559                .unwrap()
1560                .push((id.to_owned(), update.two_factor_enabled));
1561            if update.two_factor_enabled == Some(true) {
1562                update.name = Some("Hook Updated Owner".into());
1563            }
1564            Ok(update)
1565        });
1566        ctx.database = init.database_with_registered_transforms();
1567        let plaintext = decrypt_value(&ctx.config, &factor.secret).unwrap();
1568        let code = plugin.generate_totp(&plaintext).unwrap();
1569        let mut verification = AuthRequest::new(HttpMethod::Post, "/two-factor/verify-totp");
1570        verification.headers = enrollment.headers;
1571        verification.body = Some(serde_json::to_vec(&serde_json::json!({"code":code})).unwrap());
1572        let response = plugin
1573            .on_request(&verification, &ctx)
1574            .await
1575            .unwrap()
1576            .unwrap();
1577        assert_eq!(response.status, 200);
1578        let payload: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
1579        assert_eq!(
1580            (*(*(payload).get("user").unwrap_or(&serde_json::Value::Null))
1581                .get("twoFactorEnabled")
1582                .unwrap_or(&serde_json::Value::Null)),
1583            false
1584        );
1585        // Upstream returns the old snapshot while rotating the browser cookie.
1586        assert_eq!(
1587            (*(payload).get("token").unwrap_or(&serde_json::Value::Null)),
1588            session.token
1589        );
1590        let set_cookie_headers = response.headers.get_all("Set-Cookie").collect::<Vec<_>>();
1591        let rotated = alibi_core::utils::cookie_utils::verify_cookie_value(
1592            &cookie_value(
1593                (set_cookie_headers)
1594                    .first()
1595                    .expect("fixture contains the requested index"),
1596            ),
1597            &ctx.config.secret,
1598        )
1599        .expect("the session cookie must authenticate its token");
1600        assert_ne!(rotated, session.token);
1601        assert_eq!(set_cookie_headers.len(), 1);
1602        assert!(
1603            ctx.database
1604                .get_session(&session.token)
1605                .await
1606                .unwrap()
1607                .is_none(),
1608            "the original session should be deleted after re-issuing",
1609        );
1610        assert!(
1611            ctx.database.get_session(&rotated).await.unwrap().is_some(),
1612            "the new session token should be persisted",
1613        );
1614        assert_eq!(
1615            *captured.lock().unwrap(),
1616            vec![(user.id.clone(), Some(true))]
1617        );
1618        let stored_user = ctx
1619            .database
1620            .get_user_by_id(&user.id)
1621            .await
1622            .unwrap()
1623            .unwrap();
1624        assert_eq!(stored_user.two_factor_enabled, Some(true));
1625        assert_eq!(stored_user.name(), Some("Hook Updated Owner"));
1626        let verified_factor = ctx
1627            .database
1628            .get_two_factor_by_user_id(&user.id)
1629            .await
1630            .unwrap()
1631            .unwrap();
1632        assert_eq!(verified_factor.id, factor.id);
1633        assert_eq!(verified_factor.secret, factor.secret);
1634        assert_eq!(verified_factor.backup_codes, factor.backup_codes);
1635        assert_eq!(verified_factor.verified, Some(true));
1636        let mut browser = AuthRequest::new(HttpMethod::Get, "/get-session");
1637        _ = browser.headers.insert(
1638            "cookie".into(),
1639            (*(set_cookie_headers)
1640                .first()
1641                .expect("fixture contains the requested index"))
1642            .split(';')
1643            .next()
1644            .unwrap()
1645            .into(),
1646        );
1647        let (current_user, current_session) = ctx.require_session(&browser).await.unwrap();
1648        assert_eq!(current_user.id, user.id);
1649        assert_eq!(current_user.name(), Some("Hook Updated Owner"));
1650        assert_eq!(current_session.token, rotated);
1651        assert_eq!(current_session.ip_address, session.ip_address);
1652        assert_eq!(current_session.user_agent, session.user_agent);
1653        assert_eq!(
1654            current_session.active_organization_id,
1655            session.active_organization_id
1656        );
1657        assert_eq!(current_session.active_team_id, session.active_team_id);
1658        assert_eq!(current_session.impersonated_by, session.impersonated_by);
1659    }
1660
1661    #[tokio::test]
1662    async fn test_view_backup_codes_returns_decrypted_codes() {
1663        let plugin = TwoFactorPlugin::new();
1664        let (ctx, user, _session) =
1665            create_test_context_with_credential_user("view-codes@example.com", true).await;
1666
1667        let expected_codes = vec!["ABCDE-12345".to_owned(), "FGHIJ-67890".to_owned()];
1668        let encrypted = encrypt_value(
1669            &ctx.config,
1670            &serde_json::to_string(&expected_codes).unwrap(),
1671        )
1672        .unwrap();
1673        _ = ctx
1674            .database
1675            .create_two_factor(CreateTwoFactor {
1676                user_id: user.id.clone(),
1677                secret: encrypt_value(&ctx.config, "totp-secret").unwrap(),
1678                backup_codes: encrypted,
1679                ..Default::default()
1680            })
1681            .await
1682            .unwrap();
1683
1684        let backup_codes = plugin.view_backup_codes(&user.id, &ctx).await.unwrap();
1685        assert_eq!(backup_codes, serde_json::json!(expected_codes));
1686    }
1687
1688    #[tokio::test]
1689    async fn test_view_backup_codes_rejects_invalid_stored_json() {
1690        let plugin = TwoFactorPlugin::new();
1691        let (ctx, user, _session) =
1692            create_test_context_with_credential_user("invalid-view-codes@example.com", true).await;
1693
1694        _ = ctx
1695            .database
1696            .create_two_factor(CreateTwoFactor {
1697                user_id: user.id.clone(),
1698                secret: encrypt_value(&ctx.config, "totp-secret").unwrap(),
1699                backup_codes: encrypt_value(&ctx.config, "[").unwrap(),
1700                ..Default::default()
1701            })
1702            .await
1703            .unwrap();
1704
1705        let err = plugin.view_backup_codes(&user.id, &ctx).await.unwrap_err();
1706        assert_eq!(err.to_string(), "Invalid backup code");
1707    }
1708
1709    #[test]
1710    fn test_routes_do_not_expose_view_backup_codes() {
1711        let plugin = TwoFactorPlugin::new();
1712        assert!(
1713            <TwoFactorPlugin as AuthPlugin<TestSchema>>::routes(&plugin)
1714                .iter()
1715                .all(|route| route.path != "/two-factor/view-backup-codes"),
1716            "view-backup-codes must stay server-only",
1717        );
1718    }
1719
1720    #[tokio::test]
1721    #[expect(
1722        clippy::too_many_lines,
1723        reason = "Keep this ordered integration scenario and its assertions together; Result propagates setup failures"
1724    )]
1725    async fn disable_preserves_persisted_extensions_and_removes_all_matching_trust_records() {
1726        let (mut ctx, user, first_session) =
1727            create_test_context_with_credential_user("disable-extensions@fixture.test", true).await;
1728        let mut init =
1729            alibi_core::AuthInitContext::new(Arc::clone(&ctx.config), Arc::clone(&ctx.database));
1730        crate::admin::AdminPlugin::new()
1731            .on_init(&mut init)
1732            .await
1733            .unwrap();
1734        crate::organization::OrganizationPlugin::with_config(
1735            crate::organization::OrganizationConfig {
1736                teams: crate::organization::TeamsConfig {
1737                    enabled: true,
1738                    ..Default::default()
1739                },
1740                ..Default::default()
1741            },
1742        )
1743        .on_init(&mut init)
1744        .await
1745        .unwrap();
1746        ctx.metadata.extend(init.into_parts().metadata);
1747        ctx.database
1748            .delete_session(&first_session.token)
1749            .await
1750            .unwrap();
1751        let current = ctx
1752            .database
1753            .create_session(alibi_core::CreateSession {
1754                token: None,
1755                user_id: user.id.clone(),
1756                expires_at: Utc::now() + Duration::hours(1),
1757                ip_address: Some("192.0.2.45".to_owned()),
1758                user_agent: Some("fixture-agent".to_owned()),
1759                impersonated_by: Some("trusted-impersonator".to_owned()),
1760                active_organization_id: Some("trusted-organization".to_owned()),
1761                active_team_id: Some("trusted-team".to_owned()),
1762                additional_fields: alibi_core::field_policy::FieldValues::default(),
1763            })
1764            .await
1765            .unwrap();
1766        ctx.database
1767            .create_two_factor(CreateTwoFactor {
1768                user_id: user.id.clone(),
1769                secret: "stored-secret".to_owned(),
1770                backup_codes: "stored-codes".to_owned(),
1771                ..Default::default()
1772            })
1773            .await
1774            .unwrap();
1775        for _ in 0..2 {
1776            ctx.database
1777                .create_verification(CreateVerification {
1778                    identifier: "trusted-device-record".to_owned(),
1779                    value: user.id.clone(),
1780                    expires_at: Utc::now() + Duration::days(30),
1781                })
1782                .await
1783                .unwrap();
1784        }
1785        let session_cookie =
1786            alibi_core::utils::cookie_utils::sign_cookie_value(current.token(), &ctx.config.secret);
1787        let trust_cookie = alibi_core::utils::cookie_utils::sign_cookie_value(
1788            "trust-token!trusted-device-record",
1789            &ctx.config.secret,
1790        );
1791        let mut req = AuthRequest::new(HttpMethod::Post, "/two-factor/disable");
1792        req.body =
1793            Some(serde_json::to_vec(&serde_json::json!({"password":"password123"})).unwrap());
1794        _ = req.headers.insert(
1795            "cookie".to_owned(),
1796            format!(
1797                "{}={session_cookie}; {}={trust_cookie}",
1798                ctx.config.session.cookie_name,
1799                related_cookie_name(&ctx.config, TRUST_DEVICE_COOKIE_SUFFIX)
1800            ),
1801        );
1802        let response = TwoFactorPlugin::new()
1803            .on_request(&req, &ctx)
1804            .await
1805            .unwrap()
1806            .unwrap();
1807        assert_eq!(response.status, 200);
1808        let stored = ctx.database.get_user_sessions(&user.id).await.unwrap();
1809        assert_eq!(stored.len(), 1);
1810        let replacement = (stored)
1811            .first()
1812            .expect("fixture contains the requested index");
1813        assert_ne!(replacement.token(), current.token());
1814        assert_eq!(
1815            replacement.active_organization_id(),
1816            Some("trusted-organization")
1817        );
1818        assert_eq!(replacement.active_team_id(), Some("trusted-team"));
1819        assert_eq!(replacement.impersonated_by(), Some("trusted-impersonator"));
1820        assert_eq!(replacement.ip_address(), current.ip_address());
1821        assert_eq!(replacement.user_agent(), current.user_agent());
1822        assert!(
1823            ctx.database
1824                .get_session(current.token())
1825                .await
1826                .unwrap()
1827                .is_none()
1828        );
1829        assert!(
1830            !ctx.database
1831                .get_user_by_id(&user.id)
1832                .await
1833                .unwrap()
1834                .unwrap()
1835                .two_factor_enabled()
1836        );
1837        assert!(
1838            ctx.database
1839                .get_two_factor_by_user_id(&user.id)
1840                .await
1841                .unwrap()
1842                .is_none()
1843        );
1844        assert!(
1845            ctx.database
1846                .get_verification_by_identifier("trusted-device-record")
1847                .await
1848                .unwrap()
1849                .is_none()
1850        );
1851    }
1852
1853    #[tokio::test]
1854    #[expect(
1855        clippy::too_many_lines,
1856        reason = "Keep this ordered integration scenario and its assertions together; Result propagates setup failures"
1857    )]
1858    async fn otp_async_codec_failures_consume_only_their_stage_and_delivery_rejection_retains_rotation_state()
1859     {
1860        struct Callback {
1861            fail_store: bool,
1862            fail_compare: bool,
1863            observations: Arc<std::sync::Mutex<Vec<String>>>,
1864            delivered: Arc<std::sync::Mutex<Option<String>>>,
1865        }
1866        #[async_trait]
1867        impl TwoFactorOtpHasher for Callback {
1868            async fn hash(&self, otp: &str) -> AuthResult<String> {
1869                let mut observations = self.observations.lock().unwrap();
1870                observations.push(format!("hash:{otp}"));
1871                if self.fail_store || (self.fail_compare && observations.len() > 2) {
1872                    return Err(AuthError::Upstream {
1873                        status: 400,
1874                        code: "CODEC_REJECTED",
1875                        message: "Configured codec rejected",
1876                    });
1877                }
1878                drop(observations);
1879
1880                Ok(format!("stored-{otp}"))
1881            }
1882        }
1883        #[async_trait]
1884        impl SendTwoFactorOtp for Callback {
1885            async fn send(&self, _user: &UserView, otp: &str) -> AuthResult<()> {
1886                self.observations
1887                    .lock()
1888                    .unwrap()
1889                    .push(format!("send:{otp}"));
1890                *self.delivered.lock().unwrap() = Some(otp.to_owned());
1891                Err(AuthError::bad_request("Configured async delivery rejected"))
1892            }
1893        }
1894        for (fail_store, fail_compare) in [(true, false), (false, true), (false, false)] {
1895            let (mut ctx, user, original) =
1896                create_test_context_with_credential_user("codec@fixture.test", false).await;
1897            ctx.database.delete_session(&original.token).await.unwrap();
1898            let session = ctx
1899                .database
1900                .create_session(alibi_core::CreateSession {
1901                    additional_fields: alibi_core::field_policy::FieldValues::default(),
1902                    token: None,
1903                    user_id: user.id.clone(),
1904                    expires_at: original.expires_at,
1905                    ip_address: Some("127.0.0.8".into()),
1906                    user_agent: Some("otp-agent".into()),
1907                    active_organization_id: Some("otp-org".into()),
1908                    active_team_id: Some("otp-team".into()),
1909                    impersonated_by: Some("otp-admin".into()),
1910                })
1911                .await
1912                .unwrap();
1913            let observations = Arc::new(std::sync::Mutex::new(Vec::new()));
1914            let delivered = Arc::new(std::sync::Mutex::new(None));
1915            let callback = Arc::new(Callback {
1916                fail_store,
1917                fail_compare,
1918                observations: Arc::clone(&observations),
1919                delivered: Arc::clone(&delivered),
1920            });
1921            let plugin = TwoFactorPlugin::with_config(TwoFactorConfig {
1922                send_otp: Some(Arc::<Callback>::clone(&callback)),
1923                otp_storage: TwoFactorOtpStorage::CustomHash(callback),
1924                ..Default::default()
1925            });
1926            let mut init = alibi_core::AuthInitContext::new(
1927                Arc::clone(&ctx.config),
1928                Arc::clone(&ctx.database),
1929            );
1930            plugin.on_init(&mut init).await.unwrap();
1931            crate::OrganizationPlugin::with_config(crate::organization::OrganizationConfig {
1932                teams: crate::organization::TeamsConfig {
1933                    enabled: true,
1934                    ..Default::default()
1935                },
1936                ..Default::default()
1937            })
1938            .on_init(&mut init)
1939            .await
1940            .unwrap();
1941            crate::AdminPlugin::new().on_init(&mut init).await.unwrap();
1942
1943            init.register_user_update_transform(|_, mut update| {
1944                if update.two_factor_enabled == Some(true) {
1945                    update.name = Some("OTP Hook Owner".into());
1946                }
1947                Ok(update)
1948            });
1949            ctx.database = init.database_with_registered_transforms();
1950            let parts = init.into_parts();
1951            ctx.metadata = parts.metadata;
1952            ctx.extensions = parts.extensions;
1953
1954            let identifier = format!("2fa-otp-{}!{}", user.id, session.id);
1955            let mut request = AuthRequest::new(HttpMethod::Post, "/two-factor/send-otp");
1956            request.headers.insert(
1957                "cookie".into(),
1958                create_session_cookie(&session.token, &ctx.config)
1959                    .unwrap()
1960                    .split(';')
1961                    .next()
1962                    .unwrap()
1963                    .into(),
1964            );
1965            request.body = Some(b"{}".to_vec());
1966            let result = plugin.on_request(&request, &ctx).await;
1967            if fail_store {
1968                assert!(matches!(
1969                    result,
1970                    Err(AuthError::Upstream {
1971                        code: "CODEC_REJECTED",
1972                        ..
1973                    })
1974                ));
1975                assert!(delivered.lock().unwrap().is_none());
1976                assert!(
1977                    ctx.database
1978                        .get_latest_verification_by_identifier(&identifier)
1979                        .await
1980                        .unwrap()
1981                        .is_none()
1982                );
1983            } else {
1984                assert_eq!(
1985                    result.unwrap().unwrap().status,
1986                    200,
1987                    "async delivery failure must retain issued state"
1988                );
1989                let otp = delivered
1990                    .lock()
1991                    .unwrap()
1992                    .clone()
1993                    .expect("actual callback delivery");
1994                let stored = ctx
1995                    .database
1996                    .get_latest_verification_by_identifier(&identifier)
1997                    .await
1998                    .unwrap()
1999                    .unwrap();
2000                assert_eq!(stored.value, format!("stored-{otp}:0"));
2001                request.path = "/two-factor/verify-otp".into();
2002                request.body = Some(serde_json::to_vec(&serde_json::json!({"code":otp})).unwrap());
2003                let result_2 = plugin.on_request(&request, &ctx).await;
2004                assert!(
2005                    ctx.database
2006                        .get_latest_verification_by_identifier(&identifier)
2007                        .await
2008                        .unwrap()
2009                        .is_none()
2010                );
2011                if fail_compare {
2012                    assert!(matches!(
2013                        result_2,
2014                        Err(AuthError::Upstream {
2015                            code: "CODEC_REJECTED",
2016                            ..
2017                        })
2018                    ));
2019                    assert!(
2020                        !ctx.database
2021                            .get_user_by_id(&user.id)
2022                            .await
2023                            .unwrap()
2024                            .unwrap()
2025                            .two_factor_enabled()
2026                    );
2027                    assert!(
2028                        ctx.database
2029                            .get_session(&session.token)
2030                            .await
2031                            .unwrap()
2032                            .is_some()
2033                    );
2034                } else {
2035                    let response = result_2.unwrap().unwrap();
2036                    assert_eq!(response.status, 200);
2037                    let payload: serde_json::Value =
2038                        serde_json::from_slice(&response.body).unwrap();
2039                    assert_eq!(
2040                        (*(*(payload).get("user").unwrap_or(&serde_json::Value::Null))
2041                            .get("name")
2042                            .unwrap_or(&serde_json::Value::Null)),
2043                        "OTP Hook Owner"
2044                    );
2045                    assert_eq!(
2046                        (*(*(payload).get("user").unwrap_or(&serde_json::Value::Null))
2047                            .get("twoFactorEnabled")
2048                            .unwrap_or(&serde_json::Value::Null)),
2049                        true
2050                    );
2051                    let token = (*(payload).get("token").unwrap_or(&serde_json::Value::Null))
2052                        .as_str()
2053                        .unwrap();
2054                    assert_ne!(token, session.token);
2055                    let new = ctx.database.get_session(token).await.unwrap().unwrap();
2056                    assert_eq!(new.user_id, session.user_id);
2057                    assert_eq!(new.ip_address, session.ip_address);
2058                    assert_eq!(new.user_agent, session.user_agent);
2059                    assert_eq!(new.active_organization_id, session.active_organization_id);
2060                    assert_eq!(new.active_team_id, session.active_team_id);
2061                    assert_eq!(new.impersonated_by, session.impersonated_by);
2062                    assert!(
2063                        ctx.database
2064                            .get_session(&session.token)
2065                            .await
2066                            .unwrap()
2067                            .is_none()
2068                    );
2069                    let header = response.headers.get_all("Set-Cookie").next().unwrap();
2070                    assert_eq!(
2071                        alibi_core::utils::cookie_utils::verify_cookie_value(
2072                            &cookie_value(header),
2073                            &ctx.config.secret
2074                        )
2075                        .as_deref(),
2076                        Some(token)
2077                    );
2078                }
2079                assert_eq!(
2080                    observations.lock().unwrap().as_slice(),
2081                    &[
2082                        format!("hash:{otp}"),
2083                        format!("send:{otp}"),
2084                        format!("hash:{otp}")
2085                    ]
2086                );
2087            }
2088            assert!(
2089                ctx.database
2090                    .get_two_factor_by_user_id(&user.id)
2091                    .await
2092                    .unwrap()
2093                    .is_none()
2094            );
2095        }
2096    }
2097
2098    #[tokio::test]
2099    async fn otp_enable_without_delivery_checks_password_then_rejects_without_mutating_the_owner() {
2100        let (ctx, user, session) =
2101            create_test_context_with_credential_user("disabled-otp@fixture.test", false).await;
2102        let plugin = TwoFactorPlugin::new();
2103        let mut request = AuthRequest::new(HttpMethod::Post, "/two-factor/enable");
2104        request.headers.insert(
2105            "cookie".into(),
2106            create_session_cookie(&session.token, &ctx.config)
2107                .unwrap()
2108                .split(';')
2109                .next()
2110                .unwrap()
2111                .into(),
2112        );
2113        request.body = Some(br#"{"password":"wrong","method":"otp"}"#.to_vec());
2114        let wrong = plugin.on_request(&request, &ctx).await.unwrap_err();
2115        assert_eq!(
2116            serde_json::from_slice::<serde_json::Value>(&wrong.to_auth_response().body).unwrap(),
2117            serde_json::json!({"message":"Invalid password","code":"INVALID_PASSWORD"})
2118        );
2119        request.body = Some(br#"{"password":"password123","method":"otp"}"#.to_vec());
2120        assert!(matches!(
2121            plugin.on_request(&request, &ctx).await,
2122            Err(AuthError::Upstream {
2123                status: 400,
2124                code: "OTP_NOT_CONFIGURED",
2125                message: "OTP is not available"
2126            })
2127        ));
2128        assert!(
2129            !ctx.database
2130                .get_user_by_id(&user.id)
2131                .await
2132                .unwrap()
2133                .unwrap()
2134                .two_factor_enabled()
2135        );
2136        assert!(
2137            ctx.database
2138                .get_two_factor_by_user_id(&user.id)
2139                .await
2140                .unwrap()
2141                .is_none()
2142        );
2143        let sessions = ctx.database.get_user_sessions(&user.id).await.unwrap();
2144        assert_eq!(sessions.len(), 1);
2145        assert_eq!(
2146            (sessions)
2147                .first()
2148                .expect("fixture contains the requested index")
2149                .token,
2150            session.token
2151        );
2152    }
2153
2154    #[tokio::test]
2155    #[expect(
2156        clippy::too_many_lines,
2157        reason = "Keep this ordered integration scenario and its assertions together; Result propagates setup failures"
2158    )]
2159    async fn configured_backup_callback_errors_preserve_factor_user_and_current_session() {
2160        // This guards Rust callback error identity, which the cross-runtime happy-path
2161        // cipher fixture cannot exercise. Pinned runtime independently preserves both
2162        // 400 and 403 callback errors with this cancellation-like message.
2163        type Observed = Arc<std::sync::Mutex<Vec<(String, String)>>>;
2164        fn denied(status: u16) -> AuthError {
2165            AuthError::Upstream {
2166                status,
2167                code: "BACKUP_CALLBACK_DENIED",
2168                message: "session creation cancelled by database hook",
2169            }
2170        }
2171        struct RejectingCipher {
2172            status: u16,
2173            observed: Observed,
2174        }
2175        #[async_trait]
2176        impl TwoFactorBackupCipher for RejectingCipher {
2177            async fn encrypt(&self, json: &str) -> AuthResult<String> {
2178                self.observed
2179                    .lock()
2180                    .unwrap()
2181                    .push(("encrypt".into(), json.into()));
2182                Err(denied(self.status))
2183            }
2184            async fn decrypt(&self, _stored: &str) -> AuthResult<String> {
2185                panic!("enrollment and regeneration must not decode an existing factor")
2186            }
2187        }
2188        for phase in ["generate", "encrypt"] {
2189            for status in [400, 403] {
2190                let (ctx, user, session) =
2191                    create_test_context_with_credential_user("backup-callback@fixture.test", false)
2192                        .await;
2193                let observed: Observed = Arc::default();
2194                let generator_observed = Arc::clone(&observed);
2195                let plugin = TwoFactorPlugin::with_config(TwoFactorConfig {
2196                    skip_verification_on_enable: true,
2197                    custom_backup_codes_generate: Some(Arc::new(move || {
2198                        generator_observed
2199                            .lock()
2200                            .unwrap()
2201                            .push(("generate".into(), String::new()));
2202                        if phase == "generate" {
2203                            Err(denied(status))
2204                        } else {
2205                            Ok(vec!["callback-code".into(), "callback-code".into()])
2206                        }
2207                    })),
2208                    backup_storage: TwoFactorBackupStorage::CustomCipher(Arc::new(
2209                        RejectingCipher {
2210                            status,
2211                            observed: Arc::clone(&observed),
2212                        },
2213                    )),
2214                    ..Default::default()
2215                });
2216                let cookie = create_session_cookie(&session.token, &ctx.config).unwrap();
2217                let mut request = AuthRequest::new(HttpMethod::Post, "/two-factor/enable");
2218                _ = request
2219                    .headers
2220                    .insert("cookie".into(), cookie.split(';').next().unwrap().into());
2221                request.body = Some(br#"{"password":"password123"}"#.to_vec());
2222                // Resolve the short-lived native fixture session before observing
2223                // callback effects; this performs the ordinary expiry refresh.
2224                _ = ctx.require_session(&request).await.unwrap();
2225                let before_sessions =
2226                    serde_json::to_value(ctx.database.get_user_sessions(&user.id).await.unwrap())
2227                        .unwrap();
2228                let error = plugin.on_request(&request, &ctx).await.unwrap_err();
2229                assert!(
2230                    matches!(error, AuthError::Upstream { status: actual, code: "BACKUP_CALLBACK_DENIED", .. } if actual == status)
2231                );
2232                assert!(
2233                    ctx.database
2234                        .get_two_factor_by_user_id(&user.id)
2235                        .await
2236                        .unwrap()
2237                        .is_none()
2238                );
2239                assert!(
2240                    !ctx.database
2241                        .get_user_by_id(&user.id)
2242                        .await
2243                        .unwrap()
2244                        .unwrap()
2245                        .two_factor_enabled()
2246                );
2247                assert_eq!(
2248                    serde_json::to_value(ctx.database.get_user_sessions(&user.id).await.unwrap())
2249                        .unwrap(),
2250                    before_sessions
2251                );
2252                let expected = if phase == "generate" {
2253                    vec![("generate".into(), String::new())]
2254                } else {
2255                    vec![
2256                        ("generate".into(), String::new()),
2257                        (
2258                            "encrypt".into(),
2259                            "[\"callback-code\",\"callback-code\"]".into(),
2260                        ),
2261                    ]
2262                };
2263                assert_eq!(*observed.lock().unwrap(), expected);
2264
2265                _ = ctx
2266                    .database
2267                    .update_user(
2268                        &user.id,
2269                        UpdateUser {
2270                            two_factor_enabled: Some(true),
2271                            ..Default::default()
2272                        },
2273                    )
2274                    .await
2275                    .unwrap();
2276                let factor = ctx
2277                    .database
2278                    .create_two_factor(CreateTwoFactor {
2279                        user_id: user.id.clone(),
2280                        secret: "installed-factor-secret".into(),
2281                        backup_codes: "installed-factor-codes".into(),
2282                        verified: Some(true),
2283                        failed_verification_count: Some(2.5),
2284                        locked_until: Some(Utc::now() + Duration::minutes(1)),
2285                    })
2286                    .await
2287                    .unwrap();
2288                let before_user =
2289                    serde_json::to_value(ctx.database.get_user_by_id(&user.id).await.unwrap())
2290                        .unwrap();
2291                observed.lock().unwrap().clear();
2292                let mut regeneration =
2293                    AuthRequest::new(HttpMethod::Post, "/two-factor/generate-backup-codes");
2294                regeneration.headers.clone_from(&request.headers);
2295                regeneration.body.clone_from(&request.body);
2296                let error_2 = plugin.on_request(&regeneration, &ctx).await.unwrap_err();
2297                assert!(
2298                    matches!(error_2, AuthError::Upstream { status: actual, code: "BACKUP_CALLBACK_DENIED", .. } if actual == status)
2299                );
2300                assert_eq!(*observed.lock().unwrap(), expected);
2301                assert_eq!(
2302                    serde_json::to_value(
2303                        ctx.database
2304                            .get_two_factor_by_user_id(&user.id)
2305                            .await
2306                            .unwrap()
2307                    )
2308                    .unwrap(),
2309                    serde_json::json!(factor)
2310                );
2311                assert_eq!(
2312                    serde_json::to_value(ctx.database.get_user_by_id(&user.id).await.unwrap())
2313                        .unwrap(),
2314                    before_user
2315                );
2316                assert_eq!(
2317                    serde_json::to_value(ctx.database.get_user_sessions(&user.id).await.unwrap())
2318                        .unwrap(),
2319                    before_sessions
2320                );
2321                assert_eq!(
2322                    ctx.require_session(&regeneration).await.unwrap().1.token,
2323                    session.token
2324                );
2325            }
2326        }
2327    }
2328
2329    #[tokio::test]
2330    #[expect(
2331        clippy::too_many_lines,
2332        reason = "Keep this ordered integration scenario and its assertions together; Result propagates setup failures"
2333    )]
2334    async fn pending_backup_cipher_errors_restore_only_decode_stage_attempts() {
2335        type Observed = Arc<std::sync::Mutex<Vec<(String, String)>>>;
2336        struct Cipher {
2337            phase: &'static str,
2338            rejected: Arc<std::sync::atomic::AtomicBool>,
2339            observed: Observed,
2340        }
2341        #[async_trait]
2342        impl TwoFactorBackupCipher for Cipher {
2343            async fn encrypt(&self, json: &str) -> AuthResult<String> {
2344                self.observed
2345                    .lock()
2346                    .unwrap()
2347                    .push(("encrypt".into(), json.into()));
2348                if self.phase == "encrypt"
2349                    && self.rejected.load(std::sync::atomic::Ordering::SeqCst)
2350                {
2351                    return Err(AuthError::Upstream {
2352                        status: 400,
2353                        code: "BACKUP_CALLBACK_DENIED",
2354                        message: "Callback denied",
2355                    });
2356                }
2357                Ok(format!("backup-{json}"))
2358            }
2359            async fn decrypt(&self, stored: &str) -> AuthResult<String> {
2360                self.observed
2361                    .lock()
2362                    .unwrap()
2363                    .push(("decrypt".into(), stored.into()));
2364                if self.phase == "decrypt"
2365                    && self.rejected.load(std::sync::atomic::Ordering::SeqCst)
2366                {
2367                    return Err(AuthError::Upstream {
2368                        status: 400,
2369                        code: "BACKUP_CALLBACK_DENIED",
2370                        message: "Callback denied",
2371                    });
2372                }
2373                Ok(stored.strip_prefix("backup-").unwrap().into())
2374            }
2375        }
2376        for phase in ["decrypt", "encrypt"] {
2377            let (ctx, user, session) = create_test_context_with_credential_user(
2378                "backup-pending-callback@fixture.test",
2379                true,
2380            )
2381            .await;
2382            ctx.database.delete_session(&session.token).await.unwrap();
2383            let factor = ctx
2384                .database
2385                .create_two_factor(CreateTwoFactor {
2386                    user_id: user.id.clone(),
2387                    secret: "installed-factor-secret".into(),
2388                    backup_codes: "backup-[\"same\",\"same\",\"remaining\"]".into(),
2389                    verified: Some(true),
2390                    failed_verification_count: Some(2.5),
2391                    locked_until: None,
2392                })
2393                .await
2394                .unwrap();
2395            let challenge = begin_sign_in_challenge(&user, None, &ctx).await.unwrap();
2396            let cookie_name = related_cookie_name(&ctx.config, TWO_FACTOR_COOKIE_SUFFIX);
2397            let cookie = challenge
2398                .set_cookie_headers
2399                .iter()
2400                .find(|header| header.starts_with(&format!("{cookie_name}=")))
2401                .unwrap();
2402            let mut request = AuthRequest::new(HttpMethod::Post, "/two-factor/verify-backup-code");
2403            request
2404                .headers
2405                .insert("cookie".into(), cookie.split(';').next().unwrap().into());
2406            request.body =
2407                Some(br#"{"code":"same","disableSession":true,"trustDevice":true}"#.to_vec());
2408            let key = read_signed_cookie(&request, TWO_FACTOR_COOKIE_SUFFIX, &ctx).unwrap();
2409            let attempt_id = format!("2fa-attempts-{key}");
2410            let before_challenge = serde_json::to_value(
2411                ctx.database
2412                    .get_verification_by_identifier(&key)
2413                    .await
2414                    .unwrap(),
2415            )
2416            .unwrap();
2417            let before_user =
2418                serde_json::to_value(ctx.database.get_user_by_id(&user.id).await.unwrap()).unwrap();
2419            let rejected = Arc::new(std::sync::atomic::AtomicBool::new(true));
2420            let observed: Observed = Arc::default();
2421            let plugin = TwoFactorPlugin::with_config(TwoFactorConfig {
2422                backup_storage: TwoFactorBackupStorage::CustomCipher(Arc::new(Cipher {
2423                    phase,
2424                    rejected: Arc::clone(&rejected),
2425                    observed: Arc::clone(&observed),
2426                })),
2427                ..Default::default()
2428            });
2429            let error = plugin.on_request(&request, &ctx).await.unwrap_err();
2430            assert!(matches!(
2431                error,
2432                AuthError::Upstream {
2433                    status: 400,
2434                    code: "BACKUP_CALLBACK_DENIED",
2435                    ..
2436                }
2437            ));
2438            assert_eq!(
2439                serde_json::to_value(
2440                    ctx.database
2441                        .get_two_factor_by_user_id(&user.id)
2442                        .await
2443                        .unwrap()
2444                )
2445                .unwrap(),
2446                serde_json::json!(factor)
2447            );
2448            assert_eq!(
2449                serde_json::to_value(
2450                    ctx.database
2451                        .get_verification_by_identifier(&key)
2452                        .await
2453                        .unwrap()
2454                )
2455                .unwrap(),
2456                before_challenge
2457            );
2458            assert_eq!(
2459                serde_json::to_value(ctx.database.get_user_by_id(&user.id).await.unwrap()).unwrap(),
2460                before_user
2461            );
2462            assert_eq!(
2463                ctx.database
2464                    .get_user_sessions(&user.id)
2465                    .await
2466                    .unwrap()
2467                    .len(),
2468                0
2469            );
2470            let attempts = ctx
2471                .database
2472                .get_verification_by_identifier(&attempt_id)
2473                .await
2474                .unwrap();
2475            if phase == "decrypt" {
2476                assert_eq!(attempts.unwrap().value(), "0");
2477                assert_eq!(
2478                    *observed.lock().unwrap(),
2479                    vec![("decrypt".into(), factor.backup_codes.clone())]
2480                );
2481            } else {
2482                assert!(attempts.is_none());
2483                assert_eq!(
2484                    *observed.lock().unwrap(),
2485                    vec![
2486                        ("decrypt".into(), factor.backup_codes.clone()),
2487                        ("encrypt".into(), "[\"remaining\"]".into())
2488                    ]
2489                );
2490            }
2491            rejected.store(false, std::sync::atomic::Ordering::SeqCst);
2492            let retry = plugin.on_request(&request, &ctx).await;
2493            if phase == "decrypt" {
2494                let response = retry.unwrap().unwrap();
2495                assert_eq!(response.status, 200);
2496                let body: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
2497                assert!(body.get("token").is_none());
2498                assert_eq!(
2499                    (*(*(body).get("user").unwrap_or(&serde_json::Value::Null))
2500                        .get("id")
2501                        .unwrap_or(&serde_json::Value::Null)),
2502                    user.id
2503                );
2504                assert!(response.headers.get("set-cookie").is_none());
2505                let updated = ctx
2506                    .database
2507                    .get_two_factor_by_user_id(&user.id)
2508                    .await
2509                    .unwrap()
2510                    .unwrap();
2511                assert_eq!(updated.id, factor.id);
2512                assert_eq!(updated.secret, factor.secret);
2513                assert_eq!(updated.backup_codes, "backup-[\"remaining\"]");
2514                assert_eq!(updated.failed_verification_count, Some(0.0));
2515            } else {
2516                let error_2 = retry.unwrap_err();
2517                assert_eq!(error_2.status_code(), 401);
2518                assert_eq!(error_2.to_string(), "Invalid two factor cookie");
2519                assert_eq!(
2520                    serde_json::to_value(
2521                        ctx.database
2522                            .get_two_factor_by_user_id(&user.id)
2523                            .await
2524                            .unwrap()
2525                    )
2526                    .unwrap(),
2527                    serde_json::json!(factor)
2528                );
2529            }
2530            assert!(
2531                ctx.database
2532                    .get_verification_by_identifier(&attempt_id)
2533                    .await
2534                    .unwrap()
2535                    .is_none()
2536            );
2537            assert_eq!(
2538                serde_json::to_value(
2539                    ctx.database
2540                        .get_verification_by_identifier(&key)
2541                        .await
2542                        .unwrap()
2543                )
2544                .unwrap(),
2545                before_challenge
2546            );
2547            assert_eq!(
2548                ctx.database
2549                    .get_user_sessions(&user.id)
2550                    .await
2551                    .unwrap()
2552                    .len(),
2553                0
2554            );
2555        }
2556    }
2557
2558    #[tokio::test]
2559    #[expect(
2560        clippy::too_many_lines,
2561        reason = "Keep this ordered integration scenario and its assertions together; Result propagates setup failures"
2562    )]
2563    async fn authenticated_otp_maps_only_session_creation_cancellation_and_preserves_hook_inputs() {
2564        use alibi_core::{AuthConfig, CreateSession};
2565        use alibi_seaorm::{
2566            Database, DatabaseHooks, HookControl, SeaOrmBackend, SeaOrmHookContext, SeaOrmStore,
2567        };
2568
2569        struct Hook {
2570            mode: u8,
2571            observed: Arc<std::sync::Mutex<Vec<String>>>,
2572        }
2573        #[async_trait]
2574        impl DatabaseHooks<TestSchema, SeaOrmBackend> for Hook {
2575            async fn before_update_user(
2576                &self,
2577                _id: &str,
2578                update: &mut UpdateUser,
2579                _context: &SeaOrmHookContext<'_>,
2580            ) -> AuthResult<HookControl> {
2581                if update.two_factor_enabled == Some(true) {
2582                    self.observed.lock().unwrap().push("user".into());
2583                    if self.mode == 3 {
2584                        return Err(AuthError::SessionCreationCancelled);
2585                    }
2586                }
2587                Ok(HookControl::Continue)
2588            }
2589            async fn before_create_session(
2590                &self,
2591                session: &mut CreateSession,
2592                context: &SeaOrmHookContext<'_>,
2593            ) -> AuthResult<HookControl> {
2594                if context
2595                    .request
2596                    .as_ref()
2597                    .is_some_and(|request| request.path.ends_with("/two-factor/verify-otp"))
2598                {
2599                    self.observed.lock().unwrap().push("session".into());
2600                    assert_eq!(session.ip_address.as_deref(), Some("127.0.0.7"));
2601                    assert_eq!(session.user_agent.as_deref(), Some("original-otp-agent"));
2602                    return match self.mode {
2603                        0 => Ok(HookControl::Cancel),
2604                        1 => Err(AuthError::forbidden(
2605                            "session creation cancelled by database hook",
2606                        )),
2607                        _ => Err(AuthError::bad_request(
2608                            "session creation cancelled by database hook",
2609                        )),
2610                    };
2611                }
2612                Ok(HookControl::Continue)
2613            }
2614        }
2615        struct Sender(Arc<std::sync::Mutex<Option<String>>>);
2616        #[async_trait]
2617        impl SendTwoFactorOtp for Sender {
2618            async fn send(&self, _user: &UserView, otp: &str) -> AuthResult<()> {
2619                *self.0.lock().unwrap() = Some(otp.into());
2620                Ok(())
2621            }
2622        }
2623        for mode in 0..4 {
2624            let database = Database::connect("sqlite::memory:").await.unwrap();
2625            alibi_seaorm::store::__private_test_support::migrator::run_migrations(&database)
2626                .await
2627                .unwrap();
2628            let config = Arc::new(AuthConfig::new("authenticated-otp-hook-secret-at-least-32"));
2629            let mut ctx = AuthContext::new(
2630                Arc::clone(&config),
2631                Arc::new(SeaOrmStore::<TestSchema>::new(
2632                    Arc::clone(&config),
2633                    database.clone(),
2634                )),
2635            );
2636            let user = ctx
2637                .database
2638                .create_user(
2639                    CreateUser::new()
2640                        .with_name("Native OTP Owner")
2641                        .with_email("native-otp@fixture.test"),
2642                )
2643                .await
2644                .unwrap();
2645            let session = ctx
2646                .database
2647                .create_session(CreateSession {
2648                    token: None,
2649                    user_id: user.id.clone(),
2650                    expires_at: Utc::now() + Duration::hours(1),
2651                    ip_address: Some("127.0.0.7".into()),
2652                    user_agent: Some("original-otp-agent".into()),
2653                    active_organization_id: None,
2654                    active_team_id: None,
2655                    impersonated_by: None,
2656                    additional_fields: alibi_core::field_policy::FieldValues::default(),
2657                })
2658                .await
2659                .unwrap();
2660            let observed = Arc::new(std::sync::Mutex::new(Vec::new()));
2661            ctx.database = Arc::new(SeaOrmStore::<TestSchema>::new(config, database).with_hooks(
2662                vec![Arc::new(Hook {
2663                    mode,
2664                    observed: Arc::clone(&observed),
2665                })],
2666            ));
2667            let delivered = Arc::new(std::sync::Mutex::new(None));
2668            let plugin = TwoFactorPlugin::with_config(TwoFactorConfig {
2669                send_otp: Some(Arc::new(Sender(Arc::clone(&delivered)))),
2670                ..Default::default()
2671            });
2672            let mut init = alibi_core::AuthInitContext::new(
2673                Arc::clone(&ctx.config),
2674                Arc::clone(&ctx.database),
2675            );
2676            plugin.on_init(&mut init).await.unwrap();
2677            ctx.database = init.database_with_registered_transforms();
2678            let parts = init.into_parts();
2679            ctx.metadata = parts.metadata;
2680            ctx.extensions = parts.extensions;
2681            let mut request = AuthRequest::new(HttpMethod::Post, "/two-factor/send-otp");
2682            request.headers.insert(
2683                "cookie".into(),
2684                create_session_cookie(&session.token, &ctx.config)
2685                    .unwrap()
2686                    .split(';')
2687                    .next()
2688                    .unwrap()
2689                    .into(),
2690            );
2691            request.body = Some(b"{}".to_vec());
2692            assert_eq!(
2693                plugin
2694                    .on_request(&request, &ctx)
2695                    .await
2696                    .unwrap()
2697                    .unwrap()
2698                    .status,
2699                200
2700            );
2701            let before = ctx.database.get_user_sessions(&user.id).await.unwrap();
2702            assert_eq!(before.len(), 1);
2703            assert_eq!(
2704                (before)
2705                    .first()
2706                    .expect("fixture contains the requested session")
2707                    .id,
2708                session.id
2709            );
2710            assert_eq!(
2711                (before)
2712                    .first()
2713                    .expect("fixture contains the requested session")
2714                    .token,
2715                session.token
2716            );
2717            let code = delivered
2718                .lock()
2719                .unwrap()
2720                .clone()
2721                .expect("real delivery required");
2722            request.path = "/two-factor/verify-otp".into();
2723            request.body = Some(
2724                serde_json::to_vec(&serde_json::json!({"code":code,"trustDevice":true})).unwrap(),
2725            );
2726            let result =
2727                alibi_core::with_request_hook_context(&request, plugin.on_request(&request, &ctx))
2728                    .await;
2729            match mode {
2730                0 => {
2731                    let response = result.unwrap().unwrap();
2732                    assert_eq!(response.status, 500);
2733                    assert_eq!(response.body.len(), 0);
2734                    assert_eq!(response.headers.get_all("Set-Cookie").count(), 0);
2735                }
2736                1 => assert!(matches!(result, Err(AuthError::Forbidden(_)))),
2737                2 => assert!(matches!(result, Err(AuthError::BadRequest(_)))),
2738                _ => assert!(matches!(result, Err(AuthError::SessionCreationCancelled))),
2739            }
2740            assert_eq!(
2741                *observed.lock().unwrap(),
2742                if mode == 3 {
2743                    vec!["user"]
2744                } else {
2745                    vec!["user", "session"]
2746                }
2747            );
2748            let stored_user = ctx
2749                .database
2750                .get_user_by_id(&user.id)
2751                .await
2752                .unwrap()
2753                .unwrap();
2754            assert_eq!(stored_user.two_factor_enabled(), mode != 3);
2755            let sessions = ctx.database.get_user_sessions(&user.id).await.unwrap();
2756            assert_eq!(
2757                serde_json::to_value(sessions).unwrap(),
2758                serde_json::to_value(&before).unwrap()
2759            );
2760            assert!(
2761                ctx.database
2762                    .get_two_factor_by_user_id(&user.id)
2763                    .await
2764                    .unwrap()
2765                    .is_none()
2766            );
2767            let key = format!("2fa-otp-{}!{}", user.id, session.id);
2768            assert!(
2769                ctx.database
2770                    .get_verification_by_identifier(&key)
2771                    .await
2772                    .unwrap()
2773                    .is_none()
2774            );
2775            let retry = plugin.on_request(&request, &ctx).await.unwrap_err();
2776            assert!(
2777                matches!(retry, AuthError::BadRequest(ref message) if message == "OTP has expired")
2778            );
2779            assert_eq!(
2780                ctx.database
2781                    .get_user_by_id(&user.id)
2782                    .await
2783                    .unwrap()
2784                    .unwrap()
2785                    .two_factor_enabled(),
2786                mode != 3
2787            );
2788        }
2789    }
2790
2791    #[tokio::test]
2792    #[expect(
2793        clippy::too_many_lines,
2794        reason = "Keep this ordered integration scenario and its assertions together; Result propagates setup failures"
2795    )]
2796    async fn otp_delivery_keeps_owned_request_context_after_dropped_rejecting_observer_and_rotation()
2797     {
2798        use alibi_core::{BackgroundTaskCompletion, BackgroundTaskHandler};
2799        use tokio::sync::oneshot;
2800        type Observations = Arc<std::sync::Mutex<Vec<(String, String, String, bool)>>>;
2801        struct Sender {
2802            entered: std::sync::Mutex<Option<oneshot::Sender<String>>>,
2803            release: std::sync::Mutex<Option<oneshot::Receiver<()>>>,
2804            finished: std::sync::Mutex<Option<oneshot::Sender<()>>>,
2805            observed: Observations,
2806        }
2807        impl Sender {
2808            fn record(&self, user: &UserView) {
2809                let request = alibi_core::hooks::current_request_hook_context()
2810                    .expect("real delivery must retain initiating request context");
2811                self.observed.lock().unwrap().push((
2812                    request.path,
2813                    (*(request.headers)
2814                        .get("x-delivery-origin")
2815                        .expect("fixture contains the requested index"))
2816                    .clone(),
2817                    (*(request.query)
2818                        .get("delivery")
2819                        .expect("fixture contains the requested index"))
2820                    .clone(),
2821                    user.two_factor_enabled.unwrap_or(false),
2822                ));
2823            }
2824        }
2825        #[async_trait]
2826        impl SendTwoFactorOtp for Sender {
2827            async fn send(&self, user: &UserView, otp: &str) -> AuthResult<()> {
2828                self.record(user);
2829                _ = self
2830                    .entered
2831                    .lock()
2832                    .unwrap()
2833                    .take()
2834                    .unwrap()
2835                    .send(otp.into());
2836                let release = self.release.lock().unwrap().take().unwrap();
2837                release.await.unwrap();
2838                self.record(user);
2839                _ = self.finished.lock().unwrap().take().unwrap().send(());
2840                Err(AuthError::forbidden(
2841                    "actual asynchronous delivery rejected",
2842                ))
2843            }
2844        }
2845        struct Observer;
2846        impl BackgroundTaskHandler for Observer {
2847            fn handle(&self, completion: BackgroundTaskCompletion) -> AuthResult<()> {
2848                drop(completion);
2849                Err(AuthError::forbidden("actual application observer rejected"))
2850            }
2851        }
2852        let mut ctx = test_helpers::create_test_context().await;
2853        ctx.config = Arc::new((*ctx.config).clone().background_tasks(Arc::new(Observer)));
2854        let (entered, entry) = oneshot::channel();
2855        let (release, gate) = oneshot::channel();
2856        let (finished, done) = oneshot::channel();
2857        let observed = Arc::new(std::sync::Mutex::new(Vec::new()));
2858        let plugin = Arc::new(TwoFactorPlugin::with_config(TwoFactorConfig {
2859            send_otp: Some(Arc::new(Sender {
2860                entered: std::sync::Mutex::new(Some(entered)),
2861                release: std::sync::Mutex::new(Some(gate)),
2862                finished: std::sync::Mutex::new(Some(finished)),
2863                observed: Arc::clone(&observed),
2864            })),
2865            ..Default::default()
2866        }));
2867        let mut init =
2868            alibi_core::AuthInitContext::new(Arc::clone(&ctx.config), Arc::clone(&ctx.database));
2869        plugin.on_init(&mut init).await.unwrap();
2870        ctx.database = init.database_with_registered_transforms();
2871        let parts = init.into_parts();
2872        ctx.metadata = parts.metadata;
2873        ctx.extensions = parts.extensions;
2874        let user = test_helpers::create_user(
2875            &ctx,
2876            CreateUser::new()
2877                .with_name("Owned Delivery")
2878                .with_email("delivery-owner@fixture.test"),
2879        )
2880        .await;
2881        let session = test_helpers::create_session(&ctx, user.id.clone(), Duration::hours(1)).await;
2882        let ctx = Arc::new(ctx);
2883        let mut request = AuthRequest::new(HttpMethod::Post, "/two-factor/send-otp");
2884        _ = request.headers.insert(
2885            "cookie".into(),
2886            create_session_cookie(&session.token, &ctx.config)
2887                .unwrap()
2888                .split(';')
2889                .next()
2890                .unwrap()
2891                .into(),
2892        );
2893        _ = request
2894            .headers
2895            .insert("x-delivery-origin".into(), "original-sender".into());
2896        _ = request
2897            .query
2898            .insert("delivery".into(), "original-marker".into());
2899        request.body = Some(b"{}".to_vec());
2900        let running_ctx = Arc::clone(&ctx);
2901        let running_plugin = Arc::clone(&plugin);
2902        let running_request = request.clone();
2903        let mut running = tokio::spawn(async move {
2904            alibi_core::with_request_hook_context(
2905                &running_request,
2906                running_plugin.on_request(&running_request, &running_ctx),
2907            )
2908            .await
2909        });
2910        let code = entry.await.unwrap();
2911        let response = tokio::time::timeout(std::time::Duration::from_secs(1), &mut running)
2912            .await
2913            .expect("configured response must not await held application delivery")
2914            .unwrap()
2915            .unwrap()
2916            .unwrap();
2917        assert_eq!(response.status, 200);
2918        let identifier = format!("2fa-otp-{}!{}", user.id, session.id);
2919        let row = ctx
2920            .database
2921            .get_verification_by_identifier(&identifier)
2922            .await
2923            .unwrap()
2924            .unwrap();
2925        assert_eq!(row.value(), format!("{code}:0"));
2926        assert!(
2927            ctx.database
2928                .get_session(&session.token)
2929                .await
2930                .unwrap()
2931                .is_some()
2932        );
2933        request.path = "/two-factor/verify-otp".into();
2934        _ = request
2935            .headers
2936            .insert("x-delivery-origin".into(), "different-verifier".into());
2937        _ = request
2938            .query
2939            .insert("delivery".into(), "different-marker".into());
2940        request.body = Some(serde_json::to_vec(&serde_json::json!({"code":code})).unwrap());
2941        let verified =
2942            alibi_core::with_request_hook_context(&request, plugin.on_request(&request, &ctx))
2943                .await
2944                .unwrap()
2945                .unwrap();
2946        assert_eq!(verified.status, 200);
2947        assert!(
2948            ctx.database
2949                .get_user_by_id(&user.id)
2950                .await
2951                .unwrap()
2952                .unwrap()
2953                .two_factor_enabled()
2954        );
2955        assert!(
2956            ctx.database
2957                .get_session(&session.token)
2958                .await
2959                .unwrap()
2960                .is_none()
2961        );
2962        assert!(
2963            ctx.database
2964                .get_verification_by_identifier(&identifier)
2965                .await
2966                .unwrap()
2967                .is_none()
2968        );
2969        assert!(
2970            ctx.database
2971                .get_two_factor_by_user_id(&user.id)
2972                .await
2973                .unwrap()
2974                .is_none()
2975        );
2976        _ = release.send(());
2977        tokio::time::timeout(std::time::Duration::from_secs(1), done)
2978            .await
2979            .expect("owned delivery must finish after its actual release")
2980            .unwrap();
2981        assert_eq!(
2982            *observed.lock().unwrap(),
2983            vec![
2984                (
2985                    "/two-factor/send-otp".into(),
2986                    "original-sender".into(),
2987                    "original-marker".into(),
2988                    false
2989                );
2990                2
2991            ]
2992        );
2993    }
2994}
2995// LCOV_EXCL_STOP