Skip to main content

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