use better_auth_core::entity::AuthUser;
use better_auth_core::{AuthContext, AuthError, AuthRequest, AuthResult, FieldMap, UpdateUser};
use chrono::Utc;
pub(crate) fn user_email(user: &impl AuthUser) -> AuthResult<String> {
user_email_field(&user.email().field_value())
}
pub(crate) fn user_email_field(email: &better_auth_core::FieldValue) -> AuthResult<String> {
match email {
better_auth_core::FieldValue::String(email) => Ok(email.clone()),
better_auth_core::FieldValue::Utf16String(email) => email.to_utf8().map_err(|error| {
AuthError::internal(format!(
"Cannot represent user email as a Rust string: {error}"
))
}),
better_auth_core::FieldValue::Null => Err(AuthError::type_error(
"Cannot read properties of null (reading 'toLowerCase')",
)),
better_auth_core::FieldValue::Undefined => Err(AuthError::type_error(
"Cannot read properties of undefined (reading 'toLowerCase')",
)),
_ => Err(AuthError::type_error(
"user.email.toLowerCase is not a function",
)),
}
}
pub(crate) fn session_is_fresh(
session: &better_auth_core::session::NativeSessionData,
config: &better_auth_core::AuthConfig,
) -> AuthResult<bool> {
let fresh_age = config
.session
.fresh_age
.unwrap_or_else(|| chrono::Duration::hours(24));
if fresh_age.is_zero() {
return Ok(true);
}
let created_at = better_auth_core::query::field_date(&session.session_property("createdAt")?)?
.milliseconds();
let stale =
Utc::now().timestamp_millis() as f64 - created_at >= fresh_age.num_milliseconds() as f64;
Ok(!stale)
}
pub fn expires_in_to_at(expires_in_secs: Option<i64>) -> AuthResult<Option<String>> {
match expires_in_secs {
Some(secs) => {
let duration = chrono::Duration::try_seconds(secs)
.ok_or_else(|| AuthError::bad_request("expiresIn is out of range"))?;
let dt = chrono::Utc::now()
.checked_add_signed(duration)
.ok_or_else(|| AuthError::bad_request("expiresIn is out of range"))?;
Ok(Some(
dt.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
))
}
None => Ok(None),
}
}
pub async fn get_owned_api_key(
ctx: &AuthContext<impl better_auth_core::AuthSchema>,
config: &crate::plugins::api_key::ApiKeyConfig,
key_id: &str,
user_id: &better_auth_core::FieldValue,
action: &str,
) -> AuthResult<better_auth_core::ApiKey> {
use crate::plugins::api_key::{ApiKeyReferences, config_id_matches};
let api_key = crate::plugins::api_key::storage::get_by_id(config, ctx, key_id)
.await?
.ok_or_else(|| AuthError::from_code("NOT_FOUND", "KEY_NOT_FOUND", "API Key not found"))?;
if !config_id_matches(&api_key.config_id, &config.config_id) {
return Err(AuthError::from_code(
"NOT_FOUND",
"KEY_NOT_FOUND",
"API Key not found",
));
}
match config.references {
ApiKeyReferences::User => {
if !api_key.reference_id.field_value().strict_equals(user_id) {
return Err(AuthError::from_code(
"NOT_FOUND",
"KEY_NOT_FOUND",
"API Key not found",
));
}
}
ApiKeyReferences::Organization => {
require_org_api_key_permission_value(
ctx,
user_id,
&api_key.reference_id.field_value(),
action,
)
.await?;
}
}
Ok(api_key)
}
pub async fn require_org_api_key_permission(
ctx: &AuthContext<impl better_auth_core::AuthSchema>,
user_id: &better_auth_core::FieldValue,
organization_id: &str,
action: &str,
) -> AuthResult<()> {
require_org_api_key_permission_value(ctx, user_id, &organization_id.into(), action).await
}
async fn require_org_api_key_permission_value(
ctx: &AuthContext<impl better_auth_core::AuthSchema>,
user_id: &better_auth_core::FieldValue,
organization_id: &better_auth_core::FieldValue,
action: &str,
) -> AuthResult<()> {
use crate::plugins::api_key::{ApiKeyErrorCode, api_key_error};
use crate::plugins::organization::rbac::check_api_key_permission;
use crate::plugins::organization::{
METADATA_AC, METADATA_CREATOR_ROLE, METADATA_DYNAMIC_ACCESS_CONTROL, METADATA_ENABLED,
METADATA_ROLES, OrganizationConfig, RolePermissions,
};
use std::collections::HashMap;
if !ctx
.get_metadata(METADATA_ENABLED)
.and_then(|value| value.as_bool())
.unwrap_or(false)
{
return Err(api_key_error(
"INTERNAL_SERVER_ERROR",
ApiKeyErrorCode::OrganizationPluginRequired,
));
}
let Some(member) = ctx
.database
.get_member_value(organization_id, user_id)
.await?
else {
return Err(api_key_error(
"FORBIDDEN",
ApiKeyErrorCode::UserNotMemberOfOrganization,
));
};
let creator_role = ctx
.get_metadata(METADATA_CREATOR_ROLE)
.and_then(|value| value.as_str().map(str::to_string))
.unwrap_or_else(|| "owner".to_string());
let custom_roles: Option<HashMap<String, RolePermissions>> = ctx
.get_metadata(METADATA_ROLES)
.map(|value| serde_json::from_value(value.clone()))
.transpose()?
.flatten();
let config = OrganizationConfig {
creator_role,
roles: custom_roles,
dynamic_access_control: ctx
.get_metadata(METADATA_DYNAMIC_ACCESS_CONTROL)
.and_then(serde_json::Value::as_bool)
== Some(true),
ac: ctx
.get_metadata(METADATA_AC)
.map(|value| serde_json::from_value(value.clone()))
.transpose()?
.flatten(),
..Default::default()
};
let allowed = check_api_key_permission(&member.role, organization_id, action, &config, ctx)
.await
.unwrap_or(false);
if allowed {
Ok(())
} else {
Err(api_key_error(
"FORBIDDEN",
ApiKeyErrorCode::InsufficientApiKeyPermissions,
))
}
}
pub async fn get_credential_account<S: better_auth_core::AuthSchema>(
ctx: &AuthContext<S>,
user_id: impl Into<better_auth_core::SchemaValue<String>>,
) -> AuthResult<Option<better_auth_core::wire::AccountView>> {
let user_id = user_id.into();
ctx.database
.get_credential_account_value(&user_id.field_value())
.await
}
pub(crate) async fn update_password(
ctx: &AuthContext<impl better_auth_core::AuthSchema>,
user_id: &better_auth_core::FieldValue,
password: String,
) -> AuthResult<()> {
let selectors = FieldMap::from([
("userId".into(), user_id.clone()),
("providerId".into(), "credential".into()),
("accountId".into(), user_id.clone()),
]);
let _ = ctx
.database
.update_accounts(
&selectors,
better_auth_core::UpdateAccount {
password: Some(password).into(),
..Default::default()
},
)
.await?;
Ok(())
}
pub async fn get_credential_password_hash<S: better_auth_core::AuthSchema>(
ctx: &AuthContext<S>,
user_id: impl Into<better_auth_core::SchemaValue<String>>,
) -> AuthResult<Option<better_auth_core::FieldValue>> {
let Some(account) = get_credential_account(ctx, user_id).await? else {
return Ok(None);
};
if !account.password.is_truthy()? {
return Ok(None);
}
Ok(Some(account.password.into_field_value()))
}
pub async fn user_has_password(
ctx: &AuthContext<impl better_auth_core::AuthSchema>,
user: &impl AuthUser,
) -> AuthResult<bool> {
Ok(get_credential_password_hash(ctx, user.id().into_owned())
.await?
.is_some())
}
pub fn apply_default_role(
ctx: &AuthContext<impl better_auth_core::AuthSchema>,
fields: &mut FieldMap,
) {
if let Some(default_role) = ctx
.get_metadata("admin.default_role")
.and_then(|value| value.as_str())
{
let _ = fields
.entry("role".into())
.or_insert_with(|| default_role.into());
}
}
pub struct IssuedSession {
pub user: better_auth_core::wire::UserView,
pub session: better_auth_core::wire::SessionView,
}
pub enum SessionIssueError {
Auth(AuthError),
Banned { message: String },
}
impl SessionIssueError {
pub fn into_auth_error(self) -> AuthError {
match self {
Self::Auth(error) => error,
Self::Banned { message } => AuthError::from_code("FORBIDDEN", "BANNED_USER", message),
}
}
pub fn banned_message(&self) -> Option<&str> {
match self {
Self::Banned { message } => Some(message.as_str()),
Self::Auth(_) => None,
}
}
}
impl From<AuthError> for SessionIssueError {
fn from(value: AuthError) -> Self {
Self::Auth(value)
}
}
pub fn admin_plugin_enabled(ctx: &AuthContext<impl better_auth_core::AuthSchema>) -> bool {
ctx.get_metadata("admin.enabled")
.and_then(|value| value.as_bool())
.unwrap_or(false)
}
pub async fn admin_banned_user_message(
ctx: &AuthContext<impl better_auth_core::AuthSchema>,
user: &impl AuthUser,
) -> AuthResult<String> {
let message = ctx
.extensions
.get::<super::admin::BannedUserMessage>()
.cloned()
.unwrap_or_default();
message.resolve(&ctx.internal_user_view(user).await?).await
}
pub async fn issue_user_session<S: better_auth_core::AuthSchema>(
ctx: &AuthContext<S>,
user_id: &str,
ip_address: Option<String>,
user_agent: Option<String>,
) -> Result<IssuedSession, SessionIssueError> {
issue_user_session_with_lifetime(
ctx,
user_id,
ip_address,
user_agent,
ctx.config.session.expires_in(),
)
.await
}
pub(crate) async fn issue_user_session_with_lifetime<S: better_auth_core::AuthSchema>(
ctx: &AuthContext<S>,
user_id: &str,
ip_address: Option<String>,
user_agent: Option<String>,
expires_in: chrono::Duration,
) -> Result<IssuedSession, SessionIssueError> {
let user = session_user(ctx, user_id, None).await?;
let session = ctx
.session_manager()
.create_session_with_lifetime(&user, ip_address, user_agent, expires_in)
.await?;
Ok(IssuedSession { user, session })
}
pub(crate) async fn issue_selected_user_session_optional<S: better_auth_core::AuthSchema>(
ctx: &AuthContext<S>,
user: better_auth_core::FieldValue,
meta: &better_auth_core::RequestMeta,
expires_in: chrono::Duration,
) -> Result<Option<better_auth_core::session::NativeSessionData>, SessionIssueError> {
let user_id = better_auth_core::SchemaValue::<String>::from_field(user.model_property("id")?);
let session = issue_session_for_id_optional(ctx, user_id, meta, expires_in).await?;
Ok(session.map(|session| better_auth_core::session::NativeSessionData::new(session, user)))
}
pub(crate) async fn issue_session_for_id_optional<S: better_auth_core::AuthSchema>(
ctx: &AuthContext<S>,
user_id: better_auth_core::SchemaValue<String>,
meta: &better_auth_core::RequestMeta,
expires_in: chrono::Duration,
) -> Result<Option<better_auth_core::wire::SessionView>, SessionIssueError> {
admit_session_for_id(ctx, &user_id, None).await?;
Ok(ctx
.session_manager()
.create_session_for_id_with_lifetime_optional(
user_id,
meta.ip_address.clone(),
meta.user_agent.clone(),
expires_in,
)
.await?)
}
pub(crate) async fn admit_session_for_id<S: better_auth_core::AuthSchema>(
ctx: &AuthContext<S>,
user_id: &better_auth_core::SchemaValue<String>,
transaction: Option<&dyn better_auth_core::store::AuthTransaction<S>>,
) -> Result<(), SessionIssueError> {
if admin_plugin_enabled(ctx) && user_id.is_truthy()? {
let stored_user = match transaction {
Some(tx) => tx.get_user_by_id_field(user_id).await?,
None => ctx.database.get_user_by_id_field(user_id).await?,
};
if let Some(stored_user) = stored_user {
apply_session_ban(ctx, user_id, &stored_user, transaction).await?;
}
}
Ok(())
}
pub(crate) async fn session_user<S: better_auth_core::AuthSchema>(
ctx: &AuthContext<S>,
user_id: &str,
transaction: Option<&dyn better_auth_core::store::AuthTransaction<S>>,
) -> Result<better_auth_core::wire::UserView, SessionIssueError> {
let user = match transaction {
Some(tx) => tx.get_user_by_id(user_id).await?,
None => ctx.database.get_user_by_id(user_id).await?,
}
.ok_or(AuthError::UserNotFound)?;
apply_session_ban(ctx, &user_id.into(), &user, transaction).await?;
Ok(user)
}
async fn apply_session_ban<S: better_auth_core::AuthSchema>(
ctx: &AuthContext<S>,
user_id: &better_auth_core::SchemaValue<String>,
user: &better_auth_core::wire::UserView,
transaction: Option<&dyn better_auth_core::store::AuthTransaction<S>>,
) -> Result<(), SessionIssueError> {
if !admin_plugin_enabled(ctx) {
return Ok(());
}
let fields = better_auth_core::FieldMap::from(user.clone());
if fields
.get("banned")
.is_some_and(better_auth_core::FieldValue::is_truthy)
{
let expires = fields.get("banExpires");
let expired = match expires.filter(|value| value.is_truthy()) {
Some(expires) => {
better_auth_core::query::field_date(expires)?.milliseconds()
< Utc::now().timestamp_millis() as f64
}
None => false,
};
if expired {
let update = UpdateUser {
banned: Some(false),
ban_reason: Some(None),
ban_expires: Some(None),
..Default::default()
};
let _ = match transaction {
Some(tx) => {
tx.update_user_by_id_value(&user_id.field_value(), update)
.await?
}
None => {
ctx.database
.update_user_by_id_value(&user_id.field_value(), update)
.await?
}
};
} else {
return Err(SessionIssueError::Banned {
message: admin_banned_user_message(ctx, user).await?,
});
}
}
Ok(())
}
pub fn get_cookie(req: &AuthRequest, name: &str) -> Option<String> {
let header = req.headers.get("cookie")?;
header
.split(';')
.filter_map(|cookie| {
let trimmed = cookie.trim();
let (cookie_name, cookie_value) = trimmed.split_once('=')?;
(cookie_name == name).then_some(cookie_value.to_string())
})
.next()
}
pub use better_auth_core::utils::cookie_utils::delete_session_cookies;
pub(crate) fn parse_stored_scopes(scope: Option<&str>) -> Vec<String> {
scope
.unwrap_or_default()
.split(',')
.map(|scope| scope.trim_matches(oauth_scope_whitespace))
.filter(|scope| !scope.is_empty())
.map(str::to_owned)
.collect()
}
pub(crate) fn oauth_scope_whitespace(character: char) -> bool {
(character.is_whitespace() && character != '\u{85}') || character == '\u{feff}'
}
#[cfg(test)]
#[path = "helpers/response_tests.rs"]
mod response_tests;
#[cfg(test)]
#[path = "helpers/session_tests.rs"]
mod session_tests;
#[cfg(test)]
#[path = "helpers/selected_user_property_tests.rs"]
mod selected_user_property_tests;
#[cfg(test)]
#[path = "helpers/nullable_session_tests.rs"]
mod nullable_session_tests;