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