Skip to main content

better_auth_api/plugins/two_factor/
mod.rs

1use aes_gcm::aead::{Aead, KeyInit, OsRng};
2use aes_gcm::{AeadCore, Aes256Gcm, Key, Nonce};
3use async_trait::async_trait;
4use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
5use chrono::{Duration, Utc};
6use hkdf::Hkdf;
7use hmac::{Hmac, Mac};
8use rand::Rng;
9use rand::distributions::Alphanumeric;
10use serde::{Deserialize, Serialize};
11use sha2::Sha256;
12use std::sync::Arc;
13use totp_rs::{Algorithm, TOTP};
14use validator::Validate;
15
16use better_auth_core::entity::{AuthSession, AuthTwoFactor, AuthUser, AuthVerification};
17use better_auth_core::utils::cookie_utils::{
18    create_clear_cookie, create_session_cookie, create_session_cookie_with_max_age,
19    create_session_like_cookie, related_cookie_name,
20};
21use better_auth_core::wire::UserView;
22use better_auth_core::{
23    AuthContext, AuthError, AuthRequest, AuthResponse, AuthResult, CreateTwoFactor,
24    CreateVerification, RequestMeta, TwoFactor, UpdateUser,
25};
26
27use crate::plugins::helpers::{
28    SessionIssueError, delete_session_cookie_headers, get_cookie, get_credential_password_hash,
29    issue_user_session,
30};
31
32use super::StatusResponse;
33
34#[cfg(test)]
35mod tests;
36
37const TWO_FACTOR_COOKIE_SUFFIX: &str = "two_factor";
38const TRUST_DEVICE_COOKIE_SUFFIX: &str = "trust_device";
39const DONT_REMEMBER_COOKIE_SUFFIX: &str = "dont_remember";
40
41const METADATA_ENABLED: &str = "two_factor.enabled";
42const METADATA_OTP_ENABLED: &str = "two_factor.otp_enabled";
43const METADATA_TWO_FACTOR_COOKIE_MAX_AGE: &str = "two_factor.two_factor_cookie_max_age";
44const METADATA_TRUST_DEVICE_MAX_AGE: &str = "two_factor.trust_device_max_age";
45
46const DEFAULT_TWO_FACTOR_COOKIE_MAX_AGE_SECS: i64 = 10 * 60;
47const DEFAULT_TRUST_DEVICE_MAX_AGE_SECS: i64 = 30 * 24 * 60 * 60;
48const DEFAULT_TOTP_PERIOD_SECS: u64 = 30;
49const DEFAULT_TOTP_DIGITS: usize = 6;
50const DEFAULT_OTP_DIGITS: usize = 6;
51const DEFAULT_OTP_LIFETIME_SECS: i64 = 3 * 60;
52const DEFAULT_OTP_ATTEMPT_LIMIT: usize = 5;
53const DEFAULT_BACKUP_CODE_COUNT: usize = 10;
54const DEFAULT_BACKUP_CODE_LENGTH: usize = 10;
55
56const ENCRYPTION_INFO: &[u8] = b"better-auth-two-factor-encryption";
57
58type HmacSha256 = Hmac<Sha256>;
59
60/// Callback used by the two-factor plugin to deliver a one-time password.
61#[async_trait]
62pub trait SendTwoFactorOtp: Send + Sync {
63    /// Send a one-time password to the given user.
64    async fn send(&self, user: &UserView, otp: &str) -> AuthResult<()>;
65}
66
67/// Two-factor authentication plugin providing TOTP, OTP, and backup code flows.
68#[derive(Clone)]
69pub struct TwoFactorPlugin {
70    config: TwoFactorConfig,
71}
72
73/// Public configuration for the two-factor plugin.
74#[derive(Clone, better_auth_core::PluginConfig)]
75#[plugin(name = "TwoFactorPlugin")]
76pub struct TwoFactorConfig {
77    /// Override the issuer embedded in generated TOTP URIs.
78    #[config(default = None)]
79    pub issuer: Option<String>,
80    /// Skip the enrollment verification step and enable 2FA immediately.
81    #[config(default = false)]
82    pub skip_verification_on_enable: bool,
83    /// Maximum lifetime for the pending two-factor cookie used during sign-in.
84    #[config(default = DEFAULT_TWO_FACTOR_COOKIE_MAX_AGE_SECS)]
85    pub two_factor_cookie_max_age: i64,
86    /// Maximum lifetime for the trusted-device cookie.
87    #[config(default = DEFAULT_TRUST_DEVICE_MAX_AGE_SECS)]
88    pub trust_device_max_age: i64,
89    /// TOTP period in seconds.
90    #[config(default = DEFAULT_TOTP_PERIOD_SECS)]
91    pub totp_period: u64,
92    /// TOTP digit count.
93    #[config(default = DEFAULT_TOTP_DIGITS)]
94    pub totp_digits: usize,
95    /// Optional OTP sender callback. When absent, `/two-factor/send-otp` is disabled.
96    #[config(default = None, skip)]
97    pub send_otp: Option<Arc<dyn SendTwoFactorOtp>>,
98}
99
100impl std::fmt::Debug for TwoFactorConfig {
101    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102        f.debug_struct("TwoFactorConfig")
103            .field("issuer", &self.issuer)
104            .field(
105                "skip_verification_on_enable",
106                &self.skip_verification_on_enable,
107            )
108            .field("two_factor_cookie_max_age", &self.two_factor_cookie_max_age)
109            .field("trust_device_max_age", &self.trust_device_max_age)
110            .field("totp_period", &self.totp_period)
111            .field("totp_digits", &self.totp_digits)
112            .field("send_otp", &self.send_otp.as_ref().map(|_| "custom"))
113            .finish()
114    }
115}
116
117#[derive(Debug, Deserialize, Validate)]
118pub(crate) struct EnableRequest {
119    password: String,
120    issuer: Option<String>,
121}
122
123#[derive(Debug, Deserialize, Validate)]
124pub(crate) struct DisableRequest {
125    password: String,
126}
127
128#[derive(Debug, Deserialize, Validate)]
129pub(crate) struct GetTotpUriRequest {
130    password: String,
131}
132
133#[derive(Debug, Deserialize, Validate)]
134pub(crate) struct VerifyTotpRequest {
135    code: String,
136    #[serde(rename = "trustDevice")]
137    trust_device: Option<bool>,
138}
139
140#[derive(Debug, Deserialize, Validate)]
141pub(crate) struct VerifyOtpRequest {
142    code: String,
143    #[serde(rename = "trustDevice")]
144    trust_device: Option<bool>,
145}
146
147#[derive(Debug, Deserialize, Validate)]
148pub(crate) struct GenerateBackupCodesRequest {
149    password: String,
150}
151
152#[derive(Debug, Deserialize, Validate)]
153pub(crate) struct VerifyBackupCodeRequest {
154    code: String,
155    #[serde(rename = "disableSession")]
156    disable_session: Option<bool>,
157    #[serde(rename = "trustDevice")]
158    trust_device: Option<bool>,
159}
160
161#[derive(Debug, Serialize)]
162pub(crate) struct EnableResponse {
163    #[serde(rename = "totpURI")]
164    totp_uri: String,
165    #[serde(rename = "backupCodes")]
166    backup_codes: Vec<String>,
167}
168
169#[derive(Debug, Serialize)]
170pub(crate) struct TotpUriResponse {
171    #[serde(rename = "totpURI")]
172    totp_uri: String,
173}
174
175#[derive(Debug, Serialize)]
176pub(crate) struct SessionTokenResponse<U: Serialize> {
177    token: String,
178    user: U,
179}
180
181#[derive(Debug, Serialize)]
182pub(crate) struct BackupCodesResponse {
183    status: bool,
184    #[serde(rename = "backupCodes")]
185    backup_codes: Vec<String>,
186}
187
188#[derive(Debug, Serialize)]
189pub(crate) struct TwoFactorRedirectResponse {
190    #[serde(rename = "twoFactorRedirect")]
191    two_factor_redirect: bool,
192    /// Second factors this user can actually complete, so the client knows
193    /// which challenge to present.
194    #[serde(rename = "twoFactorMethods")]
195    two_factor_methods: Vec<&'static str>,
196}
197
198struct PendingTwoFactorState<S: better_auth_core::AuthSchema> {
199    user: S::User,
200    verification: S::Verification,
201    key: String,
202    dont_remember: bool,
203}
204
205enum ResolvedTwoFactorState<S: better_auth_core::AuthSchema> {
206    Session {
207        user: S::User,
208        session: S::Session,
209        key: String,
210    },
211    Pending(PendingTwoFactorState<S>),
212}
213
214pub(crate) struct SignInTwoFactorRedirect {
215    pub response: TwoFactorRedirectResponse,
216    pub set_cookie_headers: Vec<String>,
217}
218
219pub(crate) struct TrustedDeviceCheck {
220    pub trusted: bool,
221    pub set_cookie_headers: Vec<String>,
222}
223
224pub(crate) fn is_enabled(ctx: &AuthContext<impl better_auth_core::AuthSchema>) -> bool {
225    ctx.get_metadata(METADATA_ENABLED)
226        .and_then(|value| value.as_bool())
227        .unwrap_or(false)
228}
229
230pub(crate) async fn inspect_trusted_device(
231    req: &AuthRequest,
232    user: &impl AuthUser,
233    ctx: &AuthContext<impl better_auth_core::AuthSchema>,
234) -> AuthResult<TrustedDeviceCheck> {
235    let cookie_name = related_cookie_name(&ctx.config, TRUST_DEVICE_COOKIE_SUFFIX);
236    let Some(raw_cookie) = get_cookie(req, &cookie_name) else {
237        return Ok(TrustedDeviceCheck {
238            trusted: false,
239            set_cookie_headers: Vec::new(),
240        });
241    };
242
243    let clear_header = create_clear_cookie(&cookie_name, &ctx.config);
244    let Some(signed_value) = verify_signed_cookie_value(&ctx.config.secret, &raw_cookie)? else {
245        return Ok(TrustedDeviceCheck {
246            trusted: false,
247            set_cookie_headers: vec![clear_header],
248        });
249    };
250
251    let Some((token, trust_identifier)) = signed_value.split_once('!') else {
252        return Ok(TrustedDeviceCheck {
253            trusted: false,
254            set_cookie_headers: vec![clear_header],
255        });
256    };
257
258    let expected_token = sign_value(
259        &ctx.config.secret,
260        &format!("{}!{}", user.id(), trust_identifier),
261    )?;
262    if token != expected_token {
263        return Ok(TrustedDeviceCheck {
264            trusted: false,
265            set_cookie_headers: vec![clear_header],
266        });
267    }
268
269    let Some(verification) = ctx
270        .database
271        .get_verification_by_identifier(trust_identifier)
272        .await?
273    else {
274        return Ok(TrustedDeviceCheck {
275            trusted: false,
276            set_cookie_headers: vec![clear_header],
277        });
278    };
279
280    if verification.value() != user.id().as_ref() || verification.expires_at() <= Utc::now() {
281        return Ok(TrustedDeviceCheck {
282            trusted: false,
283            set_cookie_headers: vec![clear_header],
284        });
285    }
286
287    ctx.database
288        .delete_verification(verification.id().as_ref())
289        .await?;
290
291    let rotated_cookie = create_trust_device_cookie_header(user, ctx).await?;
292    Ok(TrustedDeviceCheck {
293        trusted: true,
294        set_cookie_headers: vec![rotated_cookie],
295    })
296}
297
298pub(crate) async fn begin_sign_in_challenge(
299    user: &impl AuthUser,
300    remember_me: Option<bool>,
301    ctx: &AuthContext<impl better_auth_core::AuthSchema>,
302) -> AuthResult<SignInTwoFactorRedirect> {
303    let identifier = format!("2fa-{}", uuid::Uuid::new_v4());
304    _ = ctx
305        .database
306        .create_verification(CreateVerification {
307            identifier: identifier.clone(),
308            value: user.id().to_string(),
309            expires_at: Utc::now() + Duration::seconds(two_factor_cookie_max_age(ctx)),
310        })
311        .await?;
312
313    let mut headers = delete_session_cookie_headers(&ctx.config);
314    headers.retain(|cookie| {
315        !cookie.starts_with(&format!(
316            "{}=",
317            related_cookie_name(&ctx.config, DONT_REMEMBER_COOKIE_SUFFIX)
318        ))
319    });
320    headers.push(create_signed_cookie_header(
321        &ctx.config.secret,
322        &ctx.config,
323        TWO_FACTOR_COOKIE_SUFFIX,
324        &identifier,
325        Some(two_factor_cookie_max_age(ctx)),
326    )?);
327
328    if remember_me == Some(false) {
329        headers.push(create_signed_cookie_header(
330            &ctx.config.secret,
331            &ctx.config,
332            DONT_REMEMBER_COOKIE_SUFFIX,
333            "true",
334            None,
335        )?);
336    }
337
338    // TOTP is per-user: only offered once the user has a stored secret. OTP is
339    // server-level: offered whenever a sender is configured.
340    let mut two_factor_methods = Vec::new();
341    if ctx
342        .database
343        .get_two_factor_by_user_id(user.id().as_ref())
344        .await?
345        .is_some()
346    {
347        two_factor_methods.push("totp");
348    }
349    if ctx
350        .get_metadata(METADATA_OTP_ENABLED)
351        .and_then(|value| value.as_bool())
352        .unwrap_or(false)
353    {
354        two_factor_methods.push("otp");
355    }
356
357    Ok(SignInTwoFactorRedirect {
358        response: TwoFactorRedirectResponse {
359            two_factor_redirect: true,
360            two_factor_methods,
361        },
362        set_cookie_headers: headers,
363    })
364}
365
366impl TwoFactorPlugin {
367    /// Install a custom OTP sender.
368    pub fn custom_send_otp(mut self, sender: Arc<dyn SendTwoFactorOtp>) -> Self {
369        self.config.send_otp = Some(sender);
370        self
371    }
372
373    /// Read the currently stored backup codes for a user.
374    ///
375    /// This is the Rust server-side equivalent of the TypeScript
376    /// `auth.api.viewBackupCodes` capability. It is intentionally not exposed
377    /// as a public HTTP route.
378    pub async fn view_backup_codes<S: better_auth_core::AuthSchema>(
379        &self,
380        user_id: &str,
381        ctx: &AuthContext<S>,
382    ) -> AuthResult<Vec<String>> {
383        view_backup_codes_core(user_id, ctx).await
384    }
385}
386
387better_auth_core::impl_auth_plugin! {
388    TwoFactorPlugin, "two-factor";
389    routes {
390        post "/two-factor/enable" => handle_enable, "enable_two_factor";
391        post "/two-factor/disable" => handle_disable, "disable_two_factor";
392        post "/two-factor/get-totp-uri" => handle_get_totp_uri, "get_totp_uri";
393        post "/two-factor/verify-totp" => handle_verify_totp, "verify_totp";
394        post "/two-factor/send-otp" => handle_send_otp, "send_otp";
395        post "/two-factor/verify-otp" => handle_verify_otp, "verify_otp";
396        post "/two-factor/generate-backup-codes" => handle_generate_backup_codes, "generate_backup_codes";
397        post "/two-factor/verify-backup-code" => handle_verify_backup_code, "verify_backup_code";
398    }
399    extra {
400        async fn on_init(
401            &self,
402            ctx: &mut better_auth_core::AuthInitContext<S>,
403        ) -> better_auth_core::AuthResult<()> {
404            ctx.set_metadata(METADATA_ENABLED, serde_json::Value::Bool(true));
405            ctx.set_metadata(
406                METADATA_OTP_ENABLED,
407                serde_json::Value::Bool(self.config.send_otp.is_some()),
408            );
409            ctx.set_metadata(
410                METADATA_TWO_FACTOR_COOKIE_MAX_AGE,
411                serde_json::Value::Number(self.config.two_factor_cookie_max_age.into()),
412            );
413            ctx.set_metadata(
414                METADATA_TRUST_DEVICE_MAX_AGE,
415                serde_json::Value::Number(self.config.trust_device_max_age.into()),
416            );
417            Ok(())
418        }
419    }
420}
421
422impl TwoFactorPlugin {
423    async fn handle_enable(
424        &self,
425        req: &AuthRequest,
426        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
427    ) -> AuthResult<AuthResponse> {
428        let (user, session) = ctx.require_session(req).await?;
429        let body: EnableRequest = match better_auth_core::validate_request_body(req) {
430            Ok(v) => v,
431            Err(resp) => return Ok(resp),
432        };
433
434        let (response, set_cookie_headers) =
435            enable_core(&body, &user, &session, &self.config, ctx).await?;
436        let mut auth_response = AuthResponse::json(200, &response)?;
437        for cookie in set_cookie_headers {
438            auth_response = auth_response.with_appended_header("Set-Cookie", cookie);
439        }
440        Ok(auth_response)
441    }
442
443    async fn handle_disable(
444        &self,
445        req: &AuthRequest,
446        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
447    ) -> AuthResult<AuthResponse> {
448        let (user, session) = ctx.require_session(req).await?;
449        let body: DisableRequest = match better_auth_core::validate_request_body(req) {
450            Ok(v) => v,
451            Err(resp) => return Ok(resp),
452        };
453
454        let (response, set_cookie_headers) = disable_core(&body, &user, &session, req, ctx).await?;
455        let mut auth_response = AuthResponse::json(200, &response)?;
456        for cookie in set_cookie_headers {
457            auth_response = auth_response.with_appended_header("Set-Cookie", cookie);
458        }
459        Ok(auth_response)
460    }
461
462    async fn handle_get_totp_uri(
463        &self,
464        req: &AuthRequest,
465        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
466    ) -> AuthResult<AuthResponse> {
467        let (user, _session) = ctx.require_session(req).await?;
468        let body: GetTotpUriRequest = match better_auth_core::validate_request_body(req) {
469            Ok(v) => v,
470            Err(resp) => return Ok(resp),
471        };
472
473        let response = get_totp_uri_core(&body, &user, &self.config, ctx).await?;
474        AuthResponse::json(200, &response).map_err(AuthError::from)
475    }
476
477    async fn handle_verify_totp(
478        &self,
479        req: &AuthRequest,
480        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
481    ) -> AuthResult<AuthResponse> {
482        let body: VerifyTotpRequest = match better_auth_core::validate_request_body(req) {
483            Ok(v) => v,
484            Err(resp) => return Ok(resp),
485        };
486
487        let (response, set_cookie_headers) =
488            verify_totp_core(req, &body, &self.config, ctx).await?;
489        let mut auth_response = AuthResponse::json(200, &response)?;
490        for cookie in set_cookie_headers {
491            auth_response = auth_response.with_appended_header("Set-Cookie", cookie);
492        }
493        Ok(auth_response)
494    }
495
496    async fn handle_send_otp(
497        &self,
498        req: &AuthRequest,
499        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
500    ) -> AuthResult<AuthResponse> {
501        let response = send_otp_core(req, &self.config, ctx).await?;
502        AuthResponse::json(200, &response).map_err(AuthError::from)
503    }
504
505    async fn handle_verify_otp(
506        &self,
507        req: &AuthRequest,
508        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
509    ) -> AuthResult<AuthResponse> {
510        let body: VerifyOtpRequest = match better_auth_core::validate_request_body(req) {
511            Ok(v) => v,
512            Err(resp) => return Ok(resp),
513        };
514
515        let (response, set_cookie_headers) = verify_otp_core(req, &body, ctx).await?;
516        let mut auth_response = AuthResponse::json(200, &response)?;
517        for cookie in set_cookie_headers {
518            auth_response = auth_response.with_appended_header("Set-Cookie", cookie);
519        }
520        Ok(auth_response)
521    }
522
523    async fn handle_generate_backup_codes(
524        &self,
525        req: &AuthRequest,
526        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
527    ) -> AuthResult<AuthResponse> {
528        let (user, _session) = ctx.require_session(req).await?;
529        let body: GenerateBackupCodesRequest = match better_auth_core::validate_request_body(req) {
530            Ok(v) => v,
531            Err(resp) => return Ok(resp),
532        };
533
534        let response = generate_backup_codes_core(&body, &user, ctx).await?;
535        AuthResponse::json(200, &response).map_err(AuthError::from)
536    }
537
538    async fn handle_verify_backup_code(
539        &self,
540        req: &AuthRequest,
541        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
542    ) -> AuthResult<AuthResponse> {
543        let body: VerifyBackupCodeRequest = match better_auth_core::validate_request_body(req) {
544            Ok(v) => v,
545            Err(resp) => return Ok(resp),
546        };
547
548        let (response, set_cookie_headers) = verify_backup_code_core(req, &body, ctx).await?;
549        let mut auth_response = AuthResponse::json(200, &response)?;
550        for cookie in set_cookie_headers {
551            auth_response = auth_response.with_appended_header("Set-Cookie", cookie);
552        }
553        Ok(auth_response)
554    }
555}
556
557async fn enable_core(
558    body: &EnableRequest,
559    user: &impl AuthUser,
560    current_session: &impl AuthSession,
561    config: &TwoFactorConfig,
562    ctx: &AuthContext<impl better_auth_core::AuthSchema>,
563) -> AuthResult<(EnableResponse, Vec<String>)> {
564    verify_user_password(ctx, user, &body.password).await?;
565
566    let _ = ctx.database.delete_two_factor(user.id().as_ref()).await;
567
568    let secret = generate_secret();
569    let encrypted_secret = encrypt_value(&ctx.config.secret, &secret)?;
570    let backup_codes = generate_backup_codes();
571    let encrypted_backup_codes =
572        encrypt_value(&ctx.config.secret, &serde_json::to_string(&backup_codes)?)?;
573
574    _ = ctx
575        .database
576        .create_two_factor(CreateTwoFactor {
577            user_id: user.id().to_string(),
578            secret: encrypted_secret,
579            backup_codes: encrypted_backup_codes,
580        })
581        .await?;
582
583    let mut set_cookie_headers = Vec::new();
584    if config.skip_verification_on_enable {
585        let updated_user = ctx
586            .database
587            .update_user(
588                user.id().as_ref(),
589                UpdateUser {
590                    two_factor_enabled: Some(true),
591                    ..Default::default()
592                },
593            )
594            .await?;
595        let issued = issue_user_session(
596            ctx,
597            updated_user.id().as_ref(),
598            current_session.ip_address().map(str::to_owned),
599            current_session.user_agent().map(str::to_owned),
600        )
601        .await
602        .map_err(SessionIssueError::into_auth_error)?;
603        ctx.database.delete_session(current_session.token()).await?;
604        set_cookie_headers.push(create_session_cookie(issued.session.token(), &ctx.config));
605    }
606
607    let totp_uri = build_totp(config, &secret, body.issuer.as_deref(), user, ctx)?.get_url();
608    Ok((
609        EnableResponse {
610            totp_uri,
611            backup_codes,
612        },
613        set_cookie_headers,
614    ))
615}
616
617async fn disable_core(
618    body: &DisableRequest,
619    user: &impl AuthUser,
620    current_session: &impl AuthSession,
621    req: &AuthRequest,
622    ctx: &AuthContext<impl better_auth_core::AuthSchema>,
623) -> AuthResult<(StatusResponse, Vec<String>)> {
624    verify_user_password(ctx, user, &body.password).await?;
625
626    ctx.database.delete_two_factor(user.id().as_ref()).await?;
627
628    let updated_user = ctx
629        .database
630        .update_user(
631            user.id().as_ref(),
632            UpdateUser {
633                two_factor_enabled: Some(false),
634                ..Default::default()
635            },
636        )
637        .await?;
638
639    let issued = issue_user_session(
640        ctx,
641        updated_user.id().as_ref(),
642        current_session.ip_address().map(str::to_owned),
643        current_session.user_agent().map(str::to_owned),
644    )
645    .await
646    .map_err(SessionIssueError::into_auth_error)?;
647    ctx.database.delete_session(current_session.token()).await?;
648
649    let mut set_cookie_headers = vec![create_session_cookie(issued.session.token(), &ctx.config)];
650
651    if let Some(trust_cookie) = read_signed_cookie(req, TRUST_DEVICE_COOKIE_SUFFIX, ctx)? {
652        if let Some((_, trust_identifier)) = trust_cookie.split_once('!')
653            && let Some(verification) = ctx
654                .database
655                .get_verification_by_identifier(trust_identifier)
656                .await?
657        {
658            let _ = ctx
659                .database
660                .delete_verification(verification.id().as_ref())
661                .await;
662        }
663        set_cookie_headers.push(clear_cookie_header(&ctx.config, TRUST_DEVICE_COOKIE_SUFFIX));
664    }
665
666    Ok((StatusResponse { status: true }, set_cookie_headers))
667}
668
669async fn get_totp_uri_core(
670    body: &GetTotpUriRequest,
671    user: &impl AuthUser,
672    config: &TwoFactorConfig,
673    ctx: &AuthContext<impl better_auth_core::AuthSchema>,
674) -> AuthResult<TotpUriResponse> {
675    verify_user_password(ctx, user, &body.password).await?;
676    let two_factor = load_two_factor_record(user, ctx).await?;
677    let secret = decrypt_value(&ctx.config.secret, two_factor.secret())?;
678    Ok(TotpUriResponse {
679        totp_uri: build_totp(config, &secret, None, user, ctx)?.get_url(),
680    })
681}
682
683async fn verify_totp_core(
684    req: &AuthRequest,
685    body: &VerifyTotpRequest,
686    config: &TwoFactorConfig,
687    ctx: &AuthContext<impl better_auth_core::AuthSchema>,
688) -> AuthResult<(SessionTokenResponse<UserView>, Vec<String>)> {
689    let state = resolve_two_factor_state(req, ctx).await?;
690    let two_factor = load_two_factor_record(state.user(), ctx).await?;
691    let secret = decrypt_value(&ctx.config.secret, two_factor.secret())?;
692    let totp = build_totp(config, &secret, None, state.user(), ctx)?;
693
694    if !totp
695        .check_current(&body.code)
696        .map_err(|error| AuthError::internal(format!("Failed to verify TOTP: {}", error)))?
697    {
698        return Err(AuthError::authentication_failed("Invalid code"));
699    }
700
701    match state {
702        ResolvedTwoFactorState::Session { user, session, .. } => {
703            verify_existing_session_factor(user, session, true, ctx).await
704        }
705        ResolvedTwoFactorState::Pending(pending) => {
706            finalize_pending_two_factor(pending, req, body.trust_device.unwrap_or(false), true, ctx)
707                .await
708        }
709    }
710}
711
712async fn send_otp_core(
713    req: &AuthRequest,
714    config: &TwoFactorConfig,
715    ctx: &AuthContext<impl better_auth_core::AuthSchema>,
716) -> AuthResult<StatusResponse> {
717    let sender = config
718        .send_otp
719        .as_ref()
720        .ok_or_else(|| AuthError::bad_request("otp isn't configured"))?;
721    let state = resolve_two_factor_state(req, ctx).await?;
722
723    let otp = format!(
724        "{:0width$}",
725        rand::thread_rng().gen_range(0..10u32.pow(DEFAULT_OTP_DIGITS as u32)),
726        width = DEFAULT_OTP_DIGITS
727    );
728    let hashed_otp = better_auth_core::hash_password(None, &otp).await?;
729    let identifier = otp_verification_identifier(state.key());
730
731    if let Some(existing) = ctx
732        .database
733        .get_verification_by_identifier(&identifier)
734        .await?
735    {
736        ctx.database
737            .delete_verification(existing.id().as_ref())
738            .await?;
739    }
740
741    _ = ctx
742        .database
743        .create_verification(CreateVerification {
744            identifier,
745            value: format!("{}:0", hashed_otp),
746            expires_at: Utc::now() + Duration::seconds(DEFAULT_OTP_LIFETIME_SECS),
747        })
748        .await?;
749
750    if let Err(error) = sender.send(&UserView::from(state.user()), &otp).await {
751        tracing::warn!(error = %error, "Failed to send two-factor OTP");
752    }
753
754    Ok(StatusResponse { status: true })
755}
756
757async fn verify_otp_core(
758    req: &AuthRequest,
759    body: &VerifyOtpRequest,
760    ctx: &AuthContext<impl better_auth_core::AuthSchema>,
761) -> AuthResult<(SessionTokenResponse<UserView>, Vec<String>)> {
762    let state = resolve_two_factor_state(req, ctx).await?;
763    let identifier = otp_verification_identifier(state.key());
764    let Some(verification) = ctx
765        .database
766        .get_verification_by_identifier(&identifier)
767        .await?
768    else {
769        return Err(AuthError::bad_request("OTP has expired"));
770    };
771
772    if verification.expires_at() <= Utc::now() {
773        ctx.database
774            .delete_verification(verification.id().as_ref())
775            .await?;
776        return Err(AuthError::bad_request("OTP has expired"));
777    }
778
779    let Some((stored_hash, counter)) = verification.value().rsplit_once(':') else {
780        return Err(AuthError::internal("Malformed OTP verification payload"));
781    };
782
783    let attempts = counter.parse::<usize>().map_err(|error| {
784        AuthError::internal(format!("Malformed OTP attempt counter: {}", error))
785    })?;
786    if attempts >= DEFAULT_OTP_ATTEMPT_LIMIT {
787        ctx.database
788            .delete_verification(verification.id().as_ref())
789            .await?;
790        return Err(AuthError::bad_request(
791            "Too many attempts. Please request a new code.",
792        ));
793    }
794
795    let is_valid = match better_auth_core::verify_password(None, &body.code, stored_hash).await {
796        Ok(()) => true,
797        Err(AuthError::InvalidCredentials) => false,
798        Err(error) => return Err(error),
799    };
800
801    if !is_valid {
802        let next_value = format!("{}:{}", stored_hash, attempts + 1);
803        let expires_at = verification.expires_at();
804        let verification_identifier = verification.identifier().to_string();
805        ctx.database
806            .delete_verification(verification.id().as_ref())
807            .await?;
808        _ = ctx
809            .database
810            .create_verification(CreateVerification {
811                identifier: verification_identifier,
812                value: next_value,
813                expires_at,
814            })
815            .await?;
816        return Err(AuthError::authentication_failed("Invalid code"));
817    }
818
819    ctx.database
820        .delete_verification(verification.id().as_ref())
821        .await?;
822
823    match state {
824        ResolvedTwoFactorState::Session { user, session, .. } => {
825            verify_existing_session_factor(user, session, true, ctx).await
826        }
827        ResolvedTwoFactorState::Pending(pending) => {
828            finalize_pending_two_factor(pending, req, body.trust_device.unwrap_or(false), true, ctx)
829                .await
830        }
831    }
832}
833
834async fn generate_backup_codes_core(
835    body: &GenerateBackupCodesRequest,
836    user: &impl AuthUser,
837    ctx: &AuthContext<impl better_auth_core::AuthSchema>,
838) -> AuthResult<BackupCodesResponse> {
839    if !user.two_factor_enabled() {
840        return Err(AuthError::bad_request("Two factor isn't enabled"));
841    }
842
843    verify_user_password(ctx, user, &body.password).await?;
844    let _ = load_two_factor_record(user, ctx).await?;
845
846    let backup_codes = generate_backup_codes();
847    let encrypted = encrypt_value(&ctx.config.secret, &serde_json::to_string(&backup_codes)?)?;
848    _ = ctx
849        .database
850        .update_two_factor_backup_codes(user.id().as_ref(), &encrypted)
851        .await?;
852
853    Ok(BackupCodesResponse {
854        status: true,
855        backup_codes,
856    })
857}
858
859async fn verify_backup_code_core(
860    req: &AuthRequest,
861    body: &VerifyBackupCodeRequest,
862    ctx: &AuthContext<impl better_auth_core::AuthSchema>,
863) -> AuthResult<(SessionTokenResponse<UserView>, Vec<String>)> {
864    let state = resolve_two_factor_state(req, ctx).await?;
865    let two_factor = ctx
866        .database
867        .get_two_factor_by_user_id(state.user().id().as_ref())
868        .await?
869        .ok_or_else(|| AuthError::bad_request("Backup codes aren't enabled"))?;
870
871    let Some(mut backup_codes) =
872        decrypt_backup_codes(two_factor.backup_codes(), &ctx.config.secret)?
873    else {
874        return Err(AuthError::authentication_failed("Invalid backup code"));
875    };
876    let Some(index) = backup_codes
877        .iter()
878        .position(|candidate| candidate == &body.code)
879    else {
880        return Err(AuthError::authentication_failed("Invalid backup code"));
881    };
882    let _ = backup_codes.remove(index);
883
884    let encrypted = encrypt_value(&ctx.config.secret, &serde_json::to_string(&backup_codes)?)?;
885    _ = ctx
886        .database
887        .update_two_factor_backup_codes(state.user().id().as_ref(), &encrypted)
888        .await?;
889
890    match state {
891        ResolvedTwoFactorState::Session { user, session, .. } => {
892            if body.disable_session.unwrap_or(false) {
893                Ok((
894                    SessionTokenResponse {
895                        token: session.token().to_string(),
896                        user: UserView::from(&user),
897                    },
898                    Vec::new(),
899                ))
900            } else {
901                verify_existing_session_factor(user, session, false, ctx).await
902            }
903        }
904        ResolvedTwoFactorState::Pending(pending) => {
905            finalize_pending_two_factor(
906                pending,
907                req,
908                body.trust_device.unwrap_or(false),
909                !body.disable_session.unwrap_or(false),
910                ctx,
911            )
912            .await
913        }
914    }
915}
916
917async fn view_backup_codes_core<S: better_auth_core::AuthSchema>(
918    user_id: &str,
919    ctx: &AuthContext<S>,
920) -> AuthResult<Vec<String>> {
921    let two_factor = ctx
922        .database
923        .get_two_factor_by_user_id(user_id)
924        .await?
925        .ok_or_else(|| AuthError::bad_request("Backup codes aren't enabled"))?;
926    let Some(backup_codes) = decrypt_backup_codes(two_factor.backup_codes(), &ctx.config.secret)?
927    else {
928        return Err(AuthError::bad_request("Invalid backup code"));
929    };
930    Ok(backup_codes)
931}
932
933async fn resolve_two_factor_state<S: better_auth_core::AuthSchema>(
934    req: &AuthRequest,
935    ctx: &AuthContext<S>,
936) -> AuthResult<ResolvedTwoFactorState<S>> {
937    if let Ok((user, session)) = ctx.require_session(req).await {
938        let key = format!("{}!{}", user.id(), session.id());
939        return Ok(ResolvedTwoFactorState::Session { user, session, key });
940    }
941
942    let identifier = read_signed_cookie(req, TWO_FACTOR_COOKIE_SUFFIX, ctx)?
943        .ok_or_else(|| AuthError::authentication_failed("Invalid two factor cookie"))?;
944    let verification = ctx
945        .database
946        .get_verification_by_identifier(&identifier)
947        .await?
948        .ok_or_else(|| AuthError::authentication_failed("Invalid two factor cookie"))?;
949    if verification.expires_at() <= Utc::now() {
950        ctx.database
951            .delete_verification(verification.id().as_ref())
952            .await?;
953        return Err(AuthError::authentication_failed(
954            "Invalid two factor cookie",
955        ));
956    }
957
958    let user = ctx
959        .database
960        .get_user_by_id(verification.value())
961        .await?
962        .ok_or_else(|| AuthError::authentication_failed("Invalid two factor cookie"))?;
963    let dont_remember = read_signed_cookie(req, DONT_REMEMBER_COOKIE_SUFFIX, ctx)?.is_some();
964
965    Ok(ResolvedTwoFactorState::Pending(PendingTwoFactorState {
966        user,
967        verification,
968        key: identifier,
969        dont_remember,
970    }))
971}
972
973async fn verify_existing_session_factor(
974    user: impl AuthUser,
975    session: impl AuthSession,
976    enable_two_factor_if_needed: bool,
977    ctx: &AuthContext<impl better_auth_core::AuthSchema>,
978) -> AuthResult<(SessionTokenResponse<UserView>, Vec<String>)> {
979    if enable_two_factor_if_needed && !user.two_factor_enabled() {
980        let updated_user = ctx
981            .database
982            .update_user(
983                user.id().as_ref(),
984                UpdateUser {
985                    two_factor_enabled: Some(true),
986                    ..Default::default()
987                },
988            )
989            .await?;
990        let issued = issue_user_session(
991            ctx,
992            updated_user.id().as_ref(),
993            session.ip_address().map(str::to_owned),
994            session.user_agent().map(str::to_owned),
995        )
996        .await
997        .map_err(SessionIssueError::into_auth_error)?;
998        ctx.database.delete_session(session.token()).await?;
999        return Ok((
1000            SessionTokenResponse {
1001                token: issued.session.token().to_string(),
1002                // TS keeps the verify response on the pre-update snapshot even
1003                // though the re-issued session already observes 2FA as enabled.
1004                user: UserView::from(&user),
1005            },
1006            vec![create_session_cookie(issued.session.token(), &ctx.config)],
1007        ));
1008    }
1009
1010    Ok((
1011        SessionTokenResponse {
1012            token: session.token().to_string(),
1013            user: UserView::from(&user),
1014        },
1015        Vec::new(),
1016    ))
1017}
1018
1019async fn finalize_pending_two_factor<S: better_auth_core::AuthSchema>(
1020    pending: PendingTwoFactorState<S>,
1021    req: &AuthRequest,
1022    trust_device: bool,
1023    set_session_cookie: bool,
1024    ctx: &AuthContext<S>,
1025) -> AuthResult<(SessionTokenResponse<UserView>, Vec<String>)> {
1026    let meta = RequestMeta::from_request(req);
1027    let issued = issue_user_session(
1028        ctx,
1029        pending.user.id().as_ref(),
1030        meta.ip_address,
1031        meta.user_agent,
1032    )
1033    .await
1034    .map_err(SessionIssueError::into_auth_error)?;
1035    ctx.database
1036        .delete_verification(pending.verification.id().as_ref())
1037        .await?;
1038
1039    let mut set_cookie_headers = vec![clear_cookie_header(&ctx.config, TWO_FACTOR_COOKIE_SUFFIX)];
1040    if set_session_cookie {
1041        set_cookie_headers.push(create_session_cookie_for_dont_remember(
1042            issued.session.token(),
1043            pending.dont_remember,
1044            &ctx.config,
1045        ));
1046        if pending.dont_remember {
1047            set_cookie_headers.push(create_signed_cookie_header(
1048                &ctx.config.secret,
1049                &ctx.config,
1050                DONT_REMEMBER_COOKIE_SUFFIX,
1051                "true",
1052                None,
1053            )?);
1054        }
1055    }
1056    if trust_device {
1057        set_cookie_headers.push(create_trust_device_cookie_header(&issued.user, ctx).await?);
1058        set_cookie_headers.push(clear_cookie_header(
1059            &ctx.config,
1060            DONT_REMEMBER_COOKIE_SUFFIX,
1061        ));
1062    }
1063
1064    Ok((
1065        SessionTokenResponse {
1066            token: issued.session.token().to_string(),
1067            user: UserView::from(&issued.user),
1068        },
1069        set_cookie_headers,
1070    ))
1071}
1072
1073async fn load_two_factor_record(
1074    user: &impl AuthUser,
1075    ctx: &AuthContext<impl better_auth_core::AuthSchema>,
1076) -> AuthResult<TwoFactor> {
1077    ctx.database
1078        .get_two_factor_by_user_id(user.id().as_ref())
1079        .await?
1080        .ok_or_else(|| AuthError::bad_request("TOTP not enabled"))
1081}
1082
1083fn build_totp(
1084    config: &TwoFactorConfig,
1085    secret: &str,
1086    request_issuer: Option<&str>,
1087    user: &impl AuthUser,
1088    ctx: &AuthContext<impl better_auth_core::AuthSchema>,
1089) -> AuthResult<TOTP> {
1090    let issuer = request_issuer
1091        .map(str::to_owned)
1092        .or_else(|| config.issuer.clone())
1093        .unwrap_or_else(|| ctx.config.app_name.clone());
1094    let account_name = user.email().unwrap_or("user").to_string();
1095    TOTP::new(
1096        Algorithm::SHA1,
1097        config.totp_digits,
1098        1,
1099        config.totp_period,
1100        secret.as_bytes().to_vec(),
1101        Some(issuer),
1102        account_name,
1103    )
1104    .map_err(|error| AuthError::internal(format!("Failed to create TOTP: {}", error)))
1105}
1106
1107async fn verify_user_password(
1108    ctx: &AuthContext<impl better_auth_core::AuthSchema>,
1109    user: &impl AuthUser,
1110    password: &str,
1111) -> AuthResult<()> {
1112    let stored_hash = get_credential_password_hash(ctx, user)
1113        .await?
1114        .ok_or_else(|| AuthError::bad_request("Invalid password"))?;
1115    match better_auth_core::verify_password(None, password, &stored_hash).await {
1116        Ok(()) => Ok(()),
1117        Err(AuthError::InvalidCredentials) => Err(AuthError::bad_request("Invalid password")),
1118        Err(error) => Err(error),
1119    }
1120}
1121
1122fn generate_secret() -> String {
1123    rand::thread_rng()
1124        .sample_iter(&Alphanumeric)
1125        .take(32)
1126        .map(char::from)
1127        .collect()
1128}
1129
1130fn generate_backup_codes() -> Vec<String> {
1131    (0..DEFAULT_BACKUP_CODE_COUNT)
1132        .map(|_| {
1133            rand::thread_rng()
1134                .sample_iter(&Alphanumeric)
1135                .take(DEFAULT_BACKUP_CODE_LENGTH)
1136                .map(char::from)
1137                .collect::<String>()
1138        })
1139        .map(|code| format!("{}-{}", &code[..5], &code[5..]))
1140        .collect()
1141}
1142
1143fn decrypt_backup_codes(backup_codes: &str, secret: &str) -> AuthResult<Option<Vec<String>>> {
1144    let decrypted = decrypt_value(secret, backup_codes)?;
1145    serde_json::from_str(&decrypted)
1146        .ok()
1147        .map_or(Ok(None), |codes| Ok(Some(codes)))
1148}
1149
1150fn otp_verification_identifier(key: &str) -> String {
1151    format!("2fa-otp-{}", key)
1152}
1153
1154fn two_factor_cookie_max_age(ctx: &AuthContext<impl better_auth_core::AuthSchema>) -> i64 {
1155    ctx.get_metadata(METADATA_TWO_FACTOR_COOKIE_MAX_AGE)
1156        .and_then(|value| value.as_i64())
1157        .unwrap_or(DEFAULT_TWO_FACTOR_COOKIE_MAX_AGE_SECS)
1158}
1159
1160fn trust_device_max_age(ctx: &AuthContext<impl better_auth_core::AuthSchema>) -> i64 {
1161    ctx.get_metadata(METADATA_TRUST_DEVICE_MAX_AGE)
1162        .and_then(|value| value.as_i64())
1163        .unwrap_or(DEFAULT_TRUST_DEVICE_MAX_AGE_SECS)
1164}
1165
1166fn create_session_cookie_for_dont_remember(
1167    token: &str,
1168    dont_remember: bool,
1169    config: &better_auth_core::AuthConfig,
1170) -> String {
1171    if dont_remember {
1172        create_session_cookie_with_max_age(Some(token), None, config)
1173    } else {
1174        create_session_cookie(token, config)
1175    }
1176}
1177
1178fn clear_cookie_header(config: &better_auth_core::AuthConfig, suffix: &str) -> String {
1179    create_clear_cookie(&related_cookie_name(config, suffix), config)
1180}
1181
1182async fn create_trust_device_cookie_header(
1183    user: &impl AuthUser,
1184    ctx: &AuthContext<impl better_auth_core::AuthSchema>,
1185) -> AuthResult<String> {
1186    let identifier = format!("trust-device-{}", uuid::Uuid::new_v4());
1187    let token = sign_value(&ctx.config.secret, &format!("{}!{}", user.id(), identifier))?;
1188    let value = format!("{}!{}", token, identifier);
1189    let expires_at = Utc::now() + Duration::seconds(trust_device_max_age(ctx));
1190    _ = ctx
1191        .database
1192        .create_verification(CreateVerification {
1193            identifier: identifier.clone(),
1194            value: user.id().to_string(),
1195            expires_at,
1196        })
1197        .await?;
1198    create_signed_cookie_header(
1199        &ctx.config.secret,
1200        &ctx.config,
1201        TRUST_DEVICE_COOKIE_SUFFIX,
1202        &value,
1203        Some(trust_device_max_age(ctx)),
1204    )
1205}
1206
1207fn create_signed_cookie_header(
1208    secret: &str,
1209    config: &better_auth_core::AuthConfig,
1210    suffix: &str,
1211    value: &str,
1212    max_age_seconds: Option<i64>,
1213) -> AuthResult<String> {
1214    let cookie_name = related_cookie_name(config, suffix);
1215    let signed_value = sign_cookie_value(secret, value)?;
1216    Ok(create_session_like_cookie(
1217        &cookie_name,
1218        &signed_value,
1219        max_age_seconds,
1220        config,
1221    ))
1222}
1223
1224fn read_signed_cookie<S: better_auth_core::AuthSchema>(
1225    req: &AuthRequest,
1226    suffix: &str,
1227    ctx: &AuthContext<S>,
1228) -> AuthResult<Option<String>> {
1229    let cookie_name = related_cookie_name(&ctx.config, suffix);
1230    let Some(raw_cookie) = get_cookie(req, &cookie_name) else {
1231        return Ok(None);
1232    };
1233    verify_signed_cookie_value(&ctx.config.secret, &raw_cookie)
1234}
1235
1236fn sign_cookie_value(secret: &str, value: &str) -> AuthResult<String> {
1237    Ok(format!("{}.{}", value, sign_value(secret, value)?))
1238}
1239
1240fn verify_signed_cookie_value(secret: &str, signed_value: &str) -> AuthResult<Option<String>> {
1241    let Some((value, signature)) = signed_value.rsplit_once('.') else {
1242        return Ok(None);
1243    };
1244    Ok(verify_signature(secret, value, signature)?.then(|| value.to_string()))
1245}
1246
1247fn sign_value(secret: &str, value: &str) -> AuthResult<String> {
1248    let mut mac = <HmacSha256 as Mac>::new_from_slice(secret.as_bytes())
1249        .map_err(|error| AuthError::internal(format!("Failed to initialize HMAC: {}", error)))?;
1250    mac.update(value.as_bytes());
1251    Ok(URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()))
1252}
1253
1254fn verify_signature(secret: &str, value: &str, signature: &str) -> AuthResult<bool> {
1255    let decoded = match URL_SAFE_NO_PAD.decode(signature) {
1256        Ok(decoded) => decoded,
1257        Err(_) => return Ok(false),
1258    };
1259    let mut mac = <HmacSha256 as Mac>::new_from_slice(secret.as_bytes())
1260        .map_err(|error| AuthError::internal(format!("Failed to initialize HMAC: {}", error)))?;
1261    mac.update(value.as_bytes());
1262    Ok(mac.verify_slice(&decoded).is_ok())
1263}
1264
1265fn derive_encryption_key(secret: &str) -> AuthResult<Key<Aes256Gcm>> {
1266    let hkdf = Hkdf::<Sha256>::new(None, secret.as_bytes());
1267    let mut okm = [0u8; 32];
1268    hkdf.expand(ENCRYPTION_INFO, &mut okm).map_err(|error| {
1269        AuthError::internal(format!("Failed to derive encryption key: {}", error))
1270    })?;
1271    Ok(*Key::<Aes256Gcm>::from_slice(&okm))
1272}
1273
1274fn encrypt_value(secret: &str, plaintext: &str) -> AuthResult<String> {
1275    let cipher = Aes256Gcm::new(&derive_encryption_key(secret)?);
1276    let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
1277    let ciphertext = cipher
1278        .encrypt(&nonce, plaintext.as_bytes())
1279        .map_err(|error| {
1280            AuthError::internal(format!("Failed to encrypt two-factor data: {}", error))
1281        })?;
1282    let mut output = nonce.to_vec();
1283    output.extend_from_slice(&ciphertext);
1284    Ok(URL_SAFE_NO_PAD.encode(output))
1285}
1286
1287fn decrypt_value(secret: &str, encrypted: &str) -> AuthResult<String> {
1288    let cipher = Aes256Gcm::new(&derive_encryption_key(secret)?);
1289    let bytes = URL_SAFE_NO_PAD.decode(encrypted).map_err(|error| {
1290        AuthError::internal(format!(
1291            "Failed to decode encrypted two-factor data: {}",
1292            error
1293        ))
1294    })?;
1295    if bytes.len() < 12 {
1296        return Err(AuthError::internal(
1297            "Encrypted two-factor payload is missing the nonce",
1298        ));
1299    }
1300    let (nonce_bytes, ciphertext) = bytes.split_at(12);
1301    let plaintext = cipher
1302        .decrypt(Nonce::from_slice(nonce_bytes), ciphertext)
1303        .map_err(|error| {
1304            AuthError::internal(format!("Failed to decrypt two-factor data: {}", error))
1305        })?;
1306    String::from_utf8(plaintext).map_err(|error| {
1307        AuthError::internal(format!(
1308            "Two-factor plaintext is not valid UTF-8: {}",
1309            error
1310        ))
1311    })
1312}
1313
1314impl<S: better_auth_core::AuthSchema> ResolvedTwoFactorState<S> {
1315    fn user(&self) -> &S::User {
1316        match self {
1317            Self::Session { user, .. } => user,
1318            Self::Pending(pending) => &pending.user,
1319        }
1320    }
1321
1322    fn key(&self) -> &str {
1323        match self {
1324            Self::Session { key, .. } => key,
1325            Self::Pending(pending) => &pending.key,
1326        }
1327    }
1328}