use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use better_auth_core::entity::AuthUser;
use better_auth_core::{AuthContext, AuthPlugin, AuthRoute};
use better_auth_core::{AuthError, AuthResult};
use better_auth_core::{AuthRecordFields, FieldMap, FieldValue};
use better_auth_core::{AuthRequest, AuthResponse, HttpMethod, RequestMeta};
use super::email_verification::EmailVerificationPlugin;
use better_auth_core::utils::password::{self as password_utils, PasswordHasher};
use better_auth_core::wire::UserView;
use crate::plugins::helpers::{SessionIssueError, issue_selected_user_session_optional};
mod callbacks;
mod request;
mod signup;
#[cfg(test)]
mod synthetic_tests;
use super::username::request::SignInUsernameRequest;
pub use callbacks::EmailPasswordCallbacks;
use signup::sign_up_core;
pub use signup::{CustomSyntheticUser, OnExistingUserSignUp, SyntheticUserInput};
const MESSAGE_EMAIL_NOT_VERIFIED: &str = "Email not verified";
pub struct EmailPasswordPlugin {
config: EmailPasswordConfig,
email_verification: Option<Arc<EmailVerificationPlugin>>,
}
#[derive(Clone)]
pub struct EmailPasswordConfig {
pub enable_signup: bool,
pub username: bool,
pub require_email_verification: bool,
pub password_min_length: Option<usize>,
pub password_max_length: Option<usize>,
pub auto_sign_in: Option<bool>,
pub password_hasher: Option<Arc<dyn PasswordHasher>>,
pub on_existing_user_sign_up: Option<Arc<dyn OnExistingUserSignUp>>,
pub custom_synthetic_user: Option<Arc<CustomSyntheticUser>>,
}
impl EmailPasswordConfig {
pub fn password_min_length(&self) -> usize {
self.password_min_length.unwrap_or(8)
}
pub fn password_max_length(&self) -> usize {
self.password_max_length.unwrap_or(128)
}
pub fn auto_sign_in(&self) -> bool {
self.auto_sign_in.unwrap_or(true)
}
}
impl std::fmt::Debug for EmailPasswordConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("EmailPasswordConfig")
.field("enable_signup", &self.enable_signup)
.field("username", &self.username)
.field(
"require_email_verification",
&self.require_email_verification,
)
.field("password_min_length", &self.password_min_length)
.field("password_max_length", &self.password_max_length)
.field("auto_sign_in", &self.auto_sign_in)
.field(
"password_hasher",
&self.password_hasher.as_ref().map(|_| "custom"),
)
.finish()
}
}
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct SignUpRequest {
name: String,
email: String,
password: String,
image: Option<String>,
#[serde(rename = "callbackURL")]
callback_url: Option<String>,
#[serde(rename = "rememberMe")]
remember_me: Option<bool>,
#[serde(flatten)]
additional_fields: serde_json::Map<String, serde_json::Value>,
}
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct SignInRequest {
email: String,
password: String,
#[serde(rename = "callbackURL")]
callback_url: Option<String>,
#[serde(rename = "rememberMe")]
remember_me: Option<bool>,
}
#[derive(Debug, Serialize)]
pub(crate) struct SignUpResponse {
#[serde(
with = "better_auth_core::field_value::serde::value",
skip_serializing_if = "better_auth_core::FieldValue::is_json_omitted"
)]
token: better_auth_core::FieldValue,
#[serde(with = "better_auth_core::field_value::serde::map")]
user: better_auth_core::FieldMap,
}
impl SignUpResponse {
fn into_field_value(self) -> FieldValue {
FieldMap::from([
("token".into(), self.token),
("user".into(), self.user.into()),
])
.into()
}
}
#[derive(Debug, Serialize)]
pub(crate) struct SignInResponse<U: Serialize> {
redirect: bool,
#[serde(skip_serializing_if = "better_auth_core::SchemaValue::is_json_omitted")]
token: better_auth_core::SchemaValue<String>,
#[serde(skip_serializing_if = "Option::is_none")]
url: Option<String>,
user: U,
}
impl SignInResponse<UserView> {
pub(crate) fn into_field_value(self) -> AuthResult<FieldValue> {
Ok(FieldMap::from([
("redirect".into(), self.redirect.into()),
("token".into(), self.token.into_field_value()),
(
"url".into(),
self.url.map(FieldValue::from).unwrap_or_default(),
),
("user".into(), self.user.field_values()?.into()),
])
.into())
}
}
pub(crate) struct SignInCoreResult<U: Serialize> {
pub response: SignInResponse<U>,
}
impl EmailPasswordPlugin {
#[expect(
clippy::new_without_default,
reason = "plugin construction is intentionally explicit"
)]
pub fn new() -> Self {
Self {
config: EmailPasswordConfig::default(),
email_verification: None,
}
}
pub fn with_config(config: EmailPasswordConfig) -> Self {
Self {
config,
email_verification: None,
}
}
pub fn with_email_verification(mut self, plugin: Arc<EmailVerificationPlugin>) -> Self {
self.email_verification = Some(plugin);
self
}
pub fn enable_signup(mut self, enable: bool) -> Self {
self.config.enable_signup = enable;
self
}
pub fn username(mut self, enabled: bool) -> Self {
self.config.username = enabled;
self
}
pub fn require_email_verification(mut self, require: bool) -> Self {
self.config.require_email_verification = require;
self
}
pub fn password_min_length(mut self, length: usize) -> Self {
self.config.password_min_length = Some(length);
self
}
pub fn password_max_length(mut self, length: usize) -> Self {
self.config.password_max_length = Some(length);
self
}
pub fn auto_sign_in(mut self, auto: bool) -> Self {
self.config.auto_sign_in = Some(auto);
self
}
pub fn password_hasher(mut self, hasher: Arc<dyn PasswordHasher>) -> Self {
self.config.password_hasher = Some(hasher);
self
}
pub fn on_existing_user_sign_up(mut self, callback: Arc<dyn OnExistingUserSignUp>) -> Self {
self.config.on_existing_user_sign_up = Some(callback);
self
}
pub fn custom_synthetic_user(mut self, callback: Arc<CustomSyntheticUser>) -> Self {
self.config.custom_synthetic_user = Some(callback);
self
}
async fn handle_sign_up(
&self,
req: &AuthRequest,
ctx: &AuthContext<impl better_auth_core::AuthSchema>,
) -> AuthResult<AuthResponse> {
let endpoint_body: serde_json::Value = req
.body_as_json()
.map_err(|error| AuthError::bad_request(format!("Invalid JSON: {error}")))?;
let signup_req = request::sign_up(req)?;
request::form_csrf(req, ctx).await?;
let response = sign_up_core(&signup_req, endpoint_body, &self.config, req, ctx).await?;
Ok(AuthResponse::native(None, response.into_field_value()))
}
async fn handle_sign_in(
&self,
req: &AuthRequest,
ctx: &AuthContext<impl better_auth_core::AuthSchema>,
) -> AuthResult<AuthResponse> {
let signin_req = request::sign_in(req)?;
request::form_csrf(req, ctx).await?;
if !super::json_body::valid_email(&signin_req.email)? {
return Err(AuthError::bad_request("Invalid email"));
}
let meta = RequestMeta::from_request_with_config(req, &ctx.config.advanced.ip_address);
let result = sign_in_core(
req,
&signin_req,
&self.config,
self.email_verification.as_deref(),
&meta,
ctx,
)
.await?;
Ok(AuthResponse::native(
None,
result.response.into_field_value()?,
))
}
async fn handle_sign_in_username(
&self,
req: &AuthRequest,
ctx: &AuthContext<impl better_auth_core::AuthSchema>,
) -> AuthResult<AuthResponse> {
super::username::UsernamePlugin::default()
.sign_in_with_verification(req, ctx, self.email_verification.as_deref())
.await
}
async fn handle_is_username_available(
&self,
req: &AuthRequest,
ctx: &AuthContext<impl better_auth_core::AuthSchema>,
) -> AuthResult<AuthResponse> {
super::username::UsernamePlugin::default()
.available(req, ctx)
.await
}
}
async fn load_credential_password_hash(
user: &impl AuthUser,
ctx: &AuthContext<impl better_auth_core::AuthSchema>,
) -> AuthResult<better_auth_core::FieldValue> {
super::helpers::get_credential_password_hash(ctx, user.id().into_owned())
.await?
.ok_or(AuthError::InvalidCredentials)
}
async fn verify_user_password(
user: &impl AuthUser,
password: &str,
ctx: &AuthContext<impl better_auth_core::AuthSchema>,
) -> AuthResult<()> {
let stored_hash = load_credential_password_hash(user, ctx).await?;
password_utils::verify_password_native(
ctx.password_policy.hasher.as_ref(),
password,
&stored_hash,
)
.await
}
enum CredentialSignIn<'a> {
Email(Option<&'a EmailVerificationPlugin>),
Username,
}
async fn finalize_sign_in_with_user_core(
req: &AuthRequest,
user: impl AuthUser,
remember_me: Option<bool>,
method: CredentialSignIn<'_>,
callback_url: Option<&str>,
meta: &RequestMeta,
ctx: &AuthContext<impl better_auth_core::AuthSchema>,
) -> AuthResult<SignInCoreResult<UserView>> {
if let CredentialSignIn::Email(Some(ev)) = &method
&& let Err(e) = ev
.send_verification_on_sign_in_with_request(&user, callback_url, Some(req), ctx)
.await
{
better_auth_core::observability::logger::current().warn(
"Failed to send verification email on sign-in",
&[better_auth_core::observability::LogArgument::Error(&e)],
)?;
}
let expires_in = if remember_me == Some(false) {
chrono::Duration::days(1)
} else {
ctx.config.session.expires_in()
};
let user = ctx.internal_user_view(&user).await?;
let issued = issue_selected_user_session_optional(
ctx,
better_auth_core::FieldMap::from(user.clone()).into(),
meta,
expires_in,
)
.await
.map_err(SessionIssueError::into_auth_error)?;
let Some(issued) = issued else {
let session_failure_status = match method {
CredentialSignIn::Email(_) => "UNAUTHORIZED",
CredentialSignIn::Username => "INTERNAL_SERVER_ERROR",
};
if matches!(method, CredentialSignIn::Email(_)) {
better_auth_core::observability::logger::current()
.error("Failed to create session", &[])?;
}
return Err(AuthError::from_code(
session_failure_status,
"FAILED_TO_CREATE_SESSION",
"Failed to create session",
));
};
let session = issued.session_value()?;
let manager = ctx.session_manager();
manager
.set_native_session_cookie(req, issued, Some(remember_me == Some(false)))
.await?;
if let Some(callback) = callback_url.filter(|url| !url.is_empty()) {
req.set_response_header("Location", callback)?;
}
let response = SignInResponse {
redirect: callback_url.is_some_and(|url| !url.is_empty()),
token: better_auth_core::SchemaValue::from_field(session.model_property("token")?),
url: callback_url.map(str::to_owned),
user: ctx.user_view(&user).await?,
};
Ok(SignInCoreResult { response })
}
pub(crate) async fn sign_in_core(
req: &AuthRequest,
body: &SignInRequest,
config: &EmailPasswordConfig,
email_verification: Option<&EmailVerificationPlugin>,
meta: &RequestMeta,
ctx: &AuthContext<impl better_auth_core::AuthSchema>,
) -> AuthResult<SignInCoreResult<UserView>> {
ctx.password_policy.validate_max_length(&body.password)?;
let verification = EmailVerificationPlugin::from_context(ctx);
let email_verification = email_verification.or(verification.as_ref());
let Some(record) = ctx.database.get_user_with_accounts(&body.email).await? else {
_ = password_utils::hash_password(ctx.password_policy.hasher.as_ref(), &body.password)
.await?;
return Err(AuthError::InvalidCredentials);
};
let Some(stored_hash) = credential_password_from_accounts(&record)? else {
_ = password_utils::hash_password(ctx.password_policy.hasher.as_ref(), &body.password)
.await?;
return Err(AuthError::InvalidCredentials);
};
let user = record.user;
password_utils::verify_password_native(
ctx.password_policy.hasher.as_ref(),
&body.password,
&stored_hash,
)
.await?;
if (config.require_email_verification
|| email_verification.is_some_and(EmailVerificationPlugin::is_verification_required))
&& !user.email_verified().is_truthy()?
{
if let Some(verification) = email_verification {
verification
.send_verification_on_sign_in_with_request(
&user,
body.callback_url.as_deref(),
Some(req),
ctx,
)
.await?;
}
return Err(AuthError::forbidden(MESSAGE_EMAIL_NOT_VERIFIED));
}
finalize_sign_in_with_user_core(
req,
user,
body.remember_me,
CredentialSignIn::Email(email_verification),
body.callback_url.as_deref(),
meta,
ctx,
)
.await
}
pub(super) fn credential_password_from_accounts(
record: &better_auth_core::store::UserAccounts,
) -> AuthResult<Option<better_auth_core::FieldValue>> {
let better_auth_core::store::JoinValue::Many(accounts) = &record.accounts else {
return Err(AuthError::internal("accounts.find is not a function"));
};
Ok(accounts
.iter()
.find(|account| {
account
.provider_id
.field_value()
.strict_equals(&"credential".into())
&& account
.account_id
.field_value()
.strict_equals(&record.user.id.field_value())
})
.map(|account| account.password.field_value())
.filter(better_auth_core::FieldValue::is_truthy))
}
pub(crate) async fn sign_in_username_core(
req: &AuthRequest,
body: &SignInUsernameRequest,
normalized_username: &better_auth_core::FieldValue,
config: &EmailPasswordConfig,
email_verification: Option<&EmailVerificationPlugin>,
meta: &RequestMeta,
ctx: &AuthContext<impl better_auth_core::AuthSchema>,
) -> Result<SignInCoreResult<UserView>, SignInUsernameFailure> {
ctx.password_policy
.validate_max_length(&body.password)
.map_err(SignInUsernameFailure::Auth)?;
let verification = EmailVerificationPlugin::from_context(ctx);
let email_verification = email_verification.or(verification.as_ref());
let Some(user) = ctx
.database
.get_user_by_field_value("username", normalized_username)
.await
.map_err(SignInUsernameFailure::Auth)?
else {
let _ = password_utils::hash_password(ctx.password_policy.hasher.as_ref(), &body.password)
.await
.map_err(SignInUsernameFailure::Auth)?;
return Err(SignInUsernameFailure::InvalidUsernameOrPassword);
};
verify_user_password(&user, &body.password, ctx)
.await
.map_err(|error| match error {
AuthError::InvalidCredentials => SignInUsernameFailure::InvalidUsernameOrPassword,
other => SignInUsernameFailure::Auth(other),
})?;
if (config.require_email_verification
|| email_verification.is_some_and(EmailVerificationPlugin::is_verification_required))
&& !user
.email_verified()
.is_truthy()
.map_err(SignInUsernameFailure::Auth)?
{
if let Some(ev) = email_verification {
ev.send_verification_on_sign_in_with_request(
&user,
body.callback_url.as_deref(),
Some(req),
ctx,
)
.await
.map_err(SignInUsernameFailure::Auth)?;
}
return Err(SignInUsernameFailure::EmailNotVerified);
}
finalize_sign_in_with_user_core(
req,
user,
body.remember_me,
CredentialSignIn::Username,
body.callback_url.as_deref(),
meta,
ctx,
)
.await
.map_err(SignInUsernameFailure::Auth)
}
pub(crate) enum SignInUsernameFailure {
InvalidUsernameOrPassword,
EmailNotVerified,
Auth(AuthError),
}
impl Default for EmailPasswordConfig {
fn default() -> Self {
Self {
enable_signup: true,
username: false,
require_email_verification: false,
password_min_length: None,
password_max_length: None,
auto_sign_in: None,
password_hasher: None,
on_existing_user_sign_up: None,
custom_synthetic_user: None,
}
}
}
#[async_trait]
impl<S: better_auth_core::AuthSchema> AuthPlugin<S> for EmailPasswordPlugin {
fn telemetry(&self, options: &mut better_auth_core::observability::telemetry::PluginTelemetry) {
if let Some(verification) = &self.email_verification {
<EmailVerificationPlugin as AuthPlugin<S>>::telemetry(verification, options);
}
let options = &mut options.email_and_password;
options.enabled = true;
options.disable_sign_up = !self.config.enable_signup;
options.require_email_verification = self.config.require_email_verification;
options.min_password_length = self.config.password_min_length;
options.max_password_length = self.config.password_max_length;
options.auto_sign_in = self.config.auto_sign_in == Some(true);
options.password.hash |= self.config.password_hasher.is_some();
options.password.verify |= self.config.password_hasher.is_some();
}
fn name(&self) -> &'static str {
"email-password"
}
fn telemetry_plugin_id(&self) -> Option<&'static str> {
None
}
fn openapi(&self) -> AuthResult<better_auth_core::openapi::OpenApiPluginMetadata> {
if self.config.username {
<crate::plugins::username::UsernamePlugin as AuthPlugin<S>>::openapi(&Default::default())
} else {
better_auth_core::openapi::OpenApiPluginMetadata::from_routes(
<Self as better_auth_core::AuthPlugin<S>>::name(self),
<Self as better_auth_core::AuthPlugin<S>>::routes(self),
)
}
}
fn password_hasher(&self) -> Option<Arc<dyn PasswordHasher>> {
self.config.password_hasher.clone()
}
fn schema(&self, fields: &mut better_auth_core::plugin_runtime::ModelFields) -> AuthResult<()> {
if self.config.username {
<super::username::UsernamePlugin as AuthPlugin<S>>::schema(
&super::username::UsernamePlugin::default(),
fields,
)?;
}
Ok(())
}
async fn on_init(&self, ctx: &mut better_auth_core::AuthInitContext<S>) -> AuthResult<()> {
ctx.password_policy.min_length = self.config.password_min_length();
ctx.password_policy.max_length = self.config.password_max_length();
ctx.extensions.insert(self.config.clone());
if self.config.username {
<super::username::UsernamePlugin as AuthPlugin<S>>::on_init(
&super::username::UsernamePlugin::default(),
ctx,
)
.await?;
}
Ok(())
}
fn routes(&self) -> Vec<AuthRoute> {
let mut routes = vec![
AuthRoute::post("/sign-in/email", "signInEmail")
.body_validator(request::sign_in_body)
.allowed_media_types(&["application/x-www-form-urlencoded", "application/json"]),
];
if self.config.username {
routes.push(AuthRoute::post("/sign-in/username", "signInUsername"));
routes.push(AuthRoute::post(
"/is-username-available",
"isUsernameAvailable",
));
}
if self.config.enable_signup {
routes.push(
AuthRoute::post("/sign-up/email", "signUpWithEmailAndPassword")
.body_validator(request::sign_up_body)
.allowed_media_types(&[
"application/x-www-form-urlencoded",
"application/json",
]),
);
}
routes
}
async fn before_request(
&self,
req: &AuthRequest,
ctx: &AuthContext<S>,
) -> AuthResult<Option<better_auth_core::BeforeRequestAction>> {
if self.config.username {
super::username::UsernamePlugin::default()
.before_endpoint(req, ctx)
.await
} else {
Ok(None)
}
}
async fn on_request(
&self,
req: &AuthRequest,
ctx: &AuthContext<S>,
) -> AuthResult<Option<AuthResponse>> {
match (req.method(), req.path()) {
(HttpMethod::Post, "/sign-up/email") if self.config.enable_signup => {
Ok(Some(self.handle_sign_up(req, ctx).await?))
}
(HttpMethod::Post, "/sign-in/email") => Ok(Some(self.handle_sign_in(req, ctx).await?)),
(HttpMethod::Post, "/sign-in/username") if self.config.username => {
Ok(Some(self.handle_sign_in_username(req, ctx).await?))
}
(HttpMethod::Post, "/is-username-available") if self.config.username => {
Ok(Some(self.handle_is_username_available(req, ctx).await?))
}
_ => Ok(None),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::plugins::test_helpers;
use better_auth_core::AuthContext;
use better_auth_core::config::AuthConfig;
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
type TestSchema =
better_auth_seaorm::store::__private_test_support::bundled_schema::BundledSchema;
async fn create_test_context(plugin: &EmailPasswordPlugin) -> Arc<AuthContext<TestSchema>> {
let config = AuthConfig::new("test-secret-key-at-least-32-chars-long");
let config = Arc::new(config);
let database = crate::plugins::test_helpers::create_test_database().await;
let mut model_fields = better_auth_core::plugin_runtime::ModelFields::default();
let mut init =
better_auth_core::AuthInitContext::new(config.clone(), database.clone()).unwrap();
init.password_policy.hasher =
<EmailPasswordPlugin as AuthPlugin<TestSchema>>::password_hasher(plugin);
better_auth_core::AuthPlugin::<TestSchema>::schema(plugin, &mut model_fields).unwrap();
plugin.on_init(&mut init).await.unwrap();
let parts = init.into_parts();
let (adapter_config, endpoint_config, model_fields) = model_fields.resolve(&config);
let database = database
.with_runtime(
Arc::new(adapter_config),
parts.database_hooks.clone(),
model_fields,
)
.unwrap();
let mut context = AuthContext::new(Arc::new(endpoint_config), database).unwrap();
parts.apply_request_runtime(&mut context);
context.extensions = parts.extensions;
context.email_verification_policy = parts.email_verification_policy;
context.email_provider = parts.email_provider;
context.secondary_storage = parts.secondary_storage;
context.password_policy = parts.password_policy;
context.metadata = parts.metadata;
let context = Arc::new(context);
parts.runtime.bind(&context).unwrap();
context
}
fn create_signup_request(email: &str, password: &str) -> AuthRequest {
let body = serde_json::json!({
"name": "Test User",
"email": email,
"password": password,
});
AuthRequest::from_parts(
HttpMethod::Post,
"/sign-up/email".to_string(),
HashMap::new(),
Some(body.to_string().into_bytes()),
None,
)
}
#[tokio::test]
async fn test_auto_sign_in_false_returns_no_session() {
let plugin = EmailPasswordPlugin::new().auto_sign_in(false);
let ctx = create_test_context(&plugin).await;
let req = create_signup_request("auto@example.com", "Password123!");
let response = plugin.handle_sign_up(&req, &ctx).await.unwrap();
let response = test_helpers::finalize_response(&ctx, &req, response);
assert_eq!(response.status, 200);
let has_cookie = response
.headers
.iter()
.any(|(k, _)| k.eq_ignore_ascii_case("Set-Cookie"));
assert!(!has_cookie, "auto_sign_in=false should not set a cookie");
let body: serde_json::Value =
serde_json::from_slice(&response.body.bytes().unwrap()).unwrap();
assert!(
body["token"].is_null(),
"auto_sign_in=false should return null token"
);
assert!(body["user"]["id"].is_string());
}
#[tokio::test]
async fn test_auto_sign_in_true_returns_session() {
let plugin = EmailPasswordPlugin::new(); let ctx = create_test_context(&plugin).await;
let req = create_signup_request("autotrue@example.com", "Password123!");
let response = plugin.handle_sign_up(&req, &ctx).await.unwrap();
let response = test_helpers::finalize_response(&ctx, &req, response);
assert_eq!(response.status, 200);
let has_cookie = response
.headers
.iter()
.any(|(k, _)| k.eq_ignore_ascii_case("Set-Cookie"));
assert!(has_cookie, "auto_sign_in=true should set a cookie");
let body: serde_json::Value =
serde_json::from_slice(&response.body.bytes().unwrap()).unwrap();
assert!(
body["token"].is_string(),
"auto_sign_in=true should return a session token"
);
}
#[tokio::test]
async fn test_password_max_length_rejection() {
let plugin = EmailPasswordPlugin::new().password_max_length(128);
let ctx = create_test_context(&plugin).await;
let long_password = format!("A1!{}", "a".repeat(126)); let req = create_signup_request("long@example.com", &long_password);
let err = plugin.handle_sign_up(&req, &ctx).await.unwrap_err();
assert_eq!(err.status_code().unwrap(), 400);
let ok_password = format!("A1!{}", "a".repeat(125)); let req = create_signup_request("ok@example.com", &ok_password);
let response = plugin.handle_sign_up(&req, &ctx).await.unwrap();
assert_eq!(response.status, 200);
}
#[tokio::test]
async fn test_custom_password_hasher() {
struct TestHasher;
#[async_trait]
impl PasswordHasher for TestHasher {
async fn hash(&self, password: &str) -> AuthResult<String> {
Ok(format!("hashed:{}", password))
}
async fn verify(&self, hash: &str, password: &str) -> AuthResult<bool> {
Ok(hash == format!("hashed:{}", password))
}
}
let hasher: Arc<dyn PasswordHasher> = Arc::new(TestHasher);
let plugin = EmailPasswordPlugin::new().password_hasher(hasher);
let ctx = create_test_context(&plugin).await;
let req = create_signup_request("hasher@example.com", "Password123!");
let response = plugin.handle_sign_up(&req, &ctx).await.unwrap();
assert_eq!(response.status, 200);
let user = ctx
.database
.get_user_by_email("hasher@example.com")
.await
.unwrap()
.unwrap();
let stored_hash = ctx
.database
.get_user_accounts(user.id().typed().unwrap())
.await
.unwrap()
.into_iter()
.find(|account| account.provider_id == "credential")
.and_then(|account| account.password.typed().unwrap().clone())
.expect("credential account should store hashed password");
assert_eq!(stored_hash, "hashed:Password123!");
let signin_body = serde_json::json!({
"email": "hasher@example.com",
"password": "Password123!",
});
let signin_req = AuthRequest::from_parts(
HttpMethod::Post,
"/sign-in/email".to_string(),
HashMap::new(),
Some(signin_body.to_string().into_bytes()),
None,
);
let response = plugin.handle_sign_in(&signin_req, &ctx).await.unwrap();
assert_eq!(response.status, 200);
let bad_body = serde_json::json!({
"email": "hasher@example.com",
"password": "WrongPassword!",
});
let bad_req = AuthRequest::from_parts(
HttpMethod::Post,
"/sign-in/email".to_string(),
HashMap::new(),
Some(bad_body.to_string().into_bytes()),
None,
);
let err = plugin.handle_sign_in(&bad_req, &ctx).await.unwrap_err();
assert_eq!(err.to_string(), AuthError::InvalidCredentials.to_string());
}
#[tokio::test]
async fn test_sign_in_username_verifies_password_once() {
struct CountingHasher {
verify_calls: Arc<AtomicUsize>,
}
#[async_trait]
impl PasswordHasher for CountingHasher {
async fn hash(&self, password: &str) -> AuthResult<String> {
Ok(format!("hashed:{password}"))
}
async fn verify(&self, hash: &str, password: &str) -> AuthResult<bool> {
self.verify_calls.fetch_add(1, Ordering::SeqCst);
Ok(hash == format!("hashed:{password}"))
}
}
let verify_calls = Arc::new(AtomicUsize::new(0));
let hasher: Arc<dyn PasswordHasher> = Arc::new(CountingHasher {
verify_calls: verify_calls.clone(),
});
let plugin = EmailPasswordPlugin::new()
.username(true)
.password_hasher(hasher);
let ctx = create_test_context(&plugin).await;
let signup_body = serde_json::json!({
"email": "username-counter@example.com",
"password": "Password123!",
"name": "Counter User",
"username": "Counter_User",
});
let signup_req = AuthRequest::from_parts(
HttpMethod::Post,
"/sign-up/email".to_string(),
HashMap::new(),
Some(signup_body.to_string().into_bytes()),
None,
);
let signup_response = plugin.handle_sign_up(&signup_req, &ctx).await.unwrap();
assert_eq!(signup_response.status, 200);
verify_calls.store(0, Ordering::SeqCst);
let signin_body = serde_json::json!({
"username": "COUNTER_USER",
"password": "Password123!",
});
let signin_req = AuthRequest::from_parts(
HttpMethod::Post,
"/sign-in/username".to_string(),
HashMap::from([("content-type".into(), "application/json".into())]),
Some(signin_body.to_string().into_bytes()),
None,
);
let signin_response = plugin
.handle_sign_in_username(&signin_req, &ctx)
.await
.unwrap();
assert_eq!(signin_response.status, 200);
assert_eq!(verify_calls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn test_is_username_available_route_registered() {
let plugin = EmailPasswordPlugin::new().username(true);
let routes =
<EmailPasswordPlugin as better_auth_core::AuthPlugin<TestSchema>>::routes(&plugin);
assert!(
routes.iter().any(|r| r.path == "/is-username-available"),
"route /is-username-available should be registered"
);
}
#[tokio::test]
async fn test_is_username_available_fresh() {
let plugin = EmailPasswordPlugin::new().username(true);
let ctx = create_test_context(&plugin).await;
let body = serde_json::json!({ "username": "fresh_user" });
let req = AuthRequest::from_parts(
HttpMethod::Post,
"/is-username-available".to_string(),
HashMap::from([("content-type".into(), "application/json".into())]),
Some(body.to_string().into_bytes()),
None,
);
let response = plugin
.handle_is_username_available(&req, &ctx)
.await
.unwrap();
assert_eq!(response.status, 200);
let json: serde_json::Value =
serde_json::from_slice(&response.body.bytes().unwrap()).unwrap();
assert_eq!(json["available"], true);
}
#[tokio::test]
async fn test_is_username_available_taken() {
let plugin = EmailPasswordPlugin::new().username(true);
let ctx = create_test_context(&plugin).await;
let signup_body = serde_json::json!({
"name": "Taken User",
"email": "taken@example.com",
"password": "Password123!",
"username": "taken_user",
});
let signup_req = AuthRequest::from_parts(
HttpMethod::Post,
"/sign-up/email".to_string(),
HashMap::new(),
Some(signup_body.to_string().into_bytes()),
None,
);
let resp = plugin.handle_sign_up(&signup_req, &ctx).await.unwrap();
assert_eq!(resp.status, 200);
let body = serde_json::json!({ "username": "taken_user" });
let req = AuthRequest::from_parts(
HttpMethod::Post,
"/is-username-available".to_string(),
HashMap::from([("content-type".into(), "application/json".into())]),
Some(body.to_string().into_bytes()),
None,
);
let response = plugin
.handle_is_username_available(&req, &ctx)
.await
.unwrap();
assert_eq!(response.status, 200);
let json: serde_json::Value =
serde_json::from_slice(&response.body.bytes().unwrap()).unwrap();
assert_eq!(json["available"], false);
}
#[tokio::test]
async fn test_is_username_available_too_short() {
let plugin = EmailPasswordPlugin::new().username(true);
let ctx = create_test_context(&plugin).await;
let body = serde_json::json!({ "username": "ab" });
let req = AuthRequest::from_parts(
HttpMethod::Post,
"/is-username-available".to_string(),
HashMap::from([("content-type".into(), "application/json".into())]),
Some(body.to_string().into_bytes()),
None,
);
let response = plugin
.handle_is_username_available(&req, &ctx)
.await
.unwrap_err()
.to_auth_response()
.unwrap()
.into_http_response()
.unwrap();
assert_eq!(response.status, 422);
let json: serde_json::Value =
serde_json::from_slice(&response.body.bytes().unwrap()).unwrap();
assert_eq!(json["code"], "USERNAME_TOO_SHORT");
}
#[tokio::test]
async fn test_is_username_available_invalid_chars() {
let plugin = EmailPasswordPlugin::new().username(true);
let ctx = create_test_context(&plugin).await;
let body = serde_json::json!({ "username": "bad user!" });
let req = AuthRequest::from_parts(
HttpMethod::Post,
"/is-username-available".to_string(),
HashMap::from([("content-type".into(), "application/json".into())]),
Some(body.to_string().into_bytes()),
None,
);
let response = plugin
.handle_is_username_available(&req, &ctx)
.await
.unwrap_err()
.to_auth_response()
.unwrap()
.into_http_response()
.unwrap();
assert_eq!(response.status, 422);
let json: serde_json::Value =
serde_json::from_slice(&response.body.bytes().unwrap()).unwrap();
assert_eq!(json["code"], "INVALID_USERNAME");
}
}