pas-external 0.9.0

Ppoppo Accounts System (PAS) external SDK — OAuth2 PKCE, JWT verification port, Axum middleware, session liveness
Documentation
//! [`UserinfoFetcher`] — HTTP [`super::Fetcher`] reading PAS userinfo.

use async_trait::async_trait;
use reqwest::Client;
use serde::Deserialize;

use super::{FetchError, Fetcher};

/// HTTP [`Fetcher`] that reads `session_version` from PAS's
/// `/userinfo` endpoint.
///
/// Phase 11.Z (RFC §3.5 Row 5*/9): PAS re-enables `session_version` on
/// the userinfo response, gated on the new `session_version` scope.
/// Consumers wiring this fetcher MUST also add `"session_version"` to
/// their OAuth `RequestedScope` set so issued tokens carry the scope
/// — otherwise `userinfo` returns the field as `None` and this
/// fetcher fails closed.
///
/// ## Why no in-process cache here
///
/// The cache layer is a separate concern (see [`super::Cache`] /
/// [`super::InProcessTtlCache`] / [`super::CompositeEpochRevocation`]).
/// Keeping `UserinfoFetcher` cache-free means consumers can swap any
/// `Cache` impl in front of it (TTL, KVRocks 11.AB+, custom) without
/// reconfiguring two layers. The composer owns the cache-then-fetch
/// choreography.
///
/// ## Authentication — service-account-style access_token expected
///
/// Each `fetch` call issues a `GET /userinfo` with the access_token
/// the *consumer service* itself holds (typically a long-lived
/// service-account access_token configured at boot). The looked-up
/// `sub` does NOT need to match the access_token's `sub` — userinfo
/// authenticates the *caller* (the consumer service), and authorizes
/// substrate readout for the queried subject if the access_token's
/// claims grant the `session_version` scope.
///
/// Wiring shape: consumers configure a `&'static` access token (from
/// an env var) at boot and pass it via [`Self::with_access_token`].
/// Refresh logic, if needed, is the consumer's responsibility — the
/// fetcher accepts a token-source closure for that case.
pub struct UserinfoFetcher {
    base_url: String,
    http: Client,
    token_source: TokenSource,
}

enum TokenSource {
    Static(String),
    Dynamic(std::sync::Arc<dyn Fn() -> String + Send + Sync>),
    Unset,
}

impl UserinfoFetcher {
    /// Build a fetcher pointing at PAS's base URL (e.g.
    /// `"https://accounts.ppoppo.com"`). The fetcher appends
    /// `/userinfo` to the base. Trailing slashes on `base_url` are
    /// stripped to keep `iss`-style normalization aligned.
    ///
    /// Use [`Self::with_access_token`] or [`Self::with_token_source`]
    /// to wire the consumer's authentication credential before calling
    /// `fetch`. Without one, every `fetch` returns
    /// [`FetchError`] (`UserinfoFetcher: no access_token configured`).
    #[must_use]
    pub fn new(base_url: impl Into<String>) -> Self {
        let mut base = base_url.into();
        while base.ends_with('/') {
            base.pop();
        }
        Self {
            base_url: base,
            http: Client::new(),
            token_source: TokenSource::Unset,
        }
    }

    /// Wire a long-lived access_token (consumer's service-account
    /// credential). Most production consumers use this — the token is
    /// issued once and kept fresh by the consumer's boot logic.
    #[must_use]
    pub fn with_access_token(mut self, token: impl Into<String>) -> Self {
        self.token_source = TokenSource::Static(token.into());
        self
    }

    /// Wire a token source that re-evaluates per call. Use when the
    /// consumer rotates its service-account credential and the
    /// fetcher must read the latest value at call time. Closure must
    /// be cheap — invoked on every `fetch`.
    #[must_use]
    pub fn with_token_source(
        mut self,
        source: std::sync::Arc<dyn Fn() -> String + Send + Sync>,
    ) -> Self {
        self.token_source = TokenSource::Dynamic(source);
        self
    }

    /// Override the default `reqwest::Client`. Useful for consumers
    /// that need bespoke timeouts, proxy config, or shared connection
    /// pooling.
    #[must_use]
    pub fn with_http_client(mut self, http: Client) -> Self {
        self.http = http;
        self
    }

    fn current_token(&self) -> Option<String> {
        match &self.token_source {
            TokenSource::Static(s) => Some(s.clone()),
            TokenSource::Dynamic(f) => Some(f()),
            TokenSource::Unset => None,
        }
    }
}

