openauth-plugins 0.0.5

Official OpenAuth plugin modules.
Documentation
use http::{Method, StatusCode};
use openauth_core::context::AuthContext;
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;

use super::{body, endpoint, json, SharedConfigurations};
use crate::api_key::cleanup;
use crate::api_key::errors;
use crate::api_key::hashing::default_key_hasher;
use crate::api_key::models::{ApiKeyPublicRecord, ApiKeyRecord};
use crate::api_key::options::{ApiKeyConfiguration, ApiKeyPermissions};
use crate::api_key::permissions;
use crate::api_key::rate_limit;
use crate::api_key::storage::ApiKeyStore;

#[derive(Debug, Clone, Deserialize, Serialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct VerifyApiKeyRequest {
    pub config_id: Option<String>,
    pub key: String,
    pub permissions: Option<ApiKeyPermissions>,
}

#[derive(Debug, Clone, Serialize)]
pub struct VerifyApiKeyResponse {
    pub valid: bool,
    pub error: Option<VerifyApiKeyErrorBody>,
    pub key: Option<ApiKeyPublicRecord>,
}

#[derive(Debug, Clone, Serialize)]
pub struct VerifyApiKeyErrorBody {
    pub code: String,
    pub message: String,
    #[serde(skip_serializing_if = "Option::is_none", rename = "tryAgainIn")]
    pub try_again_in: Option<i64>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ApiKeyValidationError {
    pub code: &'static str,
    pub status: StatusCode,
    pub try_again_in: Option<i64>,
}

pub fn verify_endpoint(
    configurations: SharedConfigurations,
) -> openauth_core::api::AsyncAuthEndpoint {
    endpoint(
        "/api-key/verify",
        Method::POST,
        configurations,
        |context, request, configurations| {
            Box::pin(async move {
                let input: VerifyApiKeyRequest = body(&request)?;
                let options = configurations.resolve(input.config_id.as_deref())?;
                if let Some(validator) = &options.custom_api_key_validator {
                    if !validator(context, &input.key).await? {
                        return json(
                            StatusCode::OK,
                            &VerifyApiKeyResponse {
                                valid: false,
                                error: Some(VerifyApiKeyErrorBody {
                                    code: errors::INVALID_API_KEY.to_owned(),
                                    message: errors::message(errors::INVALID_API_KEY).to_owned(),
                                    try_again_in: None,
                                }),
                                key: None,
                            },
                        );
                    }
                }
                let hashed = if options.disable_key_hashing {
                    input.key
                } else {
                    default_key_hasher(&input.key)
                };
                match validate_api_key(context, &options, &hashed, input.permissions.as_ref()).await
                {
                    Ok(api_key) => {
                        if options.defer_updates {
                            let _ = cleanup::delete_all_expired_api_keys(context, &options, false)
                                .await;
                        }
                        json(
                            StatusCode::OK,
                            &VerifyApiKeyResponse {
                                valid: true,
                                error: None,
                                key: Some(api_key.public()),
                            },
                        )
                    }
                    Err(error) => json(
                        StatusCode::OK,
                        &VerifyApiKeyResponse {
                            valid: false,
                            error: Some(VerifyApiKeyErrorBody {
                                code: error.code.to_owned(),
                                message: errors::message(error.code).to_owned(),
                                try_again_in: error.try_again_in,
                            }),
                            key: None,
                        },
                    ),
                }
            })
        },
    )
}

pub async fn validate_api_key(
    context: &AuthContext,
    options: &ApiKeyConfiguration,
    hashed_key: &str,
    required_permissions: Option<&ApiKeyPermissions>,
) -> Result<ApiKeyRecord, ApiKeyValidationError> {
    let store = ApiKeyStore::new(context, options);
    let mut api_key = store
        .get_by_hash(hashed_key)
        .await
        .map_err(|_| validation_error(errors::INVALID_API_KEY, StatusCode::UNAUTHORIZED))?
        .ok_or_else(|| validation_error(errors::INVALID_API_KEY, StatusCode::UNAUTHORIZED))?;
    if !api_key.enabled {
        return Err(validation_error(
            errors::KEY_DISABLED,
            StatusCode::UNAUTHORIZED,
        ));
    }
    let now = OffsetDateTime::now_utc();
    if api_key
        .expires_at
        .is_some_and(|expires_at| now > expires_at)
    {
        let _ = store.delete(&api_key).await;
        return Err(validation_error(
            errors::KEY_EXPIRED,
            StatusCode::UNAUTHORIZED,
        ));
    }
    if !permissions::allows(api_key.permissions.as_ref(), required_permissions) {
        return Err(validation_error(
            errors::KEY_NOT_FOUND,
            StatusCode::UNAUTHORIZED,
        ));
    }
    let mut remaining = api_key.remaining;
    let mut last_refill_at = api_key.last_refill_at;
    if api_key.remaining == Some(0) && api_key.refill_amount.is_none() {
        let _ = store.delete(&api_key).await;
        return Err(validation_error(
            errors::USAGE_EXCEEDED,
            StatusCode::TOO_MANY_REQUESTS,
        ));
    }
    if let Some(current_remaining) = remaining {
        if let (Some(refill_interval), Some(refill_amount)) =
            (api_key.refill_interval, api_key.refill_amount)
        {
            let last = last_refill_at.unwrap_or(api_key.created_at);
            if (now - last).whole_milliseconds() > i128::from(refill_interval) {
                remaining = Some(refill_amount);
                last_refill_at = Some(now);
            }
        }
        if remaining == Some(0) {
            return Err(validation_error(
                errors::USAGE_EXCEEDED,
                StatusCode::TOO_MANY_REQUESTS,
            ));
        }
        if current_remaining > 0 || remaining.is_some_and(|value| value > 0) {
            remaining = remaining.map(|value| value.saturating_sub(1));
        }
    }
    let rate_limit = rate_limit::check(&api_key, options, now);
    if !rate_limit.success {
        return Err(ApiKeyValidationError {
            code: errors::RATE_LIMIT_EXCEEDED,
            status: StatusCode::UNAUTHORIZED,
            try_again_in: rate_limit.try_again_in,
        });
    }
    api_key.remaining = remaining;
    api_key.last_refill_at = last_refill_at;
    if let Some(last_request) = rate_limit.last_request {
        api_key.last_request = Some(last_request);
    }
    if let Some(request_count) = rate_limit.request_count {
        api_key.request_count = request_count;
    }
    api_key.updated_at = now;
    if options.defer_updates {
        let updated = api_key.clone();
        let options = options.clone();
        let context = context.clone();
        let task_context = context.clone();
        if !context.run_background_task(Box::pin(async move {
            let _ = ApiKeyStore::new(&task_context, &options)
                .update(&updated)
                .await;
        })) {
            store.update(&api_key).await.map_err(|_| {
                validation_error(
                    errors::FAILED_TO_UPDATE_API_KEY,
                    StatusCode::INTERNAL_SERVER_ERROR,
                )
            })?;
        }
    } else {
        store.update(&api_key).await.map_err(|_| {
            validation_error(
                errors::FAILED_TO_UPDATE_API_KEY,
                StatusCode::INTERNAL_SERVER_ERROR,
            )
        })?;
    }
    Ok(api_key)
}

fn validation_error(code: &'static str, status: StatusCode) -> ApiKeyValidationError {
    ApiKeyValidationError {
        code,
        status,
        try_again_in: None,
    }
}