better_auth_api/plugins/email_verification/
mod.rs1use chrono::Duration;
2use std::sync::Arc;
3
4use better_auth_core::AuthUser;
5pub use better_auth_core::email::{EmailVerificationHook, SendVerificationEmail};
6use better_auth_core::{AuthContext, AuthError, AuthResult};
7use better_auth_core::{AuthRequest, AuthResponse};
8
9use super::StatusResponse;
10
11pub(super) mod handlers;
12pub(crate) mod token;
13pub(super) mod types;
14
15mod callbacks;
16pub(crate) mod delivery;
17pub use callbacks::{EmailVerificationCallbacks, VerificationEmail};
18
19#[cfg(test)]
20mod tests;
21
22use handlers::*;
23use types::*;
24
25pub struct EmailVerificationPlugin {
27 config: EmailVerificationConfig,
28}
29
30#[derive(Clone, better_auth_core::PluginConfig)]
31#[plugin(name = "EmailVerificationPlugin")]
32pub struct EmailVerificationConfig {
33 #[config(default = None)]
35 pub verification_token_expiry: Option<Duration>,
36 #[config(default = true)]
39 pub send_email_notifications: bool,
40 #[config(default = false)]
42 pub require_verification_for_signin: bool,
43 #[config(default = false)]
46 pub send_on_sign_in: bool,
47 #[config(default = None)]
49 pub send_on_sign_up: Option<bool>,
50 #[config(default = false)]
53 pub auto_sign_in_after_verification: bool,
54 #[config(default = None, skip)]
57 pub send_verification_email: Option<Arc<dyn SendVerificationEmail>>,
58 #[config(default = None)]
60 pub before_email_verification: Option<EmailVerificationHook>,
61 #[config(default = None)]
63 pub after_email_verification: Option<EmailVerificationHook>,
64}
65
66impl EmailVerificationConfig {
67 pub fn verification_token_expiry(&self) -> Duration {
69 self.verification_token_expiry
70 .unwrap_or_else(|| Duration::hours(1))
71 }
72}
73
74impl EmailVerificationPlugin {
75 pub fn custom_send_verification_email(
76 mut self,
77 sender: Arc<dyn SendVerificationEmail>,
78 ) -> Self {
79 self.config.send_verification_email = Some(sender);
80 self
81 }
82}
83
84better_auth_core::impl_auth_plugin! {
85 EmailVerificationPlugin, "email-verification";
86 routes {
87 post "/send-verification-email" => handle_send_verification_email, "sendVerificationEmail", body = types::body;
88 get "/verify-email" => handle_verify_email, "verifyEmail", query = crate::plugins::query_input::verify_email;
89 }
90 extra {
91 fn telemetry_plugin_id(&self) -> Option<&'static str> {
92 None
93 }
94
95 fn telemetry(&self, options: &mut better_auth_core::observability::telemetry::PluginTelemetry) {
96 let options = &mut options.email_verification;
97 options.expires_in = self.config.verification_token_expiry;
98 options.send_verification_email = self.config.send_verification_email.is_some();
99 options.send_on_sign_up = self.config.send_on_sign_up == Some(true);
100 options.send_on_sign_in = self.config.send_on_sign_in;
101 options.auto_sign_in_after_verification = self.config.auto_sign_in_after_verification;
102 options.before_email_verification = self.config.before_email_verification.is_some();
103 options.after_email_verification = self.config.after_email_verification.is_some();
104 }
105
106 async fn on_init(&self, ctx: &mut better_auth_core::AuthInitContext<S>) -> AuthResult<()> {
107 ctx.extensions.insert(self.config.clone());
108 ctx.email_verification_policy.auto_sign_in_after_verification = self.config.auto_sign_in_after_verification;
109 ctx.email_verification_policy.before_email_verification = self.config.before_email_verification.clone();
110 ctx.email_verification_policy.after_email_verification = self.config.after_email_verification.clone();
111 Ok(())
112 }
113 }
114}
115
116impl EmailVerificationPlugin {
121 pub(crate) fn from_context(
122 ctx: &AuthContext<impl better_auth_core::AuthSchema>,
123 ) -> Option<Self> {
124 ctx.extensions
125 .get::<EmailVerificationConfig>()
126 .map(|config| Self::with_config(config.clone()))
127 }
128
129 pub(crate) async fn send_verification_on_sign_up<S: better_auth_core::AuthSchema>(
130 &self,
131 user: &impl AuthUser,
132 required: bool,
133 callback_url: Option<&str>,
134 endpoint: &crate::plugins::endpoint_context::EndpointContext<'_, S>,
135 ) -> AuthResult<()> {
136 if self.config.send_on_sign_up.unwrap_or(required)
137 && let Some(email) = user.email()
138 {
139 self.send_verification_email_at(user, email, callback_url, endpoint)
140 .await?;
141 }
142 Ok(())
143 }
144
145 async fn handle_send_verification_email(
146 &self,
147 req: &AuthRequest,
148 ctx: &AuthContext<impl better_auth_core::AuthSchema>,
149 ) -> AuthResult<AuthResponse> {
150 let body = match req.validated_body::<SendVerificationEmailRequest>() {
151 Some(body) => body.clone(),
152 None => super::json_body::email_input(req, "email", "callbackURL")?.0,
153 };
154 let current_session = ctx.require_session(req).await.ok();
155 let mut config = self.config.clone();
156 if config.send_verification_email.is_none() {
157 config.send_verification_email = ctx.email_verification_policy.override_sender.clone();
158 }
159 let response =
160 send_verification_email_core(&body, current_session.as_ref(), Some(req), &config, ctx)
161 .await?;
162 Ok(AuthResponse::json(200, &response)?)
163 }
164
165 async fn handle_verify_email(
166 &self,
167 req: &AuthRequest,
168 ctx: &AuthContext<impl better_auth_core::AuthSchema>,
169 ) -> AuthResult<AuthResponse> {
170 let token = req
171 .query_string("token")?
172 .ok_or_else(|| AuthError::bad_request("Verification token is required"))?;
173 let callback_url = req
174 .query_string("callbackURL")?
175 .filter(|url| !url.is_empty())
176 .map(str::to_owned);
177
178 if let Some(ref url) = callback_url
181 && !ctx.config.advanced.disable_origin_check
182 && !ctx.is_redirect_target_trusted(url)
183 {
184 return Ok(AuthError::forbidden("Invalid callbackURL").to_auth_response());
185 }
186
187 let query = VerifyEmailQuery {
188 token: token.to_owned(),
189 callback_url,
190 };
191
192 let ip_address = ctx.config.advanced.ip_address.resolve(req);
193 let user_agent = req.headers.get("user-agent").cloned();
194 let current_session = ctx.require_session(req).await.ok();
195 let mut endpoint = crate::plugins::endpoint_context::EndpointContext::new(
196 Some(req),
197 better_auth_core::FieldValue::Null,
198 ctx,
199 );
200 endpoint.session = current_session.clone();
201
202 match verify_email_core(
203 &query,
204 current_session,
205 &self.config,
206 ip_address,
207 user_agent,
208 &endpoint,
209 )
210 .await?
211 {
212 VerifyEmailResult::Redirect { url, session_data } => {
213 let mut headers = better_auth_core::Headers::new();
214 _ = headers.insert("Location".to_string(), url);
215 _ = headers.insert("content-type".to_string(), "application/json".to_string());
216 if let Some(data) = session_data {
217 ctx.session_manager()
218 .set_session_cookie(req, data, None)
219 .await?;
220 }
221 let mut response = AuthResponse::new(302);
222 response.headers = headers;
223 Ok(response)
224 }
225 VerifyEmailResult::Json { body, session_data } => {
226 let response = AuthResponse::json(200, &body)?;
227 if let Some(data) = session_data {
228 ctx.session_manager()
229 .set_session_cookie(req, data, None)
230 .await?;
231 }
232 Ok(response)
233 }
234 }
235 }
236
237 async fn send_verification_email_for_user(
243 &self,
244 user: &impl AuthUser,
245 email: &str,
246 callback_url: Option<&str>,
247 request: Option<&AuthRequest>,
248 ctx: &AuthContext<impl better_auth_core::AuthSchema>,
249 ) -> AuthResult<()> {
250 let body = request
251 .filter(|request| request.body.is_some())
252 .map(|request| request.body_as_json())
253 .transpose()?
254 .unwrap_or(serde_json::Value::Null);
255 let endpoint = crate::plugins::endpoint_context::EndpointContext::new(
256 request,
257 better_auth_core::FieldValue::from_json(body)?,
258 ctx,
259 );
260 self.send_verification_email_at(user, email, callback_url, &endpoint)
261 .await
262 }
263
264 async fn send_verification_email_at<S: better_auth_core::AuthSchema>(
265 &self,
266 user: &impl AuthUser,
267 email: &str,
268 callback_url: Option<&str>,
269 endpoint: &crate::plugins::endpoint_context::EndpointContext<'_, S>,
270 ) -> AuthResult<()> {
271 let ctx = endpoint.auth;
272 let verification_token = token::create_email_verification_token(
273 ctx.config.signing_secret(),
274 email,
275 None,
276 self.config.verification_token_expiry(),
277 None,
278 )?;
279 let callback_url = callback_url.unwrap_or("/");
280 let verification_url = format!(
281 "{}/verify-email?token={}&callbackURL={}",
282 ctx.base_url(),
283 verification_token,
284 urlencoding::encode(callback_url),
285 );
286
287 if ctx
288 .extensions
289 .get::<Arc<EmailVerificationCallbacks<S>>>()
290 .is_none()
291 && self.config.send_verification_email.is_none()
292 && crate::plugins::email_otp::callbacks::overrides_verification(ctx)
293 {
294 let retained = endpoint.to_owned();
295 let email = email.to_owned();
296 better_auth_core::background::run_or_await(
297 Some(Box::pin(async move {
298 let endpoint = retained.as_endpoint();
299 crate::plugins::email_otp::callbacks::send_verification_override(
300 &email, &endpoint,
301 )
302 .await
303 })),
304 ctx.config.advanced.background_tasks.as_ref(),
305 &ctx.config.logger,
306 )
307 .await;
308 return Ok(());
309 }
310 let message = VerificationEmail {
311 user: ctx.internal_user_view(user).await?,
312 url: verification_url,
313 token: verification_token,
314 };
315 let task = delivery::delivery(Some(&self.config), message, endpoint)?;
316 better_auth_core::background::run_or_await(
317 task,
318 ctx.config.advanced.background_tasks.as_ref(),
319 &ctx.config.logger,
320 )
321 .await;
322
323 Ok(())
324 }
325
326 pub async fn send_verification_on_sign_in(
332 &self,
333 user: &impl AuthUser,
334 callback_url: Option<&str>,
335 ctx: &AuthContext<impl better_auth_core::AuthSchema>,
336 ) -> AuthResult<()> {
337 self.send_verification_on_sign_in_with_request(user, callback_url, None, ctx)
338 .await
339 }
340
341 pub(crate) async fn send_verification_on_sign_in_with_request(
342 &self,
343 user: &impl AuthUser,
344 callback_url: Option<&str>,
345 request: Option<&AuthRequest>,
346 ctx: &AuthContext<impl better_auth_core::AuthSchema>,
347 ) -> AuthResult<()> {
348 if !self.config.send_on_sign_in {
349 return Ok(());
350 }
351
352 if user.email_verified() {
353 return Ok(());
354 }
355
356 if let Some(email) = user.email() {
357 self.send_verification_email_for_user(user, email, callback_url, request, ctx)
358 .await?;
359 }
360
361 Ok(())
362 }
363
364 pub(crate) async fn send_verification_on_oauth_sign_in(
365 &self,
366 user: Option<&impl AuthUser>,
367 is_register: bool,
368 require_verification: bool,
369 callback_url: &str,
370 request: &AuthRequest,
371 ctx: &AuthContext<impl better_auth_core::AuthSchema>,
372 ) {
373 let should_send = if is_register {
374 self.config.send_on_sign_up.unwrap_or(require_verification)
375 } else {
376 require_verification && self.config.send_on_sign_in
377 };
378 if !should_send
379 || user.is_some_and(AuthUser::email_verified)
380 || !delivery::available(Some(&self.config), ctx)
381 {
382 return;
383 }
384 let result = async {
385 let (user, email) = user
386 .and_then(|user| user.email().map(|email| (user, email)))
387 .ok_or_else(|| {
388 AuthError::internal(
389 "Cannot create an OAuth verification token without a user email",
390 )
391 })?;
392 self.send_verification_email_for_user(
393 user,
394 email,
395 Some(callback_url),
396 Some(request),
397 ctx,
398 )
399 .await
400 }
401 .await;
402 if let Err(error) = result {
403 better_auth_core::observability::logger::current().error(
405 "Failed to send OAuth verification email",
406 &[better_auth_core::observability::LogArgument::Error(&error)],
407 );
408 }
409 }
410
411 pub fn should_send_on_sign_in(&self) -> bool {
413 self.config.send_on_sign_in
414 }
415
416 pub fn is_verification_required(&self) -> bool {
418 self.config.require_verification_for_signin
419 }
420
421 pub fn is_user_verified_or_not_required(&self, user: &impl AuthUser) -> bool {
423 user.email_verified() || !self.config.require_verification_for_signin
424 }
425}
426
427