impl std::fmt::Debug for UserinfoFetcher {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("UserinfoFetcher")
            .field("base_url", &self.base_url)
            .finish_non_exhaustive()
    }
}

/// Minimal projection of PAS [`UserInfoResponse`] — we only need
/// `session_version`. Decoder is forward-compatible; fields beyond
/// these are ignored. Missing `session_version` (e.g., consumer
/// forgot to grant the `session_version` scope, or the queried subject
/// is non-Human) deserializes to `None`, mapped to a typed
/// [`FetchError`] downstream.
#[derive(Deserialize)]
struct UserinfoSv {
    session_version: Option<i64>,
}

#[async_trait]
impl Fetcher for UserinfoFetcher {
    async fn fetch(&self, _sub: &str) -> Result<i64, FetchError> {
        // PAS's userinfo authenticates the *caller*, not the queried
        // subject. The service-account access_token's claims gate the
        // response — `_sub` is not a request parameter.
        let token = self.current_token().ok_or_else(|| {
            FetchError("UserinfoFetcher: no access_token configured".into())
        })?;

        let url = format!("{}/userinfo", self.base_url);
        let resp = self
            .http
            .get(&url)
            .bearer_auth(&token)
            .send()
            .await
            .map_err(|e| FetchError(format!("transport: {e}")))?;

        if !resp.status().is_success() {
            return Err(FetchError(format!("HTTP {}", resp.status())));
        }

        let body: UserinfoSv = resp
            .json()
            .await
            .map_err(|e| FetchError(format!("decode: {e}")))?;

        body.session_version.ok_or_else(|| {
            FetchError(
                "userinfo did not return session_version (missing scope or non-Human subject)"
                    .into(),
            )
        })
    }
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use super::*;
    use wiremock::matchers::{header, method, path};
    use wiremock::{Mock, MockServer, ResponseTemplate};

    #[tokio::test]
    async fn happy_path_reads_session_version() {
        let server = MockServer::start().await;
        Mock::given(method("GET"))
            .and(path("/userinfo"))
            .and(header("authorization", "Bearer svc-token"))
            .respond_with(
                ResponseTemplate::new(200)
                    .set_body_json(serde_json::json!({"sub": "abc", "session_version": 42})),
            )
            .mount(&server)
            .await;

        let fetcher =
            UserinfoFetcher::new(server.uri()).with_access_token("svc-token");
        let sv = fetcher.fetch("abc").await.unwrap();
        assert_eq!(sv, 42);
    }

    #[tokio::test]
    async fn missing_session_version_fails_closed() {
        let server = MockServer::start().await;
        Mock::given(method("GET"))
            .and(path("/userinfo"))
            .respond_with(
                ResponseTemplate::new(200).set_body_json(serde_json::json!({"sub": "abc"})),
            )
            .mount(&server)
            .await;

        let fetcher = UserinfoFetcher::new(server.uri()).with_access_token("svc-token");
        let err = fetcher.fetch("abc").await.unwrap_err();
        assert!(err.0.contains("did not return session_version"), "{}", err.0);
    }

    #[tokio::test]
    async fn http_error_fails_closed() {
        let server = MockServer::start().await;
        Mock::given(method("GET"))
            .and(path("/userinfo"))
            .respond_with(ResponseTemplate::new(503))
            .mount(&server)
            .await;

        let fetcher = UserinfoFetcher::new(server.uri()).with_access_token("svc-token");
        let err = fetcher.fetch("abc").await.unwrap_err();
        assert!(err.0.contains("HTTP 503"), "{}", err.0);
    }

    #[tokio::test]
    async fn no_token_fails_closed() {
        let fetcher = UserinfoFetcher::new("http://unused");
        let err = fetcher.fetch("abc").await.unwrap_err();
        assert!(err.0.contains("no access_token configured"), "{}", err.0);
    }

    #[tokio::test]
    async fn trailing_slash_on_base_url_normalized() {
        let server = MockServer::start().await;
        Mock::given(method("GET"))
            .and(path("/userinfo"))
            .respond_with(
                ResponseTemplate::new(200)
                    .set_body_json(serde_json::json!({"sub": "abc", "session_version": 1})),
            )
            .mount(&server)
            .await;

        // Trailing slash is stripped in the constructor; without that
        // the request would be `//userinfo` and 404.
        let url_with_slash = format!("{}/", server.uri());
        let fetcher = UserinfoFetcher::new(url_with_slash).with_access_token("svc-token");
        let sv = fetcher.fetch("abc").await.unwrap();
        assert_eq!(sv, 1);
    }
}