Skip to main content

better_auth_api/plugins/
email_otp.rs

1//! Email OTP authentication compatible with Better Auth's `emailOTP` plugin.
2
3use async_trait::async_trait;
4use better_auth_core::{AuthContext, AuthRequest, AuthResponse, AuthResult};
5use chrono::Duration;
6use serde::{Deserialize, Serialize};
7use std::sync::Arc;
8
9pub(crate) mod callbacks;
10pub use callbacks::EmailOtpCallbacks;
11mod handlers;
12mod native;
13mod otp;
14pub use native::EmailOtpApi;
15mod request;
16
17/// Purpose of an email OTP.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "kebab-case")]
20pub enum EmailOtpType {
21    /// Prove ownership of an email before signing in.
22    SignIn,
23    /// Mark an existing user's email as verified.
24    EmailVerification,
25    /// Reset the credential password.
26    ForgetPassword,
27    /// Verify the destination of an authenticated email change.
28    ChangeEmail,
29}
30
31impl EmailOtpType {
32    fn as_str(self) -> &'static str {
33        match self {
34            Self::SignIn => "sign-in",
35            Self::EmailVerification => "email-verification",
36            Self::ForgetPassword => "forget-password",
37            Self::ChangeEmail => "change-email",
38        }
39    }
40    fn identifier(self, email: &str) -> String {
41        format!("{}-otp-{email}", self.as_str())
42    }
43}
44
45/// Message delivered by the application's OTP sender.
46#[derive(Debug, Clone, Serialize)]
47pub struct EmailOtpMessage {
48    /// Normalized recipient email.
49    pub email: String,
50    /// Plain OTP sent to the mailbox owner.
51    pub otp: String,
52    /// Verification purpose.
53    #[serde(rename = "type")]
54    pub kind: EmailOtpType,
55}
56
57/// Application-owned delivery of email OTPs.
58#[async_trait]
59pub trait SendEmailOtp: Send + Sync {
60    /// Deliver an OTP; the plugin logs delivery errors like upstream background tasks.
61    async fn send(&self, message: &EmailOtpMessage) -> AuthResult<()>;
62}
63
64/// OTP persistence protection.
65#[derive(Clone, Default)]
66pub enum EmailOtpStorage {
67    /// Store the code unchanged, matching the upstream default.
68    #[default]
69    Plain,
70    /// Store a SHA-256 digest. Resends cannot recover this code.
71    Hashed,
72    /// Encrypt with the auth secret before persistence.
73    Encrypted,
74    /// Application-owned irreversible hashing.
75    CustomHash(super::one_time_token::TokenHasher),
76    /// Application-owned reversible encryption.
77    CustomEncryption(Arc<dyn EmailOtpCodec>),
78}
79
80/// Application encryption and decryption for stored OTPs.
81#[async_trait]
82pub trait EmailOtpCodec: Send + Sync {
83    /// Encrypt an OTP for persistence.
84    async fn encode(&self, otp: &str) -> AuthResult<String>;
85    /// Decrypt a stored OTP. Propagate invalid ciphertext and application failures.
86    async fn decode(&self, stored: &str) -> AuthResult<String>;
87}
88
89/// Application generator for deterministic or custom-format codes.
90pub type EmailOtpGenerator = Arc<dyn Fn(&str, EmailOtpType) -> Option<String> + Send + Sync>;
91
92/// Options for email OTP routes.
93#[derive(Clone, better_auth_core::PluginConfig)]
94#[plugin(name = "EmailOtpPlugin")]
95pub struct EmailOtpConfig {
96    /// OTP delivery callback; requests fail when no sender is configured.
97    #[config(default = None)]
98    pub sender: Option<Arc<dyn SendEmailOtp>>,
99    /// Number of decimal digits in a generated OTP. Default: 6.
100    #[config(default = 6)]
101    pub otp_length: usize,
102    /// OTP validity and resend extension. Default: 5 minutes.
103    #[config(default = Duration::seconds(300))]
104    pub expires_in: Duration,
105    /// Failed code attempts before the next check locks the code. Zero means 3.
106    #[config(default = 3)]
107    pub allowed_attempts: u32,
108    /// Prevent email OTP from creating a new user.
109    #[config(default = false)]
110    pub disable_sign_up: bool,
111    /// Enable authenticated email changes.
112    #[config(default = false)]
113    pub change_email: bool,
114    /// Require the current mailbox's verification OTP before email changes.
115    #[config(default = false)]
116    pub verify_current_email: bool,
117    /// Reuse a valid recoverable code and extend its expiry on resend.
118    #[config(default = false)]
119    pub reuse_otp: bool,
120    /// Stored code protection. Default: plain.
121    #[config(default = EmailOtpStorage::Plain)]
122    pub storage: EmailOtpStorage,
123    /// Generate a custom OTP. Empty output uses the decimal generator.
124    #[config(default = None)]
125    pub generate_otp: Option<EmailOtpGenerator>,
126    /// Send a verification OTP after successful sign-up.
127    #[config(default = false)]
128    pub send_verification_on_sign_up: bool,
129    /// Replace default email verification messages with OTP delivery.
130    #[config(default = false)]
131    pub override_default_email_verification: bool,
132    /// Endpoint rate limit window. Zero means 60 seconds.
133    #[config(default = 60.0)]
134    pub rate_limit_window: f64,
135    /// Endpoint rate limit maximum. Zero means 3.
136    #[config(default = 3.0)]
137    pub rate_limit_max: f64,
138}
139
140/// Email OTP plugin with all nine public upstream routes.
141pub struct EmailOtpPlugin {
142    config: EmailOtpConfig,
143}
144
145better_auth_core::impl_auth_plugin! {
146    EmailOtpPlugin, "email-otp";
147    routes {
148        post "/email-otp/send-verification-otp" => send, "sendEmailVerificationOTP", body = request::validate;
149        post "/email-otp/check-verification-otp" => check, "verifyEmailWithOTP", body = request::validate;
150        post "/email-otp/verify-email" => verify_email, "verifyEmailOTP", body = request::validate;
151        post "/sign-in/email-otp" => sign_in, "signInWithEmailOTP", body = request::validate;
152        post "/email-otp/request-password-reset" => request_password_reset, "requestPasswordResetWithEmailOTP", body = request::validate;
153        post "/forget-password/email-otp" => request_password_reset, "forgetPasswordWithEmailOTP", body = request::validate;
154        post "/email-otp/reset-password" => reset_password, "resetPasswordWithEmailOTP", body = request::validate;
155        post "/email-otp/request-email-change" => request_email_change, "requestEmailChangeWithEmailOTP", body = request::validate;
156        post "/email-otp/change-email" => handle_change_email, "changeEmailWithEmailOTP", body = request::validate;
157    }
158    extra {
159        async fn on_init(&self, ctx: &mut better_auth_core::AuthInitContext<S>) -> AuthResult<()> {
160            ctx.extensions.insert(self.config.clone());
161            Ok(())
162        }
163        fn rate_limits(&self) -> AuthResult<Vec<better_auth_core::middleware::PluginRateLimit>> {
164            let window = self.config.rate_limit_window;
165            let max = self.config.rate_limit_max;
166            let rule = better_auth_core::middleware::EndpointRateLimit {
167                window: if window == 0.0 || window.is_nan() { 60.0 } else { window },
168                max_requests: if max == 0.0 || max.is_nan() { 3.0 } else { max },
169            };
170            Ok(<Self as better_auth_core::AuthPlugin<S>>::routes(self).into_iter()
171                .map(|route| better_auth_core::middleware::PluginRateLimit::exact(route.path, rule)).collect())
172        }
173
174        async fn after_request(&self, req: &AuthRequest, response: &mut AuthResponse, ctx: &AuthContext<S>) -> AuthResult<()> {
175if !(self.config.send_verification_on_sign_up && !self.config.override_default_email_verification && req.path().starts_with("/sign-up")) { return Ok(()); }
176better_auth_core::observability::instrumentation::with_endpoint_hook(
177        &ctx.config, req, "after", "plugin:email-otp", async {
178            if self.config.send_verification_on_sign_up && !self.config.override_default_email_verification && req.path().starts_with("/sign-up") && response.status == 200 {
179                let body: serde_json::Value = serde_json::from_slice(&response.body.bytes()?)?;
180                if let Some(email) = body.get("user").and_then(|user| user.get("email")).and_then(serde_json::Value::as_str) {
181                    let mut endpoint = crate::plugins::endpoint_context::EndpointContext::new(Some(req), req.input_field_value()?, ctx);
182                    endpoint.response = Some(response);
183                    let otp = self.create_otp(&endpoint, email, EmailOtpType::EmailVerification, &EmailOtpType::EmailVerification.identifier(email)).await?;
184                    self.deliver(&endpoint, email, otp, EmailOtpType::EmailVerification).await?;
185                }
186            }
187            Ok(())
188         }
189    ).await
190}
191    }
192}