pas-external 0.17.0

Ppoppo Accounts System (PAS) external SDK — OAuth2 PKCE, JWT verification port, Axum middleware, session liveness
Documentation
//! # Renewing `TokenSource` — refresh-grant + rotated-token persistence
//!
//! A [`RefreshTokenSource`] is a [`ppoppo_sdk_core::token_cache::TokenSource`]
//! for clients that hold a **durable refresh token** and want a
//! [`ppoppo_sdk_core::token_cache::TokenCache`] to renew the access token on
//! its own: on cache lapse the cache calls [`TokenSource::fetch_token`], which
//! runs the OAuth `refresh_token` grant, **persists the rotated refresh token**
//! (PAS applies RTR — RFC 9700 §2.2.2 — so every refresh returns a new token),
//! and hands back the fresh access token + its TTL.
//!
//! The persistence seam is [`TokenStore`]: the consumer plugs its keystore —
//! CNC uses the macOS Keychain; a short-lived process can use
//! [`MemoryTokenStore`]. RCW/CTW do **not** use this type — their OIDC RP flow
//! refreshes per request and re-stores inline; this is for native / CLI clients
//! that feed a long-lived `TokenCache`.
//!
//! ## The `Send` discipline
//!
//! [`TokenStore`] and [`TokenSource`] are `#[async_trait]` (their futures are
//! boxed `+ Send`), and [`crate::pas_port::PasAuthPort::refresh`] is `+ Send`.
//! So [`RefreshTokenSource`]'s `fetch_token` future is `Send` **by
//! construction** — the impl only compiles if its body holds `Send` values
//! across every `await`. Never introduce an `async` closure on this path: it
//! carries a higher-ranked lifetime whose `Send`-ness fails to prove on a
//! multi-threaded runtime (the yanked-0.5.2 lesson). A `#[tokio::test(flavor =
//! "multi_thread")]` that `spawn`s the future guards it.

use std::time::Duration;

use async_trait::async_trait;
use ppoppo_sdk_core::token_cache::{TokenCacheError, TokenSource};

use crate::pas_port::{PasAuthPort, PasFailure};

/// A [`TokenStore`] backend error. Consumers wrap their native keystore error
/// in the message; it is tunnelled through [`TokenCacheError::Source`] so the
/// SDK layer can surface it intact.
#[derive(Debug, thiserror::Error)]
#[error("token store error: {0}")]
pub struct TokenStoreError(String);

impl TokenStoreError {
    /// Wrap a backend failure message.
    #[must_use]
    pub fn new(message: impl Into<String>) -> Self {
        Self(message.into())
    }
}

/// Durable persistence seam for a rotated refresh token. All methods are
/// `Send`-safe for multi-threaded runtimes.
///
/// The consumer plugs the keystore: CNC → macOS Keychain, a short-lived process
/// → [`MemoryTokenStore`]. The store owns exactly one refresh token per session
/// (the latest, after rotation).
#[async_trait]
pub trait TokenStore: Send + Sync {
    /// The currently-persisted refresh token, or `None` if unauthenticated.
    async fn load(&self) -> Result<Option<String>, TokenStoreError>;
    /// Persist a (freshly-rotated) refresh token, replacing any prior one.
    async fn save(&self, refresh_token: &str) -> Result<(), TokenStoreError>;
    /// Drop the persisted refresh token (logout / terminal refresh failure).
    async fn clear(&self) -> Result<(), TokenStoreError>;
}

/// An ephemeral in-memory [`TokenStore`] — a reference adapter and test double.
///
/// **Not durable**: the token lives only for the process lifetime. Real clients
/// plug a persistent keystore (Keychain, encrypted DB column). Useful for a
/// short-lived CLI invocation or a consumer's tests.
#[derive(Debug, Default)]
pub struct MemoryTokenStore {
    inner: std::sync::Mutex<Option<String>>,
}

impl MemoryTokenStore {
    /// An empty store (unauthenticated).
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// A store pre-seeded with an initial refresh token (e.g. straight from the
    /// authorization_code exchange, before the first renewal).
    #[must_use]
    pub fn with_token(refresh_token: impl Into<String>) -> Self {
        Self { inner: std::sync::Mutex::new(Some(refresh_token.into())) }
    }
}

