arche 4.11.0

An opinionated backend foundation for Axum applications, providing batteries-included integrations for cloud services, databases, authentication, middleware, and logging.
Documentation
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};

use serde::Deserialize;
use tokio::sync::RwLock;

use crate::error::AppError;

use super::reject_token;

const DEFAULT_TTL: Duration = Duration::from_secs(3600);
const MAX_TTL_SECS: u64 = 86_400;
const STALE_GRACE_MULTIPLIER: u32 = 2;

#[derive(Debug, Clone, Deserialize)]
pub(super) struct Jwk {
    pub(super) kid: String,
    pub(super) n: String,
    pub(super) e: String,
}

#[derive(Deserialize)]
struct Jwks {
    keys: Vec<Jwk>,
}

#[derive(Default)]
struct State {
    keys: HashMap<String, Jwk>,
    expires_at: Option<Instant>,
    ttl: Duration,
}

impl State {
    fn is_fresh(&self) -> bool {
        self.expires_at
            .map(|exp| exp > Instant::now())
            .unwrap_or(false)
    }

    fn within_stale_grace(&self) -> bool {
        let Some(exp) = self.expires_at else {
            return false;
        };
        Instant::now() < exp + self.ttl * STALE_GRACE_MULTIPLIER
    }
}

#[derive(Clone)]
pub(super) struct JwksCache {
    endpoint: String,
    dependency: String,
    state: Arc<RwLock<State>>,
}

impl JwksCache {
    pub(super) fn new(endpoint: String, dependency: String) -> Self {
        Self {
            endpoint,
            dependency,
            state: Arc::new(RwLock::new(State::default())),
        }
    }

    pub(super) async fn lookup(&self, http: &reqwest::Client, kid: &str) -> Result<Jwk, AppError> {
        if let Some(jwk) = self.lookup_if_fresh(kid).await {
            return Ok(jwk);
        }

        let mut state = self.state.write().await;
        if state.is_fresh()
            && let Some(jwk) = state.keys.get(kid).cloned()
        {
            return Ok(jwk);
        }

        match fetch(http, &self.endpoint, &self.dependency).await {
            Ok((keys, ttl)) => {
                state.keys = keys.into_iter().map(|k| (k.kid.clone(), k)).collect();
                state.expires_at = Some(Instant::now() + ttl);
                state.ttl = ttl;
                state
                    .keys
                    .get(kid)
                    .cloned()
                    .ok_or_else(|| reject_token(format!("unknown signing key: {kid}")))
            }
            Err(fetch_err) => {
                if state.within_stale_grace()
                    && let Some(jwk) = state.keys.get(kid).cloned()
                {
                    tracing::warn!(kid = %kid, "serving stale JWKS key while upstream unreachable");
                    return Ok(jwk);
                }
                Err(fetch_err)
            }
        }
    }

    async fn lookup_if_fresh(&self, kid: &str) -> Option<Jwk> {
        let state = self.state.read().await;
        if state.is_fresh() {
            state.keys.get(kid).cloned()
        } else {
            None
        }
    }
}

async fn fetch(
    http: &reqwest::Client,
    endpoint: &str,
    dependency: &str,
) -> Result<(Vec<Jwk>, Duration), AppError> {
    let resp = http
        .get(endpoint)
        .send()
        .await
        .map_err(|e| AppError::dependency_failed(dependency, format!("jwks fetch: {e}")))?;
    let status = resp.status();
    let ttl = parse_cache_control_max_age(resp.headers()).unwrap_or(DEFAULT_TTL);
    if !status.is_success() {
        return Err(AppError::dependency_failed(
            dependency,
            format!("jwks endpoint returned {status}"),
        ));
    }
    let body = resp
        .bytes()
        .await
        .map_err(|e| AppError::dependency_failed(dependency, format!("jwks body: {e}")))?;
    let parsed: Jwks = serde_json::from_slice(&body).map_err(|e| {
        AppError::dependency_failed_permanent(dependency, format!("jwks parse: {e}"))
    })?;
    Ok((parsed.keys, ttl))
}

fn parse_cache_control_max_age(headers: &reqwest::header::HeaderMap) -> Option<Duration> {
    let value = headers.get(reqwest::header::CACHE_CONTROL)?.to_str().ok()?;
    for directive in value.split(',') {
        let directive = directive.trim();
        if let Some(rest) = directive
            .strip_prefix("max-age=")
            .or_else(|| directive.strip_prefix("max-age ="))
        {
            return rest
                .parse::<u64>()
                .ok()
                .map(|s| Duration::from_secs(s.min(MAX_TTL_SECS)));
        }
    }
    None
}

#[cfg(test)]
mod tests {
    use super::*;

    fn headers_with(value: &str) -> reqwest::header::HeaderMap {
        let mut h = reqwest::header::HeaderMap::new();
        h.insert(reqwest::header::CACHE_CONTROL, value.parse().unwrap());
        h
    }

    #[test]
    fn cache_control_max_age_parsing() {
        assert_eq!(
            parse_cache_control_max_age(&headers_with("public, max-age=21600, must-revalidate")),
            Some(Duration::from_secs(21600))
        );
        assert!(parse_cache_control_max_age(&reqwest::header::HeaderMap::new()).is_none());
        assert!(parse_cache_control_max_age(&headers_with("max-age=oops")).is_none());
    }
}