1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "kebab-case")]
20pub enum EmailOtpType {
21 SignIn,
23 EmailVerification,
25 ForgetPassword,
27 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#[derive(Debug, Clone, Serialize)]
47pub struct EmailOtpMessage {
48 pub email: String,
50 pub otp: String,
52 #[serde(rename = "type")]
54 pub kind: EmailOtpType,
55}
56
57#[async_trait]
59pub trait SendEmailOtp: Send + Sync {
60 async fn send(&self, message: &EmailOtpMessage) -> AuthResult<()>;
62}
63
64#[derive(Clone, Default)]
66pub enum EmailOtpStorage {
67 #[default]
69 Plain,
70 Hashed,
72 Encrypted,
74 CustomHash(super::one_time_token::TokenHasher),
76 CustomEncryption(Arc<dyn EmailOtpCodec>),
78}
79
80#[async_trait]
82pub trait EmailOtpCodec: Send + Sync {
83 async fn encode(&self, otp: &str) -> AuthResult<String>;
85 async fn decode(&self, stored: &str) -> AuthResult<String>;
87}
88
89pub type EmailOtpGenerator = Arc<dyn Fn(&str, EmailOtpType) -> Option<String> + Send + Sync>;
91
92#[derive(Clone, better_auth_core::PluginConfig)]
94#[plugin(name = "EmailOtpPlugin")]
95pub struct EmailOtpConfig {
96 #[config(default = None)]
98 pub sender: Option<Arc<dyn SendEmailOtp>>,
99 #[config(default = 6)]
101 pub otp_length: usize,
102 #[config(default = Duration::seconds(300))]
104 pub expires_in: Duration,
105 #[config(default = 3)]
107 pub allowed_attempts: u32,
108 #[config(default = false)]
110 pub disable_sign_up: bool,
111 #[config(default = false)]
113 pub change_email: bool,
114 #[config(default = false)]
116 pub verify_current_email: bool,
117 #[config(default = false)]
119 pub reuse_otp: bool,
120 #[config(default = EmailOtpStorage::Plain)]
122 pub storage: EmailOtpStorage,
123 #[config(default = None)]
125 pub generate_otp: Option<EmailOtpGenerator>,
126 #[config(default = false)]
128 pub send_verification_on_sign_up: bool,
129 #[config(default = false)]
131 pub override_default_email_verification: bool,
132 #[config(default = 60.0)]
134 pub rate_limit_window: f64,
135 #[config(default = 3.0)]
137 pub rate_limit_max: f64,
138}
139
140pub 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}