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