Skip to main content

alibi_plugins/two_factor/
mod.rs

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