pas-external 0.18.0

Ppoppo Accounts System (PAS) external SDK — OAuth2 PKCE, JWT verification port, Axum middleware, session liveness
Documentation
//! PAS OAuth2 wire types + HTTP adapter, split across two feature tiers.
//!
//! - [`TokenResponse`] — the PAS token-endpoint response DTO. Available
//!   at the `oauth` tier because `pas_port` / `session_liveness` /
//!   `oidc::state_store` consume it without the HTTP client.
//! - [`OAuthConfig`] + [`AuthClient`] — the production HTTP adapter.
//!   Gated on `well-known-fetch`, the tier of its sole consumer
//!   ([`oidc::RelyingParty`](crate::oidc::RelyingParty), whose
//!   `new` is the only place `AuthClient` is ever constructed). Compiling
//!   the adapter at the lower `oauth` tier orphaned it under the default
//!   feature set, since the RP composition root that uses it was not
//!   compiled there.

use serde::Deserialize;
#[cfg(feature = "well-known-fetch")]
use url::Url;

#[cfg(feature = "well-known-fetch")]
use crate::error::Error;

#[cfg(feature = "well-known-fetch")]
const DEFAULT_AUTH_URL: &str = "https://accounts.ppoppo.com/oauth/authorize";
#[cfg(feature = "well-known-fetch")]
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.
#[cfg(feature = "well-known-fetch")]
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct OAuthConfig {
    pub(crate) client_id: String,
    pub(crate) auth_url: Url,
    pub(crate) token_url: Url,
    /// RFC 6749 §4.1.3 requires the token-leg `redirect_uri` to be identical
    /// to the one sent at authorize. Kept as a raw `String` for the same
    /// reason as [`Self::resource`] — a `Url` round-trip re-adds the trailing
    /// slash the `url` crate normalizes onto a host-only URI, which would
    /// break that identity for a loopback redirect written without a path.
    ///
    /// `None` for a **refresh-only** client: the `refresh_token` grant sends
    /// no `redirect_uri` at all (RFC 6749 §6), so a client that will never
    /// exchange a code does not need one. `exchange_code` rejects that
    /// configuration rather than silently omitting a required parameter.
    pub(crate) redirect_uri: Option<String>,
    /// RFC 8707 resource indicator sent on the token / refresh legs. Kept as
    /// a raw `String` — never a `Url` — because PAS matches it byte-for-byte
    /// against `OAUTH_RESOURCE_INDICATORS`, and `Url::as_str()` re-adds the
    /// trailing slash the `url` crate normalizes onto a host-only URL
    /// (`http://localhost:3200` → `…3200/`), which would miss the match and
    /// mint `aud = client_id` instead (the RCW/CTW silent-401 bug class).
    pub(crate) resource: Option<String>,
}

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

    /// Set the redirect URI for the `authorization_code` leg, verbatim. See
    /// [`Self::redirect_uri`] — the string is forwarded byte-for-byte.
    #[must_use]
    pub fn with_redirect_uri(mut self, redirect_uri: impl Into<String>) -> Self {
        self.redirect_uri = Some(redirect_uri.into());
        self
    }

    #[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
    }

    /// Set the RFC 8707 resource indicator (an absolute URI, as a raw string)
    /// to send on the token / refresh legs. See [`Self::resource`].
    #[must_use]
    pub fn with_resource(mut self, resource: impl Into<String>) -> Self {
        self.resource = Some(resource.into());
        self
    }
}

/// `OAuth2` authorization client for Ppoppo Accounts.
#[cfg(feature = "well-known-fetch")]
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>,
    /// The scope actually granted, space-delimited (RFC 6749 §5.1).
    ///
    /// **Optional by specification**, and absence is meaningful rather than
    /// missing data: the field may be omitted precisely when the granted
    /// scope is identical to the requested one. `None` therefore means
    /// "granted as requested" and must be treated as success — see
    /// [`crate::scope_grant::ensure_covers`], the one place that reads it.
    ///
    /// (Today's PAS always echoes, but this SDK is published and must speak
    /// the RFC rather than the behavior of one server.)
    #[serde(default)]
    pub scope: Option<String>,
}

#[cfg(feature = "well-known-fetch")]
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> {
        // reqwest is built with `rustls-no-provider` (keeps aws-lc-rs out of
        // consumer graphs); a process-level CryptoProvider must exist before
        // the Client builds. Idempotent; wasm reqwest uses browser fetch().
        #[cfg(not(target_arch = "wasm32"))]
        let _ = rustls::crypto::ring::default_provider().install_default();
        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> {
        // RFC 6749 §4.1.3 makes `redirect_uri` REQUIRED on this leg whenever
        // it was present at authorize — which it always is here. A
        // refresh-only client reaching this method is a wiring bug; fail
        // loudly rather than send a request PAS would reject as
        // `invalid_grant` for a reason the caller cannot see.
        let redirect_uri = self.config.redirect_uri.as_deref().ok_or_else(|| {
            Error::OAuth {
                operation: "token exchange",
                status: None,
                detail: "authorization_code exchange requires a redirect_uri \
                         (RFC 6749 §4.1.3); this client was built for refresh only"
                    .to_owned(),
            }
        })?;

        let params = code_grant_form(
            code,
            redirect_uri,
            self.config.client_id.as_str(),
            code_verifier,
            self.config.resource.as_deref(),
        );

        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}"),
        })
    }
}

