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::config::OAuthStateStrategy;
6use better_auth_core::entity::{AuthAccount, AuthUser};
7use better_auth_core::{AuthContext, AuthError, AuthRequest, AuthResult, CreateUser, UpdateUser};
8use chrono::Utc;
9
10/// Convert an `expiresIn` value (**seconds** from now) into an RFC 3339
11/// `expires_at` timestamp string.
12///
13/// Returns `None` when `expires_in_secs` is `None`.
14pub fn expires_in_to_at(expires_in_secs: Option<i64>) -> AuthResult<Option<String>> {
15    match expires_in_secs {
16        Some(secs) => {
17            let duration = chrono::Duration::try_seconds(secs)
18                .ok_or_else(|| AuthError::bad_request("expiresIn is out of range"))?;
19            let dt = chrono::Utc::now()
20                .checked_add_signed(duration)
21                .ok_or_else(|| AuthError::bad_request("expiresIn is out of range"))?;
22            Ok(Some(dt.to_rfc3339()))
23        }
24        None => Ok(None),
25    }
26}
27
28/// Fetch an API key by ID and verify that it belongs to the given user.
29///
30/// Returns `AuthError::not_found` if the key does not exist or belongs to
31/// another user.  This pattern was duplicated in `handle_get`, `handle_update`,
32/// and `handle_delete`.
33pub async fn get_owned_api_key(
34    ctx: &AuthContext<impl better_auth_core::AuthSchema>,
35    config: &crate::plugins::api_key::ApiKeyConfig,
36    key_id: &str,
37    user_id: &str,
38    action: &str,
39) -> AuthResult<better_auth_core::ApiKey> {
40    use crate::plugins::api_key::{ApiKeyReferences, config_id_matches};
41
42    let api_key = ctx
43        .database
44        .get_api_key_by_id(key_id)
45        .await?
46        .ok_or_else(|| AuthError::not_found("API Key not found"))?;
47
48    // A key only exists as far as the configuration that addressed it.
49    if !config_id_matches(&api_key.config_id, &config.config_id) {
50        return Err(AuthError::not_found("API Key not found"));
51    }
52
53    match config.references {
54        ApiKeyReferences::User => {
55            if api_key.reference_id != user_id {
56                return Err(AuthError::not_found("API Key not found"));
57            }
58        }
59        ApiKeyReferences::Organization => {
60            require_org_api_key_permission(ctx, user_id, &api_key.reference_id, action).await?;
61        }
62    }
63
64    Ok(api_key)
65}
66
67/// Authorize a user against an organization-owned API key, mirroring
68/// upstream's `checkOrgApiKeyPermission`.
69pub async fn require_org_api_key_permission(
70    ctx: &AuthContext<impl better_auth_core::AuthSchema>,
71    user_id: &str,
72    organization_id: &str,
73    action: &str,
74) -> AuthResult<()> {
75    use crate::plugins::api_key::{ApiKeyErrorCode, api_key_error};
76    use crate::plugins::organization::rbac::{Action, Resource, has_permission_any};
77    use crate::plugins::organization::{
78        METADATA_CREATOR_ROLE, METADATA_ENABLED, METADATA_ROLES, RolePermissions,
79    };
80    use std::collections::HashMap;
81
82    // Organization-owned keys are meaningless without the organization plugin,
83    // which is what supplies the access control below.
84    if !ctx
85        .get_metadata(METADATA_ENABLED)
86        .and_then(|value| value.as_bool())
87        .unwrap_or(false)
88    {
89        return Err(api_key_error(ApiKeyErrorCode::OrganizationPluginRequired));
90    }
91
92    let Some(member) = ctx.database.get_member(organization_id, user_id).await? else {
93        return Err(api_key_error(ApiKeyErrorCode::UserNotMemberOfOrganization));
94    };
95
96    // Upstream passes `allowCreatorAllPermissions`, so the creator role clears
97    // every action without consulting the statements. Roles are composite
98    // (comma-separated), so holding it alongside others still counts.
99    let creator_role = ctx
100        .get_metadata(METADATA_CREATOR_ROLE)
101        .and_then(|value| value.as_str().map(str::to_string))
102        .unwrap_or_else(|| "owner".to_string());
103    if member
104        .role
105        .split(',')
106        .map(str::trim)
107        .any(|role| role == creator_role)
108    {
109        return Ok(());
110    }
111
112    let custom_roles: HashMap<String, RolePermissions> = ctx
113        .get_metadata(METADATA_ROLES)
114        .and_then(|value| serde_json::from_value(value.clone()).ok())
115        .unwrap_or_default();
116
117    let allowed = Action::parse(action)
118        .map(|action| has_permission_any(&member.role, &Resource::ApiKey, &action, &custom_roles))
119        .unwrap_or(false);
120
121    if allowed {
122        Ok(())
123    } else {
124        Err(api_key_error(
125            ApiKeyErrorCode::InsufficientApiKeyPermissions,
126        ))
127    }
128}
129
130/// Fetch the user's credential account, if present.
131pub async fn get_credential_account<S: better_auth_core::AuthSchema>(
132    ctx: &AuthContext<S>,
133    user_id: impl AsRef<str>,
134) -> AuthResult<Option<S::Account>> {
135    Ok(ctx
136        .database
137        .get_user_accounts(user_id.as_ref())
138        .await?
139        .into_iter()
140        .find(|account| account.provider_id() == "credential"))
141}
142
143/// Resolve the user's stored password hash from the credential account.
144pub async fn get_credential_password_hash(
145    ctx: &AuthContext<impl better_auth_core::AuthSchema>,
146    user: &impl AuthUser,
147) -> AuthResult<Option<String>> {
148    Ok(get_credential_account(ctx, user.id())
149        .await?
150        .and_then(|account| account.password().map(str::to_string)))
151}
152
153/// Whether the user currently has a password set.
154pub async fn user_has_password(
155    ctx: &AuthContext<impl better_auth_core::AuthSchema>,
156    user: &impl AuthUser,
157) -> AuthResult<bool> {
158    Ok(get_credential_password_hash(ctx, user).await?.is_some())
159}
160
161/// Apply the configured default admin role to a new user when the caller
162/// didn't set an explicit role.
163pub fn apply_default_role(
164    ctx: &AuthContext<impl better_auth_core::AuthSchema>,
165    create_user: &mut CreateUser,
166) {
167    if create_user.role.is_some() {
168        return;
169    }
170
171    if let Some(default_role) = ctx
172        .get_metadata("admin.default_role")
173        .and_then(|value| value.as_str())
174    {
175        create_user.role = Some(default_role.to_string());
176    }
177}
178
179/// Result of issuing a real session for a user.
180pub struct IssuedSession<S: better_auth_core::AuthSchema> {
181    pub user: S::User,
182    pub session: S::Session,
183}
184
185/// Session issuance failures that callers may need to surface differently from
186/// a generic auth error (for example OAuth callback redirects).
187pub enum SessionIssueError {
188    Auth(AuthError),
189    Banned { message: String },
190}
191
192impl SessionIssueError {
193    pub fn into_auth_error(self) -> AuthError {
194        match self {
195            Self::Auth(error) => error,
196            Self::Banned { message } => AuthError::banned_user(message),
197        }
198    }
199
200    pub fn banned_message(&self) -> Option<&str> {
201        match self {
202            Self::Banned { message } => Some(message.as_str()),
203            Self::Auth(_) => None,
204        }
205    }
206}
207
208impl From<AuthError> for SessionIssueError {
209    fn from(value: AuthError) -> Self {
210        Self::Auth(value)
211    }
212}
213
214/// Whether the admin plugin is active for this auth instance.
215pub fn admin_plugin_enabled(ctx: &AuthContext<impl better_auth_core::AuthSchema>) -> bool {
216    ctx.get_metadata("admin.enabled")
217        .and_then(|value| value.as_bool())
218        .unwrap_or(false)
219}
220
221/// Resolve the configured message shown when a banned user attempts to create
222/// a session.
223pub fn admin_banned_user_message(
224    ctx: &AuthContext<impl better_auth_core::AuthSchema>,
225) -> Option<String> {
226    ctx.get_metadata("admin.banned_user_message")
227        .and_then(|value| value.as_str())
228        .map(ToOwned::to_owned)
229}
230
231/// Issue a session for the given user, applying admin-plugin ban semantics
232/// when the admin plugin is enabled.
233pub async fn issue_user_session<S: better_auth_core::AuthSchema>(
234    ctx: &AuthContext<S>,
235    user_id: &str,
236    ip_address: Option<String>,
237    user_agent: Option<String>,
238) -> Result<IssuedSession<S>, SessionIssueError> {
239    let mut user = ctx
240        .database
241        .get_user_by_id(user_id)
242        .await?
243        .ok_or(AuthError::UserNotFound)?;
244
245    if admin_plugin_enabled(ctx) && user.banned() {
246        if user
247            .ban_expires()
248            .is_some_and(|expires| expires <= Utc::now())
249        {
250            user = ctx
251                .database
252                .update_user(
253                    user_id,
254                    UpdateUser {
255                        banned: Some(false),
256                        ban_reason: None,
257                        ban_expires: None,
258                        ..Default::default()
259                    },
260                )
261                .await?;
262        } else {
263            return Err(SessionIssueError::Banned {
264                message: admin_banned_user_message(ctx).unwrap_or_else(|| {
265                    "You have been banned from this application. Please contact support if you believe this is an error.".to_string()
266                }),
267            });
268        }
269    }
270
271    let session = ctx
272        .session_manager()
273        .create_session(&user, ip_address, user_agent)
274        .await?;
275
276    Ok(IssuedSession { user, session })
277}
278
279/// Parse a cookie value from the request's `Cookie` header.
280pub fn get_cookie(req: &AuthRequest, name: &str) -> Option<String> {
281    let header = req.headers.get("cookie")?;
282    header
283        .split(';')
284        .filter_map(|cookie| {
285            let trimmed = cookie.trim();
286            let (cookie_name, cookie_value) = trimmed.split_once('=')?;
287            (cookie_name == name).then_some(cookie_value.to_string())
288        })
289        .next()
290}
291
292/// TS-style cookie clearing used by `deleteSessionCookie`.
293pub fn delete_session_cookie_headers(config: &better_auth_core::AuthConfig) -> Vec<String> {
294    let mut cookies = vec![
295        better_auth_core::utils::cookie_utils::create_clear_session_cookie(config),
296        better_auth_core::utils::cookie_utils::create_clear_cookie(
297            &better_auth_core::utils::cookie_utils::related_cookie_name(config, "session_data"),
298            config,
299        ),
300        better_auth_core::utils::cookie_utils::create_clear_cookie(
301            &better_auth_core::utils::cookie_utils::related_cookie_name(config, "dont_remember"),
302            config,
303        ),
304    ];
305
306    if config.account.store_account_cookie {
307        cookies.push(better_auth_core::utils::cookie_utils::create_clear_cookie(
308            &better_auth_core::utils::cookie_utils::related_cookie_name(config, "account_data"),
309            config,
310        ));
311    }
312
313    if matches!(
314        config.account.store_state_strategy,
315        OAuthStateStrategy::Cookie
316    ) {
317        cookies.push(better_auth_core::utils::cookie_utils::create_clear_cookie(
318            &better_auth_core::utils::cookie_utils::related_cookie_name(config, "oauth_state"),
319            config,
320        ));
321    }
322
323    cookies
324}