Skip to main content

better_auth_api/plugins/two_factor/
mod.rs

1use async_trait::async_trait;
2use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
3use chrono::{Duration, Utc};
4use hmac::{Hmac, Mac};
5use rand::Rng;
6use rand::distributions::Alphanumeric;
7use serde::{Deserialize, Serialize};
8use sha2::Sha256;
9use std::sync::Arc;
10use totp_rs::{Algorithm, TOTP};
11
12use better_auth_core::entity::{AuthSession, AuthTwoFactor, AuthUser};
13use better_auth_core::utils::cookie_utils::{
14    create_clear_cookie, create_session_like_cookie, related_cookie_name,
15};
16use better_auth_core::wire::UserView;
17use better_auth_core::{
18    AuthContext, AuthError, AuthRequest, AuthResponse, AuthResult, CreateTwoFactor,
19    CreateVerification, RequestMeta, TwoFactor, UpdateUser,
20};
21
22use crate::plugins::helpers::{
23    SessionIssueError, delete_session_cookies, get_cookie, get_credential_password_hash,
24    issue_user_session, issue_user_session_with_lifetime,
25};
26
27use super::StatusResponse;
28mod actions;
29mod callbacks;
30mod challenge;
31pub use callbacks::TwoFactorCallbacks;
32mod helpers;
33mod native;
34mod options;
35mod request;
36pub use native::TwoFactorApi;
37pub use options::{
38    BackupCodeOptions, BackupCodeStorage, TwoFactorCipher, TwoFactorHasher, TwoFactorOtpStorage,
39};
40mod security;
41mod totp;
42use actions::*;
43use helpers::*;
44use security::*;
45
46#[cfg(test)]
47mod enrollment_tests;
48#[cfg(test)]
49mod security_tests;
50#[cfg(test)]
51mod tests;
52
53const TWO_FACTOR_COOKIE_SUFFIX: &str = "two_factor";
54const TRUST_DEVICE_COOKIE_SUFFIX: &str = "trust_device";
55const DONT_REMEMBER_COOKIE_SUFFIX: &str = "dont_remember";
56
57const METADATA_ENABLED: &str = "two_factor.enabled";
58const METADATA_TOTP_DISABLED: &str = "two_factor.totp_disabled";
59const METADATA_OTP_ENABLED: &str = "two_factor.otp_enabled";
60const DEFAULT_TWO_FACTOR_COOKIE_MAX_AGE_SECS: f64 = 600.0;
61const DEFAULT_TRUST_DEVICE_MAX_AGE_SECS: f64 = 2_592_000.0;
62const DEFAULT_TOTP_PERIOD_SECS: f64 = 30.0;
63const DEFAULT_TOTP_DIGITS: usize = 6;
64const CHALLENGE_ATTEMPT_LIMIT: usize = 5;
65
66type HmacSha256 = Hmac<Sha256>;
67
68/// Callback used by the two-factor plugin to deliver a one-time password.
69#[async_trait]
70pub trait SendTwoFactorOtp: Send + Sync {
71    /// Send a one-time password to the given user.
72    async fn send(&self, user: &UserView, otp: &str) -> AuthResult<()>;
73}
74
75/// Two-factor authentication plugin providing TOTP, OTP, and backup code flows.
76#[derive(Clone)]
77pub struct TwoFactorPlugin {
78    config: TwoFactorConfig,
79}
80
81/// Public configuration for the two-factor plugin.
82#[derive(Clone, better_auth_core::PluginConfig)]
83#[plugin(name = "TwoFactorPlugin")]
84pub struct TwoFactorConfig {
85    /// Permit passwordless management only when the user has no credential password.
86    #[config(default = false)]
87    pub allow_passwordless: bool,
88    /// Disable TOTP enrollment, verification and generation.
89    #[config(default = false)]
90    pub totp_disabled: bool,
91    /// Override passwordless policy for reading the TOTP URI.
92    #[config(default = None)]
93    pub totp_allow_passwordless: Option<bool>,
94    /// Number of decimal digits in delivered OTPs.
95    #[config(default = 6)]
96    pub otp_digits: usize,
97    /// Delivered OTP lifetime. Zero uses the upstream three-minute default.
98    #[config(default = Duration::minutes(3))]
99    pub otp_period: Duration,
100    /// Failed OTP attempts before the next request consumes the exhausted code. Zero means five.
101    #[config(default = 5)]
102    pub otp_allowed_attempts: usize,
103    /// Stored OTP representation.
104    #[config(default = TwoFactorOtpStorage::Plain)]
105    pub otp_storage: TwoFactorOtpStorage,
106    /// Backup-code generation, storage and passwordless policy.
107    #[config(default = BackupCodeOptions::default())]
108    pub backup_code_options: BackupCodeOptions,
109    /// Account-level protection shared by all sign-in factors and challenges.
110    #[config(default = AccountLockout::default())]
111    pub account_lockout: AccountLockout,
112    /// Override the issuer embedded in generated TOTP URIs.
113    #[config(default = None)]
114    pub issuer: Option<String>,
115    /// Skip the enrollment verification step and enable 2FA immediately.
116    #[config(default = false)]
117    pub skip_verification_on_enable: bool,
118    /// Pending two-factor cookie lifetime in seconds, including fractions. Zero is preserved.
119    #[config(default = DEFAULT_TWO_FACTOR_COOKIE_MAX_AGE_SECS)]
120    pub two_factor_cookie_max_age: f64,
121    /// Trusted-device cookie lifetime in seconds, including fractions. Zero is preserved.
122    #[config(default = DEFAULT_TRUST_DEVICE_MAX_AGE_SECS)]
123    pub trust_device_max_age: f64,
124    /// TOTP period in fractional seconds. Enrollment URIs preserve the configured value.
125    /// Generation, verification, and URI retrieval use 30 seconds for zero or NaN.
126    #[config(default = DEFAULT_TOTP_PERIOD_SECS)]
127    pub totp_period: f64,
128    /// TOTP digit count.
129    #[config(default = DEFAULT_TOTP_DIGITS)]
130    pub totp_digits: usize,
131    /// Optional OTP sender callback. When absent, `/two-factor/send-otp` is disabled.
132    #[config(default = None, skip)]
133    pub send_otp: Option<Arc<dyn SendTwoFactorOtp>>,
134}
135
136impl std::fmt::Debug for TwoFactorConfig {
137    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
138        f.debug_struct("TwoFactorConfig")
139            .field("account_lockout", &self.account_lockout)
140            .field("issuer", &self.issuer)
141            .field(
142                "skip_verification_on_enable",
143                &self.skip_verification_on_enable,
144            )
145            .field("two_factor_cookie_max_age", &self.two_factor_cookie_max_age)
146            .field("trust_device_max_age", &self.trust_device_max_age)
147            .field("totp_period", &self.totp_period)
148            .field("totp_digits", &self.totp_digits)
149            .field("send_otp", &self.send_otp.as_ref().map(|_| "custom"))
150            .finish()
151    }
152}
153
154/// Consecutive verification failures allowed before the account is temporarily locked.
155#[derive(Debug, Clone)]
156pub struct AccountLockout {
157    /// Enable account-level lockout.
158    pub enabled: bool,
159    /// Failures across factors and challenges before locking the account.
160    pub max_failed_attempts: i64,
161    /// Duration of the lock in seconds, including fractions. Zero is preserved.
162    pub duration_seconds: f64,
163}
164
165impl Default for AccountLockout {
166    fn default() -> Self {
167        Self {
168            enabled: true,
169            max_failed_attempts: 10,
170            duration_seconds: 900.0,
171        }
172    }
173}
174
175#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
176#[serde(rename_all = "lowercase")]
177enum EnrollmentMethod {
178    Otp,
179    #[default]
180    Totp,
181}
182
183#[derive(Debug, Clone, Deserialize)]
184pub(crate) struct EnableRequest {
185    password: Option<String>,
186    issuer: Option<String>,
187    #[serde(default)]
188    method: EnrollmentMethod,
189}
190
191#[derive(Debug, Clone, Deserialize)]
192pub(crate) struct DisableRequest {
193    password: Option<String>,
194}
195
196#[derive(Debug, Clone, Deserialize)]
197pub(crate) struct GetTotpUriRequest {
198    password: Option<String>,
199}
200
201#[derive(Debug, Clone, Deserialize)]
202pub(crate) struct VerifyTotpRequest {
203    code: String,
204    #[serde(rename = "trustDevice")]
205    trust_device: Option<bool>,
206}
207
208#[derive(Debug, Clone, Deserialize)]
209pub(crate) struct VerifyOtpRequest {
210    code: String,
211    #[serde(rename = "trustDevice")]
212    trust_device: Option<bool>,
213}
214
215#[derive(Debug, Clone, Deserialize)]
216pub(crate) struct GenerateBackupCodesRequest {
217    password: Option<String>,
218}
219
220#[derive(Debug, Clone, Deserialize)]
221pub(crate) struct VerifyBackupCodeRequest {
222    code: String,
223    #[serde(rename = "disableSession")]
224    disable_session: Option<bool>,
225    #[serde(rename = "trustDevice")]
226    trust_device: Option<bool>,
227}
228
229#[derive(Debug, Serialize)]
230pub(crate) struct EnableResponse {
231    method: &'static str,
232    #[serde(rename = "totpURI", skip_serializing_if = "Option::is_none")]
233    totp_uri: Option<String>,
234    #[serde(rename = "backupCodes", skip_serializing_if = "Option::is_none")]
235    backup_codes: Option<Vec<String>>,
236}
237
238#[derive(Debug, Serialize)]
239pub(crate) struct TotpUriResponse {
240    #[serde(rename = "totpURI")]
241    totp_uri: String,
242}
243
244#[derive(Debug, Serialize)]
245pub(crate) struct SessionTokenResponse<U: Serialize> {
246    #[serde(skip_serializing_if = "Option::is_none")]
247    token: Option<String>,
248    user: U,
249}
250
251#[derive(Debug, Serialize)]
252pub(crate) struct BackupCodesResponse {
253    status: bool,
254    #[serde(rename = "backupCodes")]
255    backup_codes: Vec<String>,
256}
257
258#[derive(Debug, Serialize)]
259pub(crate) struct TwoFactorRedirectResponse {
260    #[serde(rename = "twoFactorRedirect")]
261    two_factor_redirect: bool,
262    /// Second factors this user can actually complete, so the client knows
263    /// which challenge to present.
264    #[serde(rename = "twoFactorMethods")]
265    two_factor_methods: Vec<&'static str>,
266}
267
268struct PendingTwoFactorState {
269    user: UserView,
270    key: String,
271    dont_remember: bool,
272}
273
274enum ResolvedTwoFactorState {
275    Session {
276        user: UserView,
277        session: Box<better_auth_core::wire::SessionView>,
278        key: String,
279    },
280    Pending(PendingTwoFactorState),
281}
282
283pub(crate) struct TrustedDeviceCheck {
284    pub trusted: bool,
285    pub set_cookie_headers: Vec<String>,
286}
287
288pub(crate) async fn inspect_trusted_device(
289    req: &AuthRequest,
290    user: &impl AuthUser,
291    ctx: &AuthContext<impl better_auth_core::AuthSchema>,
292) -> AuthResult<TrustedDeviceCheck> {
293    let cookie_name = related_cookie_name(&ctx.config, TRUST_DEVICE_COOKIE_SUFFIX);
294    let Some(raw_cookie) = get_cookie(req, &cookie_name) else {
295        return Ok(TrustedDeviceCheck {
296            trusted: false,
297            set_cookie_headers: Vec::new(),
298        });
299    };
300
301    let Some(signed_value) = verify_signed_cookie_value(ctx.config.signing_secret(), &raw_cookie)?
302        .filter(|value| !value.is_empty())
303    else {
304        return Ok(TrustedDeviceCheck {
305            trusted: false,
306            set_cookie_headers: Vec::new(),
307        });
308    };
309
310    let Some((token, trust_identifier)) = signed_value.split_once('!') else {
311        return Ok(TrustedDeviceCheck {
312            trusted: false,
313            set_cookie_headers: vec![clear_cookie_header(
314                req,
315                &ctx.config,
316                TRUST_DEVICE_COOKIE_SUFFIX,
317            )?],
318        });
319    };
320
321    let expected_token = sign_value(
322        ctx.config.signing_secret(),
323        &format!("{}!{}", user.id().display_string()?, trust_identifier),
324    )?;
325    if token != expected_token {
326        return Ok(TrustedDeviceCheck {
327            trusted: false,
328            set_cookie_headers: vec![clear_cookie_header(
329                req,
330                &ctx.config,
331                TRUST_DEVICE_COOKIE_SUFFIX,
332            )?],
333        });
334    }
335
336    let Some(verification) = ctx
337        .database
338        .get_verification_by_identifier(trust_identifier)
339        .await?
340    else {
341        return Ok(TrustedDeviceCheck {
342            trusted: false,
343            set_cookie_headers: vec![clear_cookie_header(
344                req,
345                &ctx.config,
346                TRUST_DEVICE_COOKIE_SUFFIX,
347            )?],
348        });
349    };
350
351    if verification.value != user.id().into_owned()
352        || verification.expires_at.is_before_or_equal(Utc::now())?
353    {
354        return Ok(TrustedDeviceCheck {
355            trusted: false,
356            set_cookie_headers: vec![clear_cookie_header(
357                req,
358                &ctx.config,
359                TRUST_DEVICE_COOKIE_SUFFIX,
360            )?],
361        });
362    }
363
364    ctx.database
365        .delete_verification_by_identifier(trust_identifier)
366        .await?;
367
368    let rotated_cookie = create_trust_device_cookie_header(user, ctx).await?;
369    Ok(TrustedDeviceCheck {
370        trusted: true,
371        set_cookie_headers: vec![rotated_cookie],
372    })
373}
374
375pub(crate) async fn begin_sign_in_challenge(
376    user: &impl AuthUser,
377    ctx: &AuthContext<impl better_auth_core::AuthSchema>,
378    headers: &mut better_auth_core::Headers,
379) -> AuthResult<TwoFactorRedirectResponse> {
380    let identifier = format!("2fa-{}", uuid::Uuid::new_v4());
381    let max_age = two_factor_cookie_max_age(ctx);
382    let expires_at = cookie_expires_at(max_age)?;
383    _ = ctx
384        .database
385        .create_verification(CreateVerification {
386            identifier: (identifier.clone()).into(),
387            value: user.id().into_owned(),
388            expires_at: expires_at.into(),
389            ..Default::default()
390        })
391        .await?;
392
393    let _ = ctx
394        .database
395        .create_verification(CreateVerification {
396            identifier: (format!("2fa-attempts-{identifier}")).into(),
397            value: ("0".to_owned()).into(),
398            expires_at: expires_at.into(),
399            ..Default::default()
400        })
401        .await?;
402
403    headers.append(
404        "Set-Cookie",
405        create_signed_cookie_header(
406            ctx.config.signing_secret(),
407            &ctx.config,
408            TWO_FACTOR_COOKIE_SUFFIX,
409            &identifier,
410            Some(max_age),
411        )?,
412    );
413
414    // TOTP is per-user: only offered once the user has a stored secret. OTP is
415    // server-level: offered whenever a sender is configured.
416    let mut two_factor_methods = Vec::new();
417    if !ctx
418        .get_metadata(METADATA_TOTP_DISABLED)
419        .and_then(serde_json::Value::as_bool)
420        .unwrap_or(false)
421        && ctx
422            .database
423            .get_two_factor_by_user_id(user.id().typed()?)
424            .await?
425            .is_some_and(|factor| factor.verified != Some(false))
426    {
427        two_factor_methods.push("totp");
428    }
429    if ctx
430        .get_metadata(METADATA_OTP_ENABLED)
431        .and_then(|value| value.as_bool())
432        .unwrap_or(false)
433    {
434        two_factor_methods.push("otp");
435    }
436
437    Ok(TwoFactorRedirectResponse {
438        two_factor_redirect: true,
439        two_factor_methods,
440    })
441}
442
443impl TwoFactorPlugin {
444    /// Generate a TOTP from a supplied secret in trusted server code.
445    pub fn generate_totp(&self, secret: &str) -> AuthResult<String> {
446        require_totp(&self.config)?;
447        let inner = TOTP::new_unchecked(
448            Algorithm::SHA1,
449            if self.config.totp_digits == 0 {
450                6
451            } else {
452                self.config.totp_digits
453            },
454            1,
455            1,
456            secret.as_bytes().to_vec(),
457            None,
458            String::new(),
459        );
460        totp::Totp::new(inner, self.config.totp_period)
461            .with_default_period()
462            .generate_current()
463    }
464
465    /// Install a custom OTP sender.
466    pub fn custom_send_otp(mut self, sender: Arc<dyn SendTwoFactorOtp>) -> Self {
467        self.config.send_otp = Some(sender);
468        self
469    }
470
471    /// Read the currently stored backup codes for a user.
472    ///
473    /// This is the Rust server-side equivalent of the TypeScript
474    /// `auth.api.viewBackupCodes` capability. It is intentionally not exposed
475    /// as a public HTTP route.
476    pub async fn view_backup_codes<S: better_auth_core::AuthSchema>(
477        &self,
478        user_id: &str,
479        ctx: &AuthContext<S>,
480    ) -> AuthResult<Vec<String>> {
481        view_backup_codes_core(user_id, &self.config.backup_code_options, ctx).await
482    }
483}
484
485#[async_trait]
486impl<S: better_auth_core::AuthSchema> better_auth_core::AuthPlugin<S> for TwoFactorPlugin {
487    fn name(&self) -> &'static str {
488        "two-factor"
489    }
490    async fn after_request(
491        &self,
492        req: &AuthRequest,
493        response: &mut AuthResponse,
494        ctx: &AuthContext<S>,
495    ) -> AuthResult<()> {
496        self.after_sign_in(req, response, ctx).await
497    }
498    fn routes(&self) -> Vec<better_auth_core::AuthRoute> {
499        let passwordless = self.config.allow_passwordless;
500        let totp_passwordless = self.config.totp_allow_passwordless.unwrap_or(passwordless);
501        let backup_passwordless = self
502            .config
503            .backup_code_options
504            .allow_passwordless
505            .unwrap_or(passwordless);
506        [
507            ("/two-factor/enable", "enableTwoFactor", passwordless),
508            ("/two-factor/disable", "disableTwoFactor", passwordless),
509            ("/two-factor/get-totp-uri", "getTOTPURI", totp_passwordless),
510            ("/two-factor/verify-totp", "verifyTOTP", passwordless),
511            ("/two-factor/send-otp", "sendTwoFactorOTP", passwordless),
512            ("/two-factor/verify-otp", "verifyTwoFactorOTP", passwordless),
513            (
514                "/two-factor/generate-backup-codes",
515                "generateBackupCodes",
516                backup_passwordless,
517            ),
518            (
519                "/two-factor/verify-backup-code",
520                "verifyBackupCode",
521                passwordless,
522            ),
523        ]
524        .into_iter()
525        .map(|(path, operation, optional)| {
526            better_auth_core::AuthRoute::post(path, operation)
527                .body_validator(move |req| request::validate(req, optional))
528        })
529        .collect()
530    }
531    async fn on_request(
532        &self,
533        req: &AuthRequest,
534        ctx: &AuthContext<S>,
535    ) -> AuthResult<Option<AuthResponse>> {
536        if *req.method() != better_auth_core::HttpMethod::Post {
537            return Ok(None);
538        }
539        let response = match req.path() {
540            "/two-factor/enable" => self.handle_enable(req, ctx).await?,
541            "/two-factor/disable" => self.handle_disable(req, ctx).await?,
542            "/two-factor/get-totp-uri" => self.handle_get_totp_uri(req, ctx).await?,
543            "/two-factor/verify-totp" => self.handle_verify_totp(req, ctx).await?,
544            "/two-factor/send-otp" => self.handle_send_otp(req, ctx).await?,
545            "/two-factor/verify-otp" => self.handle_verify_otp(req, ctx).await?,
546            "/two-factor/generate-backup-codes" => {
547                self.handle_generate_backup_codes(req, ctx).await?
548            }
549            "/two-factor/verify-backup-code" => self.handle_verify_backup_code(req, ctx).await?,
550            _ => return Ok(None),
551        };
552        Ok(Some(response))
553    }
554    async fn on_init(
555        &self,
556        ctx: &mut better_auth_core::AuthInitContext<S>,
557    ) -> better_auth_core::AuthResult<()> {
558        ctx.extensions.insert(self.config.clone());
559        S::User::require_plugin_fields("two-factor", &["two_factor_enabled"])?;
560        ctx.set_metadata(METADATA_ENABLED, serde_json::Value::Bool(true));
561        ctx.set_metadata(
562            METADATA_TOTP_DISABLED,
563            serde_json::Value::Bool(self.config.totp_disabled),
564        );
565        ctx.set_metadata(
566            METADATA_OTP_ENABLED,
567            serde_json::Value::Bool(
568                self.config.send_otp.is_some()
569                    || ctx
570                        .extensions
571                        .get::<Arc<TwoFactorCallbacks<S>>>()
572                        .is_some_and(|callbacks| callbacks.sender.is_some()),
573            ),
574        );
575        Ok(())
576    }
577}
578
579impl TwoFactorPlugin {
580    async fn handle_enable(
581        &self,
582        req: &AuthRequest,
583        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
584    ) -> AuthResult<AuthResponse> {
585        let body: EnableRequest = request::read(req, self.config.allow_passwordless)?;
586        let (user, session) = ctx.require_session(req).await?;
587
588        let (response, set_cookie_headers) =
589            enable_core(req, &body, &user, &session, &self.config, ctx).await?;
590        let mut auth_response = AuthResponse::json(200, &response)?;
591        for cookie in set_cookie_headers {
592            auth_response = auth_response.with_appended_header("Set-Cookie", cookie);
593        }
594        Ok(auth_response)
595    }
596
597    async fn handle_disable(
598        &self,
599        req: &AuthRequest,
600        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
601    ) -> AuthResult<AuthResponse> {
602        let body: DisableRequest = request::read(req, self.config.allow_passwordless)?;
603        let (user, session) = ctx.require_session(req).await?;
604
605        let (response, set_cookie_headers) =
606            disable_core(&body, &user, &session, req, &self.config, ctx).await?;
607        let mut auth_response = AuthResponse::json(200, &response)?;
608        for cookie in set_cookie_headers {
609            auth_response = auth_response.with_appended_header("Set-Cookie", cookie);
610        }
611        Ok(auth_response)
612    }
613
614    async fn handle_get_totp_uri(
615        &self,
616        req: &AuthRequest,
617        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
618    ) -> AuthResult<AuthResponse> {
619        let body: GetTotpUriRequest = request::read(
620            req,
621            self.config
622                .totp_allow_passwordless
623                .unwrap_or(self.config.allow_passwordless),
624        )?;
625        let (user, _session) = ctx.require_session(req).await?;
626
627        let response = get_totp_uri_core(&body, &user, &self.config, ctx).await?;
628        AuthResponse::json(200, &response).map_err(AuthError::from)
629    }
630
631    async fn handle_verify_totp(
632        &self,
633        req: &AuthRequest,
634        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
635    ) -> AuthResult<AuthResponse> {
636        let body: VerifyTotpRequest = request::read(req, false)?;
637
638        let (response, set_cookie_headers) =
639            verify_totp_core(req, &body, &self.config, ctx).await?;
640        let mut auth_response = AuthResponse::json(200, &response)?;
641        for cookie in set_cookie_headers {
642            auth_response = auth_response.with_appended_header("Set-Cookie", cookie);
643        }
644        Ok(auth_response)
645    }
646
647    async fn handle_send_otp(
648        &self,
649        req: &AuthRequest,
650        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
651    ) -> AuthResult<AuthResponse> {
652        let response = send_otp_core(req, &self.config, ctx).await?;
653        AuthResponse::json(200, &response).map_err(AuthError::from)
654    }
655
656    async fn handle_verify_otp(
657        &self,
658        req: &AuthRequest,
659        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
660    ) -> AuthResult<AuthResponse> {
661        let body: VerifyOtpRequest = request::read(req, false)?;
662
663        let (response, set_cookie_headers) = verify_otp_core(req, &body, &self.config, ctx).await?;
664        let mut auth_response = AuthResponse::json(200, &response)?;
665        for cookie in set_cookie_headers {
666            auth_response = auth_response.with_appended_header("Set-Cookie", cookie);
667        }
668        Ok(auth_response)
669    }
670
671    async fn handle_generate_backup_codes(
672        &self,
673        req: &AuthRequest,
674        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
675    ) -> AuthResult<AuthResponse> {
676        let body: GenerateBackupCodesRequest = request::read(
677            req,
678            self.config
679                .backup_code_options
680                .allow_passwordless
681                .unwrap_or(self.config.allow_passwordless),
682        )?;
683        let (user, _session) = ctx.require_session(req).await?;
684
685        let response = generate_backup_codes_core(&body, &user, &self.config, ctx).await?;
686        AuthResponse::json(200, &response).map_err(AuthError::from)
687    }
688
689    async fn handle_verify_backup_code(
690        &self,
691        req: &AuthRequest,
692        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
693    ) -> AuthResult<AuthResponse> {
694        let body: VerifyBackupCodeRequest = request::read(req, false)?;
695
696        let (response, set_cookie_headers) =
697            verify_backup_code_core(req, &body, &self.config, ctx).await?;
698        let mut auth_response = AuthResponse::json(200, &response)?;
699        for cookie in set_cookie_headers {
700            auth_response = auth_response.with_appended_header("Set-Cookie", cookie);
701        }
702        Ok(auth_response)
703    }
704}
705
706async fn resolve_two_factor_state<S: better_auth_core::AuthSchema>(
707    req: &AuthRequest,
708    ctx: &AuthContext<S>,
709) -> AuthResult<ResolvedTwoFactorState> {
710    match ctx.require_session(req).await {
711        Ok((user, session)) => {
712            let key = format!(
713                "{}!{}",
714                user.id().display_string()?,
715                session.id().display_string()?
716            );
717            return Ok(ResolvedTwoFactorState::Session {
718                user,
719                session: Box::new(session),
720                key,
721            });
722        }
723        Err(AuthError::Unauthenticated | AuthError::SessionNotFound) => {}
724        Err(error) => return Err(error),
725    }
726
727    let identifier = read_signed_cookie(req, TWO_FACTOR_COOKIE_SUFFIX, ctx)?
728        .ok_or_else(|| AuthError::authentication_failed("Invalid two factor cookie"))?;
729    let verification = ctx
730        .database
731        .get_verification_by_identifier(&identifier)
732        .await?
733        .ok_or_else(|| AuthError::authentication_failed("Invalid two factor cookie"))?;
734    if verification.expires_at.is_before_or_equal(Utc::now())? {
735        ctx.database
736            .delete_verification_by_identifier(&identifier)
737            .await?;
738        return Err(AuthError::authentication_failed(
739            "Invalid two factor cookie",
740        ));
741    }
742
743    let user = ctx
744        .database
745        .get_user_by_id(verification.value.typed()?)
746        .await?
747        .ok_or_else(|| AuthError::authentication_failed("Invalid two factor cookie"))?;
748    let dont_remember = read_signed_cookie(req, DONT_REMEMBER_COOKIE_SUFFIX, ctx)?.is_some();
749
750    Ok(ResolvedTwoFactorState::Pending(PendingTwoFactorState {
751        user: ctx.internal_user_view(&user).await?,
752        key: identifier,
753        dont_remember,
754    }))
755}
756
757async fn verify_existing_session_factor(
758    req: &AuthRequest,
759    user: impl AuthUser,
760    session: impl AuthSession,
761    enrollment: Option<EnrollmentMethod>,
762    ctx: &AuthContext<impl better_auth_core::AuthSchema>,
763    complete_enrollment: impl std::future::Future<Output = AuthResult<()>>,
764) -> AuthResult<(SessionTokenResponse<UserView>, Vec<String>)> {
765    if enrollment.is_some() && !user.two_factor_enabled() {
766        let updated_user = ctx
767            .database
768            .update_user(
769                user.id().typed()?,
770                UpdateUser {
771                    two_factor_enabled: Some(true),
772                    ..Default::default()
773                },
774            )
775            .await?;
776        let issued = issue_user_session(
777            ctx,
778            updated_user.id().typed()?,
779            session.ip_address().map(str::to_owned),
780            session.user_agent().map(str::to_owned),
781        )
782        .await
783        .map_err(SessionIssueError::into_auth_error)?;
784        let manager = ctx.session_manager();
785        manager
786            .set_session_cookie(
787                req,
788                manager
789                    .internal_data(&updated_user, &issued.session)
790                    .await?,
791                None,
792            )
793            .await?;
794        ctx.database.delete_session(session.token()).await?;
795        complete_enrollment.await?;
796        return Ok((
797            SessionTokenResponse {
798                token: Some(if enrollment == Some(EnrollmentMethod::Otp) {
799                    issued.session.token().to_string()
800                } else {
801                    session.token().to_string()
802                }),
803                // TS keeps the verify response on the pre-update snapshot even
804                // though the re-issued session already observes 2FA as enabled.
805                user: if enrollment == Some(EnrollmentMethod::Otp) {
806                    ctx.user_view(&updated_user).await?
807                } else {
808                    ctx.user_view(&user).await?
809                },
810            },
811            Vec::new(),
812        ));
813    }
814
815    complete_enrollment.await?;
816    Ok((
817        SessionTokenResponse {
818            token: Some(session.token().to_string()),
819            user: ctx.user_view(&user).await?,
820        },
821        Vec::new(),
822    ))
823}
824
825async fn finalize_pending_two_factor<S: better_auth_core::AuthSchema>(
826    pending: PendingTwoFactorState,
827    req: &AuthRequest,
828    trust_device: bool,
829    ctx: &AuthContext<S>,
830) -> AuthResult<(SessionTokenResponse<UserView>, Vec<String>)> {
831    let Some(consumed) = ctx
832        .database
833        .consume_verification_by_identifier(&pending.key)
834        .await?
835        .filter(|verification| verification.value == pending.user.id)
836    else {
837        req.append_response_header(
838            "Set-Cookie",
839            clear_cookie_header(req, &ctx.config, TWO_FACTOR_COOKIE_SUFFIX)?,
840        )?;
841        return Err(AuthError::authentication_failed(
842            "Invalid two factor cookie",
843        ));
844    };
845    let meta = RequestMeta::from_request_with_config(req, &ctx.config.advanced.ip_address);
846    let expires_in = if pending.dont_remember {
847        Duration::days(1)
848    } else {
849        ctx.config.session.expires_in()
850    };
851    let issued = issue_user_session_with_lifetime(
852        ctx,
853        consumed.value.typed()?,
854        meta.ip_address,
855        meta.user_agent,
856        expires_in,
857    )
858    .await
859    .map_err(SessionIssueError::into_auth_error)?;
860
861    let manager = ctx.session_manager();
862    manager
863        .set_session_cookie(
864            req,
865            manager
866                .internal_data(&pending.user, &issued.session)
867                .await?,
868            None,
869        )
870        .await?;
871    let mut set_cookie_headers = vec![clear_cookie_header(
872        req,
873        &ctx.config,
874        TWO_FACTOR_COOKIE_SUFFIX,
875    )?];
876    if trust_device {
877        set_cookie_headers.push(create_trust_device_cookie_header(&issued.user, ctx).await?);
878        set_cookie_headers.push(clear_cookie_header(
879            req,
880            &ctx.config,
881            DONT_REMEMBER_COOKIE_SUFFIX,
882        )?);
883    }
884
885    Ok((
886        SessionTokenResponse {
887            token: Some(issued.session.token().to_string()),
888            user: ctx.user_view(&issued.user).await?,
889        },
890        set_cookie_headers,
891    ))
892}
893
894async fn load_two_factor_record(
895    user: &impl AuthUser,
896    ctx: &AuthContext<impl better_auth_core::AuthSchema>,
897) -> AuthResult<TwoFactor> {
898    ctx.database
899        .get_two_factor_by_user_id(user.id().typed()?)
900        .await?
901        .ok_or_else(|| AuthError::bad_request("TOTP not enabled"))
902}
903
904impl ResolvedTwoFactorState {
905    fn is_sign_in(&self) -> bool {
906        matches!(self, Self::Pending(_))
907    }
908
909    fn user(&self) -> &UserView {
910        match self {
911            Self::Session { user, .. } => user,
912            Self::Pending(pending) => &pending.user,
913        }
914    }
915
916    fn key(&self) -> &str {
917        match self {
918            Self::Session { key, .. } => key,
919            Self::Pending(pending) => &pending.key,
920        }
921    }
922}