#[async_trait]
impl TokenStore for MemoryTokenStore {
    async fn load(&self) -> Result<Option<String>, TokenStoreError> {
        let guard = self.inner.lock().map_err(|_| TokenStoreError::new("lock poisoned"))?;
        Ok(guard.clone())
    }

    async fn save(&self, refresh_token: &str) -> Result<(), TokenStoreError> {
        let mut guard = self.inner.lock().map_err(|_| TokenStoreError::new("lock poisoned"))?;
        *guard = Some(refresh_token.to_owned());
        Ok(())
    }

    async fn clear(&self) -> Result<(), TokenStoreError> {
        let mut guard = self.inner.lock().map_err(|_| TokenStoreError::new("lock poisoned"))?;
        *guard = None;
        Ok(())
    }
}

/// A renewing [`TokenSource`]: mints access tokens via the OAuth `refresh_token`
/// grant and persists the rotated refresh token through a [`TokenStore`].
///
/// Depends on the [`PasAuthPort`] *port* (not the concrete `AuthClient`), so it
/// is unit-tested against an in-memory adapter with no HTTP. Wire it into a
/// [`ppoppo_sdk_core::token_cache::TokenCache`] so the cache renews on lapse.
pub struct RefreshTokenSource<P: PasAuthPort, S: TokenStore> {
    auth: P,
    store: S,
}

impl<P: PasAuthPort, S: TokenStore> RefreshTokenSource<P, S> {
    /// `auth` performs the refresh grant (typically an `AuthClient` carrying the
    /// token endpoint + client_id + RFC 8707 resource); `store` holds the
    /// durable refresh token (seed it with the initial token first).
    pub fn new(auth: P, store: S) -> Self {
        Self { auth, store }
    }
}

#[async_trait]
impl<P: PasAuthPort, S: TokenStore> TokenSource for RefreshTokenSource<P, S> {
    async fn fetch_token(&self) -> Result<(String, Duration), TokenCacheError> {
        let refresh_token = self
            .store
            .load()
            .await
            .map_err(|e| TokenCacheError::Source(Box::new(e)))?
            .ok_or_else(|| {
                TokenCacheError::Source(Box::new(TokenStoreError::new(
                    "no refresh token stored — client is not authenticated",
                )))
            })?;

        match self.auth.refresh(&refresh_token).await {
            Ok(response) => {
                // RTR: persist the rotated token so the NEXT renewal presents it.
                // Absent (a non-rotating server) → keep the current token.
                if let Some(rotated) = response.refresh_token.as_deref() {
                    self.store
                        .save(rotated)
                        .await
                        .map_err(|e| TokenCacheError::Source(Box::new(e)))?;
                }
                let ttl = Duration::from_secs(response.expires_in.unwrap_or(3600));
                Ok((response.access_token, ttl))
            }
            // 4xx: the refresh token is dead (expired / revoked / logged out
            // elsewhere, incl. RTR family-invalidation). Terminal — clear the
            // store so the next load reads "unauthenticated" rather than
            // replaying a dead token; the consumer must re-authenticate.
            Err(PasFailure::Rejected { detail, .. }) => {
                // Best-effort: we are already returning a terminal error.
                let _ = self.store.clear().await;
                Err(TokenCacheError::Fetch(format!(
                    "refresh rejected (credential dead): {detail}"
                )))
            }
            // 5xx / transport: transient. Keep the token; the caller may retry.
            Err(PasFailure::ServerError { detail, .. }) => {
                Err(TokenCacheError::Fetch(format!("PAS refresh 5xx: {detail}")))
            }
            Err(PasFailure::Transport { detail }) => {
                Err(TokenCacheError::Fetch(format!("PAS refresh transport error: {detail}")))
            }
        }
    }
}

