1use base64::Engine;
2use base64::engine::general_purpose::URL_SAFE_NO_PAD;
3use rand::seq::SliceRandom;
4use sha2::{Digest, Sha256};
5use std::sync::Mutex;
6
7use better_auth_core::entity::{AuthApiKey as _, AuthUser};
8use better_auth_core::store::ConsumeApiKeyResult;
9use better_auth_core::{AuthContext, AuthError, AuthResult, BeforeRequestAction};
10use better_auth_core::{AuthRequest, AuthResponse};
11
12pub(super) mod handlers;
13pub(super) mod types;
14
15#[cfg(test)]
16mod tests;
17
18use handlers::*;
19use types::*;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum ApiKeyErrorCode {
28 InvalidApiKey,
29 KeyDisabled,
30 KeyExpired,
31 UsageExceeded,
32 KeyNotFound,
33 RateLimited,
34 UnauthorizedSession,
35 InvalidPrefixLength,
36 InvalidNameLength,
37 MetadataDisabled,
38 NoValuesToUpdate,
39 KeyDisabledExpiration,
40 ExpiresInTooSmall,
41 ExpiresInTooLarge,
42 InvalidRemaining,
43 RefillAmountAndIntervalRequired,
44 NameRequired,
45 InvalidUserIdFromApiKey,
46 ServerOnlyProperty,
47 FailedToUpdateApiKey,
48 InvalidMetadataType,
49}
50
51impl ApiKeyErrorCode {
52 pub fn as_str(self) -> &'static str {
53 match self {
54 Self::InvalidApiKey => "INVALID_API_KEY",
55 Self::KeyDisabled => "KEY_DISABLED",
56 Self::KeyExpired => "KEY_EXPIRED",
57 Self::UsageExceeded => "USAGE_EXCEEDED",
58 Self::KeyNotFound => "KEY_NOT_FOUND",
59 Self::RateLimited => "RATE_LIMITED",
60 Self::UnauthorizedSession => "UNAUTHORIZED_SESSION",
61 Self::InvalidPrefixLength => "INVALID_PREFIX_LENGTH",
62 Self::InvalidNameLength => "INVALID_NAME_LENGTH",
63 Self::MetadataDisabled => "METADATA_DISABLED",
64 Self::NoValuesToUpdate => "NO_VALUES_TO_UPDATE",
65 Self::KeyDisabledExpiration => "KEY_DISABLED_EXPIRATION",
66 Self::ExpiresInTooSmall => "EXPIRES_IN_IS_TOO_SMALL",
67 Self::ExpiresInTooLarge => "EXPIRES_IN_IS_TOO_LARGE",
68 Self::InvalidRemaining => "INVALID_REMAINING",
69 Self::RefillAmountAndIntervalRequired => "REFILL_AMOUNT_AND_INTERVAL_REQUIRED",
70 Self::NameRequired => "NAME_REQUIRED",
71 Self::InvalidUserIdFromApiKey => "INVALID_USER_ID_FROM_API_KEY",
72 Self::ServerOnlyProperty => "SERVER_ONLY_PROPERTY",
73 Self::FailedToUpdateApiKey => "FAILED_TO_UPDATE_API_KEY",
74 Self::InvalidMetadataType => "INVALID_METADATA_TYPE",
75 }
76 }
77
78 pub fn message(self) -> &'static str {
79 match self {
80 Self::InvalidApiKey => "Invalid API key.",
81 Self::KeyDisabled => "API Key is disabled",
82 Self::KeyExpired => "API Key has expired",
83 Self::UsageExceeded => "API Key has reached its usage limit",
84 Self::KeyNotFound => "API Key not found",
85 Self::RateLimited => "Rate limit exceeded.",
86 Self::UnauthorizedSession => "Unauthorized or invalid session",
87 Self::InvalidPrefixLength => "The prefix length is either too large or too small.",
88 Self::InvalidNameLength => "The name length is either too large or too small.",
89 Self::MetadataDisabled => "Metadata is disabled.",
90 Self::NoValuesToUpdate => "No values to update.",
91 Self::KeyDisabledExpiration => "Custom key expiration values are disabled.",
92 Self::ExpiresInTooSmall => {
93 "The expiresIn is smaller than the predefined minimum value."
94 }
95 Self::ExpiresInTooLarge => "The expiresIn is larger than the predefined maximum value.",
96 Self::InvalidRemaining => "The remaining count is either too large or too small.",
97 Self::RefillAmountAndIntervalRequired => {
98 "refillAmount and refillInterval must both be provided together"
99 }
100 Self::NameRequired => "API Key name is required.",
101 Self::InvalidUserIdFromApiKey => "The user id from the API key is invalid.",
102 Self::ServerOnlyProperty => {
103 "The property you're trying to set can only be set from the server auth instance only."
104 }
105 Self::FailedToUpdateApiKey => "Failed to update API key",
106 Self::InvalidMetadataType => "metadata must be an object or undefined",
107 }
108 }
109}
110
111fn api_key_error(code: ApiKeyErrorCode) -> AuthError {
112 AuthError::bad_request(code.message())
113}
114
115pub(super) struct ApiKeyValidationError {
117 #[cfg_attr(
118 not(test),
119 expect(dead_code, reason = "read by the test module's verify_key helper")
120 )]
121 pub(super) code: ApiKeyErrorCode,
122 pub(super) message: String,
123}
124
125impl ApiKeyValidationError {
126 fn new(code: ApiKeyErrorCode) -> Self {
127 Self {
128 message: code.message().to_string(),
129 code,
130 }
131 }
132}
133
134pub struct ApiKeyPlugin {
140 pub(super) config: ApiKeyConfig,
141 last_expired_check: Mutex<Option<std::time::Instant>>,
143}
144
145#[derive(Debug, Clone)]
147pub struct ApiKeyConfig {
148 pub key_length: usize,
150 pub prefix: Option<String>,
151 pub default_remaining: Option<i64>,
152
153 pub api_key_header: String,
155
156 pub disable_key_hashing: bool,
158
159 pub starting_characters_length: usize,
161 pub store_starting_characters: bool,
162
163 pub max_prefix_length: usize,
165 pub min_prefix_length: usize,
166
167 pub max_name_length: usize,
169 pub min_name_length: usize,
170 pub require_name: bool,
171
172 pub enable_metadata: bool,
174
175 pub key_expiration: KeyExpirationConfig,
177
178 pub rate_limit: RateLimitDefaults,
180
181 pub enable_session_for_api_keys: bool,
183}
184
185#[derive(Debug, Clone)]
187pub struct KeyExpirationConfig {
188 pub default_expires_in: Option<i64>,
190 pub disable_custom_expires_time: bool,
192 pub max_expires_in: i64,
194 pub min_expires_in: i64,
196}
197
198impl Default for KeyExpirationConfig {
199 fn default() -> Self {
200 Self {
201 default_expires_in: None,
202 disable_custom_expires_time: false,
203 max_expires_in: 365,
204 min_expires_in: 1,
205 }
206 }
207}
208
209#[derive(Debug, Clone)]
211pub struct RateLimitDefaults {
212 pub enabled: bool,
213 pub time_window: i64,
215 pub max_requests: i64,
217}
218
219impl Default for RateLimitDefaults {
220 fn default() -> Self {
221 Self {
222 enabled: true,
223 time_window: 86_400_000, max_requests: 10,
225 }
226 }
227}
228
229impl Default for ApiKeyConfig {
230 fn default() -> Self {
231 Self {
232 key_length: 64,
233 prefix: None,
234 default_remaining: None,
235 api_key_header: "x-api-key".to_string(),
236 disable_key_hashing: false,
237 starting_characters_length: 6,
238 store_starting_characters: true,
239 max_prefix_length: 32,
240 min_prefix_length: 1,
241 max_name_length: 32,
242 min_name_length: 1,
243 require_name: false,
244 enable_metadata: false,
245 key_expiration: KeyExpirationConfig::default(),
246 rate_limit: RateLimitDefaults::default(),
247 enable_session_for_api_keys: false,
248 }
249 }
250}
251
252#[bon::bon]
268impl ApiKeyPlugin {
269 #[builder]
270 pub fn new(
271 #[builder(default = 64)] key_length: usize,
272 prefix: Option<String>,
273 default_remaining: Option<i64>,
274 #[builder(default = "x-api-key".to_string())] api_key_header: String,
275 #[builder(default = false)] disable_key_hashing: bool,
276 #[builder(default = 6)] starting_characters_length: usize,
277 #[builder(default = true)] store_starting_characters: bool,
278 #[builder(default = 32)] max_prefix_length: usize,
279 #[builder(default = 1)] min_prefix_length: usize,
280 #[builder(default = 32)] max_name_length: usize,
281 #[builder(default = 1)] min_name_length: usize,
282 #[builder(default = false)] require_name: bool,
283 #[builder(default = false)] enable_metadata: bool,
284 #[builder(default)] key_expiration: KeyExpirationConfig,
285 #[builder(default)] rate_limit: RateLimitDefaults,
286 #[builder(default = false)] enable_session_for_api_keys: bool,
287 ) -> Self {
288 Self {
289 config: ApiKeyConfig {
290 key_length,
291 prefix,
292 default_remaining,
293 api_key_header,
294 disable_key_hashing,
295 starting_characters_length,
296 store_starting_characters,
297 max_prefix_length,
298 min_prefix_length,
299 max_name_length,
300 min_name_length,
301 require_name,
302 enable_metadata,
303 key_expiration,
304 rate_limit,
305 enable_session_for_api_keys,
306 },
307 last_expired_check: Mutex::new(None),
308 }
309 }
310
311 pub fn with_config(config: ApiKeyConfig) -> Self {
312 Self {
313 config,
314 last_expired_check: Mutex::new(None),
315 }
316 }
317
318 pub(super) fn generate_key(&self, custom_prefix: Option<&str>) -> (String, String, String) {
321 const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
323 let mut rng = rand::thread_rng();
324 let raw: String = (0..self.config.key_length)
325 .map(|_| {
326 ALPHABET
327 .choose(&mut rng)
328 .copied()
329 .map(char::from)
330 .unwrap_or('a')
331 })
332 .collect();
333
334 let prefix = custom_prefix
335 .or(self.config.prefix.as_deref())
336 .unwrap_or("");
337 let full_key = format!("{}{}", prefix, raw);
338
339 let start_len = self.config.starting_characters_length;
342 let start: String = full_key.chars().take(start_len).collect();
343
344 let hash = if self.config.disable_key_hashing {
345 full_key.clone()
346 } else {
347 Self::hash_key(&full_key)
348 };
349
350 (full_key, hash, start)
351 }
352
353 fn hash_key(key: &str) -> String {
354 let mut hasher = Sha256::new();
355 hasher.update(key.as_bytes());
356 let digest = hasher.finalize();
357 URL_SAFE_NO_PAD.encode(digest)
358 }
359
360 pub(super) async fn maybe_delete_expired(
362 &self,
363 ctx: &AuthContext<impl better_auth_core::AuthSchema>,
364 ) {
365 let should_run = {
366 let mut last = self
367 .last_expired_check
368 .lock()
369 .unwrap_or_else(|e| e.into_inner());
370 let now = std::time::Instant::now();
371 match *last {
372 Some(prev) if now.duration_since(prev).as_secs() < 10 => false,
373 _ => {
374 *last = Some(now);
375 true
376 }
377 }
378 };
379 if should_run {
380 let _ = ctx.database.delete_expired_api_keys().await;
381 }
382 }
383
384 pub(super) fn validate_prefix(&self, prefix: Option<&str>) -> AuthResult<()> {
387 if let Some(p) = prefix {
388 let len = p.len();
389 if len < self.config.min_prefix_length || len > self.config.max_prefix_length {
390 return Err(api_key_error(ApiKeyErrorCode::InvalidPrefixLength));
391 }
392 }
393 Ok(())
394 }
395
396 pub(super) fn validate_name(&self, name: Option<&str>, is_create: bool) -> AuthResult<()> {
402 if is_create && self.config.require_name && name.is_none() {
403 return Err(api_key_error(ApiKeyErrorCode::NameRequired));
404 }
405 if let Some(n) = name {
406 let len = n.len();
407 if len < self.config.min_name_length || len > self.config.max_name_length {
408 return Err(api_key_error(ApiKeyErrorCode::InvalidNameLength));
409 }
410 }
411 Ok(())
412 }
413
414 pub(super) fn validate_expires_in(&self, expires_in: Option<i64>) -> AuthResult<Option<i64>> {
415 let cfg = &self.config.key_expiration;
416 if let Some(secs) = expires_in {
417 if cfg.disable_custom_expires_time {
418 return Err(api_key_error(ApiKeyErrorCode::KeyDisabledExpiration));
419 }
420 let days = secs as f64 / 86_400.0;
422 if days < cfg.min_expires_in as f64 {
423 return Err(api_key_error(ApiKeyErrorCode::ExpiresInTooSmall));
424 }
425 if days > cfg.max_expires_in as f64 {
426 return Err(api_key_error(ApiKeyErrorCode::ExpiresInTooLarge));
427 }
428 Ok(Some(secs))
429 } else {
430 Ok(cfg.default_expires_in)
431 }
432 }
433
434 pub(super) fn validate_metadata(&self, metadata: &Option<serde_json::Value>) -> AuthResult<()> {
435 if metadata.is_some() && !self.config.enable_metadata {
436 return Err(api_key_error(ApiKeyErrorCode::MetadataDisabled));
437 }
438 if let Some(v) = metadata
439 && !v.is_object()
440 && !v.is_null()
441 {
442 return Err(api_key_error(ApiKeyErrorCode::InvalidMetadataType));
443 }
444 Ok(())
445 }
446
447 pub(super) fn validate_refill(
448 refill_interval: Option<i64>,
449 refill_amount: Option<i64>,
450 ) -> AuthResult<()> {
451 match (refill_interval, refill_amount) {
452 (Some(_), None) | (None, Some(_)) => Err(api_key_error(
453 ApiKeyErrorCode::RefillAmountAndIntervalRequired,
454 )),
455 _ => Ok(()),
456 }
457 }
458
459 async fn handle_create(
464 &self,
465 req: &AuthRequest,
466 ctx: &AuthContext<impl better_auth_core::AuthSchema>,
467 ) -> AuthResult<AuthResponse> {
468 let (user, _session) = ctx.require_session(req).await?;
469 let body: CreateKeyRequest = match better_auth_core::validate_request_body(req) {
470 Ok(v) => v,
471 Err(resp) => return Ok(resp),
472 };
473 let response = create_key_core(&body, user.id(), self, ctx).await?;
474 Ok(AuthResponse::json(200, &response)?)
475 }
476
477 async fn handle_get(
478 &self,
479 req: &AuthRequest,
480 ctx: &AuthContext<impl better_auth_core::AuthSchema>,
481 ) -> AuthResult<AuthResponse> {
482 let (user, _session) = ctx.require_session(req).await?;
483 let id = req
484 .query
485 .get("id")
486 .ok_or_else(|| AuthError::bad_request("Query parameter 'id' is required"))?;
487 let response = get_key_core(id, user.id(), self, ctx).await?;
488 Ok(AuthResponse::json(200, &response)?)
489 }
490
491 async fn handle_list(
492 &self,
493 req: &AuthRequest,
494 ctx: &AuthContext<impl better_auth_core::AuthSchema>,
495 ) -> AuthResult<AuthResponse> {
496 let (user, _session) = ctx.require_session(req).await?;
497 let response = list_keys_core(user.id(), self, ctx).await?;
498 Ok(AuthResponse::json(200, &response)?)
499 }
500
501 async fn handle_update(
502 &self,
503 req: &AuthRequest,
504 ctx: &AuthContext<impl better_auth_core::AuthSchema>,
505 ) -> AuthResult<AuthResponse> {
506 let (user, _session) = ctx.require_session(req).await?;
507 let body: UpdateKeyRequest = match better_auth_core::validate_request_body(req) {
508 Ok(v) => v,
509 Err(resp) => return Ok(resp),
510 };
511 let response = update_key_core(&body, user.id(), self, ctx).await?;
512 Ok(AuthResponse::json(200, &response)?)
513 }
514
515 async fn handle_delete(
516 &self,
517 req: &AuthRequest,
518 ctx: &AuthContext<impl better_auth_core::AuthSchema>,
519 ) -> AuthResult<AuthResponse> {
520 let (user, _session) = ctx.require_session(req).await?;
521 let body: DeleteKeyRequest = match better_auth_core::validate_request_body(req) {
522 Ok(v) => v,
523 Err(resp) => return Ok(resp),
524 };
525 let response = delete_key_core(&body, user.id(), self, ctx).await?;
526 Ok(AuthResponse::json(200, &response)?)
527 }
528
529 pub(super) async fn validate_api_key(
537 &self,
538 ctx: &AuthContext<impl better_auth_core::AuthSchema>,
539 raw_key: &str,
540 required_permissions: Option<&serde_json::Value>,
541 ) -> Result<ApiKeyView, ApiKeyValidationError> {
542 let hashed = if self.config.disable_key_hashing {
544 raw_key.to_string()
545 } else {
546 Self::hash_key(raw_key)
547 };
548
549 let api_key = ctx
551 .database
552 .get_api_key_by_hash(&hashed)
553 .await
554 .map_err(|_| ApiKeyValidationError::new(ApiKeyErrorCode::InvalidApiKey))?
555 .ok_or_else(|| ApiKeyValidationError::new(ApiKeyErrorCode::InvalidApiKey))?;
556
557 if !api_key.enabled() {
559 return Err(ApiKeyValidationError::new(ApiKeyErrorCode::KeyDisabled));
560 }
561
562 if let Some(expires_at_str) = api_key.expires_at()
564 && let Ok(expires_at) = chrono::DateTime::parse_from_rfc3339(expires_at_str)
565 && chrono::Utc::now() > expires_at
566 {
567 let _ = ctx.database.delete_api_key(&api_key.id()).await;
568 return Err(ApiKeyValidationError::new(ApiKeyErrorCode::KeyExpired));
569 }
570
571 if let Some(required) = required_permissions {
573 let key_perms_str = api_key.permissions().unwrap_or("");
574 if key_perms_str.is_empty() {
575 return Err(ApiKeyValidationError::new(ApiKeyErrorCode::KeyNotFound));
576 }
577 if !check_permissions(key_perms_str, required) {
578 return Err(ApiKeyValidationError::new(ApiKeyErrorCode::KeyNotFound));
579 }
580 }
581
582 let updated = match ctx
587 .database
588 .consume_api_key_usage(&api_key.id(), self.config.rate_limit.enabled)
589 .await
590 .map_err(|_| ApiKeyValidationError::new(ApiKeyErrorCode::FailedToUpdateApiKey))?
591 {
592 ConsumeApiKeyResult::Allowed(key) => *key,
593 ConsumeApiKeyResult::RateLimited => {
594 return Err(ApiKeyValidationError::new(ApiKeyErrorCode::RateLimited));
595 }
596 ConsumeApiKeyResult::UsageExhausted => {
597 return Err(ApiKeyValidationError::new(ApiKeyErrorCode::UsageExceeded));
598 }
599 };
600
601 self.maybe_delete_expired(ctx).await;
603
604 Ok(ApiKeyView::from(&updated))
605 }
606}
607
608better_auth_core::impl_auth_plugin! {
613 ApiKeyPlugin, "api-key";
614 routes {
615 post "/api-key/create" => handle_create, "api_key_create";
616 get "/api-key/get" => handle_get, "api_key_get";
617 post "/api-key/update" => handle_update, "api_key_update";
618 post "/api-key/delete" => handle_delete, "api_key_delete";
619 get "/api-key/list" => handle_list, "api_key_list";
620 }
621 extra {
622 async fn before_request(
623 &self,
624 req: &AuthRequest,
625 ctx: &AuthContext<S>,
626 ) -> AuthResult<Option<BeforeRequestAction>> {
627 if !self.config.enable_session_for_api_keys {
628 return Ok(None);
629 }
630
631 let raw_key = match req.headers.get(&self.config.api_key_header) {
633 Some(k) if !k.is_empty() => k.clone(),
634 _ => return Ok(None),
635 };
636
637 let view = self
639 .validate_api_key(ctx, &raw_key, None)
640 .await
641 .map_err(|e| AuthError::bad_request(e.message))?;
642
643 let user = ctx
645 .database
646 .get_user_by_id(&view.user_id)
647 .await?
648 .ok_or_else(|| api_key_error(ApiKeyErrorCode::InvalidUserIdFromApiKey))?;
649
650 if req.path() == "/get-session" {
652 let session_json = serde_json::json!({
653 "user": {
654 "id": user.id(),
655 "email": user.email(),
656 "name": user.name(),
657 },
658 "session": {
659 "id": view.id,
660 "token": raw_key,
661 "userId": view.user_id,
662 }
663 });
664 return Ok(Some(BeforeRequestAction::Respond(AuthResponse::json(
665 200,
666 &session_json,
667 )?)));
668 }
669
670 Ok(Some(BeforeRequestAction::InjectSession {
672 user_id: view.user_id,
673 session_token: raw_key,
674 }))
675 }
676 }
677}