pas-external 3.0.0

Ppoppo Accounts System (PAS) external SDK -- OAuth2 PKCE, PASETO verification, Axum middleware, session liveness
Documentation
//! TTL-cached fetcher for the PAS well-known PASETO keyset.
//!
//! Consumers that verify PASETO tokens locally (instead of always going
//! through the OAuth `userinfo` endpoint) need PAS's public keys. PAS
//! publishes them at a well-known URL with a `cache_ttl_seconds` hint, and
//! rotates them on a published cadence. Every consumer that handled this
//! itself before this module shipped solved the same five problems —
//! fetch, parse, cache, refresh, and "what to do when a fetch fails while
//! we still have stale-but-recent keys" — and got at least one wrong.
//!
//! [`KeySet`] solves them once:
//!
//! - **Fetch**: blocking call backed by a shared [`reqwest::Client`].
//! - **Cache**: holds the parsed [`WellKnownPasetoDocument`] in an
//!   `Arc<RwLock>`; verification reads the lock without an HTTP round-trip.
//! - **TTL respect**: refreshes when `now - fetched_at > cache_ttl_seconds`.
//! - **Rotation**: a successful refresh atomically swaps the cached doc;
//!   in-flight verifications either see the old or the new keyset, never
//!   a half-applied state.
//! - **Stale serving**: when a refresh fails *and* the cached keyset is
//!   non-empty, [`KeySet::verify`] continues to use the cached keys. This
//!   keeps an active fleet authenticating through a transient PAS outage
//!   instead of converging on a fleet-wide 401 storm. The S-L3 invariant
//!   from `STANDARDS_SESSION_LIVENESS.md` applies here too.
//!
//! # Example
//!
//! ```no_run
//! # async fn example() -> Result<(), pas_external::Error> {
//! use pas_external::KeySet;
//!
//! let keyset = KeySet::fetch("https://accounts.ppoppo.com/.well-known/paseto").await?;
//! let bearer_token = "v4.public...".to_string();
//!
//! // Later, on every incoming request that carries a PASETO bearer token:
//! let claims = keyset
//!     .verify(&bearer_token, "accounts.ppoppo.com", "ppoppo/*")
//!     .await?;
//! # let _ = claims;
//! # Ok(())
//! # }
//! ```

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

use time::OffsetDateTime;
use tokio::sync::RwLock;

use crate::error::Error;
use crate::token::{VerifiedClaims, verify_v4_with_keyset};
use crate::well_known::WellKnownPasetoDocument;

/// Cached keyset with automatic TTL refresh.
///
/// Cheap to clone — the inner state is shared via `Arc`. Build one at
/// startup and clone into every middleware / handler that verifies tokens.
#[derive(Clone)]
pub struct KeySet {
    inner: Arc<KeySetInner>,
}

struct KeySetInner {
    url: String,
    http: reqwest::Client,
    state: RwLock<KeySetState>,
}

struct KeySetState {
    document: WellKnownPasetoDocument,
    fetched_at: OffsetDateTime,
}

impl KeySet {
    /// Fetch the keyset for the first time.
    ///
    /// Subsequent refreshes happen lazily inside [`Self::verify`] when the
    /// `cache_ttl_seconds` window expires.
    ///
    /// # Errors
    ///
    /// Returns [`Error::Http`] on transport failure or
    /// [`Error::OAuth`] if the response status is not 2xx or the body cannot
    /// be parsed as a [`WellKnownPasetoDocument`].
    pub async fn fetch(url: impl Into<String>) -> Result<Self, Error> {
        let url = url.into();
        let http = reqwest::Client::builder()
            .timeout(Duration::from_secs(10))
            .connect_timeout(Duration::from_secs(5))
            .build()?;

        let document = fetch_document(&http, &url).await?;
        Ok(Self {
            inner: Arc::new(KeySetInner {
                url,
                http,
                state: RwLock::new(KeySetState {
                    document,
                    fetched_at: OffsetDateTime::now_utc(),
                }),
            }),
        })
    }

    /// Build with a caller-supplied HTTP client and an already-fetched document.
    ///
    /// Useful for tests, or for sharing a single `reqwest::Client` across the
    /// SDK's `AuthClient` and `KeySet`.
    #[must_use]
    pub fn with_initial(
        url: impl Into<String>,
        http: reqwest::Client,
        document: WellKnownPasetoDocument,
    ) -> Self {
        Self {
            inner: Arc::new(KeySetInner {
                url: url.into(),
                http,
                state: RwLock::new(KeySetState {
                    document,
                    fetched_at: OffsetDateTime::now_utc(),
                }),
            }),
        }
    }

