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
//! OAuth2 `client_credentials` grant source for [`TokenCache`].

use std::time::Duration;

use super::{TokenCacheError, TokenSource};

/// Acquires tokens via the OAuth2 `client_credentials` grant (RFC 6749 §4.4).
///
/// POSTs `grant_type=client_credentials` + `client_id` + `client_secret`
/// to `token_url` and extracts `access_token` + `expires_in` from the
/// JSON response.
pub struct ClientCredentialsSource {
    http: reqwest::Client,
    token_url: String,
    client_id: String,
    client_secret: String,
}

impl ClientCredentialsSource {
    pub fn new(
        token_url: impl Into<String>,
        client_id: impl Into<String>,
        client_secret: impl Into<String>,
    ) -> Self {
        Self {
            http: reqwest::Client::new(),
            token_url: token_url.into(),
            client_id: client_id.into(),
            client_secret: client_secret.into(),
        }
    }
}

#[derive(serde::Deserialize)]
struct TokenResponse {
    access_token: String,
    expires_in: u64,
}

#[async_trait::async_trait]
impl TokenSource for ClientCredentialsSource {
    async fn fetch_token(&self) -> Result<(String, Duration), TokenCacheError> {
        let resp = self
            .http
            .post(&self.token_url)
            .form(&[
                ("grant_type", "client_credentials"),
                ("client_id", self.client_id.as_str()),
                ("client_secret", self.client_secret.as_str()),
            ])
            .send()
            .await
            .map_err(|e: reqwest::Error| TokenCacheError::Fetch(e.to_string()))?;

        if !resp.status().is_success() {
            let status = resp.status();
            let body: String = resp.text().await.unwrap_or_default();
            return Err(TokenCacheError::Fetch(format!("{status}: {body}")));
        }

        let body: TokenResponse = resp
            .json::<TokenResponse>()
            .await
            .map_err(|e: reqwest::Error| TokenCacheError::Malformed(e.to_string()))?;

        Ok((body.access_token, Duration::from_secs(body.expires_in)))
    }
}