Skip to main content

better_auth_api/plugins/api_key/
handlers.rs

1use std::collections::HashMap;
2
3use better_auth_core::{AuthContext, AuthResult, CreateApiKey, UpdateApiKey};
4
5use super::ApiKeyPlugin;
6use super::types::*;
7use crate::plugins::helpers;
8
9// ---------------------------------------------------------------------------
10// Permissions verification helper (RBAC)
11// ---------------------------------------------------------------------------
12
13/// Check whether `key_permissions` (JSON object mapping role->actions) covers
14/// all of the `required_permissions`.
15///
16/// Mirrors the TypeScript `role(apiKeyPermissions).authorize(permissions)`
17/// implementation. Required actions must be a subset of the API key's actions
18/// for each resource/role.
19pub(super) fn check_permissions(key_permissions_json: &str, required: &serde_json::Value) -> bool {
20    let required_map = match required.as_object() {
21        Some(m) => m,
22        None => return false,
23    };
24
25    let key_map: HashMap<String, Vec<String>> = match serde_json::from_str(key_permissions_json) {
26        Ok(v) => v,
27        Err(_) => return false,
28    };
29
30    for (resource, requested_actions) in required_map {
31        // Look up the allowed actions for this resource
32        let allowed_actions = match key_map.get(resource) {
33            Some(a) => a,
34            // Resource not found in key permissions -> fail (matches TS behavior)
35            None => return false,
36        };
37
38        // The request value can be:
39        // 1. An array of action strings -> all must be allowed (AND)
40        // 2. An object { actions: [...], connector: "OR"|"AND" }
41        if let Some(actions_array) = requested_actions.as_array() {
42            // Simple array -> every requested action must exist in allowed actions
43            for action_val in actions_array {
44                let action = match action_val.as_str() {
45                    Some(s) => s,
46                    None => return false,
47                };
48                if !allowed_actions.iter().any(|a| a == action) {
49                    return false;
50                }
51            }
52        } else if let Some(obj) = requested_actions.as_object() {
53            // Object form: { actions: [...], connector: "OR" | "AND" }
54            let actions = match obj.get("actions").and_then(|v| v.as_array()) {
55                Some(a) => a,
56                None => return false,
57            };
58            let connector = obj
59                .get("connector")
60                .and_then(|v| v.as_str())
61                .unwrap_or("AND");
62
63            if connector == "OR" {
64                // At least one requested action must be allowed
65                let any_allowed = actions.iter().any(|action_val| {
66                    action_val
67                        .as_str()
68                        .is_some_and(|action| allowed_actions.iter().any(|a| a == action))
69                });
70                if !any_allowed {
71                    return false;
72                }
73            } else {
74                // AND (default): every requested action must be allowed
75                for action_val in actions {
76                    let action = match action_val.as_str() {
77                        Some(s) => s,
78                        None => return false,
79                    };
80                    if !allowed_actions.iter().any(|a| a == action) {
81                        return false;
82                    }
83                }
84            }
85        } else {
86            // Invalid format
87            return false;
88        }
89    }
90
91    true
92}
93
94// ---------------------------------------------------------------------------
95// Core functions -- framework-agnostic business logic
96// ---------------------------------------------------------------------------
97
98impl ApiKeyPlugin {
99    /// Create a key on behalf of `body.user_id` from trusted server code.
100    /// Organization configurations still require the user's organization permission.
101    pub async fn create_key(
102        &self,
103        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
104        body: &CreateKeyRequest,
105    ) -> AuthResult<CreateKeyResponse> {
106        let callback_body = serde_json::to_value(body)?;
107        let session = self
108            .authenticate_api_key(
109                super::ApiKeyEndpoint::new(ctx, None, Some("/api-key/create"), &callback_body),
110                ctx,
111            )
112            .await?;
113        let validated = super::request::validate_numbers(
114            "/api-key/create",
115            Some(&callback_body),
116            &[
117                ("expiresIn", body.expires_in),
118                ("remaining", body.remaining),
119                ("refillAmount", body.refill_amount),
120                ("refillInterval", body.refill_interval),
121                ("rateLimitTimeWindow", body.rate_limit_time_window),
122                ("rateLimitMax", body.rate_limit_max),
123            ],
124        )?;
125        let body = validated.get::<CreateKeyRequest>().ok_or_else(|| {
126            better_auth_core::AuthError::internal(
127                "API key body validator returned a different input type",
128            )
129        })?;
130        let config = self.resolve_configuration(body.config_id.as_deref())?;
131        if config.references == super::ApiKeyReferences::User
132            && let Some((session, _)) = &session
133            && body
134                .user_id
135                .as_ref()
136                .is_some_and(|user_id| !user_id.is_empty() && session.user_id != user_id.as_str())
137        {
138            return Err(super::api_key_error(
139                super::ApiKeyErrorCode::UnauthorizedSession,
140            ));
141        }
142        let user_id = session
143            .as_ref()
144            .and_then(|(session, _)| session.user_id.as_str())
145            .or(body.user_id.as_deref().filter(|id| !id.is_empty()))
146            .ok_or_else(|| super::api_key_error(super::ApiKeyErrorCode::UnauthorizedSession))?;
147        create_key_for_user(body, user_id, self, ctx, None).await
148    }
149
150    /// Update a key on behalf of `body.user_id` from trusted server code.
151    /// The caller must authorize access before invoking this server-only method.
152    pub async fn update_key(
153        &self,
154        ctx: &AuthContext<impl better_auth_core::AuthSchema>,
155        body: &UpdateKeyRequest,
156    ) -> AuthResult<ApiKeyView> {
157        let callback_body = serde_json::to_value(body)?;
158        let session = self
159            .authenticate_api_key(
160                super::ApiKeyEndpoint::new(ctx, None, Some("/api-key/update"), &callback_body),
161                ctx,
162            )
163            .await?;
164        let validated = super::request::validate_numbers(
165            "/api-key/update",
166            Some(&callback_body),
167            &[
168                ("expiresIn", body.expires_in.flatten()),
169                ("remaining", body.remaining),
170                ("refillAmount", body.refill_amount),
171                ("refillInterval", body.refill_interval),
172                ("rateLimitTimeWindow", body.rate_limit_time_window),
173                ("rateLimitMax", body.rate_limit_max),
174            ],
175        )?;
176        let body = validated.get::<UpdateKeyRequest>().ok_or_else(|| {
177            better_auth_core::AuthError::internal(
178                "API key body validator returned a different input type",
179            )
180        })?;
181        if let Some((session, _)) = &session
182            && body
183                .user_id
184                .as_ref()
185                .is_some_and(|user_id| !user_id.is_empty() && session.user_id != user_id.as_str())
186        {
187            return Err(super::api_key_error(
188                super::ApiKeyErrorCode::UnauthorizedSession,
189            ));
190        }
191        let user_id = session
192            .as_ref()
193            .and_then(|(session, _)| session.user_id.as_str())
194            .or(body.user_id.as_deref().filter(|id| !id.is_empty()))
195            .ok_or_else(|| super::api_key_error(super::ApiKeyErrorCode::UnauthorizedSession))?;
196        update_key_for_user(body, user_id, self, ctx).await
197    }
198}
199
200pub(super) fn validate_client_create(body: &CreateKeyRequest) -> AuthResult<()> {
201    if body.refill_amount.is_some()
202        || body.refill_interval.is_some()
203        || body.rate_limit_max.is_some()
204        || body.rate_limit_time_window.is_some()
205        || body.rate_limit_enabled.is_some()
206        || body.permissions.is_some()
207        || body.remaining.is_some()
208    {
209        return Err(super::api_key_error(
210            super::ApiKeyErrorCode::ServerOnlyProperty,
211        ));
212    }
213    Ok(())
214}
215
216pub(super) async fn create_key_for_user(
217    body: &CreateKeyRequest,
218    user_id: &str,
219    plugin: &ApiKeyPlugin,
220    ctx: &AuthContext<impl better_auth_core::AuthSchema>,
221    request: Option<&better_auth_core::AuthRequest>,
222) -> AuthResult<CreateKeyResponse> {
223    let config = plugin.resolve_configuration(body.config_id.as_deref())?;
224    let reference_id = match config.references {
225        super::ApiKeyReferences::User => user_id.to_string(),
226        super::ApiKeyReferences::Organization => {
227            let organization_id = body
228                .organization_id
229                .as_deref()
230                .filter(|id| !id.is_empty())
231                .ok_or_else(|| {
232                    super::api_key_error(super::ApiKeyErrorCode::OrganizationIdRequired)
233                })?;
234            helpers::require_org_api_key_permission(ctx, user_id, organization_id, "create")
235                .await?;
236            organization_id.to_string()
237        }
238    };
239
240    ApiKeyPlugin::validate_metadata(config, &body.metadata)?;
241    ApiKeyPlugin::validate_refill(
242        body.refill_interval.filter(|value| *value != 0.0),
243        body.refill_amount.filter(|value| *value != 0.0),
244    )?;
245    let effective_expires_in = ApiKeyPlugin::validate_expires_in(config, body.expires_in)?;
246    ApiKeyPlugin::validate_prefix(config, body.prefix.as_deref())?;
247    ApiKeyPlugin::validate_name(config, body.name.as_deref(), true)?;
248
249    plugin.maybe_delete_expired(ctx).await;
250    let (full_key, hash) = if let Some(generator) = &config.custom_key_generator {
251        let key = generator
252            .generate(
253                config.key_length,
254                body.prefix
255                    .as_deref()
256                    .filter(|prefix| !prefix.is_empty())
257                    .or(config.prefix.as_deref()),
258            )
259            .await
260            .map_err(|error| super::callbacks::callback_error(error, request))?;
261        let hash = if config.disable_key_hashing {
262            key.clone()
263        } else {
264            ApiKeyPlugin::hash_key(&key)
265        };
266        (key, hash)
267    } else {
268        ApiKeyPlugin::generate_key(config, body.prefix.as_deref())
269    };
270    let start = config.store_starting_characters.then(|| {
271        better_auth_core::ApiKeyStart::prefix(&full_key, config.starting_characters_length)
272    });
273    let dynamic_permissions = if let Some(callback) = &config.default_permissions_callback {
274        let mut callback_body: serde_json::Map<String, serde_json::Value> =
275            serde_json::from_value(serde_json::to_value(body)?)?;
276        let _ = callback_body.insert("remaining".into(), serde_json::to_value(body.remaining)?);
277        let _ = callback_body.insert("expiresIn".into(), serde_json::to_value(body.expires_in)?);
278        let callback_body = serde_json::Value::Object(callback_body);
279        Some(
280            callback
281                .permissions(
282                    &reference_id,
283                    super::ApiKeyEndpoint::new(
284                        ctx,
285                        request,
286                        Some("/api-key/create"),
287                        &callback_body,
288                    ),
289                )
290                .await
291                .map_err(|error| super::callbacks::callback_error(error, request))?,
292        )
293    } else {
294        None
295    };
296    let input = CreateApiKey {
297        additional_fields: Default::default(),
298        reference_id,
299        config_id: config.config_id.clone(),
300        name: body.name.clone().into(),
301        prefix: body.prefix.clone().or_else(|| config.prefix.clone()),
302        key_hash: hash,
303        start,
304        expires_at: expiration_date(effective_expires_in)?,
305        remaining: body.remaining,
306        rate_limit_enabled: body.rate_limit_enabled.unwrap_or(config.rate_limit.enabled),
307        rate_limit_time_window: body
308            .rate_limit_time_window
309            .or(Some(config.rate_limit.time_window)),
310        rate_limit_max: body.rate_limit_max.or(Some(config.rate_limit.max_requests)),
311        refill_interval: body.refill_interval,
312        refill_amount: body.refill_amount,
313        permissions: body
314            .permissions
315            .as_ref()
316            .or(dynamic_permissions.as_ref())
317            .or(config.default_permissions.as_ref())
318            .map(serde_json::to_string)
319            .transpose()?,
320        metadata: Some(
321            body.metadata
322                .as_ref()
323                .filter(|value| json_truthy(value))
324                .unwrap_or(&serde_json::Value::Null)
325                .to_string(),
326        ),
327        enabled: true,
328    };
329    let api_key = super::storage::create(config, ctx, input).await?;
330    let mut api_key = ApiKeyView::from(&api_key);
331    // Upstream returns supplied falsy metadata at creation, but stores null.
332    api_key.metadata = body.metadata.clone();
333    Ok(CreateKeyResponse {
334        key: full_key,
335        api_key,
336    })
337}
338
339fn json_truthy(value: &serde_json::Value) -> bool {
340    match value {
341        serde_json::Value::Null => false,
342        serde_json::Value::Bool(value) => *value,
343        serde_json::Value::Number(value) => value.as_f64().is_some_and(|value| value != 0.0),
344        serde_json::Value::String(value) => !value.is_empty(),
345        _ => true,
346    }
347}
348
349fn expiration_date(seconds: Option<f64>) -> AuthResult<Option<better_auth_core::FieldDate>> {
350    let Some(seconds) = seconds.filter(|seconds| *seconds != 0.0) else {
351        return Ok(None);
352    };
353    let milliseconds = seconds * 1000.0;
354    if !milliseconds.is_finite() || milliseconds.abs() > i64::MAX as f64 {
355        return Err(better_auth_core::AuthError::bad_request(
356            "expiresIn is out of range",
357        ));
358    }
359    let duration = chrono::Duration::milliseconds(milliseconds as i64);
360    let date = chrono::Utc::now()
361        .checked_add_signed(duration)
362        .ok_or_else(|| better_auth_core::AuthError::bad_request("expiresIn is out of range"))?;
363    Ok(Some(date.into()))
364}
365
366pub(crate) async fn get_key_core(
367    id: &str,
368    config_id: Option<&str>,
369    user_id: impl AsRef<str>,
370    plugin: &ApiKeyPlugin,
371    ctx: &AuthContext<impl better_auth_core::AuthSchema>,
372) -> AuthResult<ApiKeyView> {
373    let config = plugin.resolve_configuration(config_id)?;
374    let api_key = helpers::get_owned_api_key(ctx, config, id, user_id.as_ref(), "read").await?;
375    plugin.maybe_delete_expired(ctx).await;
376    super::metadata::single(ApiKeyView::from(&api_key), config, ctx).await
377}
378
379pub(crate) async fn list_keys_core(
380    user_id: impl AsRef<str>,
381    query: &ListKeysQuery,
382    plugin: &ApiKeyPlugin,
383    ctx: &AuthContext<impl better_auth_core::AuthSchema>,
384) -> AuthResult<ListKeysResponse> {
385    let organization_id = query.organization_id.as_deref().filter(|id| !id.is_empty());
386    if let Some(organization_id) = organization_id {
387        helpers::require_org_api_key_permission(ctx, user_id.as_ref(), organization_id, "read")
388            .await?;
389    }
390    let reference_id = query.organization_id.as_deref().unwrap_or(user_id.as_ref());
391    let references = if organization_id.is_some() {
392        super::ApiKeyReferences::Organization
393    } else {
394        super::ApiKeyReferences::User
395    };
396    let config_id = query.config_id.as_deref().filter(|id| !id.is_empty());
397    if config_id.is_some() {
398        let _ = plugin.resolve_configuration(config_id)?;
399    }
400
401    let configurations = if let Some(id) = config_id {
402        vec![plugin.resolve_configuration(Some(id))?]
403    } else {
404        let mut groups = std::collections::HashSet::new();
405        plugin
406            .configurations
407            .iter()
408            .filter(|config| groups.insert(super::storage::group(config)))
409            .collect()
410    };
411    let sort = query
412        .sort_by
413        .as_deref()
414        .filter(|field| !field.is_empty())
415        .map(|field| (field, query.sort_direction.as_deref().unwrap_or("asc")));
416    let groups = futures_util::future::join_all(
417        configurations
418            .into_iter()
419            .map(|config| super::storage::list(config, ctx, reference_id, sort)),
420    )
421    .await;
422    let groups: Vec<_> = groups.into_iter().collect::<AuthResult<_>>()?;
423    let mut keys: Vec<_> = groups.into_iter().flatten().collect();
424    super::storage::deduplicate(&mut keys);
425    let mut views: Vec<ApiKeyView> = keys
426        .iter()
427        .filter(|key| {
428            let key_references = plugin
429                .configurations
430                .iter()
431                .find(|config| super::config_id_matches(&key.config_id, &config.config_id))
432                .map(|config| config.references)
433                .unwrap_or_default();
434            key_references == references
435                && key.reference_id == reference_id
436                && config_id.is_none_or(|id| super::config_id_matches(&key.config_id, id))
437        })
438        .map(ApiKeyView::from)
439        .collect();
440
441    let total = views.len();
442    if let Some(offset) = query.offset {
443        views = views.split_off(offset.min(views.len() as u64) as usize);
444    }
445    if let Some(limit) = query.limit {
446        views.truncate(limit.min(views.len() as u64) as usize);
447    }
448    plugin.maybe_delete_expired(ctx).await;
449    super::metadata::batch(&mut views, &plugin.configurations, ctx).await;
450    Ok(ListKeysResponse {
451        api_keys: views,
452        total,
453        limit: query.limit,
454        offset: query.offset,
455    })
456}
457
458pub(crate) async fn update_key_core(
459    body: &UpdateKeyRequest,
460    user_id: impl AsRef<str>,
461    plugin: &ApiKeyPlugin,
462    ctx: &AuthContext<impl better_auth_core::AuthSchema>,
463) -> AuthResult<ApiKeyView> {
464    if body
465        .user_id
466        .as_deref()
467        .is_some_and(|id| !id.is_empty() && id != user_id.as_ref())
468    {
469        return Err(super::api_key_error(
470            super::ApiKeyErrorCode::UnauthorizedSession,
471        ));
472    }
473    if body.refill_amount.is_some()
474        || body.refill_interval.is_some()
475        || body.rate_limit_max.is_some()
476        || body.rate_limit_time_window.is_some()
477        || body.rate_limit_enabled.is_some()
478        || body.remaining.is_some()
479        || body.permissions.is_some()
480    {
481        return Err(super::api_key_error(
482            super::ApiKeyErrorCode::ServerOnlyProperty,
483        ));
484    }
485    update_key_for_user(body, user_id.as_ref(), plugin, ctx).await
486}
487
488pub(super) async fn update_key_for_user(
489    body: &UpdateKeyRequest,
490    user_id: &str,
491    plugin: &ApiKeyPlugin,
492    ctx: &AuthContext<impl better_auth_core::AuthSchema>,
493) -> AuthResult<ApiKeyView> {
494    let config = plugin.resolve_configuration(body.config_id.as_deref())?;
495    let api_key = helpers::get_owned_api_key(ctx, config, &body.key_id, user_id, "update").await?;
496    ApiKeyPlugin::validate_name(config, body.name.as_deref(), false)?;
497
498    let expires_at = match body.expires_in {
499        None => None,
500        Some(_) if config.key_expiration.disable_custom_expires_time => {
501            return Err(super::api_key_error(
502                super::ApiKeyErrorCode::KeyDisabledExpiration,
503            ));
504        }
505        Some(None) => Some(None),
506        Some(Some(seconds)) => {
507            let validated = ApiKeyPlugin::validate_expires_in(config, Some(seconds))?;
508            Some(expiration_date(validated)?)
509        }
510    };
511    let metadata = body.metadata.as_ref().filter(|_| config.enable_metadata);
512    if let Some(metadata) = metadata
513        && !(metadata.is_null() || metadata.is_object() || metadata.is_array())
514    {
515        return Err(super::api_key_error(
516            super::ApiKeyErrorCode::InvalidMetadataType,
517        ));
518    }
519    ApiKeyPlugin::validate_refill(body.refill_interval, body.refill_amount)?;
520    if body.name.is_none()
521        && body.enabled.is_none()
522        && expires_at.is_none()
523        && metadata.is_none()
524        && body.remaining.is_none()
525        && body.refill_amount.is_none()
526        && body.refill_interval.is_none()
527        && body.rate_limit_enabled.is_none()
528        && body.rate_limit_time_window.is_none()
529        && body.rate_limit_max.is_none()
530        && body.permissions.is_none()
531    {
532        return Err(super::api_key_error(
533            super::ApiKeyErrorCode::NoValuesToUpdate,
534        ));
535    }
536    let update = UpdateApiKey {
537        name: body.name.clone().map(|value| Some(value).into()),
538        enabled: body.enabled,
539        remaining: body.remaining,
540        rate_limit_enabled: body.rate_limit_enabled,
541        rate_limit_time_window: body.rate_limit_time_window,
542        rate_limit_max: body.rate_limit_max,
543        refill_interval: body.refill_interval,
544        refill_amount: body.refill_amount,
545        permissions: body
546            .permissions
547            .as_ref()
548            .map(serde_json::to_string)
549            .transpose()?,
550        metadata: metadata.map(ToString::to_string),
551        expires_at,
552        ..Default::default()
553    };
554    let updated = super::storage::update(config, ctx, api_key, update).await?;
555    plugin.maybe_delete_expired(ctx).await;
556    super::metadata::single(ApiKeyView::from(&updated), config, ctx).await
557}
558
559pub(crate) async fn delete_key_core(
560    body: &DeleteKeyRequest,
561    user_id: impl AsRef<str>,
562    plugin: &ApiKeyPlugin,
563    ctx: &AuthContext<impl better_auth_core::AuthSchema>,
564) -> AuthResult<serde_json::Value> {
565    let config = plugin.resolve_configuration(body.config_id.as_deref())?;
566    let api_key =
567        helpers::get_owned_api_key(ctx, config, &body.key_id, user_id.as_ref(), "delete").await?;
568    super::storage::delete(config, ctx, &api_key).await?;
569    plugin.maybe_delete_expired(ctx).await;
570    Ok(serde_json::json!({ "success": true }))
571}