use async_trait::async_trait;
use better_auth_core::{AuthContext, AuthRequest, AuthResponse, AuthResult};
use chrono::Duration;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
pub(crate) mod callbacks;
pub use callbacks::EmailOtpCallbacks;
mod handlers;
mod native;
mod otp;
pub use native::EmailOtpApi;
#[cfg(test)]
mod native_tests;
mod request;
#[cfg(test)]
mod verification_native_contract;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum EmailOtpType {
SignIn,
EmailVerification,
ForgetPassword,
ChangeEmail,
}
impl EmailOtpType {
fn as_str(self) -> &'static str {
match self {
Self::SignIn => "sign-in",
Self::EmailVerification => "email-verification",
Self::ForgetPassword => "forget-password",
Self::ChangeEmail => "change-email",
}
}
fn identifier(self, email: &str) -> String {
format!("{}-otp-{email}", self.as_str())
}
}
#[derive(Debug, Clone, Serialize)]
pub struct EmailOtpMessage {
pub email: String,
pub otp: better_auth_core::SchemaValue<String>,
#[serde(rename = "type")]
pub kind: EmailOtpType,
}
#[async_trait]
pub trait SendEmailOtp: Send + Sync {
async fn send(&self, message: &EmailOtpMessage) -> AuthResult<()>;
}
#[derive(Clone, Default)]
pub enum EmailOtpStorage {
#[default]
Plain,
Hashed,
Encrypted,
CustomHash(super::one_time_token::TokenHasher),
CustomEncryption(Arc<dyn EmailOtpCodec>),
}
#[async_trait]
pub trait EmailOtpCodec: Send + Sync {
async fn encode(&self, otp: &str) -> AuthResult<String>;
async fn decode(&self, stored: &str) -> AuthResult<String>;
}
pub type EmailOtpGenerator = Arc<dyn Fn(&str, EmailOtpType) -> Option<String> + Send + Sync>;
#[derive(Clone, better_auth_core::PluginConfig)]
#[plugin(name = "EmailOtpPlugin")]
pub struct EmailOtpConfig {
#[config(default = None)]
pub sender: Option<Arc<dyn SendEmailOtp>>,
#[config(default = 6)]
pub otp_length: usize,
#[config(default = Duration::seconds(300))]
pub expires_in: Duration,
#[config(default = 3)]
pub allowed_attempts: u32,
#[config(default = false)]
pub disable_sign_up: bool,
#[config(default = false)]
pub change_email: bool,
#[config(default = false)]
pub verify_current_email: bool,
#[config(default = false)]
pub reuse_otp: bool,
#[config(default = EmailOtpStorage::Plain)]
pub storage: EmailOtpStorage,
#[config(default = None)]
pub generate_otp: Option<EmailOtpGenerator>,
#[config(default = false)]
pub send_verification_on_sign_up: bool,
#[config(default = false)]
pub override_default_email_verification: bool,
#[config(default = 60.0)]
pub rate_limit_window: f64,
#[config(default = 3.0)]
pub rate_limit_max: f64,
}
pub struct EmailOtpPlugin {
config: EmailOtpConfig,
}
better_auth_core::impl_auth_plugin! {
EmailOtpPlugin, "email-otp";
routes {
post "/email-otp/send-verification-otp" => send, "sendEmailVerificationOTP", body = request::validate;
post "/email-otp/check-verification-otp" => check, "verifyEmailWithOTP", body = request::validate;
post "/email-otp/verify-email" => verify_email, "verifyEmailOTP", body = request::validate;
post "/sign-in/email-otp" => sign_in, "signInWithEmailOTP", body = request::validate;
post "/email-otp/request-password-reset" => request_password_reset, "requestPasswordResetWithEmailOTP", body = request::validate;
post "/forget-password/email-otp" => request_password_reset, "forgetPasswordWithEmailOTP", body = request::validate;
post "/email-otp/reset-password" => reset_password, "resetPasswordWithEmailOTP", body = request::validate;
post "/email-otp/request-email-change" => request_email_change, "requestEmailChangeWithEmailOTP", body = request::validate;
post "/email-otp/change-email" => handle_change_email, "changeEmailWithEmailOTP", body = request::validate;
}
extra {
async fn on_init(&self, ctx: &mut better_auth_core::AuthInitContext<S>) -> AuthResult<()> {
ctx.extensions.insert(self.config.clone());
Ok(())
}
fn rate_limits(&self) -> AuthResult<Vec<better_auth_core::middleware::PluginRateLimit>> {
let window = self.config.rate_limit_window;
let max = self.config.rate_limit_max;
let rule = better_auth_core::middleware::EndpointRateLimit {
window: if window == 0.0 || window.is_nan() { 60.0 } else { window },
max_requests: if max == 0.0 || max.is_nan() { 3.0 } else { max },
};
Ok(<Self as better_auth_core::AuthPlugin<S>>::routes(self).into_iter()
.map(|route| better_auth_core::middleware::PluginRateLimit::exact(route.path, rule)).collect())
}
async fn after_request(&self, req: &AuthRequest, response: &mut AuthResponse, ctx: &AuthContext<S>) -> AuthResult<()> {
if !(self.config.send_verification_on_sign_up && !self.config.override_default_email_verification && req.path().starts_with("/sign-up")) { return Ok(()); }
better_auth_core::observability::instrumentation::with_endpoint_hook(
&ctx.config, req, "after", "plugin:email-otp", async {
if self.config.send_verification_on_sign_up && !self.config.override_default_email_verification && req.path().starts_with("/sign-up") && response.status == 200 {
let body: serde_json::Value = serde_json::from_slice(&response.body.bytes()?)?;
if let Some(email) = body.get("user").and_then(|user| user.get("email")).and_then(serde_json::Value::as_str) {
let mut endpoint = crate::plugins::endpoint_context::EndpointContext::new(Some(req), req.input_field_value()?, ctx);
endpoint.response = Some(response);
let otp = self.create_otp(&endpoint, email, EmailOtpType::EmailVerification, &EmailOtpType::EmailVerification.identifier(email)).await?;
self.deliver(&endpoint, email, otp, EmailOtpType::EmailVerification).await?;
}
}
Ok(())
}
).await
}
}
}