Skip to main content

alibi_plugins/api_key/
mod.rs

1mod cleanup;
2mod config;
3
4pub use config::ApiKeyConfig;
5pub use config::ApiKeyReferences;
6pub use config::KeyExpirationConfig;
7pub use config::RateLimitDefaults;
8mod callbacks;
9mod secondary_usage;
10mod storage;
11pub use storage::{ApiKeyStorage, ApiKeyStorageMode};
12mod endpoint;
13pub use endpoint::{
14    ApiKeyVerificationInput, ApiKeyVerificationOutput, DeleteKeyResponse, GetKeyQuery,
15};
16
17pub(super) mod handlers;
18
19pub(super) mod types;
20
21mod verification;
22
23use alibi_core::entity::AuthUser;
24use alibi_core::error::codes;
25use alibi_core::{AuthContext, AuthError, AuthResult, BeforeRequestAction};
26use alibi_core::{AuthRequest, AuthResponse};
27use base64::Engine;
28use base64::engine::general_purpose::URL_SAFE_NO_PAD;
29pub use callbacks::{
30    ApiKeyCallbackContext, ApiKeyDefaultPermissions, ApiKeyGenerationOptions, ApiKeyGenerator,
31    ApiKeyGetter, ApiKeyPermissions, ApiKeyValidator,
32};
33use handlers::{create_key_core, delete_key_core, get_key_core, list_keys_core, update_key_core};
34use rand::seq::IndexedRandom;
35use sha2::{Digest, Sha256};
36use std::sync::{Arc, Mutex};
37use types::parse_api_key_body;
38pub use types::{
39    CreateKeyRequest, CreateKeyResponse, DeleteExpiredApiKeysResponse, DeleteKeyRequest,
40    ListKeysQuery, ListKeysResponse, UpdateKeyRequest,
41};
42pub use verification::{
43    ApiKeyErrorDetails, ApiKeyErrorMessage, ApiKeyValidationError, ApiKeyVerificationError,
44    VerifyApiKey,
45};
46
47/// Dedicated API Key error codes aligned with the TypeScript `API_KEY_ERROR_CODES`.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum ApiKeyErrorCode {
50    InvalidApiKey,
51    KeyDisabled,
52    KeyExpired,
53    UsageExceeded,
54    KeyNotFound,
55    RateLimited,
56    UnauthorizedSession,
57    InvalidPrefixLength,
58    InvalidNameLength,
59    MetadataDisabled,
60    NoValuesToUpdate,
61    KeyDisabledExpiration,
62    ExpiresInTooSmall,
63    ExpiresInTooLarge,
64    InvalidRemaining,
65    RefillAmountAndIntervalRequired,
66    RefillIntervalAndAmountRequired,
67    NameRequired,
68    InvalidUserIdFromApiKey,
69    InvalidReferenceIdFromApiKey,
70    NoDefaultConfiguration,
71    OrganizationIdRequired,
72    OrganizationPluginRequired,
73    UserNotMemberOfOrganization,
74    InsufficientApiKeyPermissions,
75    ServerOnlyProperty,
76    FailedToUpdateApiKey,
77    InvalidMetadataType,
78}
79
80impl ApiKeyErrorCode {
81    #[must_use]
82    pub const fn as_str(self) -> &'static str {
83        match self {
84            Self::InvalidApiKey => codes::INVALID_API_KEY,
85            Self::KeyDisabled => codes::KEY_DISABLED,
86            Self::KeyExpired => codes::KEY_EXPIRED,
87            Self::UsageExceeded => codes::USAGE_EXCEEDED,
88            Self::KeyNotFound => codes::KEY_NOT_FOUND,
89            Self::RateLimited => codes::RATE_LIMITED,
90            Self::UnauthorizedSession => codes::UNAUTHORIZED_SESSION,
91            Self::InvalidPrefixLength => codes::INVALID_PREFIX_LENGTH,
92            Self::InvalidNameLength => codes::INVALID_NAME_LENGTH,
93            Self::MetadataDisabled => codes::METADATA_DISABLED,
94            Self::NoValuesToUpdate => codes::NO_VALUES_TO_UPDATE,
95            Self::KeyDisabledExpiration => codes::KEY_DISABLED_EXPIRATION,
96            Self::ExpiresInTooSmall => codes::EXPIRES_IN_IS_TOO_SMALL,
97            Self::ExpiresInTooLarge => codes::EXPIRES_IN_IS_TOO_LARGE,
98            Self::InvalidRemaining => codes::INVALID_REMAINING,
99            Self::RefillAmountAndIntervalRequired => codes::REFILL_AMOUNT_AND_INTERVAL_REQUIRED,
100            Self::RefillIntervalAndAmountRequired => codes::REFILL_INTERVAL_AND_AMOUNT_REQUIRED,
101            Self::NameRequired => codes::NAME_REQUIRED,
102            Self::InvalidUserIdFromApiKey => codes::INVALID_USER_ID_FROM_API_KEY,
103            Self::InvalidReferenceIdFromApiKey => codes::INVALID_REFERENCE_ID_FROM_API_KEY,
104            Self::NoDefaultConfiguration => codes::NO_DEFAULT_API_KEY_CONFIGURATION_FOUND,
105            Self::OrganizationIdRequired => codes::ORGANIZATION_ID_REQUIRED,
106            Self::OrganizationPluginRequired => codes::ORGANIZATION_PLUGIN_REQUIRED,
107            Self::UserNotMemberOfOrganization => codes::USER_NOT_MEMBER_OF_ORGANIZATION,
108            Self::InsufficientApiKeyPermissions => codes::INSUFFICIENT_API_KEY_PERMISSIONS,
109            Self::ServerOnlyProperty => codes::SERVER_ONLY_PROPERTY,
110            Self::FailedToUpdateApiKey => codes::FAILED_TO_UPDATE_API_KEY,
111            Self::InvalidMetadataType => codes::INVALID_METADATA_TYPE,
112        }
113    }
114
115    #[must_use]
116    pub const fn message(self) -> &'static str {
117        match self {
118            Self::InvalidApiKey => "Invalid API key.",
119            Self::KeyDisabled => "API Key is disabled",
120            Self::KeyExpired => "API Key has expired",
121            Self::UsageExceeded => "API Key has reached its usage limit",
122            Self::KeyNotFound => "API Key not found",
123            Self::RateLimited => "Rate limit exceeded.",
124            Self::UnauthorizedSession => "Unauthorized or invalid session",
125            Self::InvalidPrefixLength => "The prefix length is either too large or too small.",
126            Self::InvalidNameLength => "The name length is either too large or too small.",
127            Self::MetadataDisabled => "Metadata is disabled.",
128            Self::NoValuesToUpdate => "No values to update.",
129            Self::KeyDisabledExpiration => "Custom key expiration values are disabled.",
130            Self::ExpiresInTooSmall => {
131                "The expiresIn is smaller than the predefined minimum value."
132            }
133            Self::ExpiresInTooLarge => "The expiresIn is larger than the predefined maximum value.",
134            Self::InvalidRemaining => "The remaining count is either too large or too small.",
135            Self::RefillAmountAndIntervalRequired => {
136                "refillAmount is required when refillInterval is provided"
137            }
138            Self::RefillIntervalAndAmountRequired => {
139                "refillInterval is required when refillAmount is provided"
140            }
141            Self::NameRequired => "API Key name is required.",
142            Self::InvalidUserIdFromApiKey => "The user id from the API key is invalid.",
143            Self::InvalidReferenceIdFromApiKey => "The reference id from the API key is invalid.",
144            Self::NoDefaultConfiguration => "No default api-key configuration found.",
145            Self::OrganizationIdRequired => {
146                "Organization ID is required for organization-owned API keys."
147            }
148            Self::OrganizationPluginRequired => {
149                "Organization plugin is required for organization-owned API keys. Please install and configure the organization plugin."
150            }
151            Self::UserNotMemberOfOrganization => {
152                "You are not a member of the organization that owns this API key."
153            }
154            Self::InsufficientApiKeyPermissions => {
155                "You do not have permission to perform this action on organization API keys."
156            }
157            Self::ServerOnlyProperty => {
158                "The property you're trying to set can only be set from the server auth instance only."
159            }
160            Self::FailedToUpdateApiKey => "Failed to update API key",
161            Self::InvalidMetadataType => "metadata must be an object or undefined",
162        }
163    }
164}
165
166impl serde::Serialize for ApiKeyErrorCode {
167    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
168        serializer.serialize_str(self.as_str())
169    }
170}
171
172// Pinned api-key module state is shared by all plugin instances and databases.
173static LAST_EXPIRED_CHECK: Mutex<Option<i64>> = Mutex::new(None);
174
175/// API Key management plugin.
176#[derive(Clone)]
177pub struct ApiKeyPlugin {
178    /// Registered configurations. Unscoped requests use the default configuration.
179    pub(super) configurations: Vec<ApiKeyConfig>,
180}
181
182impl ApiKeyPlugin {
183    /// Register an additional named configuration.
184    ///
185    /// Upstream allows several api-key configurations side by side, each with
186    /// its own `config_id`, ownership model and limits.
187    #[must_use]
188    pub fn configuration(mut self, config: ApiKeyConfig) -> Self {
189        self.configurations.push(config.normalized());
190        self
191    }
192
193    /// Pick the configuration a request addressed, mirroring upstream's
194    /// `resolveConfiguration`: an unknown or absent `config_id` falls back to
195    /// the default one, and a missing default is a client error.
196    pub(super) fn resolve_configuration(
197        &self,
198        config_id: Option<&str>,
199    ) -> AuthResult<&ApiKeyConfig> {
200        if let Some(config_id) = config_id
201            && let Some(found) = self
202                .configurations
203                .iter()
204                .find(|config| config.config_id == config_id)
205        {
206            return Ok(found);
207        }
208
209        self.configurations
210            .iter()
211            .find(|config| is_default_config_id(&config.config_id))
212            .ok_or_else(|| api_key_error(ApiKeyErrorCode::NoDefaultConfiguration))
213    }
214}
215
216/// Builder for [`ApiKeyPlugin`] powered by the `bon` crate.
217///
218/// Usage:
219/// ```
220/// use alibi_plugins::api_key::{ApiKeyPlugin, RateLimitDefaults};
221/// let plugin = ApiKeyPlugin::builder()
222///     .key_length(48)
223///     .prefix("ba_".to_string())
224///     .enable_metadata(true)
225///     .rate_limit(RateLimitDefaults { enabled: true, time_window: 60_000.0, max_requests: 5.0 })
226///     .build();
227/// ```
228#[bon::bon]
229impl ApiKeyPlugin {
230    #[builder]
231    #[must_use]
232    pub fn new(
233        #[builder(default = "default".to_owned())] config_id: String,
234        #[builder(default)] references: ApiKeyReferences,
235        #[builder(default)] storage: ApiKeyStorageMode,
236        #[builder(default)] fallback_to_database: bool,
237        custom_storage: Option<Arc<dyn ApiKeyStorage>>,
238        secondary_storage: Option<Arc<dyn alibi_core::store::CacheAdapter>>,
239        #[builder(default = 64.0, into)] key_length: f64,
240        prefix: Option<String>,
241        default_permissions: Option<ApiKeyPermissions>,
242        custom_key_generator: Option<Arc<dyn ApiKeyGenerator>>,
243        default_permissions_callback: Option<Arc<dyn ApiKeyDefaultPermissions>>,
244        #[builder(default = vec!["x-api-key".to_owned()])] api_key_headers: Vec<String>,
245        custom_api_key_getter: Option<Arc<dyn ApiKeyGetter>>,
246        custom_api_key_validator: Option<Arc<dyn ApiKeyValidator>>,
247        #[builder(default = false)] disable_key_hashing: bool,
248        #[builder(default = 6.0, into)] starting_characters_length: f64,
249        #[builder(default = true)] store_starting_characters: bool,
250        #[builder(default = 32.0, into)] max_prefix_length: f64,
251        #[builder(default = 1.0, into)] min_prefix_length: f64,
252        #[builder(default = 32.0, into)] max_name_length: f64,
253        #[builder(default = 1.0, into)] min_name_length: f64,
254        #[builder(default = false)] require_name: bool,
255        #[builder(default = false)] enable_metadata: bool,
256        #[builder(default)] key_expiration: KeyExpirationConfig,
257        #[builder(default)] rate_limit: RateLimitDefaults,
258        #[builder(default = false)] enable_session_for_api_keys: bool,
259        #[builder(default = false)] defer_updates: bool,
260    ) -> Self {
261        Self {
262            configurations: vec![
263                ApiKeyConfig {
264                    config_id,
265                    references,
266                    storage,
267                    fallback_to_database,
268                    secondary_storage,
269                    custom_storage,
270                    key_length,
271                    prefix,
272                    default_permissions,
273                    custom_key_generator,
274                    default_permissions_callback,
275                    api_key_headers,
276                    custom_api_key_getter,
277                    custom_api_key_validator,
278                    disable_key_hashing,
279                    starting_characters_length,
280                    store_starting_characters,
281                    max_prefix_length,
282                    min_prefix_length,
283                    max_name_length,
284                    min_name_length,
285                    require_name,
286                    enable_metadata,
287                    key_expiration,
288                    rate_limit,
289                    enable_session_for_api_keys,
290                    defer_updates,
291                }
292                .normalized(),
293            ],
294        }
295    }
296
297    #[must_use]
298    pub fn with_config(config: ApiKeyConfig) -> Self {
299        Self {
300            configurations: vec![config.normalized()],
301        }
302    }
303
304    pub(super) fn generate_key(
305        config: &ApiKeyConfig,
306        custom_prefix: Option<&str>,
307    ) -> AuthResult<(String, String, alibi_core::ApiKeyStartingCharacters)> {
308        // Match TS: generateRandomString(length, "a-z", "A-Z") — alpha only
309        const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
310        if config.key_length <= 0.0 {
311            return Err(AuthError::internal("Length must be a positive integer."));
312        }
313        // Source's zero-byte random buffer cannot advance positive lengths
314        // below one half. Nonfinite built-in lengths also cannot terminate.
315        if !config.key_length.is_finite() || config.key_length < 0.5 {
316            return Err(AuthError::internal("Unsupported random key length"));
317        }
318        let capacity = config
319            .key_length
320            .ceil()
321            .to_string()
322            .parse::<u32>()
323            .map_err(|error| AuthError::internal(error.to_string()))?;
324        let mut rng = rand::rng();
325        let mut raw = String::new();
326        raw.try_reserve_exact(
327            usize::try_from(capacity).map_err(|error| AuthError::internal(error.to_string()))?,
328        )
329        .map_err(|error| AuthError::internal(error.to_string()))?;
330        while f64::from(
331            u32::try_from(raw.len()).map_err(|error| AuthError::internal(error.to_string()))?,
332        ) < config.key_length
333        {
334            raw.push(ALPHABET.choose(&mut rng).copied().map_or('a', char::from));
335        }
336
337        let prefix = custom_prefix
338            .filter(|prefix| !prefix.is_empty())
339            .or(config.prefix.as_deref())
340            .unwrap_or("");
341        let full_key = format!("{prefix}{raw}");
342
343        // TS computes start from the full key (including prefix):
344        //   start = key.substring(0, charactersLength)
345        let start = Self::starting_characters(&full_key, config.starting_characters_length);
346
347        let hash = if config.disable_key_hashing {
348            full_key.clone()
349        } else {
350            Self::hash_key(&full_key)
351        };
352
353        Ok((full_key, hash, start))
354    }
355
356    pub(super) fn starting_characters(
357        key: &str,
358        length: f64,
359    ) -> alibi_core::ApiKeyStartingCharacters {
360        // substring(0, length) clamps negatives/NaN to zero, truncates finite
361        // fractions, and preserves all code units for positive infinity.
362        let end = if length.is_nan() || length < 0.0 {
363            0.0
364        } else {
365            length.trunc()
366        };
367        let units = key
368            .encode_utf16()
369            .scan(0_u32, |position, unit| {
370                let keep = f64::from(*position) < end;
371                *position = position.saturating_add(1);
372                keep.then_some(unit)
373            })
374            .collect();
375        alibi_core::ApiKeyStartingCharacters::from_utf16(units)
376    }
377
378    pub(super) fn hash_key(key: &str) -> String {
379        let mut hasher = Sha256::new();
380        hasher.update(key.as_bytes());
381        let digest = hasher.finalize();
382        URL_SAFE_NO_PAD.encode(digest)
383    }
384
385    pub(super) fn validate_prefix(config: &ApiKeyConfig, prefix: Option<&str>) -> AuthResult<()> {
386        if let Some(p) = prefix.filter(|prefix| !prefix.is_empty()) {
387            let len = f64::from(
388                u32::try_from(p.encode_utf16().count())
389                    .map_err(|error| AuthError::internal(error.to_string()))?,
390            );
391            if len < config.min_prefix_length || len > config.max_prefix_length {
392                return Err(api_key_error(ApiKeyErrorCode::InvalidPrefixLength));
393            }
394        }
395        Ok(())
396    }
397
398    /// Validate the `name` field.
399    ///
400    /// When `is_create` is true, `require_name` is enforced (name must be
401    /// present).  On updates `require_name` is **not** enforced -- the
402    /// caller may be updating unrelated fields without resending the name.
403    pub(super) fn validate_name(
404        config: &ApiKeyConfig,
405        name: Option<&str>,
406        is_create: bool,
407    ) -> AuthResult<()> {
408        if is_create && config.require_name && name.is_none_or(str::is_empty) {
409            return Err(api_key_error(ApiKeyErrorCode::NameRequired));
410        }
411        if let Some(n) = name.filter(|name| !is_create || !name.is_empty()) {
412            let len = f64::from(
413                u32::try_from(n.encode_utf16().count())
414                    .map_err(|error| AuthError::internal(error.to_string()))?,
415            );
416            if len < config.min_name_length || len > config.max_name_length {
417                return Err(api_key_error(ApiKeyErrorCode::InvalidNameLength));
418            }
419        }
420        Ok(())
421    }
422
423    pub(super) fn validate_expires_in(
424        config: &ApiKeyConfig,
425        expires_in: Option<f64>,
426    ) -> AuthResult<Option<f64>> {
427        let cfg = &config.key_expiration;
428        if let Some(secs) = expires_in {
429            if cfg.disable_custom_expires_time {
430                return Err(api_key_error(ApiKeyErrorCode::KeyDisabledExpiration));
431            }
432            // expiresIn is in seconds; min/max are in days
433            let days = secs / 86_400.0;
434            if days < cfg.min_expires_in {
435                return Err(api_key_error(ApiKeyErrorCode::ExpiresInTooSmall));
436            }
437            if days > cfg.max_expires_in {
438                return Err(api_key_error(ApiKeyErrorCode::ExpiresInTooLarge));
439            }
440            Ok(Some(secs))
441        } else {
442            Ok(cfg.default_expires_in)
443        }
444    }
445
446    pub(super) fn validate_metadata(
447        config: &ApiKeyConfig,
448        metadata: Option<&alibi_core::utils::json::JsValue>,
449    ) -> AuthResult<()> {
450        if let Some(value) = metadata.filter(|value| match value {
451            alibi_core::utils::json::JsValue::Null => false,
452            alibi_core::utils::json::JsValue::Bool(value) => *value,
453            alibi_core::utils::json::JsValue::Number(value) => *value != 0.0 && !value.is_nan(),
454            alibi_core::utils::json::JsValue::String(value) => !value.is_empty(),
455            alibi_core::utils::json::JsValue::Date(_)
456            | alibi_core::utils::json::JsValue::Array(_)
457            | alibi_core::utils::json::JsValue::Object(_) => true,
458        }) {
459            if !config.enable_metadata {
460                return Err(api_key_error(ApiKeyErrorCode::MetadataDisabled));
461            }
462            if !value.is_object() && !value.is_array() {
463                return Err(api_key_error(ApiKeyErrorCode::InvalidMetadataType));
464            }
465        }
466        Ok(())
467    }
468
469    pub(super) fn validate_refill(
470        refill_interval: Option<f64>,
471        refill_amount: Option<f64>,
472    ) -> AuthResult<()> {
473        match (
474            refill_interval.filter(|value| *value != 0.0),
475            refill_amount.filter(|value| *value != 0.0),
476        ) {
477            (None, Some(_)) => Err(api_key_error(
478                ApiKeyErrorCode::RefillAmountAndIntervalRequired,
479            )),
480            (Some(_), None) => Err(api_key_error(
481                ApiKeyErrorCode::RefillIntervalAndAmountRequired,
482            )),
483            _ => Ok(()),
484        }
485    }
486
487    async fn handle_create(
488        &self,
489        req: &AuthRequest,
490        ctx: &AuthContext<impl alibi_core::AuthSchema>,
491    ) -> AuthResult<AuthResponse> {
492        let mut resolution = req.clone();
493        _ = resolution
494            .query
495            .insert("disableCookieCache".into(), "true".into());
496        let (user, _session) = ctx
497            .require_cached_session(&resolution)
498            .await
499            .map_err(|error| match error {
500                AuthError::Unauthenticated => api_key_error(ApiKeyErrorCode::UnauthorizedSession),
501                error @ (AuthError::Api { .. }
502                | AuthError::Upstream { .. }
503                | AuthError::BadRequest(_)
504                | AuthError::InvalidRequest(_)
505                | AuthError::Validation(_)
506                | AuthError::InvalidCredentials
507                | AuthError::AuthenticationFailed(_)
508                | AuthError::SessionNotFound
509                | AuthError::Forbidden(_)
510                | AuthError::UserCreationCancelled
511                | AuthError::SessionCreationCancelled
512                | AuthError::BannedUser(_)
513                | AuthError::Unauthorized
514                | AuthError::UserNotFound
515                | AuthError::NotFound(_)
516                | AuthError::Conflict(_)
517                | AuthError::MethodNotAllowed(_)
518                | AuthError::PayloadTooLarge(_)
519                | AuthError::UnprocessableEntity(_)
520                | AuthError::RateLimited { .. }
521                | AuthError::NotImplemented(_)
522                | AuthError::Config(_)
523                | AuthError::Database(_)
524                | AuthError::Serialization(_)
525                | AuthError::Plugin { .. }
526                | AuthError::CallbackFailure(_)
527                | AuthError::Internal(_)
528                | AuthError::Encryption(_)
529                | AuthError::PasswordHash(_)
530                | AuthError::Jwt(_)) => error,
531            })?;
532        let body: CreateKeyRequest = match parse_api_key_body(req) {
533            Ok(v) => v,
534            Err(resp) => return Ok(resp),
535        };
536        let response = match create_key_core(&body, user.id(), self, ctx, Some(req)).await {
537            Ok(response) => response,
538            Err(error)
539                if error.status_code() >= 500
540                    && !matches!(error, AuthError::Upstream { .. } | AuthError::Api { .. }) =>
541            {
542                return Ok(AuthResponse::new(500));
543            }
544            Err(error) => return Err(error),
545        };
546        Ok(AuthResponse::json(200, &response)?)
547    }
548
549    async fn handle_get(
550        &self,
551        req: &AuthRequest,
552        ctx: &AuthContext<impl alibi_core::AuthSchema>,
553    ) -> AuthResult<AuthResponse> {
554        let mut resolution = req.clone();
555        _ = resolution.query.remove("disableCookieCache");
556        _ = resolution.query.remove("disableRefresh");
557        let (user, _session) = super::helpers::ordinary_session(&resolution, ctx).await?;
558        let id = req
559            .query
560            .get("id")
561            .ok_or_else(|| AuthError::bad_request("Query parameter 'id' is required"))?;
562        let config_id = req.query.get("configId").map(String::as_str);
563        key_json_response(get_key_core(id, config_id, user.id(), self, ctx).await)
564    }
565
566    async fn handle_list(
567        &self,
568        req: &AuthRequest,
569        ctx: &AuthContext<impl alibi_core::AuthSchema>,
570    ) -> AuthResult<AuthResponse> {
571        let mut resolution = req.clone();
572        _ = resolution.query.remove("disableCookieCache");
573        _ = resolution.query.remove("disableRefresh");
574        let (user, _session) = super::helpers::ordinary_session(&resolution, ctx).await?;
575        let query = match ListKeysQuery::from_request(req) {
576            Ok(query) => query,
577            Err(response) => return Ok(response),
578        };
579        key_json_response(list_keys_core(user.id(), &query, self, ctx).await)
580    }
581
582    async fn handle_update(
583        &self,
584        req: &AuthRequest,
585        ctx: &AuthContext<impl alibi_core::AuthSchema>,
586    ) -> AuthResult<AuthResponse> {
587        let mut resolution = req.clone();
588        _ = resolution
589            .query
590            .insert("disableCookieCache".into(), "true".into());
591        let (user, _session) = ctx
592            .require_cached_session(&resolution)
593            .await
594            .map_err(|error| match error {
595                AuthError::Unauthenticated => api_key_error(ApiKeyErrorCode::UnauthorizedSession),
596                error @ (AuthError::Api { .. }
597                | AuthError::Upstream { .. }
598                | AuthError::BadRequest(_)
599                | AuthError::InvalidRequest(_)
600                | AuthError::Validation(_)
601                | AuthError::InvalidCredentials
602                | AuthError::AuthenticationFailed(_)
603                | AuthError::SessionNotFound
604                | AuthError::Forbidden(_)
605                | AuthError::UserCreationCancelled
606                | AuthError::SessionCreationCancelled
607                | AuthError::BannedUser(_)
608                | AuthError::Unauthorized
609                | AuthError::UserNotFound
610                | AuthError::NotFound(_)
611                | AuthError::Conflict(_)
612                | AuthError::MethodNotAllowed(_)
613                | AuthError::PayloadTooLarge(_)
614                | AuthError::UnprocessableEntity(_)
615                | AuthError::RateLimited { .. }
616                | AuthError::NotImplemented(_)
617                | AuthError::Config(_)
618                | AuthError::Database(_)
619                | AuthError::Serialization(_)
620                | AuthError::Plugin { .. }
621                | AuthError::CallbackFailure(_)
622                | AuthError::Internal(_)
623                | AuthError::Encryption(_)
624                | AuthError::PasswordHash(_)
625                | AuthError::Jwt(_)) => error,
626            })?;
627        let body: UpdateKeyRequest = match parse_api_key_body(req) {
628            Ok(v) => v,
629            Err(resp) => return Ok(resp),
630        };
631        let response = update_key_core(&body, user.id(), self, ctx).await?;
632        Ok(AuthResponse::json(200, &response)?)
633    }
634
635    async fn handle_delete(
636        &self,
637        req: &AuthRequest,
638        ctx: &AuthContext<impl alibi_core::AuthSchema>,
639    ) -> AuthResult<AuthResponse> {
640        let (user, _session) = super::helpers::ordinary_session(req, ctx).await?;
641        if user.banned() {
642            return Err(AuthError::authentication_failed("User is banned"));
643        }
644        let body: DeleteKeyRequest = match parse_api_key_body(req) {
645            Ok(v) => v,
646            Err(resp) => return Ok(resp),
647        };
648        let response = delete_key_core(&body, user.id(), self, ctx).await?;
649        Ok(AuthResponse::json(200, &response)?)
650    }
651}
652
653fn key_json_response<T: serde::Serialize>(result: AuthResult<T>) -> AuthResult<AuthResponse> {
654    match result {
655        Ok(response) => Ok(AuthResponse::json(200, &response)?),
656        Err(error)
657            if error.status_code() >= 500
658                && !matches!(error, AuthError::Upstream { .. } | AuthError::Api { .. }) =>
659        {
660            Ok(AuthResponse::new(500))
661        }
662        Err(error) => Err(error),
663    }
664}
665
666alibi_core::impl_auth_plugin! {
667    ApiKeyPlugin, "api-key";
668    routes {
669        post "/api-key/create"                    => handle_create,             "api_key_create";
670        get  "/api-key/get"                       => handle_get,                "api_key_get";
671        post "/api-key/update"                    => handle_update,             "api_key_update";
672        post "/api-key/delete"                    => handle_delete,             "api_key_delete";
673        get  "/api-key/list"                      => handle_list,               "api_key_list";
674    }
675    extra {
676    fn static_openapi_metadata(&self) -> alibi_core::PluginOpenApiMetadata {
677        crate::metadata::plugin_metadata(<Self as alibi_core::AuthPlugin<S>>::name(self), &<Self as alibi_core::AuthPlugin<S>>::routes(self))
678    }
679
680        fn openapi_metadata(&self,ctx:&alibi_core::AuthInitContext<S>)->alibi_core::PluginOpenApiMetadata {
681            let mut metadata=crate::metadata::instance_plugin_metadata("api-key",&<Self as alibi_core::AuthPlugin<S>>::routes(self),ctx);
682            let defaults=self.configurations.first().filter(|_|self.configurations.len()==1).map(|config|config.rate_limit.clone()).unwrap_or_default();
683            for model in &mut metadata.models {
684                if model.name!="Apikey" {continue;}
685                for field in &mut model.fields {
686                    let value=match field.name.as_str() {"rateLimitTimeWindow"=>defaults.time_window,"rateLimitMax"=>defaults.max_requests,_=>continue};
687                    if let Some(schema)=field.schema.as_object_mut() {_ = schema.insert("default".into(),serde_json::json!(value));}
688                }
689            }
690            metadata
691        }
692        async fn on_init(&self, _ctx: &mut alibi_core::AuthInitContext<S>) -> AuthResult<()> {
693            if self.configurations.len() > 1 {
694                let mut ids = std::collections::HashSet::new();
695                for config in &self.configurations {
696                    if config.config_id.is_empty() {
697                        return Err(AuthError::config("configId is required for each API key configuration in the api-key plugin."));
698                    }
699                    if !ids.insert(&config.config_id) {
700                        return Err(AuthError::config("configId must be unique for each API key configuration in the api-key plugin."));
701                    }
702                }
703            }
704            Ok(())
705        }
706
707        fn server_endpoints(&self) -> Vec<alibi_core::endpoint::EndpointDefinition> { endpoint::definitions() }
708
709    fn endpoint_hooks(&self) -> Vec<&dyn alibi_core::endpoint::EndpointHook<S>> { vec![self] }
710
711    fn validate_endpoint(&self, call: &alibi_core::endpoint::EndpointCall, _ctx: &AuthContext<S>) -> AuthResult<alibi_core::endpoint::EndpointInput> { endpoint::validate(call) }
712
713    async fn on_endpoint(&self, call: &alibi_core::endpoint::EndpointCall, ctx: &AuthContext<S>) -> AuthResult<alibi_core::endpoint::EndpointResponse> { self.call_endpoint(call, ctx).await }
714
715    async fn before_request(
716            &self,
717            req: &AuthRequest,
718            ctx: &AuthContext<S>,
719        ) -> AuthResult<Option<BeforeRequestAction>> {
720            self.api_key_session(req, ctx).await
721        }
722    }
723}
724
725impl std::fmt::Debug for ApiKeyPlugin {
726    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
727        f.debug_struct("ApiKeyPlugin").finish_non_exhaustive()
728    }
729}
730
731pub(super) const fn api_key_error(code: ApiKeyErrorCode) -> AuthError {
732    let status = match code {
733        ApiKeyErrorCode::UnauthorizedSession => 401,
734        ApiKeyErrorCode::OrganizationPluginRequired => 500,
735        ApiKeyErrorCode::UserNotMemberOfOrganization
736        | ApiKeyErrorCode::InsufficientApiKeyPermissions => 403,
737        ApiKeyErrorCode::InvalidApiKey
738        | ApiKeyErrorCode::KeyDisabled
739        | ApiKeyErrorCode::KeyExpired
740        | ApiKeyErrorCode::UsageExceeded
741        | ApiKeyErrorCode::KeyNotFound
742        | ApiKeyErrorCode::RateLimited
743        | ApiKeyErrorCode::InvalidPrefixLength
744        | ApiKeyErrorCode::InvalidNameLength
745        | ApiKeyErrorCode::MetadataDisabled
746        | ApiKeyErrorCode::NoValuesToUpdate
747        | ApiKeyErrorCode::KeyDisabledExpiration
748        | ApiKeyErrorCode::ExpiresInTooSmall
749        | ApiKeyErrorCode::ExpiresInTooLarge
750        | ApiKeyErrorCode::InvalidRemaining
751        | ApiKeyErrorCode::RefillAmountAndIntervalRequired
752        | ApiKeyErrorCode::RefillIntervalAndAmountRequired
753        | ApiKeyErrorCode::NameRequired
754        | ApiKeyErrorCode::InvalidUserIdFromApiKey
755        | ApiKeyErrorCode::InvalidReferenceIdFromApiKey
756        | ApiKeyErrorCode::NoDefaultConfiguration
757        | ApiKeyErrorCode::OrganizationIdRequired
758        | ApiKeyErrorCode::ServerOnlyProperty
759        | ApiKeyErrorCode::FailedToUpdateApiKey
760        | ApiKeyErrorCode::InvalidMetadataType => 400,
761    };
762    AuthError::Upstream {
763        status,
764        code: code.as_str(),
765        message: code.message(),
766    }
767}
768
769fn admit_expired_cleanup(bypass: bool) -> bool {
770    let mut last = LAST_EXPIRED_CHECK
771        .lock()
772        .unwrap_or_else(std::sync::PoisonError::into_inner);
773    let now = chrono::Utc::now().timestamp_millis();
774    if !bypass && last.is_some_and(|previous| now.saturating_sub(previous) < 10_000) {
775        return false;
776    }
777    *last = Some(now);
778    true
779}
780
781/// Keys written before `config_id` existed carry no value, so absent and
782/// `"default"` denote the same configuration.
783pub(super) fn is_default_config_id(config_id: &str) -> bool {
784    config_id.is_empty() || config_id == "default"
785}
786
787/// Whether a stored key belongs to the addressed configuration.
788pub(super) fn config_id_matches(key_config_id: &str, expected: &str) -> bool {
789    if is_default_config_id(key_config_id) && is_default_config_id(expected) {
790        return true;
791    }
792    key_config_id == expected
793}
794
795// LCOV_EXCL_START
796#[cfg(test)]
797mod tests {
798
799    use super::*;
800    use alibi_core::wire::{SessionView, UserView};
801    use alibi_core::{
802        AuthContext, AuthPlugin, CreateSession, CreateUser, HttpMethod, UpdateApiKey,
803    };
804    use chrono::{Duration, Utc};
805    use std::collections::HashMap;
806    use std::sync::Arc;
807
808    type TestSchema = alibi_seaorm::store::__private_test_support::bundled_schema::BundledSchema;
809
810    async fn create_test_context_with_user() -> (AuthContext<TestSchema>, UserView, SessionView) {
811        let config = Arc::new(alibi_core::AuthConfig::new(
812            "test-secret-key-at-least-32-chars-long",
813        ));
814        let database = crate::test_helpers::create_test_database().await;
815        let ctx = AuthContext::new(config, Arc::clone(&database));
816
817        let user = database
818            .create_user(
819                CreateUser::new()
820                    .with_email("test@example.com")
821                    .with_name("Test User"),
822            )
823            .await
824            .unwrap();
825        let wire_user = UserView::from(&user);
826
827        let session = database
828            .create_session(CreateSession {
829                additional_fields: alibi_core::field_policy::FieldValues::default(),
830                token: None,
831                active_team_id: None,
832                user_id: user.id().to_string(),
833                expires_at: Utc::now() + Duration::hours(24),
834                ip_address: Some("127.0.0.1".to_owned()),
835                user_agent: Some("test-agent".to_owned()),
836                impersonated_by: None,
837                active_organization_id: None,
838            })
839            .await
840            .unwrap();
841        let wire_session = SessionView::from(&session);
842
843        (ctx, wire_user, wire_session)
844    }
845
846    async fn create_user_with_session(
847        ctx: &AuthContext<impl alibi_core::AuthSchema>,
848        email: &str,
849    ) -> (UserView, SessionView) {
850        let user = ctx
851            .database
852            .create_user(
853                CreateUser::new()
854                    .with_email(email.to_owned())
855                    .with_name("Another User"),
856            )
857            .await
858            .unwrap();
859        let wire_user = UserView::from(&user);
860
861        let session = ctx
862            .database
863            .create_session(CreateSession {
864                additional_fields: alibi_core::field_policy::FieldValues::default(),
865                token: None,
866                active_team_id: None,
867                user_id: user.id().to_string(),
868                expires_at: Utc::now() + Duration::hours(24),
869                ip_address: None,
870                user_agent: None,
871                impersonated_by: None,
872                active_organization_id: None,
873            })
874            .await
875            .unwrap();
876        let wire_session = SessionView::from(&session);
877
878        (wire_user, wire_session)
879    }
880
881    fn create_auth_request(
882        method: HttpMethod,
883        path: &str,
884        token: Option<&str>,
885        body: Option<serde_json::Value>,
886        query: Option<HashMap<String, String>>,
887    ) -> AuthRequest {
888        let mut headers = HashMap::new();
889        if let Some(token) = token {
890            headers.insert(
891                "cookie".to_owned(),
892                format!(
893                    "better-auth.session_token={}",
894                    alibi_core::utils::cookie_utils::sign_cookie_value(
895                        token,
896                        &crate::test_helpers::create_test_config().secret
897                    )
898                ),
899            );
900        }
901
902        AuthRequest::from_parts(
903            method,
904            path.to_owned(),
905            headers,
906            body.map(|b| serde_json::to_vec(&b).unwrap()),
907            query.unwrap_or_default(),
908        )
909    }
910
911    fn json_body(response: &AuthResponse) -> serde_json::Value {
912        serde_json::from_slice(&response.body).unwrap()
913    }
914
915    /// Test helper: verify a key and return the same JSON shape the old HTTP
916    /// handler produced. Calls the public server-only verification method.
917    async fn verify_key(
918        plugin: &ApiKeyPlugin,
919        ctx: &AuthContext<impl alibi_core::AuthSchema>,
920        raw_key: &str,
921        permissions: Option<&serde_json::Value>,
922    ) -> serde_json::Value {
923        match plugin
924            .verify_api_key(
925                &VerifyApiKey {
926                    key: raw_key,
927                    config_id: None,
928                    permissions,
929                },
930                ctx,
931            )
932            .await
933        {
934            Ok(view) => serde_json::json!({ "valid": true, "error": null, "key": view }),
935            Err(ApiKeyVerificationError::Validation(error)) => serde_json::json!({
936                "valid": false,
937                "error": error,
938                "key": null,
939            }),
940            Err(
941                ApiKeyVerificationError::Internal(error)
942                | ApiKeyVerificationError::ExplicitValidator(error),
943            ) => panic!("Verification failed: {error}"),
944        }
945    }
946
947    /// Create a key via the HTTP handler (client-allowed fields only), then
948    /// patch server-only fields directly through the database.
949    ///
950    /// `server_fields` may contain: remaining, `refill_interval`, `refill_amount`,
951    /// `rate_limit_enabled`, `rate_limit_time_window`, `rate_limit_max`, permissions.
952    async fn create_key_with_server_fields(
953        plugin: &ApiKeyPlugin,
954        ctx: &AuthContext<impl alibi_core::AuthSchema>,
955        token: &str,
956        client_body: serde_json::Value,
957        server_fields: UpdateApiKey,
958    ) -> (String, String) {
959        let (id, raw_key) = create_key_and_get_raw(plugin, ctx, token, client_body).await;
960        ctx.database
961            .update_api_key(&id, server_fields)
962            .await
963            .unwrap();
964        (id, raw_key)
965    }
966
967    /// Test helper: delete all expired keys and return a success JSON.
968    async fn delete_all_expired(
969        ctx: &AuthContext<impl alibi_core::AuthSchema>,
970    ) -> serde_json::Value {
971        _ = ctx.database.delete_expired_api_keys().await.unwrap();
972        serde_json::json!({ "success": true, "error": null })
973    }
974
975    async fn create_key_and_get_id(
976        plugin: &ApiKeyPlugin,
977        ctx: &AuthContext<impl alibi_core::AuthSchema>,
978        token: &str,
979        name: &str,
980    ) -> String {
981        let req = create_auth_request(
982            HttpMethod::Post,
983            "/api-key/create",
984            Some(token),
985            Some(serde_json::json!({ "name": name })),
986            None,
987        );
988        let response = plugin.handle_create(&req, ctx).await.unwrap();
989        assert_eq!(response.status, 200);
990        (*(json_body(&response))
991            .get("id")
992            .unwrap_or(&serde_json::Value::Null))
993        .as_str()
994        .unwrap()
995        .to_owned()
996    }
997
998    /// Helper: create a key and return (id, `raw_key`)
999    async fn create_key_and_get_raw(
1000        plugin: &ApiKeyPlugin,
1001        ctx: &AuthContext<impl alibi_core::AuthSchema>,
1002        token: &str,
1003        body: serde_json::Value,
1004    ) -> (String, String) {
1005        let req = create_auth_request(
1006            HttpMethod::Post,
1007            "/api-key/create",
1008            Some(token),
1009            Some(body),
1010            None,
1011        );
1012        let response = plugin.handle_create(&req, ctx).await.unwrap();
1013        assert_eq!(response.status, 200);
1014        let b = json_body(&response);
1015        (
1016            (*(b).get("id").expect("fixture contains the requested index"))
1017                .as_str()
1018                .unwrap()
1019                .to_owned(),
1020            (*(b)
1021                .get("key")
1022                .expect("fixture contains the requested index"))
1023            .as_str()
1024            .unwrap()
1025            .to_owned(),
1026        )
1027    }
1028
1029    // Upstream reference: packages/better-auth/src/plugins/api-key/api-key.test.ts :: describe("api-key"); adapted to the Rust API key plugin handlers.
1030    #[tokio::test]
1031    async fn test_create_and_get_do_not_expose_hash() {
1032        let plugin = ApiKeyPlugin::builder().prefix("ba_".to_owned()).build();
1033        let (ctx, _user, session) = create_test_context_with_user().await;
1034
1035        let create_req = create_auth_request(
1036            HttpMethod::Post,
1037            "/api-key/create",
1038            Some(&session.token),
1039            Some(serde_json::json!({ "name": "primary" })),
1040            None,
1041        );
1042        let create_response = plugin.handle_create(&create_req, &ctx).await.unwrap();
1043        assert_eq!(create_response.status, 200);
1044
1045        let body = json_body(&create_response);
1046        assert!(body.get("key").is_some());
1047        assert!(body.get("key_hash").is_none());
1048        assert!(body.get("hash").is_none());
1049
1050        let id = (*(body).get("id").unwrap_or(&serde_json::Value::Null))
1051            .as_str()
1052            .unwrap();
1053        let mut query = HashMap::new();
1054        query.insert("id".to_owned(), id.to_owned());
1055
1056        let get_req = create_auth_request(
1057            HttpMethod::Get,
1058            "/api-key/get",
1059            Some(&session.token),
1060            None,
1061            Some(query),
1062        );
1063        let get_response = plugin.handle_get(&get_req, &ctx).await.unwrap();
1064        assert_eq!(get_response.status, 200);
1065
1066        let get_body = json_body(&get_response);
1067        assert!(get_body.get("key").is_none());
1068        assert!(get_body.get("key_hash").is_none());
1069    }
1070
1071    // Upstream reference: packages/better-auth/src/plugins/api-key/api-key.test.ts :: describe("api-key"); adapted to the Rust API key plugin handlers.
1072    #[tokio::test]
1073    async fn test_create_rejects_invalid_expires_in() {
1074        let plugin = ApiKeyPlugin::builder().build();
1075        let (ctx, _user, session) = create_test_context_with_user().await;
1076
1077        let req = create_auth_request(
1078            HttpMethod::Post,
1079            "/api-key/create",
1080            Some(&session.token),
1081            Some(serde_json::json!({ "expiresIn": -1 })),
1082            None,
1083        );
1084        let response = plugin.handle_create(&req, &ctx).await;
1085        // Should be rejected due to validation (negative expires_in)
1086        assert!(response.is_err() || response.unwrap().status != 200);
1087    }
1088
1089    // Upstream reference: packages/better-auth/src/plugins/api-key/api-key.test.ts :: describe("api-key"); adapted to the Rust API key plugin handlers.
1090    #[tokio::test]
1091    async fn test_get_update_delete_return_404_for_non_owner() {
1092        let plugin = ApiKeyPlugin::builder().build();
1093        let (ctx, _user1, session1) = create_test_context_with_user().await;
1094        let (_user2, session2) = create_user_with_session(&ctx, "other@example.com").await;
1095        let key_id = create_key_and_get_id(&plugin, &ctx, &session1.token, "owner-key").await;
1096
1097        let mut get_query = HashMap::new();
1098        get_query.insert("id".to_owned(), key_id.clone());
1099        let get_req = create_auth_request(
1100            HttpMethod::Get,
1101            "/api-key/get",
1102            Some(&session2.token),
1103            None,
1104            Some(get_query),
1105        );
1106        let get_err = plugin.handle_get(&get_req, &ctx).await.unwrap_err();
1107        assert_eq!(get_err.status_code(), 404);
1108
1109        let update_req = create_auth_request(
1110            HttpMethod::Post,
1111            "/api-key/update",
1112            Some(&session2.token),
1113            Some(serde_json::json!({ "keyId": key_id, "name": "new-name" })),
1114            None,
1115        );
1116        let update_err = plugin.handle_update(&update_req, &ctx).await.unwrap_err();
1117        assert_eq!(update_err.status_code(), 404);
1118
1119        let delete_req = create_auth_request(
1120            HttpMethod::Post,
1121            "/api-key/delete",
1122            Some(&session2.token),
1123            Some(serde_json::json!({ "keyId": key_id })),
1124            None,
1125        );
1126        let delete_err = plugin.handle_delete(&delete_req, &ctx).await.unwrap_err();
1127        assert_eq!(delete_err.status_code(), 404);
1128    }
1129
1130    // Upstream reference: packages/better-auth/src/plugins/api-key/api-key.test.ts :: describe("api-key"); adapted to the Rust API key plugin handlers.
1131    #[tokio::test]
1132    async fn test_list_returns_only_user_keys() {
1133        let plugin = ApiKeyPlugin::builder().build();
1134        let (ctx, user1, session1) = create_test_context_with_user().await;
1135        let (_user2, session2) = create_user_with_session(&ctx, "other@example.com").await;
1136
1137        _ = create_key_and_get_id(&plugin, &ctx, &session1.token, "u1-key").await;
1138        _ = create_key_and_get_id(&plugin, &ctx, &session2.token, "u2-key").await;
1139
1140        let list_req = create_auth_request(
1141            HttpMethod::Get,
1142            "/api-key/list",
1143            Some(&session1.token),
1144            None,
1145            None,
1146        );
1147        let list_response = plugin.handle_list(&list_req, &ctx).await.unwrap();
1148        assert_eq!(list_response.status, 200);
1149
1150        let list_body = json_body(&list_response);
1151        // Upstream returns a paginated envelope rather than a bare array.
1152        let list = (*(list_body)
1153            .get("apiKeys")
1154            .unwrap_or(&serde_json::Value::Null))
1155        .as_array()
1156        .unwrap();
1157        assert_eq!(list.len(), 1);
1158        assert_eq!(
1159            (*(list_body).get("total").unwrap_or(&serde_json::Value::Null)),
1160            1
1161        );
1162        // Upstream renamed the owner field to `referenceId` (a user or an
1163        // organization id) and stamps the owning configuration on every key.
1164        assert_eq!(
1165            (*(*(list)
1166                .first()
1167                .expect("fixture contains the requested index"))
1168            .get("referenceId")
1169            .expect("fixture contains the requested index"))
1170            .as_str()
1171            .unwrap(),
1172            user1.id
1173        );
1174        assert_eq!(
1175            (*(*(list)
1176                .first()
1177                .expect("fixture contains the requested index"))
1178            .get("configId")
1179            .expect("fixture contains the requested index"))
1180            .as_str()
1181            .unwrap(),
1182            "default"
1183        );
1184        assert!(
1185            (*(list)
1186                .first()
1187                .expect("fixture contains the requested index"))
1188            .get("userId")
1189            .is_none()
1190        );
1191        assert!(
1192            (*(list)
1193                .first()
1194                .expect("fixture contains the requested index"))
1195            .get("key")
1196            .is_none()
1197        );
1198        assert!(
1199            (*(list)
1200                .first()
1201                .expect("fixture contains the requested index"))
1202            .get("key_hash")
1203            .is_none()
1204        );
1205    }
1206
1207    // Upstream reference: packages/better-auth/src/plugins/api-key/api-key.test.ts :: describe("api-key"); adapted to the Rust API key plugin handlers.
1208    #[tokio::test]
1209    async fn test_owner_can_delete_key() {
1210        let plugin = ApiKeyPlugin::builder().build();
1211        let (ctx, _user, session) = create_test_context_with_user().await;
1212        let key_id = create_key_and_get_id(&plugin, &ctx, &session.token, "to-delete").await;
1213
1214        let delete_req = create_auth_request(
1215            HttpMethod::Post,
1216            "/api-key/delete",
1217            Some(&session.token),
1218            Some(serde_json::json!({ "keyId": key_id })),
1219            None,
1220        );
1221        let delete_response = plugin.handle_delete(&delete_req, &ctx).await.unwrap();
1222        assert_eq!(delete_response.status, 200);
1223
1224        let deleted = ctx.database.get_api_key_by_id(&key_id).await.unwrap();
1225        assert!(deleted.is_none());
1226    }
1227
1228    // Upstream reference: packages/better-auth/src/plugins/api-key/api-key.test.ts :: describe("api-key"); adapted to the Rust API key plugin handlers.
1229    #[tokio::test]
1230    async fn test_verify_valid_key() {
1231        let plugin = ApiKeyPlugin::builder().build();
1232        let (ctx, _user, session) = create_test_context_with_user().await;
1233
1234        let (_id, raw_key) = create_key_and_get_raw(
1235            &plugin,
1236            &ctx,
1237            &session.token,
1238            serde_json::json!({ "name": "verify-test" }),
1239        )
1240        .await;
1241
1242        let body = verify_key(&plugin, &ctx, &raw_key, None).await;
1243        assert_eq!(
1244            (*(body).get("valid").unwrap_or(&serde_json::Value::Null)),
1245            true
1246        );
1247        assert!((*(body).get("key").unwrap_or(&serde_json::Value::Null)).is_object());
1248    }
1249
1250    #[tokio::test]
1251    async fn test_verify_invalid_key() {
1252        let plugin = ApiKeyPlugin::builder().build();
1253        let (ctx, _user, _session) = create_test_context_with_user().await;
1254
1255        let body = verify_key(&plugin, &ctx, "definitely-not-a-valid-key", None).await;
1256        assert_eq!(
1257            (*(body).get("valid").unwrap_or(&serde_json::Value::Null)),
1258            false
1259        );
1260        assert!((*(body).get("error").unwrap_or(&serde_json::Value::Null)).is_object());
1261    }
1262
1263    #[tokio::test]
1264    async fn test_verify_disabled_key() {
1265        let plugin = ApiKeyPlugin::builder().build();
1266        let (ctx, _user, session) = create_test_context_with_user().await;
1267
1268        let (id, raw_key) = create_key_and_get_raw(
1269            &plugin,
1270            &ctx,
1271            &session.token,
1272            serde_json::json!({ "name": "disable-test" }),
1273        )
1274        .await;
1275
1276        let update = UpdateApiKey {
1277            enabled: Some(false),
1278            ..Default::default()
1279        };
1280        ctx.database.update_api_key(&id, update).await.unwrap();
1281
1282        let body = verify_key(&plugin, &ctx, &raw_key, None).await;
1283        assert_eq!(
1284            (*(body).get("valid").unwrap_or(&serde_json::Value::Null)),
1285            false
1286        );
1287        assert_eq!(
1288            (*(*(body).get("error").unwrap_or(&serde_json::Value::Null))
1289                .get("code")
1290                .unwrap_or(&serde_json::Value::Null)),
1291            "KEY_DISABLED"
1292        );
1293    }
1294
1295    #[tokio::test]
1296    async fn test_verify_expired_key() {
1297        let plugin = ApiKeyPlugin::builder().build();
1298        let (ctx, _user, session) = create_test_context_with_user().await;
1299
1300        let (id, raw_key) = create_key_and_get_raw(
1301            &plugin,
1302            &ctx,
1303            &session.token,
1304            serde_json::json!({ "name": "expire-test" }),
1305        )
1306        .await;
1307
1308        let past = (Utc::now() - Duration::hours(1)).to_rfc3339();
1309        let update = UpdateApiKey {
1310            expires_at: Some(Some(past)),
1311            ..Default::default()
1312        };
1313        ctx.database.update_api_key(&id, update).await.unwrap();
1314
1315        let body = verify_key(&plugin, &ctx, &raw_key, None).await;
1316        assert_eq!(
1317            (*(body).get("valid").unwrap_or(&serde_json::Value::Null)),
1318            false
1319        );
1320        assert_eq!(
1321            (*(*(body).get("error").unwrap_or(&serde_json::Value::Null))
1322                .get("code")
1323                .unwrap_or(&serde_json::Value::Null)),
1324            "KEY_EXPIRED"
1325        );
1326
1327        // The key should have been deleted
1328        let deleted = ctx.database.get_api_key_by_id(&id).await.unwrap();
1329        assert!(deleted.is_none());
1330    }
1331
1332    // Upstream reference: packages/better-auth/src/plugins/api-key/api-key.test.ts :: describe("api-key"); adapted to the Rust API key plugin handlers.
1333    #[tokio::test]
1334    async fn test_verify_remaining_consumption() {
1335        let plugin = ApiKeyPlugin::builder().build();
1336        let (ctx, _user, session) = create_test_context_with_user().await;
1337
1338        let (_id, raw_key) = create_key_with_server_fields(
1339            &plugin,
1340            &ctx,
1341            &session.token,
1342            serde_json::json!({ "name": "remain-test" }),
1343            UpdateApiKey {
1344                remaining: Some(2.0),
1345                ..Default::default()
1346            },
1347        )
1348        .await;
1349
1350        // First verify - remaining goes from 2 to 1
1351        let r1 = verify_key(&plugin, &ctx, &raw_key, None).await;
1352        assert_eq!(
1353            (*(r1).get("valid").unwrap_or(&serde_json::Value::Null)),
1354            true
1355        );
1356        assert_eq!(
1357            (*(*(r1).get("key").unwrap_or(&serde_json::Value::Null))
1358                .get("remaining")
1359                .unwrap_or(&serde_json::Value::Null)),
1360            1
1361        );
1362
1363        // Second verify - remaining goes from 1 to 0
1364        let r2 = verify_key(&plugin, &ctx, &raw_key, None).await;
1365        assert_eq!(
1366            (*(r2).get("valid").unwrap_or(&serde_json::Value::Null)),
1367            true
1368        );
1369        assert_eq!(
1370            (*(*(r2).get("key").unwrap_or(&serde_json::Value::Null))
1371                .get("remaining")
1372                .unwrap_or(&serde_json::Value::Null)),
1373            0
1374        );
1375
1376        // Third verify - should fail (usage exceeded)
1377        let r3 = verify_key(&plugin, &ctx, &raw_key, None).await;
1378        assert_eq!(
1379            (*(r3).get("valid").unwrap_or(&serde_json::Value::Null)),
1380            false
1381        );
1382        assert_eq!(
1383            (*(*(r3).get("error").unwrap_or(&serde_json::Value::Null))
1384                .get("code")
1385                .unwrap_or(&serde_json::Value::Null)),
1386            "USAGE_EXCEEDED"
1387        );
1388    }
1389
1390    // Upstream reference: packages/better-auth/src/plugins/api-key/api-key.test.ts :: describe("api-key"); adapted to the Rust API key plugin handlers.
1391    #[tokio::test]
1392    async fn test_verify_rate_limiting() {
1393        let plugin = ApiKeyPlugin::builder()
1394            .rate_limit(RateLimitDefaults {
1395                enabled: true,
1396                time_window: 60_000.0,
1397                max_requests: 2.0,
1398            })
1399            .build();
1400        let (ctx, _user, session) = create_test_context_with_user().await;
1401
1402        let (_id, raw_key) = create_key_with_server_fields(
1403            &plugin,
1404            &ctx,
1405            &session.token,
1406            serde_json::json!({ "name": "rl-test" }),
1407            UpdateApiKey {
1408                rate_limit_enabled: Some(true),
1409                rate_limit_time_window: Some(60_000.0),
1410                rate_limit_max: Some(2.0),
1411                ..Default::default()
1412            },
1413        )
1414        .await;
1415
1416        // First two should succeed
1417        let r1 = verify_key(&plugin, &ctx, &raw_key, None).await;
1418        assert_eq!(
1419            (*(r1).get("valid").unwrap_or(&serde_json::Value::Null)),
1420            true
1421        );
1422
1423        let r2 = verify_key(&plugin, &ctx, &raw_key, None).await;
1424        assert_eq!(
1425            (*(r2).get("valid").unwrap_or(&serde_json::Value::Null)),
1426            true
1427        );
1428
1429        // Third should fail with rate limit
1430        let r3 = verify_key(&plugin, &ctx, &raw_key, None).await;
1431        assert_eq!(
1432            (*(r3).get("valid").unwrap_or(&serde_json::Value::Null)),
1433            false
1434        );
1435        assert_eq!(
1436            (*(*(r3).get("error").unwrap_or(&serde_json::Value::Null))
1437                .get("code")
1438                .unwrap_or(&serde_json::Value::Null)),
1439            "RATE_LIMITED"
1440        );
1441    }
1442
1443    #[tokio::test]
1444    async fn test_delete_all_expired() {
1445        let plugin = ApiKeyPlugin::builder().build();
1446        let (ctx, fixture_user, session) = create_test_context_with_user().await;
1447
1448        // Create two keys
1449        let (id1, _) = create_key_and_get_raw(
1450            &plugin,
1451            &ctx,
1452            &session.token,
1453            serde_json::json!({ "name": "will-expire" }),
1454        )
1455        .await;
1456        let (_id2, _) = create_key_and_get_raw(
1457            &plugin,
1458            &ctx,
1459            &session.token,
1460            serde_json::json!({ "name": "wont-expire" }),
1461        )
1462        .await;
1463
1464        // Expire the first key
1465        let past = (Utc::now() - Duration::hours(1)).to_rfc3339();
1466        ctx.database
1467            .update_api_key(
1468                &id1,
1469                UpdateApiKey {
1470                    expires_at: Some(Some(past)),
1471                    ..Default::default()
1472                },
1473            )
1474            .await
1475            .unwrap();
1476
1477        let body = delete_all_expired(&ctx).await;
1478        assert_eq!(
1479            (*(body).get("success").unwrap_or(&serde_json::Value::Null)),
1480            true
1481        );
1482
1483        // Only the non-expired key should remain
1484        let remaining_keys = ctx
1485            .database
1486            .list_api_keys_by_reference(&fixture_user.id)
1487            .await
1488            .unwrap();
1489        assert_eq!(remaining_keys.len(), 1);
1490    }
1491
1492    // Upstream reference: packages/better-auth/src/plugins/api-key/api-key.test.ts :: describe("api-key"); adapted to the Rust API key plugin handlers.
1493    #[tokio::test]
1494    async fn test_verify_permissions() {
1495        let plugin = ApiKeyPlugin::builder().build();
1496        let (ctx, _user, session) = create_test_context_with_user().await;
1497
1498        let (_id, raw_key) = create_key_with_server_fields(
1499            &plugin,
1500            &ctx,
1501            &session.token,
1502            serde_json::json!({ "name": "perm-test" }),
1503            UpdateApiKey {
1504                permissions: Some(
1505                    serde_json::to_string(&serde_json::json!({
1506                        "admin": ["read", "write"],
1507                        "user": ["read"]
1508                    }))
1509                    .unwrap(),
1510                ),
1511                ..Default::default()
1512            },
1513        )
1514        .await;
1515
1516        // Verify with matching permissions -> should pass
1517        let perms_ok = serde_json::json!({ "admin": ["read"] });
1518        let r1 = verify_key(&plugin, &ctx, &raw_key, Some(&perms_ok)).await;
1519        assert_eq!(
1520            (*(r1).get("valid").unwrap_or(&serde_json::Value::Null)),
1521            true
1522        );
1523
1524        // Verify with non-matching permissions -> should fail
1525        let perms_fail = serde_json::json!({ "superadmin": ["delete"] });
1526        let r2 = verify_key(&plugin, &ctx, &raw_key, Some(&perms_fail)).await;
1527        assert_eq!(
1528            (*(r2).get("valid").unwrap_or(&serde_json::Value::Null)),
1529            false
1530        );
1531    }
1532
1533    // Upstream reference: packages/better-auth/src/plugins/api-key/api-key.test.ts :: describe("api-key"); adapted to the Rust API key plugin handlers.
1534    #[tokio::test]
1535    async fn test_config_validation_prefix_length() {
1536        let plugin = ApiKeyPlugin::builder()
1537            .min_prefix_length(2)
1538            .max_prefix_length(5)
1539            .build();
1540        let (ctx, _user, session) = create_test_context_with_user().await;
1541
1542        // Too short prefix
1543        let req = create_auth_request(
1544            HttpMethod::Post,
1545            "/api-key/create",
1546            Some(&session.token),
1547            Some(serde_json::json!({ "name": "test", "prefix": "a" })),
1548            None,
1549        );
1550        let err = plugin.handle_create(&req, &ctx).await.unwrap_err();
1551        assert!(err.to_string().contains("prefix length"));
1552
1553        // Too long prefix
1554        let req2 = create_auth_request(
1555            HttpMethod::Post,
1556            "/api-key/create",
1557            Some(&session.token),
1558            Some(serde_json::json!({ "name": "test", "prefix": "toolong" })),
1559            None,
1560        );
1561        let err2 = plugin.handle_create(&req2, &ctx).await.unwrap_err();
1562        assert!(err2.to_string().contains("prefix length"));
1563    }
1564
1565    // Upstream reference: packages/better-auth/src/plugins/api-key/api-key.test.ts :: describe("api-key"); adapted to the Rust API key plugin handlers.
1566    #[tokio::test]
1567    async fn test_config_require_name() {
1568        let plugin = ApiKeyPlugin::builder().require_name(true).build();
1569        let (ctx, _user, session) = create_test_context_with_user().await;
1570
1571        // No name provided -> should fail
1572        let req = create_auth_request(
1573            HttpMethod::Post,
1574            "/api-key/create",
1575            Some(&session.token),
1576            Some(serde_json::json!({})),
1577            None,
1578        );
1579        let err = plugin.handle_create(&req, &ctx).await.unwrap_err();
1580        assert!(err.to_string().contains("name is required"));
1581    }
1582
1583    // Upstream reference: packages/better-auth/src/plugins/api-key/api-key.test.ts :: describe("api-key"); adapted to the Rust API key plugin handlers.
1584    #[tokio::test]
1585    async fn test_config_metadata_disabled() {
1586        let plugin = ApiKeyPlugin::builder().build(); // enable_metadata defaults to false
1587        let (ctx, _user, session) = create_test_context_with_user().await;
1588
1589        let req = create_auth_request(
1590            HttpMethod::Post,
1591            "/api-key/create",
1592            Some(&session.token),
1593            Some(serde_json::json!({ "name": "test", "metadata": { "env": "prod" } })),
1594            None,
1595        );
1596        let err = plugin.handle_create(&req, &ctx).await.unwrap_err();
1597        assert!(err.to_string().contains("Metadata is disabled"));
1598    }
1599
1600    // Upstream reference: packages/better-auth/src/plugins/api-key/api-key.test.ts :: describe("api-key"); adapted to the Rust API key plugin handlers.
1601    #[tokio::test]
1602    async fn test_config_metadata_enabled() {
1603        let plugin = ApiKeyPlugin::builder().enable_metadata(true).build();
1604        let (ctx, _user, session) = create_test_context_with_user().await;
1605
1606        let req = create_auth_request(
1607            HttpMethod::Post,
1608            "/api-key/create",
1609            Some(&session.token),
1610            Some(serde_json::json!({ "name": "test", "metadata": { "env": "prod" } })),
1611            None,
1612        );
1613        let resp = plugin.handle_create(&req, &ctx).await.unwrap();
1614        assert_eq!(resp.status, 200);
1615        let body = json_body(&resp);
1616        assert_eq!(
1617            (*(*(body).get("metadata").unwrap_or(&serde_json::Value::Null))
1618                .get("env")
1619                .unwrap_or(&serde_json::Value::Null)),
1620            "prod"
1621        );
1622    }
1623
1624    // Upstream reference: packages/better-auth/src/plugins/api-key/api-key.test.ts :: describe("api-key"); adapted to the Rust API key plugin handlers.
1625    #[tokio::test]
1626    async fn test_update_with_expires_in() {
1627        let plugin = ApiKeyPlugin::builder().build();
1628        let (ctx, _user, session) = create_test_context_with_user().await;
1629        let key_id = create_key_and_get_id(&plugin, &ctx, &session.token, "update-exp").await;
1630
1631        let update_req = create_auth_request(
1632            HttpMethod::Post,
1633            "/api-key/update",
1634            Some(&session.token),
1635            Some(serde_json::json!({
1636                "keyId": key_id,
1637                "expiresIn": 86400
1638            })),
1639            None,
1640        );
1641        let resp = plugin.handle_update(&update_req, &ctx).await.unwrap();
1642        assert_eq!(resp.status, 200);
1643        let body = json_body(&resp);
1644        assert!((*(body).get("expiresAt").unwrap_or(&serde_json::Value::Null)).is_string());
1645    }
1646
1647    // Note: /api-key/verify and /api-key/delete-all-expired-api-keys are
1648    // server-only in TS (no HTTP routes), so the Rust plugin does not expose
1649    // them either. Route dispatch tests for these have been removed.
1650
1651    // Ensure refillInterval + refillAmount require each other (validation logic)
1652    #[tokio::test]
1653    async fn test_refill_logic() {
1654        let result = ApiKeyPlugin::validate_refill(Some(60_000.0), None);
1655        assert!(result.is_err());
1656        assert!(result.unwrap_err().to_string().contains("refillAmount"));
1657
1658        let result_2 = ApiKeyPlugin::validate_refill(None, Some(10.0));
1659        assert!(result_2.is_err());
1660
1661        let result_3 = ApiKeyPlugin::validate_refill(Some(60_000.0), Some(10.0));
1662        assert!(result_3.is_ok());
1663    }
1664
1665    mod session_tests {
1666        use super::*;
1667
1668        #[tokio::test]
1669        async fn test_virtual_session_answers_get_and_post_get_session() {
1670            use alibi_core::AuthPlugin;
1671
1672            let plugin = ApiKeyPlugin::builder()
1673                .enable_session_for_api_keys(true)
1674                .build();
1675            let (ctx, user, session) = create_test_context_with_user().await;
1676            let (id, raw_key) = create_key_and_get_raw(
1677                &plugin,
1678                &ctx,
1679                &session.token,
1680                serde_json::json!({ "name": "get-session" }),
1681            )
1682            .await;
1683
1684            for method in [HttpMethod::Get, HttpMethod::Post] {
1685                let mut request = create_auth_request(method, "/get-session", None, None, None);
1686                _ = request
1687                    .headers
1688                    .insert("x-api-key".to_owned(), raw_key.clone());
1689                let action = AuthPlugin::<TestSchema>::before_request(&plugin, &request, &ctx)
1690                    .await
1691                    .unwrap();
1692                let Some(BeforeRequestAction::Respond(response)) = action else {
1693                    panic!("API key sessions must answer GET and POST before route dispatch")
1694                };
1695                assert_eq!(response.status, 200);
1696                let body = json_body(&response);
1697                assert_eq!(
1698                    (*(*(body).get("session").unwrap_or(&serde_json::Value::Null))
1699                        .get("id")
1700                        .unwrap_or(&serde_json::Value::Null)),
1701                    id
1702                );
1703                assert_eq!(
1704                    (*(*(body).get("session").unwrap_or(&serde_json::Value::Null))
1705                        .get("token")
1706                        .unwrap_or(&serde_json::Value::Null)),
1707                    raw_key
1708                );
1709                assert_eq!(
1710                    (*(*(body).get("session").unwrap_or(&serde_json::Value::Null))
1711                        .get("userId")
1712                        .unwrap_or(&serde_json::Value::Null)),
1713                    user.id
1714                );
1715            }
1716        }
1717
1718        // 1. Virtual session: before_request injects session without DB writes
1719        // Upstream reference: packages/better-auth/src/plugins/api-key/api-key.test.ts :: describe("api-key"); adapted to the Rust API key plugin handlers.
1720        #[tokio::test]
1721        async fn test_virtual_session_creates_no_db_session() {
1722            let plugin = ApiKeyPlugin::builder()
1723                .enable_session_for_api_keys(true)
1724                .build();
1725            let (ctx, fixture_user, session) = create_test_context_with_user().await;
1726
1727            // Create an API key
1728            let (_id, raw_key) = create_key_and_get_raw(
1729                &plugin,
1730                &ctx,
1731                &session.token,
1732                serde_json::json!({ "name": "virtual-session-test" }),
1733            )
1734            .await;
1735
1736            // Count sessions before
1737            let sessions_before = ctx
1738                .database
1739                .get_user_sessions(&fixture_user.id)
1740                .await
1741                .unwrap()
1742                .len();
1743
1744            // Simulate a request to a protected route with only x-api-key header
1745            let mut headers = HashMap::new();
1746            headers.insert("x-api-key".to_owned(), raw_key.clone());
1747            let req = AuthRequest::from_parts(
1748                HttpMethod::Post,
1749                "/update-user".to_owned(),
1750                headers,
1751                None,
1752                HashMap::new(),
1753            );
1754
1755            // Call before_request -- should return InjectSession
1756            let action = plugin.before_request(&req, &ctx).await.unwrap();
1757            assert!(action.is_some(), "before_request should return an action");
1758            match action.unwrap() {
1759                BeforeRequestAction::InjectSession { session: session_2 } => {
1760                    assert_eq!(session_2.user_id, fixture_user.id);
1761                }
1762                BeforeRequestAction::Respond(_) | BeforeRequestAction::ReplaceHeaders { .. } => {
1763                    panic!("Expected InjectSession, got Respond");
1764                }
1765            }
1766
1767            // Count sessions after -- should be unchanged (no DB writes)
1768            let sessions_after = ctx
1769                .database
1770                .get_user_sessions(&fixture_user.id)
1771                .await
1772                .unwrap()
1773                .len();
1774            assert_eq!(
1775                sessions_before, sessions_after,
1776                "No new sessions should be created in the database"
1777            );
1778        }
1779
1780        // 2. Virtual session on /get-session: synthetic response
1781        // Upstream reference: packages/better-auth/src/plugins/api-key/api-key.test.ts :: describe("api-key"); adapted to the Rust API key plugin handlers.
1782        #[tokio::test]
1783        async fn test_virtual_session_on_get_session() {
1784            let plugin = ApiKeyPlugin::builder()
1785                .enable_session_for_api_keys(true)
1786                .build();
1787            let (ctx, user, session) = create_test_context_with_user().await;
1788
1789            let (_id, raw_key) = create_key_and_get_raw(
1790                &plugin,
1791                &ctx,
1792                &session.token,
1793                serde_json::json!({ "name": "get-session-test" }),
1794            )
1795            .await;
1796
1797            // Send request to /get-session with x-api-key header
1798            let mut headers = HashMap::new();
1799            headers.insert("x-api-key".to_owned(), raw_key.clone());
1800            let req = AuthRequest::from_parts(
1801                HttpMethod::Get,
1802                "/get-session".to_owned(),
1803                headers,
1804                None,
1805                HashMap::new(),
1806            );
1807
1808            let action = plugin.before_request(&req, &ctx).await.unwrap();
1809            assert!(action.is_some());
1810            match action.unwrap() {
1811                BeforeRequestAction::Respond(resp) => {
1812                    assert_eq!(resp.status, 200);
1813                    let body: serde_json::Value = serde_json::from_slice(&resp.body).unwrap();
1814                    // Should contain user data
1815                    assert_eq!(
1816                        (*(*(body).get("user").unwrap_or(&serde_json::Value::Null))
1817                            .get("id")
1818                            .unwrap_or(&serde_json::Value::Null)),
1819                        user.id
1820                    );
1821                    assert_eq!(
1822                        (*(*(body).get("user").unwrap_or(&serde_json::Value::Null))
1823                            .get("email")
1824                            .unwrap_or(&serde_json::Value::Null)),
1825                        "test@example.com"
1826                    );
1827                    // Should contain session-like data
1828                    assert!(
1829                        (*(*(body).get("session").unwrap_or(&serde_json::Value::Null))
1830                            .get("id")
1831                            .unwrap_or(&serde_json::Value::Null))
1832                        .is_string()
1833                    );
1834                    assert_eq!(
1835                        (*(*(body).get("session").unwrap_or(&serde_json::Value::Null))
1836                            .get("userId")
1837                            .unwrap_or(&serde_json::Value::Null)),
1838                        user.id
1839                    );
1840                }
1841                BeforeRequestAction::InjectSession { .. }
1842                | BeforeRequestAction::ReplaceHeaders { .. } => {
1843                    panic!("Expected Respond for /get-session, got InjectSession");
1844                }
1845            }
1846        }
1847
1848        // 3. Rate limiting: create key with rateLimitMax=2, 3rd call fails
1849        // Upstream reference: packages/better-auth/src/plugins/api-key/api-key.test.ts :: describe("api-key"); adapted to the Rust API key plugin handlers.
1850        #[tokio::test]
1851        async fn test_rate_limiting_third_call_fails() {
1852            let plugin = ApiKeyPlugin::builder()
1853                .rate_limit(RateLimitDefaults {
1854                    enabled: true,
1855                    time_window: 60_000.0,
1856                    max_requests: 2.0,
1857                })
1858                .build();
1859            let (ctx, _user, session) = create_test_context_with_user().await;
1860
1861            let (_id, raw_key) = create_key_with_server_fields(
1862                &plugin,
1863                &ctx,
1864                &session.token,
1865                serde_json::json!({ "name": "rl-integration" }),
1866                UpdateApiKey {
1867                    rate_limit_enabled: Some(true),
1868                    rate_limit_time_window: Some(60_000.0),
1869                    rate_limit_max: Some(2.0),
1870                    ..Default::default()
1871                },
1872            )
1873            .await;
1874
1875            // First two pass
1876            let r1 = verify_key(&plugin, &ctx, &raw_key, None).await;
1877            assert_eq!(
1878                (*(r1).get("valid").unwrap_or(&serde_json::Value::Null)),
1879                true,
1880                "1st request should pass"
1881            );
1882
1883            let r2 = verify_key(&plugin, &ctx, &raw_key, None).await;
1884            assert_eq!(
1885                (*(r2).get("valid").unwrap_or(&serde_json::Value::Null)),
1886                true,
1887                "2nd request should pass"
1888            );
1889
1890            // Third should fail
1891            let r3 = verify_key(&plugin, &ctx, &raw_key, None).await;
1892            assert_eq!(
1893                (*(r3).get("valid").unwrap_or(&serde_json::Value::Null)),
1894                false,
1895                "3rd request should be rate-limited"
1896            );
1897            assert_eq!(
1898                (*(*(r3).get("error").unwrap_or(&serde_json::Value::Null))
1899                    .get("code")
1900                    .unwrap_or(&serde_json::Value::Null)),
1901                "RATE_LIMITED"
1902            );
1903        }
1904
1905        // 4. Remaining consumption: remaining=2, no refill, 3rd fails
1906        // Upstream reference: packages/better-auth/src/plugins/api-key/api-key.test.ts :: describe("api-key"); adapted to the Rust API key plugin handlers.
1907        #[tokio::test]
1908        async fn test_remaining_consumption_no_refill() {
1909            let plugin = ApiKeyPlugin::builder().build();
1910            let (ctx, _user, session) = create_test_context_with_user().await;
1911
1912            let (_id, raw_key) = create_key_with_server_fields(
1913                &plugin,
1914                &ctx,
1915                &session.token,
1916                serde_json::json!({ "name": "remaining-test" }),
1917                UpdateApiKey {
1918                    remaining: Some(2.0),
1919                    ..Default::default()
1920                },
1921            )
1922            .await;
1923
1924            // 1st: remaining 2->1
1925            let r1 = verify_key(&plugin, &ctx, &raw_key, None).await;
1926            assert_eq!(
1927                (*(r1).get("valid").unwrap_or(&serde_json::Value::Null)),
1928                true
1929            );
1930            assert_eq!(
1931                (*(*(r1).get("key").unwrap_or(&serde_json::Value::Null))
1932                    .get("remaining")
1933                    .unwrap_or(&serde_json::Value::Null)),
1934                1
1935            );
1936
1937            // 2nd: remaining 1->0
1938            let r2 = verify_key(&plugin, &ctx, &raw_key, None).await;
1939            assert_eq!(
1940                (*(r2).get("valid").unwrap_or(&serde_json::Value::Null)),
1941                true
1942            );
1943            assert_eq!(
1944                (*(*(r2).get("key").unwrap_or(&serde_json::Value::Null))
1945                    .get("remaining")
1946                    .unwrap_or(&serde_json::Value::Null)),
1947                0
1948            );
1949
1950            // 3rd: usage exceeded
1951            let r3 = verify_key(&plugin, &ctx, &raw_key, None).await;
1952            assert_eq!(
1953                (*(r3).get("valid").unwrap_or(&serde_json::Value::Null)),
1954                false
1955            );
1956            assert_eq!(
1957                (*(*(r3).get("error").unwrap_or(&serde_json::Value::Null))
1958                    .get("code")
1959                    .unwrap_or(&serde_json::Value::Null)),
1960                "USAGE_EXCEEDED"
1961            );
1962        }
1963
1964        // 5. Refill logic: remaining=1, refillInterval=100ms, refillAmount=10,
1965        //    verify once -> remaining=0, wait 150ms, verify -> refill to 10 then
1966        //    decrement to 9.
1967        // Upstream reference: packages/better-auth/src/plugins/api-key/api-key.test.ts :: describe("api-key"); adapted to the Rust API key plugin handlers.
1968        #[tokio::test]
1969        async fn test_refill_resets_remaining_after_interval() {
1970            let plugin = ApiKeyPlugin::builder().build();
1971            let (ctx, _user, session) = create_test_context_with_user().await;
1972
1973            // Use a very short refill interval for testing (100 ms)
1974            let (_id, raw_key) = create_key_with_server_fields(
1975                &plugin,
1976                &ctx,
1977                &session.token,
1978                serde_json::json!({ "name": "refill-test" }),
1979                UpdateApiKey {
1980                    remaining: Some(1.0),
1981                    refill_interval: Some(100.0),
1982                    refill_amount: Some(10.0),
1983                    ..Default::default()
1984                },
1985            )
1986            .await;
1987
1988            // First verify: remaining 1->0
1989            let r1 = verify_key(&plugin, &ctx, &raw_key, None).await;
1990            assert_eq!(
1991                (*(r1).get("valid").unwrap_or(&serde_json::Value::Null)),
1992                true
1993            );
1994            assert_eq!(
1995                (*(*(r1).get("key").unwrap_or(&serde_json::Value::Null))
1996                    .get("remaining")
1997                    .unwrap_or(&serde_json::Value::Null)),
1998                0
1999            );
2000
2001            // Wait for refill interval to elapse
2002            tokio::time::sleep(std::time::Duration::from_millis(150)).await;
2003
2004            // Second verify: should refill to 10 and then decrement -> 9
2005            let r2 = verify_key(&plugin, &ctx, &raw_key, None).await;
2006            assert_eq!(
2007                (*(r2).get("valid").unwrap_or(&serde_json::Value::Null)),
2008                true,
2009                "Should succeed after refill"
2010            );
2011            assert_eq!(
2012                (*(*(r2).get("key").unwrap_or(&serde_json::Value::Null))
2013                    .get("remaining")
2014                    .unwrap_or(&serde_json::Value::Null)),
2015                9,
2016                "Should be refillAmount - 1 = 9"
2017            );
2018        }
2019
2020        // 6. Permissions: key with {"admin": ["read"]}, verify with
2021        //    {"admin": ["write"]} should fail
2022        // Upstream reference: packages/better-auth/src/plugins/api-key/api-key.test.ts :: describe("api-key"); adapted to the Rust API key plugin handlers.
2023        #[tokio::test]
2024        async fn test_permissions_mismatch_fails() {
2025            let plugin = ApiKeyPlugin::builder().build();
2026            let (ctx, _user, session) = create_test_context_with_user().await;
2027
2028            let (_id, raw_key) = create_key_with_server_fields(
2029                &plugin,
2030                &ctx,
2031                &session.token,
2032                serde_json::json!({ "name": "perm-mismatch" }),
2033                UpdateApiKey {
2034                    permissions: Some(
2035                        serde_json::to_string(&serde_json::json!({ "admin": ["read"] })).unwrap(),
2036                    ),
2037                    ..Default::default()
2038                },
2039            )
2040            .await;
2041
2042            // Verify with matching permission -> pass
2043            let perms_ok = serde_json::json!({ "admin": ["read"] });
2044            let r1 = verify_key(&plugin, &ctx, &raw_key, Some(&perms_ok)).await;
2045            assert_eq!(
2046                (*(r1).get("valid").unwrap_or(&serde_json::Value::Null)),
2047                true
2048            );
2049
2050            // Verify with mismatched permission -> fail
2051            let perms_fail = serde_json::json!({ "admin": ["write"] });
2052            let r2 = verify_key(&plugin, &ctx, &raw_key, Some(&perms_fail)).await;
2053            assert_eq!(
2054                (*(r2).get("valid").unwrap_or(&serde_json::Value::Null)),
2055                false
2056            );
2057        }
2058
2059        // 7. Concurrent rate limiting: send 5 sequential verify requests with
2060        //    rateLimitMax=2, only first 2 succeed (sequential proves logic is
2061        //    correct; true concurrency race conditions are documented above).
2062        // Upstream reference: packages/better-auth/src/plugins/api-key/api-key.test.ts :: describe("api-key"); adapted to the Rust API key plugin handlers.
2063        #[tokio::test]
2064        async fn test_concurrent_rate_limiting() {
2065            let plugin = ApiKeyPlugin::builder()
2066                .rate_limit(RateLimitDefaults {
2067                    enabled: true,
2068                    time_window: 60_000.0,
2069                    max_requests: 2.0,
2070                })
2071                .build();
2072            let (ctx, _user, session) = create_test_context_with_user().await;
2073
2074            let (_id, raw_key) = create_key_with_server_fields(
2075                &plugin,
2076                &ctx,
2077                &session.token,
2078                serde_json::json!({ "name": "concurrent-rl" }),
2079                UpdateApiKey {
2080                    rate_limit_enabled: Some(true),
2081                    rate_limit_time_window: Some(60_000.0),
2082                    rate_limit_max: Some(2.0),
2083                    ..Default::default()
2084                },
2085            )
2086            .await;
2087
2088            let mut success_count = 0;
2089            let mut fail_count = 0;
2090
2091            for _ in 0..5 {
2092                let body = verify_key(&plugin, &ctx, &raw_key, None).await;
2093                if (*(body).get("valid").unwrap_or(&serde_json::Value::Null)) == true {
2094                    success_count += 1;
2095                } else {
2096                    fail_count += 1;
2097                    assert_eq!(
2098                        (*(*(body).get("error").unwrap_or(&serde_json::Value::Null))
2099                            .get("code")
2100                            .unwrap_or(&serde_json::Value::Null)),
2101                        "RATE_LIMITED"
2102                    );
2103                }
2104            }
2105
2106            assert_eq!(success_count, 2, "Only 2 out of 5 should succeed");
2107            assert_eq!(fail_count, 3, "3 out of 5 should be rate-limited");
2108        }
2109
2110        // 8. Database compatibility: test delete_expired_api_keys through the
2111        //    in-repo auth store implementation.
2112        // Upstream reference: packages/better-auth/src/plugins/api-key/api-key.test.ts :: describe("api-key"); adapted to the Rust API key plugin handlers.
2113        #[tokio::test]
2114        async fn test_delete_expired_api_keys_memory_adapter() {
2115            let (ctx, fixture_user, session) = create_test_context_with_user().await;
2116            let plugin = ApiKeyPlugin::builder().build();
2117
2118            // Create two keys
2119            let (id1, _) = create_key_and_get_raw(
2120                &plugin,
2121                &ctx,
2122                &session.token,
2123                serde_json::json!({ "name": "will-expire" }),
2124            )
2125            .await;
2126            let (_id2, _) = create_key_and_get_raw(
2127                &plugin,
2128                &ctx,
2129                &session.token,
2130                serde_json::json!({ "name": "wont-expire" }),
2131            )
2132            .await;
2133
2134            // Expire the first key by setting expires_at to the past
2135            let past = (Utc::now() - Duration::hours(1)).to_rfc3339();
2136            ctx.database
2137                .update_api_key(
2138                    &id1,
2139                    UpdateApiKey {
2140                        expires_at: Some(Some(past)),
2141                        ..Default::default()
2142                    },
2143                )
2144                .await
2145                .unwrap();
2146
2147            // Delete expired keys
2148            let deleted = ctx.database.delete_expired_api_keys().await.unwrap();
2149            assert_eq!(deleted, 1, "Should delete exactly 1 expired key");
2150
2151            // Verify only the non-expired key remains
2152            let remaining = ctx
2153                .database
2154                .list_api_keys_by_reference(&fixture_user.id)
2155                .await
2156                .unwrap();
2157            assert_eq!(remaining.len(), 1);
2158        }
2159
2160        // 9. Delete expired: calling the store function directly removes only expired keys
2161        #[tokio::test]
2162        async fn test_delete_expired_removes_only_expired() {
2163            let plugin = ApiKeyPlugin::builder().build();
2164            let (ctx, fixture_user, session) = create_test_context_with_user().await;
2165
2166            // Create two keys, expire one
2167            let (id1, _) = create_key_and_get_raw(
2168                &plugin,
2169                &ctx,
2170                &session.token,
2171                serde_json::json!({ "name": "expired" }),
2172            )
2173            .await;
2174            let (_id2, _) = create_key_and_get_raw(
2175                &plugin,
2176                &ctx,
2177                &session.token,
2178                serde_json::json!({ "name": "active" }),
2179            )
2180            .await;
2181
2182            let past = (Utc::now() - Duration::hours(1)).to_rfc3339();
2183            ctx.database
2184                .update_api_key(
2185                    &id1,
2186                    UpdateApiKey {
2187                        expires_at: Some(Some(past)),
2188                        ..Default::default()
2189                    },
2190                )
2191                .await
2192                .unwrap();
2193
2194            let deleted = ctx.database.delete_expired_api_keys().await.unwrap();
2195            assert_eq!(deleted, 1);
2196
2197            let remaining = ctx
2198                .database
2199                .list_api_keys_by_reference(&fixture_user.id)
2200                .await
2201                .unwrap();
2202            assert_eq!(remaining.len(), 1);
2203        }
2204
2205        // 10. before_request returns None when enableSessionForAPIKeys is false
2206        // Upstream reference: packages/better-auth/src/plugins/api-key/api-key.test.ts :: describe("api-key"); adapted to the Rust API key plugin handlers.
2207        #[tokio::test]
2208        async fn test_before_request_disabled_returns_none() {
2209            let plugin = ApiKeyPlugin::builder().build(); // enable_session_for_api_keys defaults to false
2210            let (ctx, _user, session) = create_test_context_with_user().await;
2211
2212            let (_id, raw_key) = create_key_and_get_raw(
2213                &plugin,
2214                &ctx,
2215                &session.token,
2216                serde_json::json!({ "name": "disabled-session" }),
2217            )
2218            .await;
2219
2220            let mut headers = HashMap::new();
2221            headers.insert("x-api-key".to_owned(), raw_key);
2222            let req = AuthRequest::from_parts(
2223                HttpMethod::Get,
2224                "/get-session".to_owned(),
2225                headers,
2226                None,
2227                HashMap::new(),
2228            );
2229
2230            let action = plugin.before_request(&req, &ctx).await.unwrap();
2231            assert!(
2232                action.is_none(),
2233                "before_request should return None when session emulation is disabled"
2234            );
2235        }
2236
2237        // Upstream reference: @better-auth/api-key :: resolveConfiguration — an absent
2238        // or unknown configId falls back to the default configuration.
2239        #[tokio::test]
2240        async fn test_resolve_configuration_falls_back_to_default() {
2241            let plugin = ApiKeyPlugin::builder().build().configuration(ApiKeyConfig {
2242                config_id: "billing".to_owned(),
2243                ..ApiKeyConfig::default()
2244            });
2245
2246            assert_eq!(
2247                plugin.resolve_configuration(None).unwrap().config_id,
2248                "default"
2249            );
2250            assert_eq!(
2251                plugin
2252                    .resolve_configuration(Some("billing"))
2253                    .unwrap()
2254                    .config_id,
2255                "billing"
2256            );
2257            // Unknown ids fall back rather than erroring.
2258            assert_eq!(
2259                plugin
2260                    .resolve_configuration(Some("nope"))
2261                    .unwrap()
2262                    .config_id,
2263                "default"
2264            );
2265        }
2266
2267        // Upstream reference: @better-auth/api-key :: resolveConfiguration errors when
2268        // no configuration is registered as the default.
2269        #[tokio::test]
2270        async fn test_resolve_configuration_without_default_is_an_error() {
2271            let plugin = ApiKeyPlugin::builder()
2272                .config_id("billing".to_owned())
2273                .build();
2274
2275            let err = plugin.resolve_configuration(None).unwrap_err();
2276            assert_eq!(err.status_code(), 400);
2277            assert_eq!(err.to_string(), "No default api-key configuration found.");
2278        }
2279
2280        // Upstream reference: @better-auth/api-key :: configIdMatches treats a missing
2281        // configId as the default, for keys written before the column existed.
2282        #[tokio::test]
2283        async fn test_config_id_matches_treats_missing_as_default() {
2284            assert!(config_id_matches("", "default"));
2285            assert!(config_id_matches("default", ""));
2286            assert!(config_id_matches("billing", "billing"));
2287            assert!(!config_id_matches("billing", "default"));
2288        }
2289
2290        // Upstream reference: @better-auth/api-key :: create with `references:
2291        // "organization"` requires organizationId.
2292        #[tokio::test]
2293        async fn test_create_for_organization_requires_organization_id() {
2294            let plugin = ApiKeyPlugin::builder()
2295                .references(ApiKeyReferences::Organization)
2296                .build();
2297            let (ctx, _user, session) = create_test_context_with_user().await;
2298
2299            let req = create_auth_request(
2300                HttpMethod::Post,
2301                "/api-key/create",
2302                Some(&session.token),
2303                Some(serde_json::json!({ "name": "org-key" })),
2304                None,
2305            );
2306            let err = plugin.handle_create(&req, &ctx).await.unwrap_err();
2307
2308            assert_eq!(err.status_code(), 400);
2309            assert_eq!(
2310                err.to_string(),
2311                "Organization ID is required for organization-owned API keys."
2312            );
2313        }
2314
2315        // Upstream reference: @better-auth/api-key :: checkOrgApiKeyPermission fails
2316        // when the organization plugin, which supplies the access control, is absent.
2317        #[tokio::test]
2318        async fn test_create_for_organization_requires_the_organization_plugin() {
2319            let plugin = ApiKeyPlugin::builder()
2320                .references(ApiKeyReferences::Organization)
2321                .build();
2322            let (ctx, _user, session) = create_test_context_with_user().await;
2323
2324            let req = create_auth_request(
2325                HttpMethod::Post,
2326                "/api-key/create",
2327                Some(&session.token),
2328                Some(serde_json::json!({ "name": "org-key", "organizationId": "org-1" })),
2329                None,
2330            );
2331            let err = plugin.handle_create(&req, &ctx).await.unwrap_err();
2332
2333            assert_eq!(
2334                err.to_string(),
2335                "Organization plugin is required for organization-owned API keys. Please install and configure the organization plugin."
2336            );
2337        }
2338
2339        // Upstream reference: @better-auth/api-key :: checkOrgApiKeyPermission rejects
2340        // a caller who is not a member of the owning organization.
2341        #[tokio::test]
2342        async fn test_create_for_organization_rejects_non_member() {
2343            let plugin = ApiKeyPlugin::builder()
2344                .references(ApiKeyReferences::Organization)
2345                .build();
2346            let (ctx, _user, session) = create_test_context_with_user().await;
2347
2348            // Stand in for a registered organization plugin.
2349            let mut metadata: HashMap<String, serde_json::Value> = HashMap::new();
2350            _ = metadata.insert(
2351                crate::organization::METADATA_ENABLED.to_owned(),
2352                serde_json::Value::Bool(true),
2353            );
2354            let ctx = AuthContext::with_metadata(
2355                Arc::clone(&ctx.config),
2356                Arc::clone(&ctx.database),
2357                metadata,
2358            );
2359
2360            let req = create_auth_request(
2361                HttpMethod::Post,
2362                "/api-key/create",
2363                Some(&session.token),
2364                Some(
2365                    serde_json::json!({ "name": "org-key", "organizationId": "org-the-user-is-not-in" }),
2366                ),
2367                None,
2368            );
2369            let err = plugin.handle_create(&req, &ctx).await.unwrap_err();
2370
2371            assert_eq!(
2372                err.to_string(),
2373                "You are not a member of the organization that owns this API key."
2374            );
2375        }
2376    }
2377
2378    mod verification_tests {
2379        use super::*;
2380
2381        #[tokio::test]
2382        async fn scoped_verification_uses_the_configuration_hashing_and_rejects_other_configs() {
2383            let plugin = ApiKeyPlugin::builder().build().configuration(ApiKeyConfig {
2384                config_id: "machines".to_owned(),
2385                disable_key_hashing: true,
2386                ..Default::default()
2387            });
2388            let (ctx, _, session) = create_test_context_with_user().await;
2389            let (id, key) = create_key_and_get_raw(
2390                &plugin,
2391                &ctx,
2392                &session.token,
2393                serde_json::json!({ "configId": "machines", "name": "machine" }),
2394            )
2395            .await;
2396            let scoped = VerifyApiKey {
2397                key: &key,
2398                config_id: Some("machines"),
2399                permissions: None,
2400            };
2401            let verified = plugin.verify_api_key(&scoped, &ctx).await.unwrap();
2402            assert_eq!(verified.id, id);
2403            assert_eq!(verified.config_id, "machines");
2404            let serialized = serde_json::to_value(verified).unwrap();
2405            assert!(serialized.get("key").is_none());
2406            assert!(serialized.get("keyHash").is_none());
2407
2408            for config_id in [None, Some("default"), Some("unknown")] {
2409                let error = plugin
2410                    .verify_api_key(
2411                        &VerifyApiKey {
2412                            config_id,
2413                            ..scoped
2414                        },
2415                        &ctx,
2416                    )
2417                    .await
2418                    .unwrap_err();
2419                assert!(matches!(
2420                    error,
2421                    ApiKeyVerificationError::Validation(ApiKeyValidationError {
2422                        code: ApiKeyErrorCode::InvalidApiKey,
2423                        ..
2424                    })
2425                ));
2426            }
2427            assert_eq!(
2428                ctx.database
2429                    .get_api_key_by_id(&id)
2430                    .await
2431                    .unwrap()
2432                    .unwrap()
2433                    .request_count,
2434                Some(1.0)
2435            );
2436        }
2437
2438        #[tokio::test]
2439        async fn unscoped_verification_uses_the_issuing_configuration_limits() {
2440            let plugin = ApiKeyPlugin::builder().build().configuration(ApiKeyConfig {
2441                config_id: "machines".to_owned(),
2442                rate_limit: RateLimitDefaults {
2443                    enabled: false,
2444                    ..Default::default()
2445                },
2446                ..Default::default()
2447            });
2448            let (ctx, _, session) = create_test_context_with_user().await;
2449            let (id, key) = create_key_and_get_raw(
2450                &plugin,
2451                &ctx,
2452                &session.token,
2453                serde_json::json!({ "configId": "machines" }),
2454            )
2455            .await;
2456            ctx.database
2457                .update_api_key(
2458                    &id,
2459                    UpdateApiKey {
2460                        rate_limit_enabled: Some(true),
2461                        rate_limit_max: Some(1.0),
2462                        ..Default::default()
2463                    },
2464                )
2465                .await
2466                .unwrap();
2467
2468            let input = VerifyApiKey {
2469                key: &key,
2470                config_id: None,
2471                permissions: None,
2472            };
2473            for _ in 0..3 {
2474                assert_eq!(plugin.verify_api_key(&input, &ctx).await.unwrap().id, id);
2475            }
2476            let error = plugin
2477                .verify_api_key(
2478                    &VerifyApiKey {
2479                        config_id: Some("default"),
2480                        ..input
2481                    },
2482                    &ctx,
2483                )
2484                .await
2485                .unwrap_err();
2486            assert!(matches!(
2487                error,
2488                ApiKeyVerificationError::Validation(ApiKeyValidationError {
2489                    code: ApiKeyErrorCode::InvalidApiKey,
2490                    ..
2491                })
2492            ));
2493            assert_eq!(
2494                ctx.database
2495                    .get_api_key_by_id(&id)
2496                    .await
2497                    .unwrap()
2498                    .unwrap()
2499                    .request_count,
2500                Some(0.0)
2501            );
2502        }
2503
2504        #[tokio::test]
2505        async fn permissions_failure_preserves_usage_and_rate_limit_reports_retry_time() {
2506            let plugin = ApiKeyPlugin::builder().build();
2507            let (ctx, _, session) = create_test_context_with_user().await;
2508            let (id, key) = create_key_with_server_fields(
2509                &plugin,
2510                &ctx,
2511                &session.token,
2512                serde_json::json!({}),
2513                UpdateApiKey {
2514                    remaining: Some(3.0),
2515                    permissions: Some(serde_json::json!({ "nodes": ["read"] }).to_string()),
2516                    rate_limit_max: Some(1.0),
2517                    rate_limit_time_window: Some(60_000.0),
2518                    ..Default::default()
2519                },
2520            )
2521            .await;
2522            let denied = serde_json::json!({ "nodes": ["delete"] });
2523            let allowed = serde_json::json!({ "nodes": ["read"] });
2524            let input = VerifyApiKey {
2525                key: &key,
2526                config_id: None,
2527                permissions: Some(&denied),
2528            };
2529            let error = plugin.verify_api_key(&input, &ctx).await.unwrap_err();
2530            assert!(matches!(
2531                error,
2532                ApiKeyVerificationError::Validation(ApiKeyValidationError {
2533                    code: ApiKeyErrorCode::KeyNotFound,
2534                    ..
2535                })
2536            ));
2537            assert_eq!(
2538                ctx.database
2539                    .get_api_key_by_id(&id)
2540                    .await
2541                    .unwrap()
2542                    .unwrap()
2543                    .remaining,
2544                Some(3.0)
2545            );
2546            let input = VerifyApiKey {
2547                permissions: Some(&allowed),
2548                ..input
2549            };
2550            assert_eq!(
2551                plugin.verify_api_key(&input, &ctx).await.unwrap().remaining,
2552                Some(2.0)
2553            );
2554            let error_2 = plugin.verify_api_key(&input, &ctx).await.unwrap_err();
2555            let ApiKeyVerificationError::Validation(error_2_3) = error_2 else {
2556                panic!("Expected rate limit rejection")
2557            };
2558            assert_eq!(error_2_3.code, ApiKeyErrorCode::RateLimited);
2559            let retry = error_2_3.details.unwrap().try_again_in;
2560            assert!(retry > 0.0 && retry <= 60_000.0);
2561            assert_eq!(
2562                ctx.database
2563                    .get_api_key_by_id(&id)
2564                    .await
2565                    .unwrap()
2566                    .unwrap()
2567                    .remaining,
2568                Some(1.0)
2569            );
2570        }
2571
2572        #[tokio::test]
2573        async fn session_header_selects_a_named_config_without_a_default() {
2574            let plugin = ApiKeyPlugin::builder()
2575                .config_id("machines".to_owned())
2576                .api_key_headers(vec![
2577                    "x-machine-key".to_owned(),
2578                    "x-alternate-key".to_owned(),
2579                ])
2580                .enable_session_for_api_keys(true)
2581                .build();
2582            let (ctx, user, session) = create_test_context_with_user().await;
2583            let (_, key) = create_key_and_get_raw(
2584                &plugin,
2585                &ctx,
2586                &session.token,
2587                serde_json::json!({ "configId": "machines" }),
2588            )
2589            .await;
2590            let mut request =
2591                create_auth_request(HttpMethod::Get, "/get-session", None, None, None);
2592            request
2593                .headers
2594                .insert("x-alternate-key".to_owned(), key.clone());
2595            let result = plugin
2596                .before_request(&request, &ctx)
2597                .await
2598                .unwrap()
2599                .unwrap();
2600            let BeforeRequestAction::Respond(response) = result else {
2601                panic!("Expected virtual session")
2602            };
2603            assert_eq!(response.status, 200);
2604            let body = json_body(&response);
2605            assert_eq!(
2606                (*(*(body).get("session").unwrap_or(&serde_json::Value::Null))
2607                    .get("token")
2608                    .unwrap_or(&serde_json::Value::Null)),
2609                key
2610            );
2611            assert_eq!(
2612                (*(*(body).get("session").unwrap_or(&serde_json::Value::Null))
2613                    .get("userId")
2614                    .unwrap_or(&serde_json::Value::Null)),
2615                user.id
2616            );
2617            assert_eq!(
2618                (*(*(body).get("user").unwrap_or(&serde_json::Value::Null))
2619                    .get("emailVerified")
2620                    .unwrap_or(&serde_json::Value::Null)),
2621                false
2622            );
2623            assert!(
2624                (*(*(body).get("session").unwrap_or(&serde_json::Value::Null))
2625                    .get("createdAt")
2626                    .unwrap_or(&serde_json::Value::Null))
2627                .is_string()
2628            );
2629            assert!(
2630                (*(*(body).get("session").unwrap_or(&serde_json::Value::Null))
2631                    .get("expiresAt")
2632                    .unwrap_or(&serde_json::Value::Null))
2633                .is_string()
2634            );
2635        }
2636
2637        #[tokio::test]
2638        async fn session_header_cannot_authenticate_a_different_configuration() {
2639            let plugin = ApiKeyPlugin::builder()
2640                .enable_session_for_api_keys(true)
2641                .build()
2642                .configuration(ApiKeyConfig {
2643                    config_id: "machines".to_owned(),
2644                    api_key_headers: vec!["x-machine-key".to_owned()],
2645                    enable_session_for_api_keys: true,
2646                    ..Default::default()
2647                });
2648            let (ctx, _, session) = create_test_context_with_user().await;
2649            let (id, key) = create_key_and_get_raw(
2650                &plugin,
2651                &ctx,
2652                &session.token,
2653                serde_json::json!({ "configId": "machines" }),
2654            )
2655            .await;
2656            let mut request =
2657                create_auth_request(HttpMethod::Get, "/get-session", None, None, None);
2658            request.headers.insert("x-api-key".to_owned(), key);
2659            let result = plugin
2660                .before_request(&request, &ctx)
2661                .await
2662                .unwrap()
2663                .unwrap();
2664            let BeforeRequestAction::Respond(response) = result else {
2665                panic!("Expected credential rejection")
2666            };
2667            assert_eq!(response.status, 401);
2668            assert_eq!(
2669                (*(json_body(&response))
2670                    .get("code")
2671                    .unwrap_or(&serde_json::Value::Null)),
2672                "INVALID_API_KEY"
2673            );
2674            assert_eq!(
2675                ctx.database
2676                    .get_api_key_by_id(&id)
2677                    .await
2678                    .unwrap()
2679                    .unwrap()
2680                    .request_count,
2681                Some(0.0)
2682            );
2683        }
2684
2685        #[tokio::test]
2686        async fn organization_key_verifies_without_emulating_a_user_session() {
2687            let plugin = ApiKeyPlugin::builder()
2688                .references(ApiKeyReferences::Organization)
2689                .enable_session_for_api_keys(true)
2690                .build();
2691            let (ctx, user, _) = create_test_context_with_user().await;
2692            let (key, key_hash, start) =
2693                ApiKeyPlugin::generate_key(&ApiKeyConfig::default(), None).unwrap();
2694            ctx.database
2695                .create_api_key(alibi_core::CreateApiKey {
2696                    // A colliding user ID must not turn an organization key into a user session.
2697                    reference_id: user.id,
2698                    config_id: "default".to_owned(),
2699                    key_hash,
2700                    start: Some(start),
2701                    enabled: true,
2702                    name: None,
2703                    prefix: None,
2704                    expires_at: None,
2705                    remaining: None,
2706                    rate_limit_enabled: false,
2707                    rate_limit_time_window: None,
2708                    rate_limit_max: None,
2709                    refill_interval: None,
2710                    refill_amount: None,
2711                    permissions: None,
2712                    metadata: None,
2713                })
2714                .await
2715                .unwrap();
2716            plugin
2717                .verify_api_key(
2718                    &VerifyApiKey {
2719                        key: &key,
2720                        config_id: None,
2721                        permissions: None,
2722                    },
2723                    &ctx,
2724                )
2725                .await
2726                .unwrap();
2727            let mut request =
2728                create_auth_request(HttpMethod::Get, "/get-session", None, None, None);
2729            request.headers.insert("x-api-key".to_owned(), key);
2730            let result = plugin
2731                .before_request(&request, &ctx)
2732                .await
2733                .unwrap()
2734                .unwrap();
2735            let BeforeRequestAction::Respond(response) = result else {
2736                panic!("Expected organization rejection")
2737            };
2738            assert_eq!(response.status, 401);
2739            assert_eq!(
2740                (*(json_body(&response))
2741                    .get("code")
2742                    .unwrap_or(&serde_json::Value::Null)),
2743                "INVALID_REFERENCE_ID_FROM_API_KEY"
2744            );
2745        }
2746
2747        #[tokio::test]
2748        async fn api_key_initialization_rejects_ambiguous_configurations() {
2749            let (ctx, _, _) = create_test_context_with_user().await;
2750            for config_id in ["", "default"] {
2751                let plugin = ApiKeyPlugin::builder().build().configuration(ApiKeyConfig {
2752                    config_id: config_id.to_owned(),
2753                    ..Default::default()
2754                });
2755                let mut init = alibi_core::AuthInitContext::new(
2756                    Arc::clone(&ctx.config),
2757                    Arc::clone(&ctx.database),
2758                );
2759                assert!(matches!(
2760                    plugin.on_init(&mut init).await,
2761                    Err(AuthError::Config(_))
2762                ));
2763            }
2764        }
2765
2766        #[tokio::test]
2767        async fn verification_preserves_database_failures() {
2768            let connection = alibi_seaorm::Database::connect("sqlite::memory:")
2769                .await
2770                .unwrap();
2771            connection.clone().close().await.unwrap();
2772            let config = Arc::new(crate::test_helpers::create_test_config());
2773            let database = Arc::new(alibi_seaorm::SeaOrmStore::<TestSchema>::new(
2774                Arc::clone(&config),
2775                connection,
2776            ));
2777            let ctx = AuthContext::new(config, database);
2778            let plugin = ApiKeyPlugin::builder().build();
2779            let error = plugin
2780                .verify_api_key(
2781                    &VerifyApiKey {
2782                        key: "not-looked-up",
2783                        config_id: None,
2784                        permissions: None,
2785                    },
2786                    &ctx,
2787                )
2788                .await
2789                .unwrap_err();
2790            assert!(matches!(
2791                error,
2792                ApiKeyVerificationError::Internal(AuthError::Database(_))
2793            ));
2794        }
2795
2796        #[tokio::test]
2797        async fn verified_session_authenticates_a_protected_plugin_route_without_a_database_session()
2798         {
2799            let plugin = ApiKeyPlugin::builder()
2800                .enable_session_for_api_keys(true)
2801                .build();
2802            let (ctx, user, session) = create_test_context_with_user().await;
2803            let (id, key) =
2804                create_key_and_get_raw(&plugin, &ctx, &session.token, serde_json::json!({})).await;
2805            let mut request =
2806                create_auth_request(HttpMethod::Get, "/api-key/list", None, None, None);
2807            request.headers.insert("x-api-key".to_owned(), key.clone());
2808            let action = plugin
2809                .before_request(&request, &ctx)
2810                .await
2811                .unwrap()
2812                .unwrap();
2813            let BeforeRequestAction::InjectSession { session: session_2 } = action else {
2814                panic!("Expected virtual session")
2815            };
2816            assert_eq!(session_2.token, key);
2817            request.set_virtual_session(session_2);
2818            let response = plugin.on_request(&request, &ctx).await.unwrap().unwrap();
2819            assert_eq!(response.status, 200);
2820            assert_eq!(
2821                (*(*(*(json_body(&response))
2822                    .get("apiKeys")
2823                    .unwrap_or(&serde_json::Value::Null))
2824                .get(0)
2825                .unwrap_or(&serde_json::Value::Null))
2826                .get("id")
2827                .unwrap_or(&serde_json::Value::Null)),
2828                id
2829            );
2830            assert_eq!(
2831                ctx.database
2832                    .get_user_sessions(&user.id)
2833                    .await
2834                    .unwrap()
2835                    .len(),
2836                1
2837            );
2838        }
2839
2840        // The SDK uses an actual HTTP request. This guards the separate public Rust
2841        // contract: server-only verification without a request can use typed application
2842        // policy from immutable context extensions and preserves quota on rejection.
2843        #[tokio::test]
2844        async fn programmatic_validator_uses_typed_policy_without_a_request() {
2845            use std::sync::atomic::{AtomicBool, Ordering};
2846            struct Policy(AtomicBool);
2847            struct Predicate {
2848                private_policy_secret: String,
2849            }
2850            #[async_trait::async_trait]
2851            impl ApiKeyValidator for Predicate {
2852                async fn validate(
2853                    &self,
2854                    context: &ApiKeyCallbackContext<'_>,
2855                    _key: &str,
2856                ) -> AuthResult<bool> {
2857                    Ok(context.request.is_none()
2858                        && context.configuration_id == "programmatic"
2859                        && context.auth_config.secret == "test-secret-key-at-least-32-chars-long"
2860                        && self.private_policy_secret == "application-private-policy-secret"
2861                        && context
2862                            .extensions
2863                            .get::<Policy>()
2864                            .is_some_and(|policy| policy.0.load(Ordering::SeqCst)))
2865                }
2866            }
2867            let (mut ctx, user, _) = create_test_context_with_user().await;
2868            ctx.extensions.insert(Policy(AtomicBool::new(false)));
2869            let configuration = ApiKeyConfig {
2870                config_id: "programmatic".to_owned(),
2871                custom_api_key_validator: Some(Arc::new(Predicate {
2872                    private_policy_secret: "application-private-policy-secret".to_owned(),
2873                })),
2874                rate_limit: RateLimitDefaults {
2875                    enabled: false,
2876                    ..Default::default()
2877                },
2878                ..Default::default()
2879            };
2880            assert!(!format!("{configuration:?}").contains("application-private-policy-secret"));
2881            let plugin = ApiKeyPlugin::with_config(configuration);
2882            let created = plugin
2883                .create_key(
2884                    &ctx,
2885                    &CreateKeyRequest {
2886                        config_id: Some("programmatic".to_owned()),
2887                        user_id: Some(user.id.clone()),
2888                        remaining: Some(2.0),
2889                        ..Default::default()
2890                    },
2891                )
2892                .await
2893                .unwrap();
2894            let input = VerifyApiKey {
2895                key: &created.key,
2896                config_id: Some("programmatic"),
2897                permissions: None,
2898            };
2899            let rejected = plugin.verify_api_key(&input, &ctx).await.unwrap_err();
2900            assert!(matches!(
2901                rejected,
2902                ApiKeyVerificationError::Validation(ApiKeyValidationError {
2903                    code: ApiKeyErrorCode::KeyNotFound,
2904                    ..
2905                })
2906            ));
2907            assert_eq!(
2908                ctx.database
2909                    .get_api_key_by_id(&created.api_key.id)
2910                    .await
2911                    .unwrap()
2912                    .unwrap()
2913                    .remaining,
2914                Some(2.0)
2915            );
2916            ctx.extensions
2917                .get::<Policy>()
2918                .unwrap()
2919                .0
2920                .store(true, Ordering::SeqCst);
2921            let accepted = plugin.verify_api_key(&input, &ctx).await.unwrap();
2922            assert_eq!(accepted.reference_id, user.id);
2923            assert_eq!(accepted.id, created.api_key.id);
2924            assert_eq!(accepted.remaining, Some(1.0));
2925            assert_eq!(
2926                ctx.database
2927                    .get_api_key_by_id(&created.api_key.id)
2928                    .await
2929                    .unwrap()
2930                    .unwrap()
2931                    .remaining,
2932                Some(1.0)
2933            );
2934        }
2935    }
2936}
2937// LCOV_EXCL_STOP
2938
2939// LCOV_EXCL_START
2940#[cfg(test)]
2941mod crud_tests {
2942    use super::*;
2943    use alibi_core::{AuthConfig, CreateSession, CreateUser, HttpMethod};
2944    use chrono::{Duration, Utc};
2945    use serde_json::json;
2946    use std::collections::HashMap;
2947    use std::sync::Arc;
2948
2949    type TestSchema = alibi_seaorm::store::__private_test_support::bundled_schema::BundledSchema;
2950
2951    async fn context() -> (AuthContext<TestSchema>, String, String) {
2952        let database = crate::test_helpers::create_test_database().await;
2953        let user = database
2954            .create_user(
2955                CreateUser::new()
2956                    .with_email("crud@example.com")
2957                    .with_name("CRUD user"),
2958            )
2959            .await
2960            .unwrap();
2961        let user_id = user.id().to_string();
2962        let session = database
2963            .create_session(CreateSession {
2964                additional_fields: alibi_core::field_policy::FieldValues::default(),
2965                token: None,
2966                active_team_id: None,
2967                user_id: user_id.clone(),
2968                expires_at: Utc::now() + Duration::hours(1),
2969                ip_address: None,
2970                user_agent: None,
2971                impersonated_by: None,
2972                active_organization_id: None,
2973            })
2974            .await
2975            .unwrap();
2976        let token = alibi_core::entity::AuthSession::token(&session).to_owned();
2977        (
2978            AuthContext::new(
2979                Arc::new(AuthConfig::new("a-secret-that-is-at-least-32-characters")),
2980                database,
2981            ),
2982            user_id,
2983            token,
2984        )
2985    }
2986
2987    fn request(token: &str, path: &str, body: &serde_json::Value) -> AuthRequest {
2988        AuthRequest::from_parts(
2989            HttpMethod::Post,
2990            path.to_owned(),
2991            HashMap::from([(
2992                "cookie".to_owned(),
2993                format!(
2994                    "better-auth.session_token={}",
2995                    alibi_core::utils::cookie_utils::sign_cookie_value(
2996                        token,
2997                        "a-secret-that-is-at-least-32-characters"
2998                    )
2999                ),
3000            )]),
3001            Some(serde_json::to_vec(&body).unwrap()),
3002            HashMap::new(),
3003        )
3004    }
3005
3006    async fn server_key(
3007        plugin: &ApiKeyPlugin,
3008        ctx: &AuthContext<TestSchema>,
3009        user_id: &str,
3010        config_id: &str,
3011    ) -> CreateKeyResponse {
3012        plugin
3013            .create_key(
3014                ctx,
3015                &CreateKeyRequest {
3016                    user_id: Some(user_id.to_owned()),
3017                    config_id: Some(config_id.to_owned()),
3018                    ..Default::default()
3019                },
3020            )
3021            .await
3022            .unwrap()
3023    }
3024
3025    #[tokio::test]
3026    async fn list_without_configuration_includes_all_user_configurations() {
3027        let (ctx, user_id, _) = context().await;
3028        let plugin = ApiKeyPlugin::builder().build().configuration(ApiKeyConfig {
3029            config_id: "secondary".to_owned(),
3030            ..Default::default()
3031        });
3032        server_key(&plugin, &ctx, &user_id, "default").await;
3033        server_key(&plugin, &ctx, &user_id, "secondary").await;
3034        let list = list_keys_core(&user_id, &ListKeysQuery::default(), &plugin, &ctx)
3035            .await
3036            .unwrap();
3037        assert_eq!(list.total, 2);
3038        let list_2 = list_keys_core(
3039            &user_id,
3040            &ListKeysQuery {
3041                config_id: Some("secondary".to_owned()),
3042                ..Default::default()
3043            },
3044            &plugin,
3045            &ctx,
3046        )
3047        .await
3048        .unwrap();
3049        assert_eq!(list_2.total, 1);
3050        assert_eq!(
3051            (list_2.api_keys)
3052                .first()
3053                .expect("fixture contains the requested index")
3054                .config_id,
3055            "secondary"
3056        );
3057        let list_3 = list_keys_core(
3058            &user_id,
3059            &ListKeysQuery {
3060                config_id: Some("missing".to_owned()),
3061                ..Default::default()
3062            },
3063            &plugin,
3064            &ctx,
3065        )
3066        .await
3067        .unwrap();
3068        assert_eq!(list_3.total, 0);
3069
3070        let no_default = ApiKeyPlugin::builder()
3071            .config_id("secondary".to_owned())
3072            .build();
3073        assert_eq!(
3074            list_keys_core(&user_id, &ListKeysQuery::default(), &no_default, &ctx)
3075                .await
3076                .unwrap()
3077                .total,
3078            2
3079        );
3080    }
3081
3082    #[tokio::test]
3083    async fn http_requests_cannot_impersonate_users_or_change_server_permissions() {
3084        let (ctx, user_id, token) = context().await;
3085        let plugin = ApiKeyPlugin::builder().build();
3086        let create = request(&token, "/api-key/create", &(json!({"userId":user_id})));
3087        assert_eq!(
3088            plugin
3089                .handle_create(&create, &ctx)
3090                .await
3091                .unwrap_err()
3092                .status_code(),
3093            401
3094        );
3095        let key = server_key(&plugin, &ctx, &user_id, "default").await;
3096        let update = request(
3097            &token,
3098            "/api-key/update",
3099            &(json!({"keyId":key.api_key.id,"userId":"someone-else","enabled":false})),
3100        );
3101        assert_eq!(
3102            plugin
3103                .handle_update(&update, &ctx)
3104                .await
3105                .unwrap_err()
3106                .status_code(),
3107            401
3108        );
3109        let update_2 = request(
3110            &token,
3111            "/api-key/update",
3112            &(json!({"keyId":key.api_key.id,"permissions":null})),
3113        );
3114        assert_eq!(
3115            plugin
3116                .handle_update(&update_2, &ctx)
3117                .await
3118                .unwrap_err()
3119                .to_string(),
3120            ApiKeyErrorCode::ServerOnlyProperty.message()
3121        );
3122        let update_3 = request(&token, "/api-key/update", &(json!({"keyId":"missing"})));
3123        assert_eq!(
3124            plugin
3125                .handle_update(&update_3, &ctx)
3126                .await
3127                .unwrap_err()
3128                .status_code(),
3129            404
3130        );
3131    }
3132
3133    #[tokio::test]
3134    async fn metadata_can_be_cleared_and_disabled_metadata_is_ignored_on_update() {
3135        let (ctx, user_id, token) = context().await;
3136        let enabled = ApiKeyPlugin::builder().enable_metadata(true).build();
3137        let key = enabled
3138            .create_key(
3139                &ctx,
3140                &CreateKeyRequest {
3141                    user_id: Some(user_id.clone()),
3142                    metadata: Some(json!(["initial"]).into()),
3143                    ..Default::default()
3144                },
3145            )
3146            .await
3147            .unwrap();
3148        assert_eq!(key.api_key.metadata, Some(json!(["initial"])));
3149        let update = request(
3150            &token,
3151            "/api-key/update",
3152            &(json!({"keyId":key.api_key.id,"metadata":null})),
3153        );
3154        let response = enabled.handle_update(&update, &ctx).await.unwrap();
3155        assert!(
3156            (*(serde_json::from_slice::<serde_json::Value>(&response.body).unwrap())
3157                .get("metadata")
3158                .unwrap_or(&serde_json::Value::Null))
3159            .is_null()
3160        );
3161        let disabled = ApiKeyPlugin::builder().build();
3162        let update_2 = request(
3163            &token,
3164            "/api-key/update",
3165            &(json!({"keyId":key.api_key.id,"metadata":{"ignored":true},"enabled":false})),
3166        );
3167        let response_2 = disabled.handle_update(&update_2, &ctx).await.unwrap();
3168        let result: serde_json::Value = serde_json::from_slice(&response_2.body).unwrap();
3169        assert!((*(result).get("metadata").unwrap_or(&serde_json::Value::Null)).is_null());
3170        assert_eq!(
3171            (*(result).get("enabled").unwrap_or(&serde_json::Value::Null)),
3172            false
3173        );
3174        let update_3 = request(
3175            &token,
3176            "/api-key/update",
3177            &(json!({"keyId":key.api_key.id,"metadata":null})),
3178        );
3179        assert_eq!(
3180            disabled
3181                .handle_update(&update_3, &ctx)
3182                .await
3183                .unwrap_err()
3184                .to_string(),
3185            ApiKeyErrorCode::NoValuesToUpdate.message()
3186        );
3187    }
3188
3189    #[tokio::test]
3190    async fn trusted_creation_and_update_preserve_permissions_and_fractional_expiration() {
3191        let (ctx, user_id, _) = context().await;
3192        let plugin = ApiKeyPlugin::builder()
3193            .key_expiration(KeyExpirationConfig {
3194                min_expires_in: 0.0,
3195                ..Default::default()
3196            })
3197            .build();
3198        let before = Utc::now();
3199        let key = plugin
3200            .create_key(
3201                &ctx,
3202                &CreateKeyRequest {
3203                    user_id: Some(user_id.clone()),
3204                    expires_in: Some(86400.25),
3205                    remaining: Some(3.0),
3206                    permissions: Some(ApiKeyPermissions::from([(
3207                        "device".to_owned(),
3208                        vec!["read".to_owned()],
3209                    )])),
3210                    ..Default::default()
3211                },
3212            )
3213            .await
3214            .unwrap();
3215        assert_eq!(key.api_key.remaining, Some(3.0));
3216        assert_eq!(key.api_key.permissions, Some(json!({"device":["read"]})));
3217        let expires_at =
3218            chrono::DateTime::parse_from_rfc3339(key.api_key.expires_at.as_deref().unwrap())
3219                .unwrap()
3220                .with_timezone(&Utc);
3221        assert!((expires_at - before).num_milliseconds() >= 86_400_249);
3222        assert!((expires_at - Utc::now()).num_milliseconds() <= 86_400_250);
3223        let updated = plugin
3224            .update_key(
3225                &ctx,
3226                &UpdateKeyRequest {
3227                    key_id: key.api_key.id.clone(),
3228                    user_id: Some(user_id),
3229                    remaining: Some(5.0),
3230                    permissions: Some(None),
3231                    expires_in: Some(None),
3232                    ..Default::default()
3233                },
3234            )
3235            .await
3236            .unwrap();
3237        assert_eq!(updated.remaining, Some(5.0));
3238        assert_eq!(updated.permissions, Some(serde_json::Value::Null));
3239        assert_eq!(updated.expires_at, None);
3240        let stored = ctx
3241            .database
3242            .get_api_key_by_id(&key.api_key.id)
3243            .await
3244            .unwrap()
3245            .unwrap();
3246        assert_ne!(stored.key_hash, key.key);
3247    }
3248
3249    #[tokio::test]
3250    async fn list_rejects_invalid_pagination_instead_of_ignoring_it() {
3251        let (ctx, _, token) = context().await;
3252        let plugin = ApiKeyPlugin::builder().build();
3253        for (field, value) in [
3254            ("limit", "-1"),
3255            ("offset", "1.5"),
3256            ("limit", "bad"),
3257            ("sortDirection", "sideways"),
3258        ] {
3259            let mut req = request(&token, "/api-key/list", &(json!({})));
3260            req.query.insert(field.to_owned(), value.to_owned());
3261            let response = plugin.handle_list(&req, &ctx).await.unwrap();
3262            assert_eq!(response.status, 400);
3263            assert_eq!(
3264                (*(serde_json::from_slice::<serde_json::Value>(&response.body).unwrap())
3265                    .get("code")
3266                    .unwrap_or(&serde_json::Value::Null)),
3267                "VALIDATION_ERROR"
3268            );
3269        }
3270    }
3271
3272    #[tokio::test]
3273    async fn forced_cleanup_preserves_rows_on_store_failure_and_retries_without_throttle() {
3274        use alibi_seaorm::sea_orm::{ConnectionTrait, DbBackend, Statement};
3275        let config = Arc::new(AuthConfig::new("forced-cleanup-application-secret32"));
3276        let connection = alibi_seaorm::Database::connect("sqlite::memory:")
3277            .await
3278            .unwrap();
3279        alibi_seaorm::store::__private_test_support::migrator::run_migrations(&connection)
3280            .await
3281            .unwrap();
3282        let database = Arc::new(alibi_seaorm::SeaOrmStore::<TestSchema>::new(
3283            std::sync::Arc::clone(&config),
3284            connection.clone(),
3285        ));
3286        let ctx = AuthContext::new(config, database);
3287        let owner = ctx
3288            .database
3289            .create_user(CreateUser::new().with_email("cleanup@native.local"))
3290            .await
3291            .unwrap();
3292        let plugin = ApiKeyPlugin::with_config(ApiKeyConfig {
3293            key_expiration: KeyExpirationConfig {
3294                min_expires_in: 0.0,
3295                ..Default::default()
3296            },
3297            ..Default::default()
3298        });
3299        let key = plugin
3300            .create_key(
3301                &ctx,
3302                &CreateKeyRequest {
3303                    user_id: Some(owner.id().to_string()),
3304                    name: Some("expired".into()),
3305                    ..Default::default()
3306                },
3307            )
3308            .await
3309            .unwrap();
3310        let expired = ctx
3311            .database
3312            .update_api_key(
3313                &key.api_key.id,
3314                alibi_core::UpdateApiKey {
3315                    expires_at: Some(Some("1970-01-01T00:00:00.000Z".into())),
3316                    ..Default::default()
3317                },
3318            )
3319            .await
3320            .unwrap();
3321        connection.execute_raw(Statement::from_string(DbBackend::Sqlite,"CREATE TRIGGER reject_api_key_cleanup BEFORE DELETE ON \"apikey\" BEGIN SELECT RAISE(ABORT,'application cleanup rejected'); END")).await.unwrap();
3322        let failed = plugin.delete_all_expired_api_keys(&ctx).await;
3323        assert!(failed.success);
3324        assert!(failed.error.is_none());
3325        let retained = ctx
3326            .database
3327            .get_api_key_by_id(&key.api_key.id)
3328            .await
3329            .unwrap()
3330            .unwrap();
3331        assert_eq!(
3332            alibi_core::utils::json::to_value(&retained).unwrap(),
3333            alibi_core::utils::json::to_value(&expired).unwrap()
3334        );
3335        connection
3336            .execute_raw(Statement::from_string(
3337                DbBackend::Sqlite,
3338                "DROP TRIGGER reject_api_key_cleanup",
3339            ))
3340            .await
3341            .unwrap();
3342        let retry = plugin.delete_all_expired_api_keys(&ctx).await;
3343        assert!(retry.success);
3344        assert!(retry.error.is_none());
3345        assert!(
3346            ctx.database
3347                .get_api_key_by_id(&key.api_key.id)
3348                .await
3349                .unwrap()
3350                .is_none()
3351        );
3352        assert_eq!(
3353            ctx.database
3354                .get_user_by_id(owner.id().as_ref())
3355                .await
3356                .unwrap()
3357                .unwrap(),
3358            owner
3359        );
3360    }
3361    struct ApplicationKeyStorage {
3362        cache: Arc<alibi_core::store::MemoryCacheAdapter>,
3363        fail_writes: std::sync::atomic::AtomicBool,
3364    }
3365    #[async_trait::async_trait]
3366    impl ApiKeyStorage for ApplicationKeyStorage {
3367        async fn get(&self, key: &str) -> AuthResult<Option<String>> {
3368            use alibi_core::store::CacheAdapter as _;
3369            self.cache.get(key).await
3370        }
3371        async fn set(&self, key: &str, value: &str, ttl: Option<Duration>) -> AuthResult<()> {
3372            use alibi_core::store::CacheAdapter as _;
3373            if self.fail_writes.load(std::sync::atomic::Ordering::SeqCst) {
3374                return Err(AuthError::internal("application storage unavailable"));
3375            }
3376            match ttl {
3377                Some(ttl) => self.cache.set(key, value, ttl).await,
3378                None => self.cache.set_without_expiry(key, value).await,
3379            }
3380        }
3381        async fn delete(&self, key: &str) -> AuthResult<()> {
3382            use alibi_core::store::CacheAdapter as _;
3383            self.cache.delete(key).await
3384        }
3385    }
3386    #[derive(Default)]
3387    struct ApplicationCompletions(std::sync::Mutex<Vec<alibi_core::BackgroundTaskCompletion>>);
3388    impl alibi_core::BackgroundTaskHandler for ApplicationCompletions {
3389        fn handle(&self, completion: alibi_core::BackgroundTaskCompletion) -> AuthResult<()> {
3390            self.0.lock().unwrap().push(completion);
3391            Ok(())
3392        }
3393    }
3394    impl ApplicationCompletions {
3395        async fn drain(&self) {
3396            let tasks = std::mem::take(&mut *self.0.lock().unwrap());
3397            for task in tasks {
3398                task.await.unwrap();
3399            }
3400        }
3401    }
3402
3403    #[tokio::test]
3404    async fn source_boxed_metadata_rows_list_successfully_without_mutating_storage() {
3405        use alibi_core::store::{CacheAdapter, MemoryCacheAdapter};
3406        let (ctx, owner, token) = context().await;
3407        let cache = Arc::new(MemoryCacheAdapter::new());
3408        let plugin = ApiKeyPlugin::with_config(ApiKeyConfig {
3409            storage: ApiKeyStorageMode::SecondaryStorage,
3410            secondary_storage: Some(cache.clone()),
3411            ..Default::default()
3412        });
3413        let mut rows = Vec::new();
3414        let mut expected = std::collections::BTreeMap::new();
3415        // Actual pinned Source trusted creation admits boxed String("10"),
3416        // String("2"), Number(3), then serializes these primitive metadata
3417        // values. Their relational comparator is cyclic. Listing still
3418        // succeeds; no cross-engine total ordering is specified.
3419        for index in 0..64 {
3420            let created = server_key(&plugin, &ctx, &owner, "default").await;
3421            let id = format!("api-key:by-id:{}", created.api_key.id);
3422            let hash = format!("api-key:{}", ApiKeyPlugin::hash_key(&created.key));
3423            let mut row: serde_json::Value =
3424                serde_json::from_str(&cache.get(&id).await.unwrap().unwrap()).unwrap();
3425            *row.get_mut("metadata").unwrap() = match index % 6 {
3426                0 => json!("10"),
3427                1 => json!("2"),
3428                2 => json!(3),
3429                3 => json!(null),
3430                4 => json!("0"),
3431                _ => json!({"literal":"object"}),
3432            };
3433            let value = serde_json::to_string(&row).unwrap();
3434            cache.set_without_expiry(&id, &value).await.unwrap();
3435            cache.set_without_expiry(&hash, &value).await.unwrap();
3436            rows.push((id, value.clone()));
3437            rows.push((hash, value));
3438            expected.insert(
3439                created.api_key.id,
3440                [
3441                    json!(10),
3442                    json!(2),
3443                    json!(3),
3444                    json!(null),
3445                    json!(0),
3446                    json!({"literal":"object"}),
3447                ]
3448                .get(index % 6)
3449                .unwrap()
3450                .clone(),
3451            );
3452        }
3453        let reference = format!("api-key:by-ref:{owner}");
3454        let reference_before = cache.get(&reference).await.unwrap();
3455        for direction in ["asc", "desc"] {
3456            let mut req = request(&token, "/api-key/list", &json!(null));
3457            req.method = HttpMethod::Get;
3458            req.body = None;
3459            req.query = HashMap::from([
3460                ("sortBy".to_owned(), "metadata".to_owned()),
3461                ("sortDirection".to_owned(), direction.to_owned()),
3462            ]);
3463            let response = plugin.handle_list(&req, &ctx).await.unwrap();
3464            assert_eq!(response.status, 200);
3465            let body: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
3466            assert_eq!(body.get("total").unwrap(), 64);
3467            let actual: std::collections::BTreeMap<_, _> = body
3468                .get("apiKeys")
3469                .unwrap()
3470                .as_array()
3471                .unwrap()
3472                .iter()
3473                .map(|row| {
3474                    (
3475                        row.get("id").unwrap().as_str().unwrap().to_owned(),
3476                        row.get("metadata").unwrap().clone(),
3477                    )
3478                })
3479                .collect();
3480            assert_eq!(actual, expected);
3481            for (index, value) in &rows {
3482                assert_eq!(
3483                    cache.get(index).await.unwrap().as_deref(),
3484                    Some(value.as_str())
3485                );
3486            }
3487            assert_eq!(cache.get(&reference).await.unwrap(), reference_before);
3488            assert!(
3489                ctx.database
3490                    .list_api_keys_by_reference(&owner)
3491                    .await
3492                    .unwrap()
3493                    .is_empty()
3494            );
3495        }
3496        // Numeric metadata has an independent total ordering. Exercise a list
3497        // larger than 64 entries, then assert a page in both directions.
3498        for _ in 64..96 {
3499            let created = server_key(&plugin, &ctx, &owner, "default").await;
3500            let id = format!("api-key:by-id:{}", created.api_key.id);
3501            let hash = format!("api-key:{}", ApiKeyPlugin::hash_key(&created.key));
3502            let value = cache.get(&id).await.unwrap().unwrap();
3503            rows.push((id, value.clone()));
3504            rows.push((hash, value));
3505        }
3506        let mut ordered = std::collections::BTreeMap::new();
3507        for (index, pair) in rows.as_chunks_mut::<2>().0.iter_mut().enumerate() {
3508            let rank = (index * 37) % 96;
3509            let mut row: serde_json::Value =
3510                serde_json::from_str(&pair.first().unwrap().1).unwrap();
3511            _ = ordered.insert(rank, row.get("id").unwrap().clone());
3512            *row.get_mut("metadata").unwrap() = json!(rank);
3513            let serialized = serde_json::to_string(&row).unwrap();
3514            for (key, value) in pair {
3515                cache.set_without_expiry(key, &serialized).await.unwrap();
3516                *value = serialized.clone();
3517            }
3518        }
3519        let reference_before = cache.get(&reference).await.unwrap();
3520        for direction in ["asc", "desc"] {
3521            let mut req = request(&token, "/api-key/list", &json!(null));
3522            req.method = HttpMethod::Get;
3523            req.body = None;
3524            req.query = HashMap::from([
3525                ("sortBy".into(), "metadata".into()),
3526                ("sortDirection".into(), direction.into()),
3527                ("offset".into(), "11".into()),
3528                ("limit".into(), "9".into()),
3529            ]);
3530            let response = plugin.handle_list(&req, &ctx).await.unwrap();
3531            assert_eq!(response.status, 200);
3532            let body: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
3533            assert_eq!(body.get("total").unwrap(), 96);
3534            let page = body.get("apiKeys").unwrap().as_array().unwrap();
3535            assert_eq!(page.len(), 9);
3536            for (offset, row) in page.iter().enumerate() {
3537                let rank = if direction == "asc" {
3538                    11 + offset
3539                } else {
3540                    84 - offset
3541                };
3542                assert_eq!(row.get("id").unwrap(), ordered.get(&rank).unwrap());
3543                assert_eq!(row.get("metadata").unwrap(), &json!(rank));
3544            }
3545            for (key, value) in &rows {
3546                assert_eq!(
3547                    cache.get(key).await.unwrap().as_deref(),
3548                    Some(value.as_str())
3549                );
3550            }
3551            assert_eq!(cache.get(&reference).await.unwrap(), reference_before);
3552        }
3553    }
3554
3555    #[tokio::test]
3556    async fn application_storage_preserves_authority_indexes_and_usage() {
3557        use alibi_core::store::{CacheAdapter, MemoryCacheAdapter};
3558        // HTTP creation and trusted verification use the real plugin and stores;
3559        // independent database reads prove secondary-only keys create no SQL rows.
3560        for (fallback, custom, deferred) in [
3561            (false, false, false),
3562            (true, false, false),
3563            (false, true, false),
3564            (true, true, false),
3565            (false, true, true),
3566            (true, true, true),
3567        ] {
3568            let (mut ctx, owner, token) = context().await;
3569            let completions = Arc::new(ApplicationCompletions::default());
3570            Arc::make_mut(&mut ctx.config).background_tasks = Some(completions.clone());
3571            let outsider = ctx
3572                .database
3573                .create_user(
3574                    CreateUser::new()
3575                        .with_email("outsider@example.com")
3576                        .with_name("outsider"),
3577                )
3578                .await
3579                .unwrap();
3580            let cache = Arc::new(MemoryCacheAdapter::new());
3581            let decoy = Arc::new(MemoryCacheAdapter::new());
3582            let application = Arc::new(ApplicationKeyStorage {
3583                cache: cache.clone(),
3584                fail_writes: std::sync::atomic::AtomicBool::new(false),
3585            });
3586            let plugin = ApiKeyPlugin::with_config(ApiKeyConfig {
3587                storage: ApiKeyStorageMode::SecondaryStorage,
3588                secondary_storage: Some(
3589                    if custom { decoy.clone() } else { cache.clone() } as Arc<dyn CacheAdapter>
3590                ),
3591                custom_storage: custom.then(|| application.clone() as Arc<dyn ApiKeyStorage>),
3592                fallback_to_database: fallback,
3593                defer_updates: deferred,
3594                rate_limit: RateLimitDefaults {
3595                    enabled: false,
3596                    ..Default::default()
3597                },
3598                ..Default::default()
3599            });
3600            let response = plugin
3601                .handle_create(
3602                    &request(&token, "/api-key/create", &json!({"name":"stored"})),
3603                    &ctx,
3604                )
3605                .await
3606                .unwrap();
3607            let created: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
3608            let id = created.get("id").unwrap().as_str().unwrap();
3609            let raw = created.get("key").unwrap().as_str().unwrap();
3610            plugin
3611                .update_key(
3612                    &ctx,
3613                    &UpdateKeyRequest {
3614                        key_id: id.to_owned(),
3615                        user_id: Some(owner.clone()),
3616                        remaining: Some(2.0),
3617                        ..Default::default()
3618                    },
3619                )
3620                .await
3621                .unwrap();
3622            let hash = ApiKeyPlugin::hash_key(raw);
3623            let by_id = format!("api-key:by-id:{id}");
3624            let by_hash = format!("api-key:{hash}");
3625            let by_ref = format!("api-key:by-ref:{owner}");
3626            let initial = cache.get(&by_id).await.unwrap().unwrap();
3627            assert_eq!(cache.get(&by_hash).await.unwrap(), Some(initial.clone()));
3628            assert_eq!(
3629                ctx.database.get_api_key_by_id(id).await.unwrap().is_some(),
3630                fallback
3631            );
3632            assert_eq!(cache.get(&by_ref).await.unwrap().is_none(), fallback);
3633            assert!(
3634                get_key_core(id, None, &outsider.id().to_string(), &plugin, &ctx)
3635                    .await
3636                    .is_err()
3637            );
3638            assert_eq!(cache.get(&by_id).await.unwrap(), Some(initial.clone()));
3639            assert!(
3640                plugin
3641                    .verify_api_key(
3642                        &VerifyApiKey {
3643                            key: raw,
3644                            config_id: Some("wrong"),
3645                            permissions: None
3646                        },
3647                        &ctx
3648                    )
3649                    .await
3650                    .is_err()
3651            );
3652            assert_eq!(cache.get(&by_id).await.unwrap(), Some(initial));
3653            let list = list_keys_core(&owner, &ListKeysQuery::default(), &plugin, &ctx)
3654                .await
3655                .unwrap();
3656            assert_eq!(list.total, 1);
3657            let input = VerifyApiKey {
3658                key: raw,
3659                config_id: None,
3660                permissions: None,
3661            };
3662            assert_eq!(
3663                plugin.verify_api_key(&input, &ctx).await.unwrap().remaining,
3664                Some(1.0)
3665            );
3666            completions.drain().await;
3667            assert!(decoy.get(&by_id).await.unwrap().is_none());
3668            let row: alibi_core::ApiKey =
3669                serde_json::from_str(&cache.get(&by_id).await.unwrap().unwrap()).unwrap();
3670            assert_eq!(row.remaining, Some(1.0));
3671            assert_eq!(row.reference_id, owner);
3672            assert_eq!(
3673                cache.get(&by_hash).await.unwrap(),
3674                cache.get(&by_id).await.unwrap()
3675            );
3676            if fallback {
3677                let persisted = ctx.database.get_api_key_by_id(id).await.unwrap().unwrap();
3678                let mut persisted_json = serde_json::to_value(&persisted).unwrap();
3679                // The database model stores JSON text; the public application
3680                // storage contract stores the actual metadata value.
3681                *persisted_json.get_mut("metadata").unwrap() =
3682                    serde_json::from_str(persisted.metadata.as_deref().unwrap_or("null")).unwrap();
3683                assert_eq!(persisted_json, serde_json::to_value(&row).unwrap());
3684                cache.delete(&by_id).await.unwrap();
3685                cache.delete(&by_hash).await.unwrap();
3686                assert_eq!(
3687                    get_key_core(id, None, &owner, &plugin, &ctx)
3688                        .await
3689                        .unwrap()
3690                        .remaining,
3691                    Some(1.0)
3692                );
3693                assert_eq!(
3694                    cache.get(&by_hash).await.unwrap(),
3695                    cache.get(&by_id).await.unwrap()
3696                );
3697            }
3698            assert_eq!(
3699                plugin.verify_api_key(&input, &ctx).await.unwrap().remaining,
3700                Some(0.0)
3701            );
3702            completions.drain().await;
3703            assert!(plugin.verify_api_key(&input, &ctx).await.is_err());
3704            completions.drain().await;
3705            assert!(cache.get(&by_id).await.unwrap().is_none());
3706            assert!(cache.get(&by_hash).await.unwrap().is_none());
3707            assert!(cache.get(&by_ref).await.unwrap().is_none());
3708            assert!(ctx.database.get_api_key_by_id(id).await.unwrap().is_none());
3709            if custom {
3710                let target = plugin
3711                    .create_key(
3712                        &ctx,
3713                        &CreateKeyRequest {
3714                            user_id: Some(owner.clone()),
3715                            remaining: Some(4.0),
3716                            ..Default::default()
3717                        },
3718                    )
3719                    .await
3720                    .unwrap();
3721                let raw = target.key.as_str();
3722                let key_id = &target.api_key.id;
3723                let id_index = format!("api-key:by-id:{key_id}");
3724                let hash_index = format!("api-key:{}", ApiKeyPlugin::hash_key(raw));
3725                let before = cache.get(&id_index).await.unwrap();
3726                application
3727                    .fail_writes
3728                    .store(true, std::sync::atomic::Ordering::SeqCst);
3729                let input = VerifyApiKey {
3730                    key: raw,
3731                    config_id: None,
3732                    permissions: None,
3733                };
3734                let failed = plugin.verify_api_key(&input, &ctx).await;
3735                assert_eq!(failed.is_ok(), deferred && !fallback);
3736                completions.drain().await;
3737                assert_eq!(cache.get(&id_index).await.unwrap(), before);
3738                assert_eq!(cache.get(&hash_index).await.unwrap(), before);
3739                if fallback {
3740                    assert_eq!(
3741                        ctx.database
3742                            .get_api_key_by_id(key_id)
3743                            .await
3744                            .unwrap()
3745                            .unwrap()
3746                            .remaining,
3747                        Some(3.0)
3748                    );
3749                } else {
3750                    assert!(
3751                        ctx.database
3752                            .get_api_key_by_id(key_id)
3753                            .await
3754                            .unwrap()
3755                            .is_none()
3756                    );
3757                }
3758                application
3759                    .fail_writes
3760                    .store(false, std::sync::atomic::Ordering::SeqCst);
3761                let retried = plugin.verify_api_key(&input, &ctx).await.unwrap();
3762                assert_eq!(retried.remaining, Some(if fallback { 2.0 } else { 3.0 }));
3763                completions.drain().await;
3764                assert_eq!(
3765                    cache.get(&hash_index).await.unwrap(),
3766                    cache.get(&id_index).await.unwrap()
3767                );
3768                let row: alibi_core::ApiKey =
3769                    serde_json::from_str(&cache.get(&id_index).await.unwrap().unwrap()).unwrap();
3770                assert_eq!(row.remaining, retried.remaining);
3771            }
3772        }
3773        // Secondary-only rate limiting owns its own update path. A denied
3774        // request must not spend quota, and an elapsed window must refill once.
3775        let (ctx, owner, _) = context().await;
3776        let cache = Arc::new(MemoryCacheAdapter::new());
3777        let plugin = ApiKeyPlugin::with_config(ApiKeyConfig {
3778            storage: ApiKeyStorageMode::SecondaryStorage,
3779            secondary_storage: Some(cache.clone()),
3780            ..Default::default()
3781        });
3782        let created = plugin
3783            .create_key(
3784                &ctx,
3785                &CreateKeyRequest {
3786                    user_id: Some(owner.clone()),
3787                    remaining: Some(2.0),
3788                    rate_limit_enabled: Some(true),
3789                    rate_limit_time_window: Some(60_000.0),
3790                    rate_limit_max: Some(1.0),
3791                    refill_interval: Some(60_000.0),
3792                    refill_amount: Some(5.0),
3793                    ..Default::default()
3794                },
3795            )
3796            .await
3797            .unwrap();
3798        let by_id = format!("api-key:by-id:{}", created.api_key.id);
3799        let by_hash = format!("api-key:{}", ApiKeyPlugin::hash_key(&created.key));
3800        let input = VerifyApiKey {
3801            key: &created.key,
3802            config_id: None,
3803            permissions: None,
3804        };
3805        let first = plugin.verify_api_key(&input, &ctx).await.unwrap();
3806        assert_eq!(first.remaining, Some(1.0));
3807        assert_eq!(first.request_count, Some(1.0));
3808        let before = cache.get(&by_id).await.unwrap();
3809        assert_eq!(cache.get(&by_hash).await.unwrap(), before);
3810        let denied = plugin.verify_api_key(&input, &ctx).await.unwrap_err();
3811        let ApiKeyVerificationError::Validation(denied) = denied else {
3812            panic!("Expected rate-limit validation");
3813        };
3814        assert_eq!(denied.code, ApiKeyErrorCode::RateLimited);
3815        assert!(denied.details.unwrap().try_again_in > 0.0);
3816        assert_eq!(cache.get(&by_id).await.unwrap(), before);
3817        assert_eq!(cache.get(&by_hash).await.unwrap(), before);
3818        let mut elapsed: alibi_core::ApiKey =
3819            serde_json::from_str(before.as_deref().unwrap()).unwrap();
3820        let previous = (chrono::Utc::now() - chrono::Duration::minutes(2)).to_rfc3339();
3821        elapsed.last_request = Some(previous.clone());
3822        elapsed.last_refill_at = Some(previous.clone());
3823        let seeded = serde_json::to_string(&elapsed).unwrap();
3824        cache.set_without_expiry(&by_id, &seeded).await.unwrap();
3825        cache.set_without_expiry(&by_hash, &seeded).await.unwrap();
3826        let refilled = plugin.verify_api_key(&input, &ctx).await.unwrap();
3827        assert_eq!(refilled.remaining, Some(4.0));
3828        assert_eq!(refilled.request_count, Some(1.0));
3829        assert_ne!(refilled.last_refill_at.as_deref(), Some(previous.as_str()));
3830        let observed: alibi_core::ApiKey =
3831            serde_json::from_str(&cache.get(&by_id).await.unwrap().unwrap()).unwrap();
3832        assert_eq!(observed.remaining, Some(4.0));
3833        assert_eq!(observed.request_count, Some(1.0));
3834        assert_eq!(
3835            cache.get(&by_id).await.unwrap(),
3836            cache.get(&by_hash).await.unwrap()
3837        );
3838        assert!(
3839            ctx.database
3840                .list_api_keys_by_reference(&owner)
3841                .await
3842                .unwrap()
3843                .is_empty()
3844        );
3845    }
3846}
3847// LCOV_EXCL_STOP