use jsonwebtoken::errors::ErrorKind;
use crate::plugins::endpoint_context::EndpointContext;
use crate::plugins::helpers::{SessionIssueError, issue_selected_user_session_optional};
use better_auth_core::session::{NativeSessionData, SessionRead};
use better_auth_core::{
AuthContext, AuthError, AuthRequest, AuthResult, FieldMap, FieldValue, UpdateUser,
};
use super::token::{
create_email_verification_token, create_native_email_verification_token,
decode_email_verification_token,
};
use super::types::*;
use super::{EmailVerificationConfig, StatusResponse, VerificationEmail};
fn verification_url(base_url: &str, token: &str, callback_url: Option<&str>) -> String {
let callback_url = callback_url.filter(|url| !url.is_empty()).unwrap_or("/");
format!(
"{base_url}/verify-email?token={token}&callbackURL={}",
urlencoding::encode(callback_url),
)
}
async fn send_for_user<S: better_auth_core::AuthSchema>(
user: FieldValue,
email: &FieldValue,
body: &SendVerificationEmailRequest,
config: &EmailVerificationConfig,
endpoint: &EndpointContext<'_, S>,
) -> AuthResult<()> {
let ctx = endpoint.auth;
let token = create_email_verification_token(
ctx.config.signing_secret(),
&crate::plugins::helpers::user_email_field(email)?,
None,
config.verification_token_expiry(),
None,
)?;
let url = verification_url(&ctx.base_url(), &token, body.callback_url.as_deref());
if let Some(task) = super::delivery::delivery(
Some(config),
VerificationEmail { user, url, token },
endpoint,
)? {
let _ = task.await?;
}
Ok(())
}
pub(super) async fn send_verification_email_core(
body: &SendVerificationEmailRequest,
req: &AuthRequest,
config: &EmailVerificationConfig,
ctx: &AuthContext<impl better_auth_core::AuthSchema>,
) -> AuthResult<StatusResponse> {
if !super::delivery::available(Some(config), ctx) {
return Err(AuthError::from_code(
"BAD_REQUEST",
"VERIFICATION_EMAIL_NOT_ENABLED",
"Verification email isn't enabled",
));
}
let mut endpoint_body = FieldMap::from([("email".into(), body.email.clone().into())]);
if let Some(callback_url) = &body.callback_url {
let _ = endpoint_body.insert("callbackURL".into(), callback_url.clone().into());
}
let mut endpoint = EndpointContext::new(Some(req), endpoint_body.into(), ctx);
endpoint.session = ctx.native_session(req, SessionRead::Cached).await?;
if let Some(data) = &endpoint.session {
let email = data.user_property("email")?;
if crate::plugins::helpers::user_email_field(&email)?.to_lowercase()
!= body.email.to_lowercase()
{
return Err(AuthError::from_code(
"BAD_REQUEST",
"EMAIL_MISMATCH",
"Email mismatch",
));
}
if data.user_property("emailVerified")?.is_truthy() {
return Err(AuthError::from_code(
"BAD_REQUEST",
"EMAIL_ALREADY_VERIFIED",
"Email is already verified",
));
}
send_for_user(data.user_value()?, &email, body, config, &endpoint).await?;
} else {
let start = std::time::Instant::now();
let user = ctx.database.get_user_by_email(&body.email).await?;
let result = match user.filter(|user| !user.email_verified.field_value().is_truthy()) {
Some(user) => {
let email = user.email.field_value();
send_for_user(FieldMap::from(user).into(), &email, body, config, &endpoint).await
}
None => {
let _ = create_email_verification_token(
ctx.config.signing_secret(),
&body.email,
None,
config.verification_token_expiry(),
None,
)?;
Ok(())
}
};
if let Some(remaining) = std::time::Duration::from_millis(500).checked_sub(start.elapsed())
{
tokio::time::sleep(remaining).await;
}
result?;
}
Ok(StatusResponse { status: true })
}
fn verification_error(
query: &VerifyEmailQuery,
code: &'static str,
message: &'static str,
) -> AuthResult<VerifyEmailResult> {
if let Some(callback_url) = query.callback_url.as_deref().filter(|url| !url.is_empty()) {
return Ok(VerifyEmailResult::Redirect {
url: better_auth_core::utils::url::append_query_params(
callback_url,
&format!("error={code}"),
)?,
});
}
Err(AuthError::from_code("UNAUTHORIZED", code, message))
}
fn success(query: &VerifyEmailQuery, user: Option<FieldValue>) -> VerifyEmailResult {
if let Some(url) = query.callback_url.as_ref().filter(|url| !url.is_empty()) {
return VerifyEmailResult::Redirect { url: url.clone() };
}
let mut body = FieldMap::from([("status".into(), true.into())]);
if let Some(user) = user {
let _ = body.insert("user".into(), user);
}
VerifyEmailResult::Json { body: body.into() }
}
async fn active_session(
current: Option<NativeSessionData>,
user: &FieldValue,
req: &AuthRequest,
ctx: &AuthContext<impl better_auth_core::AuthSchema>,
) -> AuthResult<NativeSessionData> {
if let Some(current) = current {
return Ok(current);
}
issue_selected_user_session_optional(
ctx,
user.clone(),
&better_auth_core::RequestMeta {
ip_address: ctx.config.advanced.ip_address.resolve(req),
user_agent: req.headers.get("user-agent").cloned(),
},
ctx.config.session.expires_in(),
)
.await
.map_err(SessionIssueError::into_auth_error)?
.ok_or_else(|| {
AuthError::from_code(
"INTERNAL_SERVER_ERROR",
"FAILED_TO_CREATE_SESSION",
"Failed to create session",
)
})
}
pub(super) async fn verify_email_core(
query: &VerifyEmailQuery,
config: &EmailVerificationConfig,
req: &AuthRequest,
ctx: &AuthContext<impl better_auth_core::AuthSchema>,
) -> AuthResult<VerifyEmailResult> {
let claims = match decode_email_verification_token(ctx.config.signing_secret(), &query.token) {
Ok(claims) => claims,
Err(AuthError::Jwt(error)) => {
if matches!(error.kind(), ErrorKind::ExpiredSignature) {
return verification_error(query, "TOKEN_EXPIRED", "Token expired");
}
return verification_error(query, "INVALID_TOKEN", "Invalid token");
}
Err(error) => return Err(error),
};
let Some(user) = ctx.database.get_user_by_email(&claims.email).await? else {
return verification_error(query, "USER_NOT_FOUND", "User not found");
};
let verified = user.email_verified.field_value().is_truthy();
let user = FieldValue::from(FieldMap::from(user));
let email = FieldValue::from(claims.email.clone());
let email_selector = FieldValue::from(claims.email.to_lowercase());
let mut endpoint = EndpointContext::new(Some(req), FieldValue::Null, ctx);
if let Some(update_to) = claims.update_to.as_ref().filter(|email| email.is_truthy()) {
endpoint.session = ctx.native_session(req, SessionRead::Cached).await?;
if let Some(data) = &endpoint.session
&& !data.user_property("email")?.strict_equals(&email)
{
return verification_error(query, "INVALID_USER", "Invalid user");
}
let request_type = claims.request_type.as_ref().and_then(FieldValue::as_str);
if request_type == Some("change-email-confirmation") {
let token = create_native_email_verification_token(
ctx.config.signing_secret(),
&claims.email.as_str().into(),
Some(&update_to.display_utf16()?),
config.verification_token_expiry(),
Some("change-email-verification"),
)?;
let url = verification_url(&ctx.base_url(), &token, query.callback_url.as_deref());
if super::delivery::available(Some(config), ctx) {
let mut fields = user.enumerable_fields()?;
let _ = fields.insert("email".into(), update_to.clone());
let task = super::delivery::delivery(
Some(config),
VerificationEmail {
user: fields.into(),
url,
token,
},
&endpoint,
)?;
better_auth_core::background::run_or_await(
task,
ctx.config.advanced.background_tasks.as_ref(),
ctx.logger(),
)
.await?;
}
return Ok(success(query, None));
}
let mut active = active_session(endpoint.session.clone(), &user, req, ctx).await?;
let verified = request_type == Some("change-email-verification");
let updated = ctx
.database
.update_user_by_field_value(
"email",
&email_selector,
UpdateUser {
additional_fields: FieldMap::from([
("email".into(), update_to.clone()),
("emailVerified".into(), verified.into()),
]),
..Default::default()
},
)
.await?;
let updated_value = updated
.clone()
.map(|user| FieldMap::from(user).into())
.unwrap_or(FieldValue::Null);
if verified {
super::delivery::after(&updated_value, Some(config), &endpoint).await?;
} else {
let token = create_native_email_verification_token(
ctx.config.signing_secret(),
&update_to.display_utf16()?,
None,
chrono::Duration::hours(1),
None,
)?;
let url = verification_url(&ctx.base_url(), &token, query.callback_url.as_deref());
if super::delivery::available(Some(config), ctx) {
let task = super::delivery::delivery(
Some(config),
VerificationEmail {
user: updated_value,
url,
token,
},
&endpoint,
)?;
better_auth_core::background::run_or_await(
task,
ctx.config.advanced.background_tasks.as_ref(),
ctx.logger(),
)
.await?;
}
}
let session_value = active.session_value()?;
let mut fields = active.user_value()?.enumerable_fields()?;
let _ = fields.insert("email".into(), update_to.clone());
let _ = fields.insert("emailVerified".into(), verified.into());
active = active.with_values(session_value, fields.into());
ctx.session_manager()
.set_native_session_cookie(req, active, None)
.await?;
if query
.callback_url
.as_ref()
.is_some_and(|url| !url.is_empty())
{
return Ok(success(query, None));
}
let output = match updated {
Some(user) => FieldMap::from(ctx.user_view(&user).await?).into(),
None => FieldValue::Null,
};
return Ok(success(query, Some(output)));
}
if verified {
return Ok(success(query, Some(FieldValue::Null)));
}
super::delivery::before(&user, Some(config), &endpoint).await?;
let updated = ctx
.database
.update_user_by_field_value(
"email",
&email_selector,
UpdateUser {
email_verified: Some(true),
..Default::default()
},
)
.await?;
{
let updated = updated
.map(|user| FieldMap::from(user).into())
.unwrap_or(FieldValue::Null);
super::delivery::after(&updated, Some(config), &endpoint).await?;
}
if config.auto_sign_in_after_verification {
let current = ctx.native_session(req, SessionRead::Cached).await?;
let current = match current {
Some(data) if data.user_property("email")?.strict_equals(&email) => Some(data),
_ => None,
};
let mut data = active_session(current, &user, req, ctx).await?;
let session_value = data.session_value()?;
let mut fields = data.user_value()?.enumerable_fields()?;
let _ = fields.insert("emailVerified".into(), true.into());
data = data.with_values(session_value, fields.into());
ctx.session_manager()
.set_native_session_cookie(req, data, None)
.await?;
}
Ok(success(query, Some(FieldValue::Null)))
}