better_auth_api/plugins/email_verification/
mod.rs1use async_trait::async_trait;
2use chrono::{Duration, Utc};
3use std::future::Future;
4use std::pin::Pin;
5use std::sync::Arc;
6use uuid::Uuid;
7
8use better_auth_core::{AuthContext, AuthError, AuthResult};
9use better_auth_core::{AuthRequest, AuthResponse, CreateVerification};
10use better_auth_core::{AuthUser, DatabaseAdapter, User};
11
12use better_auth_core::utils::cookie_utils::create_session_cookie;
13
14use super::StatusResponse;
15
16pub(super) mod handlers;
17pub(super) mod types;
18
19#[cfg(test)]
20mod tests;
21
22use handlers::*;
23use types::*;
24
25#[async_trait]
30pub trait SendVerificationEmail: Send + Sync {
31 async fn send(&self, user: &User, url: &str, token: &str) -> AuthResult<()>;
32}
33
34pub type EmailVerificationHook =
38 Arc<dyn Fn(&User) -> Pin<Box<dyn Future<Output = AuthResult<()>> + Send>> + Send + Sync>;
39
40pub struct EmailVerificationPlugin {
42 config: EmailVerificationConfig,
43}
44
45#[derive(better_auth_core::PluginConfig)]
46#[plugin(name = "EmailVerificationPlugin")]
47pub struct EmailVerificationConfig {
48 #[config(default = Duration::hours(24))]
50 pub verification_token_expiry: Duration,
51 #[config(default = true)]
53 pub send_email_notifications: bool,
54 #[config(default = false)]
56 pub require_verification_for_signin: bool,
57 #[config(default = false)]
59 pub auto_verify_new_users: bool,
60 #[config(default = false)]
63 pub send_on_sign_in: bool,
64 #[config(default = false)]
67 pub auto_sign_in_after_verification: bool,
68 #[config(default = None, skip)]
71 pub send_verification_email: Option<Arc<dyn SendVerificationEmail>>,
72 #[config(default = None)]
74 pub before_email_verification: Option<EmailVerificationHook>,
75 #[config(default = None)]
77 pub after_email_verification: Option<EmailVerificationHook>,
78}
79
80impl EmailVerificationConfig {
81 pub fn expiry_hours(&self) -> i64 {
84 self.verification_token_expiry.num_hours()
85 }
86}
87
88impl EmailVerificationPlugin {
89 pub fn verification_token_expiry_hours(mut self, hours: i64) -> Self {
91 self.config.verification_token_expiry = Duration::hours(hours);
92 self
93 }
94
95 pub fn custom_send_verification_email(
96 mut self,
97 sender: Arc<dyn SendVerificationEmail>,
98 ) -> Self {
99 self.config.send_verification_email = Some(sender);
100 self
101 }
102}
103
104better_auth_core::impl_auth_plugin! {
105 EmailVerificationPlugin, "email-verification";
106 routes {
107 post "/send-verification-email" => handle_send_verification_email, "send_verification_email";
108 get "/verify-email" => handle_verify_email, "verify_email";
109 }
110 extra {
111 async fn on_user_created(&self, user: &DB::User, ctx: &AuthContext<DB>) -> AuthResult<()> {
112 if (self.config.send_email_notifications || self.config.send_verification_email.is_some())
115 && !user.email_verified()
116 && let Some(email) = user.email()
117 && let Err(e) = self
118 .send_verification_email_for_user(user, email, None, ctx)
119 .await
120 {
121 tracing::warn!(
122 email = %email,
123 error = %e,
124 "Failed to send verification email"
125 );
126 }
127 Ok(())
128 }
129 }
130}
131
132impl EmailVerificationPlugin {
137 async fn handle_send_verification_email<DB: DatabaseAdapter>(
138 &self,
139 req: &AuthRequest,
140 ctx: &AuthContext<DB>,
141 ) -> AuthResult<AuthResponse> {
142 let body: SendVerificationEmailRequest = match better_auth_core::validate_request_body(req)
143 {
144 Ok(v) => v,
145 Err(resp) => return Ok(resp),
146 };
147 let response = send_verification_email_core(&body, &self.config, ctx).await?;
148 Ok(AuthResponse::json(200, &response)?)
149 }
150
151 async fn handle_verify_email<DB: DatabaseAdapter>(
152 &self,
153 req: &AuthRequest,
154 ctx: &AuthContext<DB>,
155 ) -> AuthResult<AuthResponse> {
156 let token = req
157 .query
158 .get("token")
159 .ok_or_else(|| AuthError::bad_request("Verification token is required"))?;
160 let callback_url = req.query.get("callbackURL").cloned();
161 let query = VerifyEmailQuery {
162 token: token.clone(),
163 callback_url,
164 };
165
166 let ip_address = req.headers.get("x-forwarded-for").cloned();
167 let user_agent = req.headers.get("user-agent").cloned();
168
169 match verify_email_core(&query, &self.config, ip_address, user_agent, ctx).await? {
170 VerifyEmailResult::AlreadyVerified(data) => Ok(AuthResponse::json(200, &data)?),
171 VerifyEmailResult::Redirect { url, session_token } => {
172 let mut headers = std::collections::HashMap::new();
173 headers.insert("Location".to_string(), url);
174 if let Some(token) = session_token {
175 let cookie = create_session_cookie(&token, &ctx.config);
176 headers.insert("Set-Cookie".to_string(), cookie);
177 }
178 Ok(AuthResponse {
179 status: 302,
180 headers,
181 body: Vec::new(),
182 })
183 }
184 VerifyEmailResult::Json(data) => Ok(AuthResponse::json(200, &data)?),
185 VerifyEmailResult::JsonWithSession {
186 response,
187 session_token,
188 } => {
189 let cookie = create_session_cookie(&session_token, &ctx.config);
190 Ok(AuthResponse::json(200, &response)?.with_header("Set-Cookie", cookie))
191 }
192 }
193 }
194
195 async fn send_verification_email_for_user<DB: DatabaseAdapter>(
201 &self,
202 user: &DB::User,
203 email: &str,
204 callback_url: Option<&str>,
205 ctx: &AuthContext<DB>,
206 ) -> AuthResult<()> {
207 let verification_token = format!("verify_{}", Uuid::new_v4());
209 let expires_at = Utc::now() + self.config.verification_token_expiry;
210
211 let create_verification = CreateVerification {
213 identifier: email.to_string(),
214 value: verification_token.clone(),
215 expires_at,
216 };
217
218 ctx.database
219 .create_verification(create_verification)
220 .await?;
221
222 let verification_url = if let Some(callback_url) = callback_url {
223 format!("{}?token={}", callback_url, verification_token)
224 } else {
225 format!(
226 "{}/verify-email?token={}",
227 ctx.config.base_url, verification_token
228 )
229 };
230
231 if let Some(ref custom_sender) = self.config.send_verification_email {
233 let user = User::from(user);
234 custom_sender
235 .send(&user, &verification_url, &verification_token)
236 .await?;
237 } else if self.config.send_email_notifications {
238 if ctx.email_provider.is_some() {
240 let subject = "Verify your email address";
241 let html = format!(
242 "<p>Click the link below to verify your email address:</p>\
243 <p><a href=\"{url}\">Verify Email</a></p>",
244 url = verification_url
245 );
246 let text = format!("Verify your email address: {}", verification_url);
247
248 ctx.email_provider()?
249 .send(email, subject, &html, &text)
250 .await?;
251 } else {
252 tracing::warn!(
253 email = %email,
254 "No email provider configured, skipping verification email"
255 );
256 }
257 }
258
259 Ok(())
260 }
261
262 pub async fn send_verification_on_sign_in<DB: DatabaseAdapter>(
268 &self,
269 user: &DB::User,
270 callback_url: Option<&str>,
271 ctx: &AuthContext<DB>,
272 ) -> AuthResult<()> {
273 if !self.config.send_on_sign_in {
274 return Ok(());
275 }
276
277 if user.email_verified() {
278 return Ok(());
279 }
280
281 if let Some(email) = user.email() {
282 self.send_verification_email_for_user(user, email, callback_url, ctx)
283 .await?;
284 }
285
286 Ok(())
287 }
288
289 pub fn should_send_on_sign_in(&self) -> bool {
291 self.config.send_on_sign_in
292 }
293
294 pub fn is_verification_required(&self) -> bool {
296 self.config.require_verification_for_signin
297 }
298
299 pub async fn is_user_verified_or_not_required(&self, user: &impl AuthUser) -> bool {
301 user.email_verified() || !self.config.require_verification_for_signin
302 }
303}
304
305#[cfg(feature = "axum")]
310mod axum_impl {
311 use super::*;
312 use std::sync::Arc;
313
314 use axum::extract::{Extension, Query, State};
315 use axum::response::IntoResponse;
316 use axum::{Json, http::header};
317 use better_auth_core::{AuthError, AuthState, ValidatedJson};
318
319 struct PluginState {
324 config: EmailVerificationConfig,
325 }
326
327 fn clone_config(c: &EmailVerificationConfig) -> EmailVerificationConfig {
328 EmailVerificationConfig {
329 verification_token_expiry: c.verification_token_expiry,
330 send_email_notifications: c.send_email_notifications,
331 require_verification_for_signin: c.require_verification_for_signin,
332 auto_verify_new_users: c.auto_verify_new_users,
333 send_on_sign_in: c.send_on_sign_in,
334 auto_sign_in_after_verification: c.auto_sign_in_after_verification,
335 send_verification_email: c.send_verification_email.clone(),
336 before_email_verification: c.before_email_verification.clone(),
337 after_email_verification: c.after_email_verification.clone(),
338 }
339 }
340
341 async fn handle_send_verification_email<DB: DatabaseAdapter>(
342 State(state): State<AuthState<DB>>,
343 Extension(ps): Extension<Arc<PluginState>>,
344 ValidatedJson(body): ValidatedJson<SendVerificationEmailRequest>,
345 ) -> Result<Json<StatusResponse>, AuthError> {
346 let ctx = state.to_context();
347 let response = send_verification_email_core(&body, &ps.config, &ctx).await?;
348 Ok(Json(response))
349 }
350
351 async fn handle_verify_email<DB: DatabaseAdapter>(
352 State(state): State<AuthState<DB>>,
353 Extension(ps): Extension<Arc<PluginState>>,
354 Query(query): Query<VerifyEmailQuery>,
355 ) -> Result<axum::response::Response, AuthError> {
356 let ctx = state.to_context();
357 match verify_email_core(&query, &ps.config, None, None, &ctx).await? {
360 VerifyEmailResult::AlreadyVerified(data) => Ok(Json(data).into_response()),
361 VerifyEmailResult::Redirect { url, session_token } => {
362 if let Some(token) = session_token {
363 let cookie = state.session_cookie(&token);
364 Ok((
365 [(header::SET_COOKIE, cookie)],
366 axum::response::Redirect::to(&url),
367 )
368 .into_response())
369 } else {
370 Ok(axum::response::Redirect::to(&url).into_response())
371 }
372 }
373 VerifyEmailResult::Json(data) => Ok(Json(data).into_response()),
374 VerifyEmailResult::JsonWithSession {
375 response,
376 session_token,
377 } => {
378 let cookie = state.session_cookie(&session_token);
379 Ok(([(header::SET_COOKIE, cookie)], Json(response)).into_response())
380 }
381 }
382 }
383
384 #[async_trait::async_trait]
385 impl<DB: DatabaseAdapter> better_auth_core::AxumPlugin<DB> for EmailVerificationPlugin {
386 fn name(&self) -> &'static str {
387 "email-verification"
388 }
389
390 fn router(&self) -> axum::Router<AuthState<DB>> {
391 use axum::routing::{get, post};
392
393 let plugin_state = Arc::new(PluginState {
394 config: clone_config(&self.config),
395 });
396
397 axum::Router::new()
398 .route(
399 "/send-verification-email",
400 post(handle_send_verification_email::<DB>),
401 )
402 .route("/verify-email", get(handle_verify_email::<DB>))
403 .layer(Extension(plugin_state))
404 }
405
406 async fn on_user_created(
407 &self,
408 user: &DB::User,
409 ctx: &better_auth_core::AuthContext<DB>,
410 ) -> better_auth_core::AuthResult<()> {
411 if (self.config.send_email_notifications
413 || self.config.send_verification_email.is_some())
414 && !user.email_verified()
415 && let Some(email) = user.email()
416 && let Err(e) = self
417 .send_verification_email_for_user(user, email, None, ctx)
418 .await
419 {
420 tracing::warn!(
421 email = %email,
422 error = %e,
423 "Failed to send verification email"
424 );
425 }
426 Ok(())
427 }
428 }
429}