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