#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
    use super::*;
    use crate::oauth::TokenResponse;
    use std::sync::Mutex;

    /// A canned `PasAuthPort` — returns a preset outcome, recording the refresh
    /// token it was presented so a test can assert the rotation chain.
    struct MockAuth {
        outcome: Mutex<Result<TokenResponse, PasFailure>>,
        seen: Mutex<Vec<String>>,
    }

    impl MockAuth {
        fn ok(access: &str, rotated: Option<&str>, expires_in: Option<u64>) -> Self {
            Self {
                outcome: Mutex::new(Ok(TokenResponse {
                    access_token: access.to_owned(),
                    token_type: "Bearer".to_owned(),
                    expires_in,
                    refresh_token: rotated.map(str::to_owned),
                    id_token: None,
                })),
                seen: Mutex::new(Vec::new()),
            }
        }
        fn rejected() -> Self {
            Self {
                outcome: Mutex::new(Err(PasFailure::Rejected {
                    status: 400,
                    detail: "invalid_grant".to_owned(),
                })),
                seen: Mutex::new(Vec::new()),
            }
        }
    }

    // `PasAuthPort::refresh` is native RPITIT (`-> impl Future + Send`), not
    // `#[async_trait]` — the mock matches that shape. The returned future owns
    // `token` and borrows `&self` (Send, since `MockAuth: Sync`).
    impl PasAuthPort for MockAuth {
        fn refresh(
            &self,
            refresh_token: &str,
        ) -> impl std::future::Future<Output = Result<TokenResponse, PasFailure>> + Send {
            let token = refresh_token.to_owned();
            async move {
                self.seen.lock().unwrap().push(token);
                self.outcome.lock().unwrap().clone()
            }
        }
    }

    #[tokio::test]
    async fn rotation_is_persisted_to_the_store() {
        let auth = MockAuth::ok("AT-1", Some("RT-2"), Some(900));
        let store = MemoryTokenStore::with_token("RT-1");
        let source = RefreshTokenSource::new(auth, store);

        let (access, ttl) = source.fetch_token().await.expect("fetch");
        assert_eq!(access, "AT-1");
        assert_eq!(ttl, Duration::from_secs(900));
        // The presented token was RT-1; the rotated RT-2 is now persisted.
        assert_eq!(source.auth.seen.lock().unwrap().as_slice(), ["RT-1"]);
        assert_eq!(source.store.load().await.unwrap().as_deref(), Some("RT-2"));

        // A second renewal presents the rotated token, not the original.
        let _ = source.fetch_token().await.expect("second fetch");
        assert_eq!(source.auth.seen.lock().unwrap().as_slice(), ["RT-1", "RT-2"]);
    }

    #[tokio::test]
    async fn absent_rotation_keeps_the_current_token() {
        let auth = MockAuth::ok("AT-1", None, None);
        let store = MemoryTokenStore::with_token("RT-1");
        let source = RefreshTokenSource::new(auth, store);

        let (_, ttl) = source.fetch_token().await.expect("fetch");
        assert_eq!(ttl, Duration::from_secs(3600), "missing expires_in defaults to 1h");
        assert_eq!(source.store.load().await.unwrap().as_deref(), Some("RT-1"));
    }

    #[tokio::test]
    async fn missing_credential_is_a_source_error() {
        let auth = MockAuth::ok("AT", Some("RT-2"), None);
        let source = RefreshTokenSource::new(auth, MemoryTokenStore::new());
        let err = source.fetch_token().await.expect_err("empty store must fail");
        assert!(matches!(err, TokenCacheError::Source(_)));
    }

    #[tokio::test]
    async fn rejected_refresh_clears_the_store() {
        let auth = MockAuth::rejected();
        let store = MemoryTokenStore::with_token("RT-dead");
        let source = RefreshTokenSource::new(auth, store);

        let err = source.fetch_token().await.expect_err("dead credential must fail");
        assert!(matches!(err, TokenCacheError::Fetch(_)));
        // The dead token is cleared so no retry replays it.
        assert_eq!(source.store.load().await.unwrap(), None);
    }

    /// The load-bearing `Send` guard: on a multi-thread runtime, `spawn`
    /// requires the future be `Send`. This only compiles if `fetch_token`'s
    /// body is `Send` across every await (the yanked-0.5.2 regression).
    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn fetch_token_future_is_send() {
        let source = RefreshTokenSource::new(
            MockAuth::ok("AT", Some("RT-2"), None),
            MemoryTokenStore::with_token("RT-1"),
        );
        let handle = tokio::spawn(async move { source.fetch_token().await });
        let (access, _) = handle.await.expect("join").expect("fetch");
        assert_eq!(access, "AT");
    }
}