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