ppoppo-sdk-core 0.2.0

Internal shared primitives for the Ppoppo SDK family (pas-external, pas-plims, pcs-external) — verifier port, audit trait, session liveness port, OIDC discovery, perimeter Bearer-auth Layer kit, identity types. Not a stable public API; do not depend on this crate directly. Consume the SDK crates that re-export from it (e.g. `pas-external`).
Documentation
//! TTL-cached JWKS fetcher.
//!
//! Fetches `/.well-known/jwks.json` from PAS, parses to
//! [`ppoppo_token::Jwks`], converts to [`ppoppo_token::KeySet`] (the
//! engine's verification key store), and serves snapshots to
//! [`super::JwtVerifier::verify`] under a refresh-if-stale rule.
//!
//! Design notes
//!
//! - **Visibility — `pub` (Phase A)**: Phase A audit decision lifted
//!   the visibility from `pub(crate)` (pas-external 0.10.x) to `pub`
//!   here. sdk-core's verifier cohesive group migration moves the
//!   primitive to where multiple SDK crates can layer atop, so the
//!   constructor/snapshot surface is reachable without re-exposing
//!   internal pas-external state. Consumer-facing entry is still
//!   [`super::JwtVerifier::from_jwks_url`] — direct `JwksCache`
//!   construction is not part of the documented SDK boundary, but the
//!   `pub` lift lets future SDK crates compose without a re-export
//!   passthrough.
//! - **Stale-on-failure**: a failed refresh logs and continues with the
//!   cached snapshot. Active sessions survive a transient PAS outage;
//!   a fleet-wide 401 storm after one fetch error is the failure mode
//!   this rule prevents.
//! - **Atomic rotation**: refresh swaps an `Arc<EngineKeySet>` under a
//!   tokio RwLock; in-flight verifications observe either the old or
//!   the new keyset, never a half-applied state.

use std::sync::Arc;
use std::time::Duration;

use ppoppo_clock::ArcClock;
use ppoppo_clock::native::WallClock;
use time::OffsetDateTime;
use tokio::sync::RwLock;

use ppoppo_token::{Jwks, KeySet as EngineKeySet};

use super::VerifyError;

/// Default JWKS cache TTL when the HTTP `Cache-Control: max-age` header
/// is absent. Conservative — refreshes are cheap (small JSON, single
/// in-flight per-pod), so a tighter default is preferable to one that
/// silently extends the rotation window.
const DEFAULT_TTL: Duration = Duration::from_secs(300);

/// Refresh-on-demand JWKS cache. Constructed by
/// [`super::JwtVerifier::from_jwks_url`] and consumed via
/// [`Self::snapshot`] on every verify.
#[derive(Clone)]
pub struct JwksCache {
    clock: ArcClock,
    inner: Arc<JwksCacheInner>,
}

impl std::fmt::Debug for JwksCache {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("JwksCache")
            .field("url", &self.inner.url)
            .finish_non_exhaustive()
    }
}

impl JwksCache {
    /// Swap the clock used for TTL calculation.
    ///
    /// Defaults to `WallClock`. Tests inject a frozen clock to control
    /// the stale-check without sleeping.
    #[must_use]
    pub fn with_clock(mut self, clock: ArcClock) -> Self {
        self.clock = clock;
        self
    }
}

struct JwksCacheInner {
    url: String,
    http: reqwest::Client,
    state: RwLock<JwksCacheState>,
}

struct JwksCacheState {
    keyset: Arc<EngineKeySet>,
    fetched_at: OffsetDateTime,
    ttl: Duration,
}

#[derive(Debug, thiserror::Error)]
#[error("JWKS fetch failed (status={status:?}): {detail}")]
struct FetchFailure {
    status: Option<u16>,
    detail: String,
}

