pas-external 0.12.0

Ppoppo Accounts System (PAS) external SDK — OAuth2 PKCE, JWT verification port, Axum middleware, session liveness
Documentation
use serde::Deserialize;
use url::Url;

use crate::error::Error;

const DEFAULT_AUTH_URL: &str = "https://accounts.ppoppo.com/oauth/authorize";
const DEFAULT_TOKEN_URL: &str = "https://accounts.ppoppo.com/oauth/token";

/// Ppoppo Accounts `OAuth2` configuration.
///
/// SDK-internal as of 0.8.0 — `oidc::RelyingParty<S>` constructs this
/// from `oidc::Config` + the discovered endpoints. Consumers reach OAuth
/// through the OIDC RP composition root, not this builder directly.
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct OAuthConfig {
    pub(crate) client_id: String,
    pub(crate) auth_url: Url,
    pub(crate) token_url: Url,
    pub(crate) redirect_uri: Url,
}

impl OAuthConfig {
    #[must_use]
    #[allow(clippy::expect_used)] // Infallible parse — URLs are compile-time constants
    pub fn new(client_id: impl Into<String>, redirect_uri: Url) -> Self {
        Self {
            client_id: client_id.into(),
            redirect_uri,
            auth_url: DEFAULT_AUTH_URL.parse().expect("valid default URL"),
            token_url: DEFAULT_TOKEN_URL.parse().expect("valid default URL"),
        }
    }

    #[must_use]
    pub fn with_auth_url(mut self, url: Url) -> Self {
        self.auth_url = url;
        self
    }

    #[must_use]
    pub fn with_token_url(mut self, url: Url) -> Self {
        self.token_url = url;
        self
    }
}

/// `OAuth2` authorization client for Ppoppo Accounts.
pub struct AuthClient {
    config: OAuthConfig,
    http: reqwest::Client,
}

/// Token response from PAS token endpoint.
///
/// `id_token` is OIDC-only (RFC 6749 token responses carry only
/// access + refresh; OIDC Core §3.1.3.3 adds `id_token` when scope
/// includes `openid`). [`crate::oidc::RelyingParty<S>`] reads it
/// internally and converts to [`crate::oidc::RefreshOutcome`] /
/// [`crate::oidc::Completion`] at the SDK boundary.
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct TokenResponse {
    pub access_token: String,
    pub token_type: String,
    #[serde(default)]
    pub expires_in: Option<u64>,
    #[serde(default)]
    pub refresh_token: Option<String>,
    #[serde(default)]
    pub id_token: Option<String>,
}

impl AuthClient {
    /// Create a new Ppoppo Accounts auth client.
    ///
    /// Returns an error iff `reqwest::Client::builder()` cannot construct a
    /// client with the configured timeouts (TLS init failure, OS-level
    /// resource exhaustion). The previous `unwrap_or_default()` path silently
    /// substituted a no-timeout client, which converted a startup failure
    /// into a runtime hang on the first PAS call — fail loudly instead.
    ///
    /// # Errors
    ///
    /// Returns [`Error::Http`] if the underlying HTTP client cannot be built.
    pub fn try_new(config: OAuthConfig) -> Result<Self, Error> {
        let builder = reqwest::Client::builder();
        #[cfg(not(target_arch = "wasm32"))]
        let builder = builder
            .timeout(std::time::Duration::from_secs(10))
            .connect_timeout(std::time::Duration::from_secs(5));
        Ok(Self {
            config,
            http: builder.build()?,
        })
    }

    /// Exchange an authorization code for tokens using PKCE.
    ///
    /// # Errors
    ///
    /// Returns [`Error::Http`] on network failure, or
    /// [`Error::OAuth`] if the token endpoint returns an error.
    pub async fn exchange_code(
        &self,
        code: &str,
        code_verifier: &str,
    ) -> Result<TokenResponse, Error> {
        let params = [
            ("grant_type", "authorization_code"),
            ("code", code),
            ("redirect_uri", self.config.redirect_uri.as_str()),
            ("client_id", self.config.client_id.as_str()),
            ("code_verifier", code_verifier),
        ];

        self.send_classified(
            self.http.post(self.config.token_url.clone()).form(&params),
        )
        .await
        .map_err(|f| f.into_legacy_error("token exchange"))
    }

    /// The single place in this module that reads HTTP status codes
    /// from PAS token / exchange-code responses. The `PasAuthPort::refresh`
    /// impl consumes the resulting [`PasFailure`] directly; the
    /// legacy-signature inherent method `exchange_code` converts via
    /// [`PasFailure::into_legacy_error`].
    ///
    /// Note: `keyset::fetch_document` performs its own status-reading
    /// for the well-known keyset document and does not route through
    /// here.
    async fn send_classified<T: serde::de::DeserializeOwned>(
        &self,
        request: reqwest::RequestBuilder,
    ) -> Result<T, crate::pas_port::PasFailure> {
        use crate::pas_port::PasFailure;

        let response = request
            .send()
            .await
            .map_err(|e| PasFailure::Transport { detail: e.to_string() })?;

        let status = response.status();
        if status.is_server_error() {
            let body = response.text().await.unwrap_or_default();
            return Err(PasFailure::ServerError { status: status.as_u16(), detail: body });
        }
        if !status.is_success() {
            let body = response.text().await.unwrap_or_default();
            return Err(PasFailure::Rejected { status: status.as_u16(), detail: body });
        }

        response.json::<T>().await.map_err(|e| PasFailure::Transport {
            detail: format!("response deserialization failed: {e}"),
        })
    }
}

impl crate::pas_port::PasAuthPort for AuthClient {
    async fn refresh(
        &self,
        refresh_token: &str,
    ) -> Result<TokenResponse, crate::pas_port::PasFailure> {
        let params = [
            ("grant_type", "refresh_token"),
            ("refresh_token", refresh_token),
            ("client_id", self.config.client_id.as_str()),
        ];

        self.send_classified(
            self.http.post(self.config.token_url.clone()).form(&params),
        )
        .await
    }
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use super::*;

    #[test]
    fn config_constructor_sets_defaults() {
        let config = OAuthConfig::new("my-app", "https://my-app.com/callback".parse().unwrap());

        assert_eq!(config.client_id, "my-app");
        assert_eq!(config.redirect_uri.as_str(), "https://my-app.com/callback");
        assert_eq!(
            config.auth_url.as_str(),
            "https://accounts.ppoppo.com/oauth/authorize"
        );
        assert_eq!(
            config.token_url.as_str(),
            "https://accounts.ppoppo.com/oauth/token"
        );
    }

    #[test]
    fn config_with_overrides_swap_endpoints() {
        let config = OAuthConfig::new("my-app", "https://my-app.com/callback".parse().unwrap())
            .with_auth_url("https://custom.example.com/authorize".parse().unwrap())
            .with_token_url("https://custom.example.com/token".parse().unwrap());

        assert_eq!(
            config.auth_url.as_str(),
            "https://custom.example.com/authorize"
        );
        assert_eq!(
            config.token_url.as_str(),
            "https://custom.example.com/token"
        );
    }
}