cedarling 0.0.49

The Cedarling: a high-performance local authorization service powered by the Rust Cedar Engine.
Documentation
// This software is available under the Apache-2.0 license.
//
// See https://www.apache.org/licenses/LICENSE-2.0.txt for full text.
//
// Copyright (c) 2024, Gluu, Inc.

use jsonwebtoken::Algorithm;
use serde::{Deserialize, Serialize};
use std::{collections::HashSet, num::NonZeroUsize};

/// The set of Bootstrap properties related to JWT validation.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct JwtConfig {
    /// A Json Web Key Store (JWKS) with public keys.
    ///
    /// If this is used, Cedarling will no longer try to fetch JWK Stores from
    /// a trustede identity provider and stick to using the local JWKS.
    pub jwks: Option<String>,
    /// Check the signature for all the Json Web Tokens.
    ///
    /// This Requires the `iss` claim to be present in all the tokens and
    /// and the scheme must be `https`.
    ///
    /// This setting overrides the `iss` validation settings in the following:
    ///
    /// - `access_token_config`
    /// - `id_token_config`
    /// - `userinfo_token_config`
    pub jwt_sig_validation: bool,
    /// Whether to check the status of the JWT.
    ///
    /// On startup, the Cedarling will fetch and retreive the latest Status List
    /// JWT from the `.well-known/openid-configuration` via the `status_list_endpoint`
    /// claim and cache it.
    ///
    /// See the [`IETF Draft`] for more info.
    ///
    /// [`IETF Draft`]: https://datatracker.ietf.org/doc/draft-ietf-oauth-status-list/
    pub jwt_status_validation: bool,
    /// Only tokens signed with algorithms in this list can be valid.
    pub signature_algorithms_supported: HashSet<Algorithm>,
    /// Maximum TTL (in seconds) for cached tokens.
    /// Zero disables the token cache entirely.
    ///
    /// Defaults to [`Self::DEFAULT_TOKEN_CACHE_MAX_TTL_SECS`] (5 seconds): small
    /// enough that revocation / status-list changes are picked up promptly while
    /// still serving repeated requests for the same token from cache. Keep this
    /// value short (seconds, not minutes) to bound cache growth and staleness.
    pub token_cache_max_ttl_secs: usize,
    /// Maximum number of tokens the cache can store.
    pub token_cache_capacity: usize,
    /// Enables eviction policy based on the earliest expiration time.
    ///
    /// When the cache reaches its capacity, the entry with the nearest
    /// expiration timestamp will be removed to make room for a new one.
    pub token_cache_earliest_expiration_eviction: bool,
    /// Configuration for loading trusted issuers.
    pub trusted_issuer_loader: TrustedIssuerLoaderConfig,
    /// Optional override for JWKS periodic refresh interval in seconds.
    /// Set to `None` to use the server-driven or fallback interval.
    pub jwks_refresh_interval: Option<u64>,
    /// Minimum interval in seconds between on-demand JWKS re-fetches per issuer.
    pub jwks_refresh_min_interval: u64,
    /// Upper bound on the Status List JWT refresh interval in seconds.
    ///
    /// Caps how long Cedarling waits between Status List refreshes. When the Status
    /// List JWT carries a `ttl` claim (per the IETF `oauth-status-list` spec), the
    /// effective refresh interval is `min(jwt_ttl, status_list_refresh_interval_max)`
    /// so the issuer can always request a *more frequent* refresh, but never a less
    /// frequent one. When the JWT omits `ttl`, this value is used directly.
    ///
    /// A value of `0` is treated as "use the default" so the cache cannot be left to
    /// go stale forever. Non-zero values below `MIN_STATUS_LIST_REFRESH_SECS` (5) are
    /// clamped to that minimum.
    pub status_list_refresh_interval_max: u64,
}

/// Default periodic JWKS refresh interval when neither `Cache-Control: max-age`
/// nor the bootstrap config provides a value.
pub(crate) const DEFAULT_JWKS_REFRESH_INTERVAL_SECS: u64 = 3600;

/// Minimum allowed refresh interval, in seconds. Values below this are clamped.
pub(crate) const MIN_JWKS_REFRESH_SECS: u64 = 5;

/// Minimum allowed Status List JWT refresh interval (in seconds). Non-zero values
/// below this are clamped up to this floor.
pub(crate) const MIN_STATUS_LIST_REFRESH_SECS: u64 = 5;