impl JwksCache {
    /// Initial fetch + cache. Returns
    /// [`VerifyError::KeysetUnavailable`] if the first fetch fails or
    /// the response is not a parseable JWKS — the caller cannot
    /// proceed without at least one usable keyset, by design.
    pub async fn fetch(url: impl Into<String>) -> Result<Self, VerifyError> {
        let clock: ArcClock = Arc::new(WallClock);
        let url = url.into();
        let http = reqwest::Client::builder()
            .timeout(Duration::from_secs(10))
            .connect_timeout(Duration::from_secs(5))
            .build()
            .map_err(|_| VerifyError::KeysetUnavailable)?;

        let (jwks, ttl) = fetch_jwks(&http, &url)
            .await
            .map_err(|_| VerifyError::KeysetUnavailable)?;
        let keyset = jwks.into_key_set().map_err(|_| VerifyError::KeysetUnavailable)?;

        Ok(Self {
            inner: Arc::new(JwksCacheInner {
                url,
                http,
                state: RwLock::new(JwksCacheState {
                    keyset: Arc::new(keyset),
                    fetched_at: clock.now_utc(),
                    ttl,
                }),
            }),
            clock,
        })
    }

    /// Test-support ctor — bypasses JWKS fetch entirely. The cache
    /// holds an empty keyset so any engine verify path rejects on
    /// `KidUnknown`.
    #[cfg(any(test, feature = "test-support"))]
    pub fn for_test_empty() -> Self {
        let clock: ArcClock = Arc::new(WallClock);
        Self {
            inner: Arc::new(JwksCacheInner {
                url: String::from("test://empty"),
                http: reqwest::Client::new(),
                state: RwLock::new(JwksCacheState {
                    keyset: Arc::new(EngineKeySet::default()),
                    fetched_at: clock.now_utc(),
                    ttl: Duration::from_secs(86_400),
                }),
            }),
            clock,
        }
    }

    /// Snapshot — refreshes if the TTL has expired, then returns an
    /// Arc to the current engine keyset.
    pub async fn snapshot(&self) -> Arc<EngineKeySet> {
        self.refresh_if_stale().await;
        self.inner.state.read().await.keyset.clone()
    }

    async fn refresh_if_stale(&self) {
        let needs_refresh = {
            let guard = self.inner.state.read().await;
            let elapsed = self.clock.now_utc() - guard.fetched_at;
            let ttl = time::Duration::try_from(guard.ttl).unwrap_or(time::Duration::seconds(300));
            elapsed >= ttl
        };
        if !needs_refresh {
            return;
        }
        match fetch_jwks(&self.inner.http, &self.inner.url).await {
            Ok((jwks, ttl)) => {
                if let Ok(keyset) = jwks.into_key_set() {
                    let mut guard = self.inner.state.write().await;
                    guard.keyset = Arc::new(keyset);
                    guard.fetched_at = self.clock.now_utc();
                    guard.ttl = ttl;
                }
            }
            Err(_e) => {
                #[cfg(feature = "axum")]
                tracing::warn!(error = %_e, "JwksCache refresh failed; serving stale snapshot");
            }
        }
    }
}

async fn fetch_jwks(http: &reqwest::Client, url: &str) -> Result<(Jwks, Duration), FetchFailure> {
    let response = http.get(url).send().await.map_err(|e| FetchFailure {
        status: None,
        detail: format!("send: {e}"),
    })?;
    if !response.status().is_success() {
        let status = response.status().as_u16();
        let body = response.text().await.unwrap_or_default();
        return Err(FetchFailure {
            status: Some(status),
            detail: body,
        });
    }
    let ttl = parse_max_age(&response).unwrap_or(DEFAULT_TTL);
    let jwks = response.json::<Jwks>().await.map_err(|e| FetchFailure {
        status: None,
        detail: format!("parse failed: {e}"),
    })?;
    Ok((jwks, ttl))
}

fn parse_max_age(response: &reqwest::Response) -> Option<Duration> {
    let value = response.headers().get(reqwest::header::CACHE_CONTROL)?;
    let s = value.to_str().ok()?;
    for part in s.split(',') {
        let part = part.trim();
        if let Some(rest) = part.strip_prefix("max-age=") {
            if let Ok(secs) = rest.trim().parse::<u64>() {
                return Some(Duration::from_secs(secs));
            }
        }
    }
    None
}