use jsonwebtoken::errors::ErrorKind;
use crate::plugins::helpers::{SessionIssueError, issue_user_session};
use better_auth_core::wire::{SessionView, UserView};
use better_auth_core::{AuthContext, AuthError, AuthResult, UpdateUser};
use better_auth_core::{AuthSession, AuthUser};
use super::token::{create_email_verification_token, decode_email_verification_token};
use super::types::*;
use super::{EmailVerificationConfig, StatusResponse};
fn verification_url(base_url: &str, token: &str, callback_url: Option<&str>) -> String {
let callback_url = callback_url.unwrap_or("/");
format!(
"{base_url}/verify-email?token={token}&callbackURL={}",
urlencoding::encode(callback_url),
)
}
pub(super) async fn send_verification_email_core(
body: &SendVerificationEmailRequest,
current_session: Option<&(UserView, SessionView)>,
request: Option<&better_auth_core::AuthRequest>,
config: &EmailVerificationConfig,
ctx: &AuthContext<impl better_auth_core::AuthSchema>,
) -> AuthResult<StatusResponse> {
let mut endpoint_body =
serde_json::Map::from_iter([("email".into(), serde_json::json!(body.email))]);
if let Some(callback_url) = &body.callback_url {
let _ = endpoint_body.insert("callbackURL".into(), serde_json::json!(callback_url));
}
let mut endpoint = crate::plugins::endpoint_context::EndpointContext::new(
request,
better_auth_core::FieldValue::from_json(endpoint_body.into())?,
ctx,
);
endpoint.session = current_session.cloned();
if !super::delivery::available(Some(config), ctx) {
return Err(AuthError::bad_request("Verification email isn't enabled"));
}
match current_session.map(|(user, _)| user) {
Some(user) => {
let session_email = user.email().unwrap_or_default();
if session_email != body.email {
return Err(AuthError::bad_request("Email mismatch"));
}
if user.email_verified() {
return Err(AuthError::bad_request("Email is already verified"));
}
let token = create_email_verification_token(
ctx.config.signing_secret(),
&body.email,
None,
config.verification_token_expiry(),
None,
)?;
let url = verification_url(ctx.base_url(), &token, body.callback_url.as_deref());
let user = ctx.user_view(user).await?;
if let Some(task) = super::delivery::delivery(
Some(config),
super::VerificationEmail { user, url, token },
&endpoint,
)? {
task.await?;
}
}
None => {
let user = match ctx.database.get_user_by_email(&body.email).await? {
Some(user) => user,
None => return Ok(StatusResponse { status: true }),
};
if user.email_verified() {
let _ = create_email_verification_token(
ctx.config.signing_secret(),
&body.email,
None,
config.verification_token_expiry(),
None,
)?;
return Ok(StatusResponse { status: true });
}
let token = create_email_verification_token(
ctx.config.signing_secret(),
&body.email,
None,
config.verification_token_expiry(),
None,
)?;
let url = verification_url(ctx.base_url(), &token, body.callback_url.as_deref());
let user = ctx.user_view(&user).await?;
if let Some(task) = super::delivery::delivery(
Some(config),
super::VerificationEmail { user, url, token },
&endpoint,
)? {
task.await?;
}
}
}
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}"),
)?,
session_data: None,
});
}
Err(AuthError::Upstream {
status: 401,
code,
message,
})
}
pub(super) async fn verify_email_core<U, S>(
query: &VerifyEmailQuery,
current_session: Option<(U, S)>,
config: &EmailVerificationConfig,
ip_address: Option<String>,
user_agent: Option<String>,
endpoint: &crate::plugins::endpoint_context::EndpointContext<
'_,
impl better_auth_core::AuthSchema,
>,
) -> AuthResult<VerifyEmailResult>
where
U: AuthUser,
S: AuthSession,
{
let ctx = endpoint.auth;
let current_session = if let Some((user, session)) = current_session {
Some((
ctx.user_view(&user).await?,
ctx.session_view(&session).await?,
))
} else {
None
};
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");
};
if let Some(update_to) = claims.update_to.as_deref() {
if let Some((ref session_user, _)) = current_session
&& session_user.email().unwrap_or_default() != claims.email
{
return verification_error(query, "INVALID_USER", "Invalid user");
}
match claims.request_type.as_deref() {
Some("change-email-confirmation") => {
let new_token = create_email_verification_token(
ctx.config.signing_secret(),
&claims.email,
Some(update_to),
config.verification_token_expiry(),
Some("change-email-verification"),
)?;
let url =
verification_url(ctx.base_url(), &new_token, query.callback_url.as_deref());
if super::delivery::available(Some(config), ctx) {
let mut updated_user = ctx.user_view(&user).await?;
updated_user.email = Some(update_to.to_string());
let task = super::delivery::delivery(
Some(config),
super::VerificationEmail {
user: updated_user,
url,
token: new_token,
},
endpoint,
)?;
better_auth_core::background::run_or_await(
task,
ctx.config.advanced.background_tasks.as_ref(),
&ctx.config.logger,
)
.await;
}
if let Some(callback_url) = query.callback_url.as_deref() {
return Ok(VerifyEmailResult::Redirect {
url: callback_url.to_owned(),
session_data: None,
});
}
return Ok(VerifyEmailResult::Json {
body: serde_json::json!({ "status": true }),
session_data: None,
});
}
Some("change-email-verification") => {
let (mut session_user, session): (UserView, SessionView) = match current_session {
Some((user, session)) => (user, session),
None => {
let session =
issue_user_session(ctx, user.id().typed()?, ip_address, user_agent)
.await
.map_err(SessionIssueError::into_auth_error)?
.session;
(
ctx.internal_user_view(&user).await?,
ctx.session_manager()
.internal_session_view(&session)
.await?,
)
}
};
let updated_user = ctx
.database
.update_user(
user.id().typed()?,
UpdateUser {
email: Some(update_to.to_string()),
email_verified: Some(true),
..Default::default()
},
)
.await?;
if let Some(ref hook) = config.after_email_verification {
let hook_user = ctx.user_view(&updated_user).await?;
hook(&hook_user).await?;
}
session_user.email = Some(update_to.into());
session_user.email_verified = true;
let data = better_auth_core::session::SessionData {
user: session_user,
session,
};
if let Some(callback_url) = query.callback_url.as_deref() {
return Ok(VerifyEmailResult::Redirect {
url: callback_url.to_owned(),
session_data: Some(data),
});
}
return Ok(VerifyEmailResult::Json {
body: serde_json::json!({
"status": true,
"user": ctx.user_view(&updated_user).await?,
}),
session_data: Some(data),
});
}
_ => {
let (mut session_user, session) = match current_session {
Some(pair) => pair,
None => {
let issued =
issue_user_session(ctx, user.id().typed()?, ip_address, user_agent)
.await
.map_err(SessionIssueError::into_auth_error)?;
(
ctx.internal_user_view(&user).await?,
ctx.session_manager()
.internal_session_view(&issued.session)
.await?,
)
}
};
let updated_user = ctx
.database
.update_user(
user.id().typed()?,
UpdateUser {
email: Some(update_to.to_string()),
email_verified: Some(false),
..Default::default()
},
)
.await?;
let new_token = create_email_verification_token(
ctx.config.signing_secret(),
update_to,
None,
config.verification_token_expiry(),
None,
)?;
let url =
verification_url(ctx.base_url(), &new_token, query.callback_url.as_deref());
if super::delivery::available(Some(config), ctx) {
let task = super::delivery::delivery(
Some(config),
super::VerificationEmail {
user: ctx.user_view(&updated_user).await?,
url,
token: new_token,
},
endpoint,
)?;
better_auth_core::background::run_or_await(
task,
ctx.config.advanced.background_tasks.as_ref(),
&ctx.config.logger,
)
.await;
}
session_user.email = Some(update_to.into());
session_user.email_verified = false;
let data = better_auth_core::session::SessionData {
user: session_user,
session,
};
if let Some(callback_url) = query.callback_url.as_deref() {
return Ok(VerifyEmailResult::Redirect {
url: callback_url.to_owned(),
session_data: Some(data),
});
}
return Ok(VerifyEmailResult::Json {
body: serde_json::json!({
"status": true,
"user": updated_user,
}),
session_data: Some(data),
});
}
}
}
if user.email_verified() {
if let Some(callback_url) = query.callback_url.as_deref() {
return Ok(VerifyEmailResult::Redirect {
url: callback_url.to_owned(),
session_data: None,
});
}
return Ok(VerifyEmailResult::Json {
body: serde_json::json!({ "status": true, "user": serde_json::Value::Null }),
session_data: None,
});
}
if let Some(ref hook) = config.before_email_verification {
let hook_user = ctx.user_view(&user).await?;
hook(&hook_user).await?;
}
let updated_user = ctx
.database
.update_user(
user.id().typed()?,
UpdateUser {
email_verified: Some(true),
..Default::default()
},
)
.await?;
if let Some(ref hook) = config.after_email_verification {
let hook_user = ctx.user_view(&updated_user).await?;
hook(&hook_user).await?;
}
let session_data = if config.auto_sign_in_after_verification {
let mut data = if let Some((session_user, session)) =
current_session.filter(|(user, _)| user.email().unwrap_or_default() == claims.email)
{
better_auth_core::session::SessionData {
user: session_user,
session,
}
} else {
let issued = issue_user_session(ctx, user.id().typed()?, ip_address, user_agent)
.await
.map_err(SessionIssueError::into_auth_error)?;
ctx.session_manager()
.internal_data(&user, &issued.session)
.await?
};
data.user.email_verified = true;
Some(data)
} else {
None
};
if let Some(callback_url) = query.callback_url.as_deref() {
return Ok(VerifyEmailResult::Redirect {
url: callback_url.to_owned(),
session_data,
});
}
Ok(VerifyEmailResult::Json {
body: serde_json::json!({ "status": true, "user": serde_json::Value::Null }),
session_data,
})
}