/// Normalize a candidate Status List JWT refresh maximum (seconds) into a safe
/// interval. Single source of truth shared by the bootstrap deserializer and
/// [`JwtConfig::normalize`]:
///
/// - `0` -> [`JwtConfig::DEFAULT_STATUS_LIST_REFRESH_INTERVAL_MAX_SECS`]
///   (300s): the cache must never be left to go stale forever, so "disabled"
///   is not a supported state.
/// - Non-zero values below [`MIN_STATUS_LIST_REFRESH_SECS`] are clamped up to
///   that floor to avoid hammering the IDP.
/// - Any other value is returned unchanged.
#[must_use]
pub(crate) fn normalize_status_list_refresh_interval_max(value: u64) -> u64 {
    match value {
        0 => JwtConfig::DEFAULT_STATUS_LIST_REFRESH_INTERVAL_MAX_SECS,
        v => v.max(MIN_STATUS_LIST_REFRESH_SECS),
    }
}

impl Default for JwtConfig {
    /// Cedarling uses strict-by-default validation.
    ///
    /// The returned value is identical to what is produced when parsing an
    /// empty bootstrap configuration (`BootstrapConfigRaw::default()` →
    /// `JwtConfig`). Signature and status validation are **on**. To opt out
    /// for testing or trusted environments, set the flags explicitly or use
    /// [`JwtConfig::new_without_validation`].
    fn default() -> Self {
        let config = Self {
            jwks: None,
            jwt_sig_validation: true,
            jwt_status_validation: true,
            signature_algorithms_supported: HashSet::new(),
            token_cache_capacity: Self::DEFAULT_TOKEN_CACHE_CAPACITY,
            token_cache_earliest_expiration_eviction: true,
            token_cache_max_ttl_secs: Self::DEFAULT_TOKEN_CACHE_MAX_TTL_SECS,
            trusted_issuer_loader: TrustedIssuerLoaderConfig::default(),
            jwks_refresh_interval: None,
            jwks_refresh_min_interval: Self::DEFAULT_JWKS_REFRESH_MIN_INTERVAL,
            status_list_refresh_interval_max:
                JwtConfig::DEFAULT_STATUS_LIST_REFRESH_INTERVAL_MAX_SECS,
        };
        config.allow_all_algorithms()
    }
}

impl JwtConfig {
    /// Default maximum number of tokens the token cache can store.
    pub const DEFAULT_TOKEN_CACHE_CAPACITY: usize = 100;

    /// Default maximum TTL (seconds) for cached tokens.
    ///
    /// Chosen as a small value so that repeated requests for the same token are
    /// served from cache, while revocation / status-list changes are still
    /// picked up within a few seconds. Prevents unbounded cache growth for
    /// tokens without an `exp` claim.
    pub const DEFAULT_TOKEN_CACHE_MAX_TTL_SECS: usize = 5;

    /// Default minimum interval (seconds) between on-demand JWKS re-fetches per issuer.
    pub const DEFAULT_JWKS_REFRESH_MIN_INTERVAL: u64 = 30;

    /// Default upper bound for the Status List JWT refresh interval (in seconds),
    /// applied when the Status List JWT has no `ttl` claim and as a cap on JWT-
    /// provided values. Also used when the bootstrap property is set to `0`.
    pub const DEFAULT_STATUS_LIST_REFRESH_INTERVAL_MAX_SECS: u64 = 300;

    /// Enforce all field-level invariants on the config in place. Called once
    /// during service initialization on a cloned `JwtConfig`, so that callers
    /// who construct the struct programmatically cannot bypass the bootstrap
    /// deserializer's normalization.
    pub(crate) fn normalize(&mut self) {
        self.status_list_refresh_interval_max = normalize_status_list_refresh_interval_max(
            self.status_list_refresh_interval_max,
        );
    }

    /// Creates a new `JwtConfig` instance with validation turned off for all tokens.
    ///
    /// Intended for tests and trusted-environment embedders. Production
    /// deployments should use [`JwtConfig::default`] (strict).
    #[must_use]
    pub fn new_without_validation() -> Self {
        Self {
            jwks: None,
            jwt_sig_validation: false,
            jwt_status_validation: false,
            signature_algorithms_supported: HashSet::new(),
            ..Default::default()
        }
        .allow_all_algorithms()
    }

    pub(crate) fn supported_algorithms() -> HashSet<Algorithm> {
        HashSet::from_iter([
            Algorithm::HS256,
            Algorithm::HS384,
            Algorithm::HS512,
            Algorithm::ES256,
            Algorithm::ES384,
            Algorithm::RS256,
            Algorithm::RS384,
            Algorithm::RS512,
            Algorithm::PS256,
            Algorithm::PS384,
            Algorithm::PS512,
            Algorithm::EdDSA,
        ])
    }