    /// Verify a PASETO v4.public token, refreshing the cached keyset if
    /// the TTL has expired.
    ///
    /// On refresh failure with a non-empty cached keyset, the call continues
    /// with the stale cache (S-L3 in `STANDARDS_SESSION_LIVENESS.md`). On
    /// refresh failure with an empty keyset (impossible after `fetch`
    /// succeeded once, but possible if a consumer constructed via
    /// [`Self::with_initial`] with empty keys), the verification fails.
    ///
    /// # Errors
    ///
    /// Returns the underlying [`Error`] from
    /// [`verify_v4_with_keyset`].
    pub async fn verify(
        &self,
        token: &str,
        expected_issuer: &str,
        expected_audience: &str,
    ) -> Result<VerifiedClaims, Error> {
        self.refresh_if_stale().await;
        let guard = self.inner.state.read().await;
        verify_v4_with_keyset(&guard.document, token, expected_issuer, expected_audience)
    }

    /// Force a refresh regardless of TTL. Returns the new fetched-at instant
    /// on success, or the underlying fetch error on failure (the cached
    /// keyset is preserved either way).
    ///
    /// # Errors
    ///
    /// Returns the underlying [`Error`] from the HTTP fetch.
    pub async fn refresh_now(&self) -> Result<OffsetDateTime, Error> {
        let document = fetch_document(&self.inner.http, &self.inner.url).await?;
        let mut guard = self.inner.state.write().await;
        guard.document = document;
        guard.fetched_at = OffsetDateTime::now_utc();
        Ok(guard.fetched_at)
    }

    /// Snapshot the currently cached document.
    pub async fn snapshot(&self) -> WellKnownPasetoDocument {
        self.inner.state.read().await.document.clone()
    }

    async fn refresh_if_stale(&self) {
        let needs_refresh = {
            let guard = self.inner.state.read().await;
            let ttl = Duration::from_secs(guard.document.cache_ttl_seconds);
            let elapsed = OffsetDateTime::now_utc() - guard.fetched_at;
            elapsed >= time::Duration::try_from(ttl).unwrap_or(time::Duration::seconds(3600))
        };
        if !needs_refresh {
            return;
        }
        // Best-effort refresh. On failure, retain the stale cache — the
        // S-L3 "transient failures must not force logout" invariant from
        // STANDARDS_SESSION_LIVENESS.md applies to keyset rotation too.
        if let Err(e) = self.refresh_now().await {
            #[cfg(feature = "axum")]
            tracing::warn!(error = %e, "KeySet refresh failed, serving stale cache");
            // Without the axum feature there is no tracing dep; swallow.
            let _ = e;
        }
    }
}

async fn fetch_document(
    http: &reqwest::Client,
    url: &str,
) -> Result<WellKnownPasetoDocument, 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,
        });
    }
    response.json::<WellKnownPasetoDocument>().await.map_err(|e| {
        Error::OAuth {
            operation: "well-known fetch",
            status: None,
            detail: format!("parse failed: {e}"),
        }
    })
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use super::*;
    use crate::types::KeyId;
    use crate::well_known::{WellKnownKeyStatus, WellKnownPasetoKey};

    fn doc(ttl_secs: u64) -> WellKnownPasetoDocument {
        WellKnownPasetoDocument {
            issuer: "accounts.ppoppo.com".into(),
            version: "v4.public".into(),
            keys: vec![WellKnownPasetoKey {
                kid: KeyId("test-1".into()),
                public_key_hex: "a".repeat(64),
                status: WellKnownKeyStatus::Active,
                created_at: OffsetDateTime::now_utc(),
            }],
            cache_ttl_seconds: ttl_secs,
        }
    }

    #[tokio::test]
    async fn snapshot_returns_initial_document() {
        let http = reqwest::Client::new();
        let keyset = KeySet::with_initial("http://example.invalid", http, doc(3600));
        let snap = keyset.snapshot().await;
        assert_eq!(snap.cache_ttl_seconds, 3600);
        assert_eq!(snap.keys.len(), 1);
    }

    #[tokio::test]
    async fn refresh_failure_preserves_cache() {
        let http = reqwest::Client::new();
        // .invalid TLD per RFC 6761 — guaranteed not to resolve.
        let keyset = KeySet::with_initial("http://nonexistent.invalid", http, doc(0));
        let snap_before = keyset.snapshot().await;
        let _ = keyset.refresh_now().await;
        let snap_after = keyset.snapshot().await;
        assert_eq!(snap_before, snap_after, "cache must survive refresh failure");
    }
}