pas-external 0.8.0

Ppoppo Accounts System (PAS) external SDK — OAuth2 PKCE, JWT verification port, Axum middleware, session liveness
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::PasJwtVerifier::verify`] under a refresh-if-stale rule.
//!
//! Design notes
//!
//! - **Hidden from SDK boundary** (`pub(crate)`). The Finding 2 audit
//!   moved the JWKS-cache concern out of the consumer-visible surface
//!   so [`super::PasJwtVerifier::from_jwks_url`] is the single entry
//!   point and consumers never construct a [`JwksCache`] themselves.
//! - **Stale-on-failure**: a failed refresh logs and continues with the
//!   cached snapshot (S-L3 in `STANDARDS_SESSION_LIVENESS.md`). 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 time::OffsetDateTime;
use tokio::sync::RwLock;

use ppoppo_token::{Jwks, KeySet as EngineKeySet};
use crate::error::Error;
use crate::token::port::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::PasJwtVerifier::from_jwks_url`] and consumed via
/// [`Self::snapshot`] on every verify.
#[derive(Clone)]
pub(crate) struct JwksCache {
    inner: Arc<JwksCacheInner>,
}

impl std::fmt::Debug for JwksCache {
    /// Manual `Debug` because the engine's `KeySet` carries opaque
    /// `DecodingKey` values without a useful Display, and the
    /// `reqwest::Client` would dump connection-pool state. Surface
    /// only the URL — that's the actionable identity for log dumps.
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("JwksCache")
            .field("url", &self.inner.url)
            .finish_non_exhaustive()
    }
}

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

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

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(crate) async fn fetch(url: impl Into<String>) -> Result<Self, VerifyError> {
        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: OffsetDateTime::now_utc(),
                    ttl,
                }),
            }),
        })
    }

    /// Test-support ctor — bypasses JWKS fetch entirely. The cache
    /// holds an empty keyset so any engine verify path rejects on
    /// `KidUnknown` (mapped to [`VerifyError::SignatureInvalid`] per
    /// `map_auth_error`); adapter-side rejection paths in
    /// [`super::PasJwtVerifier::verify`] (InvalidFormat,
    /// IdTokenAsBearer) reject BEFORE consulting the keyset and are
    /// fully exercisable.
    ///
    /// Used by Phase 9.D's `tests/bearer_verifier_boundary.rs` audit-
    /// emission integration tests to drive the verify path without a
    /// wiremock-shaped JWKS endpoint. NOT for production use.
    #[cfg(any(test, feature = "test-support"))]
    pub(crate) fn for_test_empty() -> Self {
        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: OffsetDateTime::now_utc(),
                    // Long TTL so refresh_if_stale never fires during
                    // tests (avoids spurious network attempts on a
                    // machine with no DNS / firewalled outbound).
                    ttl: Duration::from_secs(86_400),
                }),
            }),
        }
    }

    /// Snapshot — refreshes if the TTL has expired, then returns an
    /// Arc to the current engine keyset. Callers verify against
    /// `&*snapshot` and drop the Arc when verify completes.
    pub(crate) 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 = OffsetDateTime::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;
        }
        // Best-effort refresh. On failure, retain the stale cache —
        // active sessions survive a transient PAS outage.
        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 = OffsetDateTime::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), Error> {
    let response = http.get(url).send().await?;
    if !response.status().is_success() {
        let status = response.status().as_u16();
        let body = response.text().await.unwrap_or_default();
        return Err(Error::OAuth {
            operation: "well-known fetch",
            status: Some(status),
            detail: body,
        });
    }
    let ttl = parse_max_age(&response).unwrap_or(DEFAULT_TTL);
    let jwks = response
        .json::<Jwks>()
        .await
        .map_err(|e| Error::OAuth {
            operation: "well-known fetch",
            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
}