#[cfg(feature = "well-known-fetch")]
impl crate::pas_port::PasAuthPort for AuthClient {
    async fn refresh(
        &self,
        refresh_token: &str,
    ) -> Result<TokenResponse, crate::pas_port::PasFailure> {
        let params = refresh_grant_form(
            refresh_token,
            self.config.client_id.as_str(),
            self.config.resource.as_deref(),
        );

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

// ────────────────────────────────────────────────────────────────────────
// Token-request form builders — extracted for boundary-test introspection
// ────────────────────────────────────────────────────────────────────────

/// Build the `authorization_code` grant form params. Pulled out as a free
/// function (mirroring `oidc::build_authorize_url`) so the RFC 8707 resource
/// boundary test can assert the wire params without a live token endpoint.
/// The `resource` indicator, when present, rides RFC 8707 §2.2 so the minted
/// `aud` matches the resource bound at authorize.
#[cfg(feature = "well-known-fetch")]
fn code_grant_form<'a>(
    code: &'a str,
    redirect_uri: &'a str,
    client_id: &'a str,
    code_verifier: &'a str,
    resource: Option<&'a str>,
) -> Vec<(&'a str, &'a str)> {
    let mut params = vec![
        ("grant_type", "authorization_code"),
        ("code", code),
        ("redirect_uri", redirect_uri),
        ("client_id", client_id),
        ("code_verifier", code_verifier),
    ];
    if let Some(r) = resource {
        params.push(("resource", r));
    }
    params
}

/// Build the `refresh_token` grant form params. `resource` rides RFC 8707
/// §2.1: a PCS-bound client MUST re-send it on refresh or the refreshed
/// token's `aud` falls back to `client_id` and every request 401s after the
/// first hour (the silent-401 trap this SDK's docstrings warn against).
#[cfg(feature = "well-known-fetch")]
fn refresh_grant_form<'a>(
    refresh_token: &'a str,
    client_id: &'a str,
    resource: Option<&'a str>,
) -> Vec<(&'a str, &'a str)> {
    let mut params = vec![
        ("grant_type", "refresh_token"),
        ("refresh_token", refresh_token),
        ("client_id", client_id),
    ];
    if let Some(r) = resource {
        params.push(("resource", r));
    }
    params
}

#[cfg(all(test, feature = "well-known-fetch"))]
#[allow(clippy::unwrap_used)]
mod tests {
    use super::*;

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

        assert_eq!(config.client_id, "my-app");
        assert_eq!(config.redirect_uri.as_deref(), Some("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")
            .with_redirect_uri("https://my-app.com/callback")
            .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"
        );
    }

    #[test]
    fn config_resource_defaults_none_and_is_stored_verbatim() {
        let base = OAuthConfig::new("cwc").with_redirect_uri("https://ppoppo.com/callback");
        assert_eq!(base.resource, None, "no resource unless opted in (RCW/CTW path)");

        // Stored byte-for-byte — NOT round-tripped through `Url` (no trailing
        // slash added to a host-only URI). This is the dev flag-day value.
        let with = base.with_resource("http://localhost:3200");
        assert_eq!(with.resource.as_deref(), Some("http://localhost:3200"));
    }

    #[test]
    fn code_grant_omits_resource_when_absent() {
        let params = code_grant_form("the-code", "https://rp/cb", "cwc", "verifier", None);
        assert!(
            !params.iter().any(|(k, _)| *k == "resource"),
            "identity RPs (RCW/CTW) send no resource → aud stays client_id"
        );
        assert_eq!(params.len(), 5);
    }

    #[test]
    fn code_grant_appends_resource_when_present() {
        let r = "https://api.ppoppo.com/grpc";
        let params = code_grant_form("the-code", "https://rp/cb", "cwc", "verifier", Some(r));
        assert_eq!(
            params.iter().find(|(k, _)| *k == "resource").map(|(_, v)| *v),
            Some(r),
            "resource rides authorization_code (RFC 8707 §2.2)"
        );
    }

    #[test]
    fn refresh_grant_carries_resource_so_aud_survives_the_refresh_leg() {
        let r = "https://api.ppoppo.com/grpc";
        // The silent-401 trap: without this, the refreshed aud drops to
        // client_id after the first hour and every PCS request 401s.
        let with = refresh_grant_form("rt", "cwc", Some(r));
        assert_eq!(
            with.iter().find(|(k, _)| *k == "resource").map(|(_, v)| *v),
            Some(r)
        );
        // And omitted for identity RPs.
        let without = refresh_grant_form("rt", "cwc", None);
        assert!(!without.iter().any(|(k, _)| *k == "resource"));
    }
}