Skip to main content

better_auth_api/plugins/
helpers.rs

1//! Shared helpers for plugin implementations.
2//!
3//! Extracted to avoid duplicating common patterns across plugins (DRY).
4
5use better_auth_core::entity::AuthUser;
6use better_auth_core::{AuthContext, AuthError, AuthRequest, AuthResult, CreateUser, UpdateUser};
7use chrono::Utc;
8
9mod user_input;
10pub(crate) use user_input::apply_user_create_fields;
11
12pub(crate) fn session_is_fresh(
13    session: &impl better_auth_core::AuthSession,
14    config: &better_auth_core::AuthConfig,
15) -> bool {
16    let fresh_age = config
17        .session
18        .fresh_age
19        .unwrap_or_else(|| chrono::Duration::hours(24));
20    fresh_age.is_zero()
21        || Utc::now().timestamp_millis() as f64 - session.created_at().milliseconds()
22            < fresh_age.num_milliseconds() as f64
23}
24
25/// Convert an `expiresIn` value (**seconds** from now) into an RFC 3339
26/// `expires_at` timestamp string.
27///
28/// Returns `None` when `expires_in_secs` is `None`.
29pub fn expires_in_to_at(expires_in_secs: Option<i64>) -> AuthResult<Option<String>> {
30    match expires_in_secs {
31        Some(secs) => {
32            let duration = chrono::Duration::try_seconds(secs)
33                .ok_or_else(|| AuthError::bad_request("expiresIn is out of range"))?;
34            let dt = chrono::Utc::now()
35                .checked_add_signed(duration)
36                .ok_or_else(|| AuthError::bad_request("expiresIn is out of range"))?;
37            Ok(Some(
38                dt.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
39            ))
40        }
41        None => Ok(None),
42    }
43}
44
45/// Fetch an API key by ID and verify that it belongs to the given user.
46///
47/// Returns `AuthError::not_found` if the key does not exist or belongs to
48/// another user.  This pattern was duplicated in `handle_get`, `handle_update`,
49/// and `handle_delete`.
50pub async fn get_owned_api_key(
51    ctx: &AuthContext<impl better_auth_core::AuthSchema>,
52    config: &crate::plugins::api_key::ApiKeyConfig,
53    key_id: &str,
54    user_id: &str,
55    action: &str,
56) -> AuthResult<better_auth_core::ApiKey> {
57    use crate::plugins::api_key::{ApiKeyReferences, config_id_matches};
58
59    let api_key = crate::plugins::api_key::storage::get_by_id(config, ctx, key_id)
60        .await?
61        .ok_or_else(|| AuthError::not_found("API Key not found"))?;
62
63    // A key only exists as far as the configuration that addressed it.
64    if !config_id_matches(&api_key.config_id, &config.config_id) {
65        return Err(AuthError::not_found("API Key not found"));
66    }
67
68    match config.references {
69        ApiKeyReferences::User => {
70            if api_key.reference_id != user_id {
71                return Err(AuthError::not_found("API Key not found"));
72            }
73        }
74        ApiKeyReferences::Organization => {
75            require_org_api_key_permission(ctx, user_id, &api_key.reference_id, action).await?;
76        }
77    }
78
79    Ok(api_key)
80}
81
82/// Authorize a user against an organization-owned API key, mirroring
83/// upstream's `checkOrgApiKeyPermission`.
84pub async fn require_org_api_key_permission(
85    ctx: &AuthContext<impl better_auth_core::AuthSchema>,
86    user_id: &str,
87    organization_id: &str,
88    action: &str,
89) -> AuthResult<()> {
90    use crate::plugins::api_key::{ApiKeyErrorCode, api_key_error};
91    use crate::plugins::organization::rbac::check_permission;
92    use crate::plugins::organization::{
93        METADATA_AC, METADATA_CREATOR_ROLE, METADATA_DYNAMIC_ACCESS_CONTROL, METADATA_ENABLED,
94        METADATA_ROLES, OrganizationConfig, RolePermissions,
95    };
96    use std::collections::HashMap;
97
98    // Organization-owned keys are meaningless without the organization plugin,
99    // which is what supplies the access control below.
100    if !ctx
101        .get_metadata(METADATA_ENABLED)
102        .and_then(|value| value.as_bool())
103        .unwrap_or(false)
104    {
105        return Err(api_key_error(ApiKeyErrorCode::OrganizationPluginRequired));
106    }
107
108    let Some(member) = ctx.database.get_member(organization_id, user_id).await? else {
109        return Err(api_key_error(ApiKeyErrorCode::UserNotMemberOfOrganization));
110    };
111
112    // Upstream passes `allowCreatorAllPermissions`, so the creator role clears
113    // every action without consulting the statements. Roles are composite
114    // (comma-separated), so holding it alongside others still counts.
115    let creator_role = ctx
116        .get_metadata(METADATA_CREATOR_ROLE)
117        .and_then(|value| value.as_str().map(str::to_string))
118        .unwrap_or_else(|| "owner".to_string());
119    if member
120        .role
121        .typed()?
122        .split(',')
123        .map(str::trim)
124        .any(|role| role == creator_role)
125    {
126        return Ok(());
127    }
128
129    let custom_roles: Option<HashMap<String, RolePermissions>> = ctx
130        .get_metadata(METADATA_ROLES)
131        .map(|value| serde_json::from_value(value.clone()))
132        .transpose()?
133        .flatten();
134
135    let config = OrganizationConfig {
136        roles: custom_roles,
137        dynamic_access_control: ctx
138            .get_metadata(METADATA_DYNAMIC_ACCESS_CONTROL)
139            .and_then(serde_json::Value::as_bool)
140            == Some(true),
141        ac: ctx
142            .get_metadata(METADATA_AC)
143            .map(|value| serde_json::from_value(value.clone()))
144            .transpose()?
145            .flatten(),
146        ..Default::default()
147    };
148    let allowed = check_permission(
149        member.role.typed()?,
150        organization_id,
151        "apikey",
152        &[action],
153        &config,
154        ctx,
155    )
156    .await?;
157
158    if allowed {
159        Ok(())
160    } else {
161        Err(api_key_error(
162            ApiKeyErrorCode::InsufficientApiKeyPermissions,
163        ))
164    }
165}
166
167/// Fetch the user's credential account, if present.
168pub async fn get_credential_account<S: better_auth_core::AuthSchema>(
169    ctx: &AuthContext<S>,
170    user_id: impl Into<better_auth_core::SchemaValue<String>>,
171) -> AuthResult<Option<better_auth_core::wire::AccountView>> {
172    let user_id = user_id.into();
173    let Some(user_id) = user_id.as_str() else {
174        return Ok(None);
175    };
176    ctx.database.get_credential_account(user_id).await
177}
178
179/// Resolve the user's stored password hash from the credential account.
180pub async fn get_credential_password_hash(
181    ctx: &AuthContext<impl better_auth_core::AuthSchema>,
182    user: &impl AuthUser,
183) -> AuthResult<Option<String>> {
184    let Some(account) = get_credential_account(ctx, user.id().into_owned()).await? else {
185        return Ok(None);
186    };
187    if !account.password.is_truthy()? {
188        return Ok(None);
189    }
190    Ok(account.password.typed()?.clone())
191}
192
193/// Whether the user currently has a password set.
194pub async fn user_has_password(
195    ctx: &AuthContext<impl better_auth_core::AuthSchema>,
196    user: &impl AuthUser,
197) -> AuthResult<bool> {
198    Ok(get_credential_password_hash(ctx, user).await?.is_some())
199}
200
201/// Apply the configured default admin role to a new user when the caller
202/// didn't set an explicit role.
203pub fn apply_default_role(
204    ctx: &AuthContext<impl better_auth_core::AuthSchema>,
205    create_user: &mut CreateUser,
206) {
207    if create_user.role.is_some() {
208        return;
209    }
210
211    if let Some(default_role) = ctx
212        .get_metadata("admin.default_role")
213        .and_then(|value| value.as_str())
214    {
215        create_user.role = Some(default_role.to_string());
216    }
217}
218
219/// Result of issuing a real session for a user.
220pub struct IssuedSession {
221    pub user: better_auth_core::wire::UserView,
222    pub session: better_auth_core::wire::SessionView,
223}
224
225/// Session issuance failures that callers may need to surface differently from
226/// a generic auth error (for example OAuth callback redirects).
227pub enum SessionIssueError {
228    Auth(AuthError),
229    Banned { message: String },
230}
231
232impl SessionIssueError {
233    pub fn into_auth_error(self) -> AuthError {
234        match self {
235            Self::Auth(error) => error,
236            Self::Banned { message } => AuthError::banned_user(message),
237        }
238    }
239
240    pub fn banned_message(&self) -> Option<&str> {
241        match self {
242            Self::Banned { message } => Some(message.as_str()),
243            Self::Auth(_) => None,
244        }
245    }
246}
247
248impl From<AuthError> for SessionIssueError {
249    fn from(value: AuthError) -> Self {
250        Self::Auth(value)
251    }
252}
253
254/// Whether the admin plugin is active for this auth instance.
255pub fn admin_plugin_enabled(ctx: &AuthContext<impl better_auth_core::AuthSchema>) -> bool {
256    ctx.get_metadata("admin.enabled")
257        .and_then(|value| value.as_bool())
258        .unwrap_or(false)
259}
260
261/// Resolve the configured message shown when a banned user attempts to create
262/// a session.
263pub async fn admin_banned_user_message(
264    ctx: &AuthContext<impl better_auth_core::AuthSchema>,
265    user: &impl AuthUser,
266) -> AuthResult<String> {
267    let message = ctx
268        .extensions
269        .get::<super::admin::BannedUserMessage>()
270        .cloned()
271        .unwrap_or_default();
272    message.resolve(&ctx.internal_user_view(user).await?).await
273}
274
275/// Issue a session for the given user, applying admin-plugin ban semantics
276/// when the admin plugin is enabled.
277pub async fn issue_user_session<S: better_auth_core::AuthSchema>(
278    ctx: &AuthContext<S>,
279    user_id: &str,
280    ip_address: Option<String>,
281    user_agent: Option<String>,
282) -> Result<IssuedSession, SessionIssueError> {
283    issue_user_session_with_lifetime(
284        ctx,
285        user_id,
286        ip_address,
287        user_agent,
288        ctx.config.session.expires_in(),
289    )
290    .await
291}
292
293pub(crate) async fn issue_user_session_with_lifetime<S: better_auth_core::AuthSchema>(
294    ctx: &AuthContext<S>,
295    user_id: &str,
296    ip_address: Option<String>,
297    user_agent: Option<String>,
298    expires_in: chrono::Duration,
299) -> Result<IssuedSession, SessionIssueError> {
300    let user = session_user(ctx, user_id, None).await?;
301
302    let session = ctx
303        .session_manager()
304        .create_session_with_lifetime(&user, ip_address, user_agent, expires_in)
305        .await?;
306
307    Ok(IssuedSession { user, session })
308}
309
310/// Issue a session from the selected adapter result, preserving the route's user snapshot.
311pub(crate) async fn issue_selected_user_session<S: better_auth_core::AuthSchema>(
312    ctx: &AuthContext<S>,
313    user: better_auth_core::FieldValue,
314    meta: &better_auth_core::RequestMeta,
315    expires_in: chrono::Duration,
316) -> Result<better_auth_core::session::NativeSessionData, SessionIssueError> {
317    let user_id = better_auth_core::SchemaValue::<String>::from_field(
318        user.as_object()
319            .and_then(|fields| fields.get("id"))
320            .cloned()
321            .unwrap_or_default(),
322    );
323    if admin_plugin_enabled(ctx)
324        && user_id.is_truthy()?
325        && let Some(stored_user) = ctx.database.get_user_by_id_field(&user_id).await?
326    {
327        apply_session_ban(ctx, &user_id, &stored_user, None).await?;
328    }
329    let session = ctx
330        .session_manager()
331        .create_session_for_id_with_lifetime(
332            user_id,
333            meta.ip_address.clone(),
334            meta.user_agent.clone(),
335            expires_in,
336        )
337        .await?;
338    Ok(better_auth_core::session::NativeSessionData { user, session })
339}
340
341/// Resolve the user and apply session admission through the active transaction.
342pub(crate) async fn session_user<S: better_auth_core::AuthSchema>(
343    ctx: &AuthContext<S>,
344    user_id: &str,
345    transaction: Option<&dyn better_auth_core::store::AuthTransaction<S>>,
346) -> Result<better_auth_core::wire::UserView, SessionIssueError> {
347    let user = match transaction {
348        Some(tx) => tx.get_user_by_id(user_id).await?,
349        None => ctx.database.get_user_by_id(user_id).await?,
350    }
351    .ok_or(AuthError::UserNotFound)?;
352
353    apply_session_ban(ctx, &user_id.into(), &user, transaction).await?;
354    Ok(user)
355}
356
357async fn apply_session_ban<S: better_auth_core::AuthSchema>(
358    ctx: &AuthContext<S>,
359    user_id: &better_auth_core::SchemaValue<String>,
360    user: &better_auth_core::wire::UserView,
361    transaction: Option<&dyn better_auth_core::store::AuthTransaction<S>>,
362) -> Result<(), SessionIssueError> {
363    if admin_plugin_enabled(ctx) && user.banned() {
364        if user
365            .ban_expires()
366            .is_some_and(|expires| expires.milliseconds() < Utc::now().timestamp_millis() as f64)
367        {
368            let update = UpdateUser {
369                banned: Some(false),
370                ban_reason: Some(None),
371                ban_expires: Some(None),
372                ..Default::default()
373            };
374            // Session admission updates storage without replacing the route's user snapshot.
375            let _ = match transaction {
376                Some(tx) => {
377                    tx.update_user_by_id_value(&user_id.field_value(), update)
378                        .await?
379                }
380                None => {
381                    ctx.database
382                        .update_user_by_id_value(&user_id.field_value(), update)
383                        .await?
384                }
385            };
386        } else {
387            return Err(SessionIssueError::Banned {
388                message: admin_banned_user_message(ctx, user).await?,
389            });
390        }
391    }
392
393    Ok(())
394}
395
396/// Parse a cookie value from the request's `Cookie` header.
397pub fn get_cookie(req: &AuthRequest, name: &str) -> Option<String> {
398    let header = req.headers.get("cookie")?;
399    header
400        .split(';')
401        .filter_map(|cookie| {
402            let trimmed = cookie.trim();
403            let (cookie_name, cookie_value) = trimmed.split_once('=')?;
404            (cookie_name == name).then_some(cookie_value.to_string())
405        })
406        .next()
407}
408
409/// Clear session cookies, publishing each header before attempting the next cookie.
410pub use better_auth_core::utils::cookie_utils::delete_session_cookies;
411
412/// Parse the comma-delimited scopes stored on an OAuth account.
413pub(crate) fn parse_stored_scopes(scope: Option<&str>) -> Vec<String> {
414    scope
415        .unwrap_or_default()
416        .split(',')
417        .map(|scope| scope.trim_matches(oauth_scope_whitespace))
418        .filter(|scope| !scope.is_empty())
419        .map(str::to_owned)
420        .collect()
421}
422
423pub(crate) fn oauth_scope_whitespace(character: char) -> bool {
424    (character.is_whitespace() && character != '\u{85}') || character == '\u{feff}'
425}
426
427#[cfg(test)]
428#[path = "helpers/response_tests.rs"]
429mod response_tests;
430
431#[cfg(test)]
432#[path = "helpers/session_tests.rs"]
433mod session_tests;