Skip to main content

alibi_plugins/api_key/
verification.rs

1use super::{
2    ApiKeyCallbackContext, ApiKeyConfig, ApiKeyErrorCode, ApiKeyPlugin, ApiKeyReferences,
3    config_id_matches,
4};
5use alibi_core::entity::AuthUser;
6use alibi_core::store::ConsumeApiKeyResult;
7use alibi_core::wire::{ApiKeyView, SessionView};
8use alibi_core::{
9    AuthContext, AuthError, AuthRequest, AuthResponse, AuthResult, BeforeRequestAction,
10};
11use serde::Serialize;
12
13/// Inputs for server-only API key verification. Verification consumes one use.
14pub struct VerifyApiKey<'a> {
15    /// The plaintext API key presented by the caller.
16    pub key: &'a str,
17    /// Restrict verification to this configuration, or use the default lookup configuration.
18    pub config_id: Option<&'a str>,
19    /// Required resource permissions, checked before quota or rate-limit consumption.
20    pub permissions: Option<&'a serde_json::Value>,
21}
22
23/// Additional information returned when a key reaches its rate limit.
24#[derive(Debug, Serialize)]
25pub struct ApiKeyErrorDetails {
26    /// Milliseconds until the key's rate-limit window elapses.
27    #[serde(rename = "tryAgainIn")]
28    pub try_again_in: f64,
29}
30
31/// Upstream verification messages can contain either text or an error-code object.
32#[derive(Debug, Serialize)]
33#[serde(untagged)]
34pub enum ApiKeyErrorMessage {
35    Text(String),
36    CodeMessage {
37        code: ApiKeyErrorCode,
38        message: String,
39    },
40}
41
42impl ApiKeyErrorMessage {
43    fn text(&self) -> &str {
44        match self {
45            Self::Text(value) | Self::CodeMessage { message: value, .. } => value,
46        }
47    }
48}
49
50/// An API key rejection with the upstream error code and response fields.
51#[derive(Debug, Serialize)]
52pub struct ApiKeyValidationError {
53    /// Stable upstream API key error code.
54    pub code: ApiKeyErrorCode,
55    /// Upstream error message.
56    pub message: ApiKeyErrorMessage,
57    /// Rate-limit timing, when the rejection is a rate limit.
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub details: Option<ApiKeyErrorDetails>,
60}
61
62impl ApiKeyValidationError {
63    pub(super) fn new(code: ApiKeyErrorCode) -> Self {
64        Self {
65            code,
66            message: ApiKeyErrorMessage::Text(code.message().to_owned()),
67            details: None,
68        }
69    }
70
71    pub(super) const fn status(&self) -> u16 {
72        match self.code {
73            ApiKeyErrorCode::NoDefaultConfiguration => 400,
74            ApiKeyErrorCode::RateLimited | ApiKeyErrorCode::UsageExceeded => 429,
75            ApiKeyErrorCode::InvalidApiKey
76            | ApiKeyErrorCode::KeyDisabled
77            | ApiKeyErrorCode::KeyExpired
78            | ApiKeyErrorCode::KeyNotFound
79            | ApiKeyErrorCode::UnauthorizedSession
80            | ApiKeyErrorCode::InvalidPrefixLength
81            | ApiKeyErrorCode::InvalidNameLength
82            | ApiKeyErrorCode::MetadataDisabled
83            | ApiKeyErrorCode::NoValuesToUpdate
84            | ApiKeyErrorCode::KeyDisabledExpiration
85            | ApiKeyErrorCode::ExpiresInTooSmall
86            | ApiKeyErrorCode::ExpiresInTooLarge
87            | ApiKeyErrorCode::InvalidRemaining
88            | ApiKeyErrorCode::RefillAmountAndIntervalRequired
89            | ApiKeyErrorCode::RefillIntervalAndAmountRequired
90            | ApiKeyErrorCode::NameRequired
91            | ApiKeyErrorCode::InvalidUserIdFromApiKey
92            | ApiKeyErrorCode::InvalidReferenceIdFromApiKey
93            | ApiKeyErrorCode::OrganizationIdRequired
94            | ApiKeyErrorCode::OrganizationPluginRequired
95            | ApiKeyErrorCode::UserNotMemberOfOrganization
96            | ApiKeyErrorCode::InsufficientApiKeyPermissions
97            | ApiKeyErrorCode::ServerOnlyProperty
98            | ApiKeyErrorCode::FailedToUpdateApiKey
99            | ApiKeyErrorCode::InvalidMetadataType => 401,
100        }
101    }
102
103    fn response(&self) -> AuthResult<AuthResponse> {
104        Ok(AuthResponse::json(self.status(), self)?)
105    }
106}
107
108/// Distinguishes a rejected credential from a storage or infrastructure failure.
109#[derive(Debug)]
110pub enum ApiKeyVerificationError {
111    /// The credential or its permissions, quota, or configuration was rejected.
112    Validation(ApiKeyValidationError),
113    /// An internal operation failed. The original typed error is preserved.
114    Internal(AuthError),
115    /// An explicitly selected validator failed before verification's catch
116    /// boundary. Server endpoint adapters propagate this original typed error.
117    ExplicitValidator(AuthError),
118}
119
120impl std::fmt::Display for ApiKeyVerificationError {
121    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
122        match self {
123            Self::Validation(error) => f.write_str(error.message.text()),
124            Self::Internal(error) | Self::ExplicitValidator(error) => {
125                write!(f, "API key verification failed: {error}")
126            }
127        }
128    }
129}
130
131impl std::error::Error for ApiKeyVerificationError {
132    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
133        match self {
134            Self::Internal(error) | Self::ExplicitValidator(error) => Some(error),
135            Self::Validation(_) => None,
136        }
137    }
138}
139
140impl From<AuthError> for ApiKeyVerificationError {
141    fn from(error: AuthError) -> Self {
142        Self::Internal(error)
143    }
144}
145
146impl From<ApiKeyErrorCode> for ApiKeyVerificationError {
147    fn from(code: ApiKeyErrorCode) -> Self {
148        Self::Validation(ApiKeyValidationError::new(code))
149    }
150}
151
152impl ApiKeyPlugin {
153    /// Verify a machine credential without a user session or public HTTP endpoint.
154    ///
155    /// A successful verification consumes quota and a rate-limit slot. Permissions
156    /// and configuration mismatches do not consume usage. The returned key omits
157    /// the plaintext credential and stored hash.
158    ///
159    /// Without `config_id`, lookup uses the default configuration's hashing
160    /// setting. Validation then uses the configuration that issued the key.
161    ///
162    /// # Errors
163    ///
164    /// Returns an error if verification hooks or storage operations fail.
165    pub async fn verify_api_key(
166        &self,
167        input: &VerifyApiKey<'_>,
168        ctx: &AuthContext<impl alibi_core::AuthSchema>,
169    ) -> Result<ApiKeyView, ApiKeyVerificationError> {
170        self.verify_api_key_with_registration(input, None, ctx)
171            .await
172    }
173
174    /// Verify with the actual caller request available to trusted predicates.
175    /// This performs the same server-only verification operation as `verify_api_key`.
176    ///
177    /// # Errors
178    ///
179    /// Propagates verification-hook or storage errors for the supplied request.
180    pub async fn verify_api_key_with_request(
181        &self,
182        input: &VerifyApiKey<'_>,
183        request: &AuthRequest,
184        ctx: &AuthContext<impl alibi_core::AuthSchema>,
185    ) -> Result<ApiKeyView, ApiKeyVerificationError> {
186        self.verify_api_key_with_registration(input, Some(request), ctx)
187            .await
188    }
189
190    pub(super) async fn verify_api_key_with_registration(
191        &self,
192        input: &VerifyApiKey<'_>,
193        request: Option<&AuthRequest>,
194        ctx: &AuthContext<impl alibi_core::AuthSchema>,
195    ) -> Result<ApiKeyView, ApiKeyVerificationError> {
196        let view = self
197            .verify_api_key_checked(input, request, ctx, true)
198            .await?;
199        if self
200            .resolve_configuration(Some(&view.config_id))?
201            .defer_updates
202        {
203            self.register_expired_cleanup(ctx).await?;
204        }
205        Ok(view)
206    }
207
208    pub(super) async fn verify_api_key_checked(
209        &self,
210        input: &VerifyApiKey<'_>,
211        request: Option<&AuthRequest>,
212        ctx: &AuthContext<impl alibi_core::AuthSchema>,
213        server_operation: bool,
214    ) -> Result<ApiKeyView, ApiKeyVerificationError> {
215        let lookup_config = self
216            .resolve_configuration(input.config_id)
217            .map_err(|_error| ApiKeyErrorCode::NoDefaultConfiguration)?;
218        if server_operation
219            && input.config_id.is_some()
220            && let Some(validator) = &lookup_config.custom_api_key_validator
221            && !validator
222                .validate(
223                    &ApiKeyCallbackContext::new(request, ctx, &lookup_config.config_id)
224                        .with_verification_input(input),
225                    input.key,
226                )
227                .await
228                .map_err(ApiKeyVerificationError::ExplicitValidator)?
229        {
230            return Err(ApiKeyVerificationError::Validation(ApiKeyValidationError {
231                code: ApiKeyErrorCode::KeyNotFound,
232                message: ApiKeyErrorMessage::CodeMessage {
233                    code: ApiKeyErrorCode::InvalidApiKey,
234                    message: ApiKeyErrorCode::InvalidApiKey.message().to_owned(),
235                },
236                details: None,
237            }));
238        }
239        let hashed = if lookup_config.disable_key_hashing {
240            input.key.to_owned()
241        } else {
242            Self::hash_key(input.key)
243        };
244        let api_key = lookup_config
245            .read_key(ctx, &hashed, true)
246            .await?
247            .ok_or(ApiKeyErrorCode::InvalidApiKey)?;
248
249        if input
250            .config_id
251            .is_some_and(|expected| !config_id_matches(&api_key.config_id, expected))
252        {
253            return Err(ApiKeyErrorCode::InvalidApiKey.into());
254        }
255        let config = self
256            .resolve_configuration(Some(&api_key.config_id))
257            .map_err(|_error| ApiKeyErrorCode::NoDefaultConfiguration)?;
258
259        if server_operation
260            && input.config_id.is_none()
261            && let Some(validator) = &config.custom_api_key_validator
262            && !validator
263                .validate(
264                    &ApiKeyCallbackContext::new(request, ctx, &config.config_id)
265                        .with_verification_input(input),
266                    input.key,
267                )
268                .await?
269        {
270            return Err(ApiKeyErrorCode::KeyNotFound.into());
271        }
272
273        if !api_key.enabled {
274            return Err(ApiKeyErrorCode::KeyDisabled.into());
275        }
276        if let Some(expires_at) = api_key.expires_at.as_deref() {
277            let expiration = chrono::DateTime::parse_from_rfc3339(expires_at).map_err(|error| {
278                AuthError::internal(format!("Invalid stored API key expiration: {error}"))
279            })?;
280            if chrono::Utc::now() > expiration {
281                Self::delete_rejected_key(&api_key, config, ctx).await?;
282                return Err(ApiKeyErrorCode::KeyExpired.into());
283            }
284        }
285        if let Some(required) = input.permissions {
286            let permitted = match api_key.permissions.as_deref() {
287                Some(permissions) => super::handlers::check_permissions(permissions, required)?,
288                None => false,
289            };
290            if !permitted {
291                return Err(ApiKeyErrorCode::KeyNotFound.into());
292            }
293        }
294
295        // Source deletes only an initially observed zero/no-refill row.
296        // A positive-snapshot loser of atomic consumption rejects without deletion.
297        if api_key.remaining == Some(0.0) && api_key.refill_amount.is_none() {
298            Self::delete_rejected_key(&api_key, config, ctx).await?;
299            return Err(ApiKeyErrorCode::UsageExceeded.into());
300        }
301
302        if !config.uses_database() {
303            let updated = Self::consume_secondary_usage(&api_key, config, ctx).await?;
304            return Ok(ApiKeyView::from(&updated));
305        }
306        let updated = match ctx
307            .database
308            .consume_api_key_usage_from_snapshot(&api_key, config.rate_limit.enabled)
309            .await?
310        {
311            ConsumeApiKeyResult::Allowed(key) => key,
312            ConsumeApiKeyResult::RateLimited { try_again_in } => {
313                let mut error = ApiKeyValidationError::new(ApiKeyErrorCode::RateLimited);
314                error.details = Some(ApiKeyErrorDetails { try_again_in });
315                return Err(ApiKeyVerificationError::Validation(error));
316            }
317            ConsumeApiKeyResult::UsageExhausted => {
318                return Err(ApiKeyErrorCode::UsageExceeded.into());
319            }
320        };
321        config.cache_key(updated.as_ref()).await?;
322        Ok(ApiKeyView::from(updated.as_ref()))
323    }
324
325    async fn delete_rejected_key(
326        key: &alibi_core::ApiKey,
327        config: &ApiKeyConfig,
328        ctx: &AuthContext<impl alibi_core::AuthSchema>,
329    ) -> AuthResult<()> {
330        if !config.defer_updates {
331            return config.remove_key(ctx, key).await;
332        }
333        let database = std::sync::Arc::clone(&ctx.database);
334        let key = key.clone();
335        let config = config.clone();
336        let storage = config.secondary();
337        let completion = Self::start_background_work(async move {
338            let deletion = async {
339                if config.storage == super::ApiKeyStorageMode::SecondaryStorage {
340                    let storage = storage
341                        .ok_or_else(|| AuthError::internal("Secondary storage is required"))?;
342                    super::storage::remove_storage(storage, &key, config.fallback_to_database)
343                        .await?;
344                }
345                if config.uses_database() {
346                    database.delete_api_key(&key.id).await?;
347                }
348                Ok::<_, AuthError>(())
349            }
350            .await;
351            if let Err(error) = deletion {
352                tracing::error!(%error, "Deferred update failed");
353            }
354            Ok(())
355        })
356        .await?;
357        if let Some(handler) = &ctx.config.background_tasks {
358            handler.handle(completion)
359        } else {
360            drop(completion);
361            Ok(())
362        }
363    }
364
365    pub(super) fn find_session_key<'a>(
366        &'a self,
367        req: &AuthRequest,
368        ctx: &AuthContext<impl alibi_core::AuthSchema>,
369    ) -> AuthResult<Option<(&'a ApiKeyConfig, String)>> {
370        self.find_session_key_for_input(&req.headers, Some(req), None, ctx)
371    }
372
373    pub(super) fn find_session_key_for_input<'a>(
374        &'a self,
375        headers: &std::collections::HashMap<String, String>,
376        request: Option<&AuthRequest>,
377        endpoint: Option<&alibi_core::endpoint::EndpointCall>,
378        ctx: &AuthContext<impl alibi_core::AuthSchema>,
379    ) -> AuthResult<Option<(&'a ApiKeyConfig, String)>> {
380        for config in self
381            .configurations
382            .iter()
383            .filter(|config| config.enable_session_for_api_keys)
384        {
385            let key = match &config.custom_api_key_getter {
386                Some(getter) => {
387                    let mut context = ApiKeyCallbackContext::new(request, ctx, &config.config_id);
388                    if let Some(endpoint) = endpoint {
389                        context = context.with_endpoint(endpoint);
390                    }
391                    getter.get_key(&context)?
392                }
393                None => config.api_key_headers.iter().find_map(|header| {
394                    headers
395                        .get(&header.to_ascii_lowercase())
396                        .filter(|key| !key.is_empty())
397                        .cloned()
398                }),
399            };
400            if let Some(key) = key.filter(|key| !key.is_empty()) {
401                return Ok(Some((config, key)));
402            }
403        }
404        Ok(None)
405    }
406
407    #[expect(
408        clippy::too_many_lines,
409        reason = "Keep API key validation and session substitution in one ordered middleware decision"
410    )]
411    pub(super) async fn api_key_session(
412        &self,
413        req: &AuthRequest,
414        ctx: &AuthContext<impl alibi_core::AuthSchema>,
415    ) -> AuthResult<Option<BeforeRequestAction>> {
416        // The Source hook matcher executes before its middleware handler. A
417        // getter failure at that stage becomes the dispatcher's matcher error,
418        // including an intentional API error thrown by the application.
419        if self
420            .find_session_key(req, ctx)
421            .map_err(|_error| AuthError::Api {
422                status: 500,
423                code: None,
424                message: "An error occurred during hook matcher execution. Check the logs for more details.".into(),
425            })?
426            .is_none()
427        {
428            return Ok(None);
429        }
430        let (config, key) = match self.find_session_key(req, ctx) {
431            Ok(Some(value)) => value,
432            Ok(None) => {
433                tracing::error!("API key getter did not return a key after matching");
434                return Ok(Some(BeforeRequestAction::Respond(AuthResponse::new(500))));
435            }
436            Err(error)
437                if error.status_code() >= 500
438                    && !matches!(error, AuthError::Api { .. } | AuthError::Upstream { .. }) =>
439            {
440                tracing::error!(error = %error, "API key getter failed");
441                return Ok(Some(BeforeRequestAction::Respond(AuthResponse::new(500))));
442            }
443            Err(error) => return Err(error),
444        };
445
446        if f64::from(
447            u32::try_from(key.encode_utf16().count())
448                .map_err(|error| AuthError::internal(error.to_string()))?,
449        ) < config.key_length
450        {
451            return Ok(Some(BeforeRequestAction::Respond(AuthResponse::json(
452                403,
453                &ApiKeyValidationError::new(ApiKeyErrorCode::InvalidApiKey),
454            )?)));
455        }
456        if let Some(validator) = &config.custom_api_key_validator {
457            let valid = match validator
458                .validate(
459                    &ApiKeyCallbackContext::new(Some(req), ctx, &config.config_id),
460                    &key,
461                )
462                .await
463            {
464                Ok(valid) => valid,
465                Err(error)
466                    if error.status_code() >= 500
467                        && !matches!(error, AuthError::Api { .. } | AuthError::Upstream { .. }) =>
468                {
469                    tracing::error!(error = %error, "API key validator failed");
470                    return Ok(Some(BeforeRequestAction::Respond(AuthResponse::new(500))));
471                }
472                Err(error) => return Err(error),
473            };
474            if !valid {
475                return Ok(Some(BeforeRequestAction::Respond(AuthResponse::json(
476                    403,
477                    &ApiKeyValidationError::new(ApiKeyErrorCode::InvalidApiKey),
478                )?)));
479            }
480        }
481        let input = VerifyApiKey {
482            key: &key,
483            config_id: Some(&config.config_id),
484            permissions: None,
485        };
486        let view = match self
487            .verify_api_key_checked(&input, Some(req), ctx, false)
488            .await
489        {
490            Ok(view) => view,
491            Err(ApiKeyVerificationError::Validation(error)) => {
492                return Ok(Some(BeforeRequestAction::Respond(error.response()?)));
493            }
494            Err(
495                ApiKeyVerificationError::Internal(error)
496                | ApiKeyVerificationError::ExplicitValidator(error),
497            ) => {
498                // At this source middleware validation stage ordinary failures
499                // become an empty 500; explicit application API errors survive.
500                if error.status_code() >= 500
501                    && !matches!(error, AuthError::Api { .. } | AuthError::Upstream { .. })
502                {
503                    return Ok(Some(BeforeRequestAction::Respond(AuthResponse::new(500))));
504                }
505                return Err(error);
506            }
507        };
508        if config.defer_updates {
509            let completion = self.start_configured_cleanup(ctx).await?;
510            if let Some(handler) = &ctx.config.background_tasks {
511                if let Err(error) = handler.handle(completion) {
512                    if error.status_code() >= 500
513                        && !matches!(error, AuthError::Api { .. } | AuthError::Upstream { .. })
514                    {
515                        return Ok(Some(BeforeRequestAction::Respond(AuthResponse::new(500))));
516                    }
517                    return Err(error);
518                }
519            } else {
520                drop(completion);
521            }
522        } else {
523            self.maybe_delete_expired(ctx).await?;
524        }
525
526        if config.references != ApiKeyReferences::User {
527            return Ok(Some(BeforeRequestAction::Respond(
528                ApiKeyValidationError::new(ApiKeyErrorCode::InvalidReferenceIdFromApiKey)
529                    .response()?,
530            )));
531        }
532        let Some(user) = ctx.database.get_user_by_id(&view.reference_id).await? else {
533            return Ok(Some(BeforeRequestAction::Respond(
534                ApiKeyValidationError::new(ApiKeyErrorCode::InvalidReferenceIdFromApiKey)
535                    .response()?,
536            )));
537        };
538
539        let session = Self::virtual_session_from_key(&view, &key, &user, Some(req), ctx)?;
540        // Upstream answers this path in its hook before the route method gate.
541        if req.path() == "/get-session" {
542            return Ok(Some(BeforeRequestAction::Respond(AuthResponse::json(
543                200,
544                &serde_json::json!({
545                    "user": ctx.user_view(&user),
546                    "session": session,
547                }),
548            )?)));
549        }
550        Ok(Some(BeforeRequestAction::InjectSession { session }))
551    }
552}
553
554impl ApiKeyPlugin {
555    pub(super) fn virtual_session_from_key<S: alibi_core::AuthSchema>(
556        view: &ApiKeyView,
557        key: &str,
558        user: &S::User,
559        request: Option<&AuthRequest>,
560        ctx: &AuthContext<S>,
561    ) -> AuthResult<SessionView> {
562        let now = chrono::Utc::now();
563        let expires_at = match view.expires_at.as_deref() {
564            Some(value) => chrono::DateTime::parse_from_rfc3339(value)
565                .map_err(|error| {
566                    AuthError::internal(format!("Invalid stored API key expiration: {error}"))
567                })?
568                .with_timezone(&chrono::Utc),
569            // Upstream passes its session lifetime in seconds to getDate(..., "ms").
570            None => {
571                now + chrono::Duration::milliseconds(ctx.config.session.expires_in.num_seconds())
572            }
573        };
574        // Virtual principals retain the resolver's nullable result. Physical
575        // session creation supplies empty defaults through RequestMeta.
576        let ip_policy = request
577            .and_then(|request| {
578                request
579                    .extensions()
580                    .get::<alibi_core::config::IpAddressConfig>()
581            })
582            .unwrap_or_default();
583        let session = SessionView {
584            omitted_fields: std::collections::BTreeSet::default(),
585            active_team_id: None,
586            extension_fields: std::collections::BTreeMap::default(),
587            id: view.id.clone(),
588            token: key.to_owned(),
589            user_id: user.id().into_owned(),
590            created_at: now,
591            updated_at: now,
592            expires_at,
593            ip_address: request.and_then(|request| ip_policy.resolve_ip(&request.headers)),
594            user_agent: request.and_then(|request| {
595                request
596                    .headers
597                    .iter()
598                    .find(|(name, _)| name.eq_ignore_ascii_case("user-agent"))
599                    .map(|(_, value)| value.clone())
600            }),
601            impersonated_by: None,
602            active_organization_id: None,
603        };
604        Ok(session)
605    }
606}
607
608impl std::fmt::Debug for VerifyApiKey<'_> {
609    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
610        f.debug_struct("VerifyApiKey").finish_non_exhaustive()
611    }
612}