    /// Adds all supported algorithms to `signature_algorithms_supported`.
    #[must_use]
    pub fn allow_all_algorithms(mut self) -> Self {
        self.signature_algorithms_supported = Self::supported_algorithms();
        self
    }
}

/// Raw representation of trusted issuer loader type from environment variable.
/// Is used in [`BootstrapConfigRaw`].
#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
pub enum TrustedIssuerLoaderTypeRaw {
    /// Synchronous loading
    #[default]
    #[serde(rename = "SYNC")]
    Sync,
    /// Asynchronous loading
    #[serde(rename = "ASYNC")]
    Async,
}

impl TrustedIssuerLoaderTypeRaw {
    /// Converts raw representation to `TrustedIssuerLoaderConfig`.
    pub(crate) fn to_config(&self, workers: WorkersCount) -> TrustedIssuerLoaderConfig {
        match self {
            TrustedIssuerLoaderTypeRaw::Sync => TrustedIssuerLoaderConfig::Sync { workers },
            TrustedIssuerLoaderTypeRaw::Async => TrustedIssuerLoaderConfig::Async { workers },
        }
    }
}

/// Config structure that define how trusted issuers will be loaded.
///
/// Default is `Sync` with 1 worker.
#[derive(Debug, Copy, Clone, PartialEq, Serialize, Deserialize)]
pub enum TrustedIssuerLoaderConfig {
    /// Synchronous loading, on start program.
    /// The Cedarling will load all entities on start in "blocking mode" (you need to wait).
    Sync {
        /// Workers count
        workers: WorkersCount,
    },
    /// Asynchronous loading, on start program.
    /// The Cedarling will load all entities on the background.
    /// You need specify workers count.
    Async {
        /// Workers count
        workers: WorkersCount,
    },
}

impl Default for TrustedIssuerLoaderConfig {
    fn default() -> Self {
        Self::Sync {
            workers: WorkersCount::MIN,
        }
    }
}

/// A wrapper around `NonZeroUsize` that enforces a maximum value.
/// This is used to ensure that the number of workers for loading trusted issuers does not exceed a reasonable limit.
/// The maximum value is defined based on the target architecture to prevent excessive resource usage.
///
/// On non-WebAssembly targets, the maximum is set to 1000, while on WebAssembly targets,
/// it is set to 4 to align with typical browser limits on concurrent HTTP connections.
#[derive(
    Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, derive_more::Deref, serde::Serialize,
)]
pub struct WorkersCount(NonZeroUsize);

#[cfg(not(target_arch = "wasm32"))]
impl WorkersCount {
    /// Maximum number of workers is 1000 to prevent excessive resource usage.
    pub const MAX: WorkersCount = WorkersCount(NonZeroUsize::new(1000).unwrap());

    /// For native architecture default value is 10. Should cover most of cases.
    pub const DEFAULT: WorkersCount = WorkersCount(NonZeroUsize::new(10).unwrap());
}

#[cfg(target_arch = "wasm32")]
impl WorkersCount {
    /// On WebAssembly targets, we set workers limit 6.
    ///
    /// Most of web browsers have limit of 6 concurrent http connections, so we respect that.
    pub const MAX: WorkersCount = WorkersCount(NonZeroUsize::new(6).unwrap());

    /// For WASM architecture default value is 2
    pub const DEFAULT: WorkersCount = WorkersCount(NonZeroUsize::new(2).unwrap());
}

impl WorkersCount {
    /// Minimum number of workers is 1.
    pub const MIN: WorkersCount = WorkersCount(NonZeroUsize::MIN);
}

impl WorkersCount {
    /// Creates a new `NonZeroUsizeLimited` instance, ensuring the value is non-zero and does not exceed the defined maximum.
    #[must_use]
    pub fn new(value: usize) -> Self {
        let value = NonZeroUsize::new(value)
            .unwrap_or(NonZeroUsize::MIN)
            .min(Self::MAX.0);

        Self(value)
    }
}

impl Default for WorkersCount {
    fn default() -> Self {
        Self::DEFAULT
    }
}

impl<'de> serde::Deserialize<'de> for WorkersCount {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        let value = usize::deserialize(deserializer)?;
        Ok(Self::new(value))
    }
}

impl PartialEq<usize> for WorkersCount {
    fn eq(&self, other: &usize) -> bool {
        self.0.get() == *other
    }
}