openidauthzen 0.1.0-alpha.1

OpenID AuthZEN Authorization API 1.0 — Policy Decision and Enforcement Points for Rust
Documentation
//! AuthZEN client for discovery, access evaluation, and search.
//!
//! [`AuthZenClient`] is the single entry point for PEPs interacting with
//! a PDP. It handles metadata discovery, caches
//! [`PdpConfiguration`] per PDP identifier, and provides typed methods
//! for every endpoint defined by the [Authorization API 1.0][authzen].
//!
//! [authzen]: https://openid.net/specs/authorization-api-1_0.html

use std::time::Duration;

use serde::Serialize;
use serde::de::DeserializeOwned;
use uuid::Uuid;

use crate::cache::TtlCache;
use crate::error::Error;
use crate::evaluation::{
    EvaluationRequest, EvaluationResponse, EvaluationsRequest, EvaluationsResponse,
};
use crate::http::{HttpClient, HttpResponse, Method};
use crate::search::{
    ActionSearchRequest, ActionSearchResponse, ResourceSearchRequest, ResourceSearchResponse,
    SubjectSearchRequest, SubjectSearchResponse,
};

const AUTHZEN_WELL_KNOWN_PATH: &str = "/.well-known/authzen-configuration";

/// PDP metadata returned by the `/.well-known/authzen-configuration`
/// discovery endpoint ([AuthZEN §9]).
///
/// This document allows PEPs to discover a PDP's available endpoints
/// and capabilities.
///
/// [AuthZEN §9]: https://openid.net/specs/authorization-api-1_0.html#section-9
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct PdpConfiguration {
    /// The PDP's identifier URL (HTTPS). Must match the identifier used
    /// to construct the well-known URL. Required.
    pub policy_decision_point: String,
    /// URL of the single access evaluation endpoint. Required.
    pub access_evaluation_endpoint: String,
    /// URL of the batch access evaluations endpoint.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub access_evaluations_endpoint: Option<String>,
    /// URL of the subject search endpoint.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub search_subject_endpoint: Option<String>,
    /// URL of the resource search endpoint.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub search_resource_endpoint: Option<String>,
    /// URL of the action search endpoint.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub search_action_endpoint: Option<String>,
    /// Capability URNs advertised by the PDP.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub capabilities: Option<Vec<String>>,
    /// A signed JWT containing the metadata, per [AuthZEN §9].
    /// When present, signed values take precedence over unsigned fields.
    ///
    /// [AuthZEN §9]: https://openid.net/specs/authorization-api-1_0.html#section-9
    #[serde(skip_serializing_if = "Option::is_none")]
    pub signed_metadata: Option<String>,
}

/// A client for interacting with an AuthZEN PDP.
///
/// Handles discovery, access evaluation, and search. Caches
/// [`PdpConfiguration`] per PDP identifier with a configurable TTL.
pub struct AuthZenClient<C: HttpClient> {
    http: C,
    cache: TtlCache<PdpConfiguration>,
}

impl<C: HttpClient> AuthZenClient<C> {
    /// Create a new client.
    ///
    /// `cache_ttl` controls how long a discovered [`PdpConfiguration`]
    /// is reused before re-fetching from the well-known endpoint.
    #[must_use]
    pub fn new(http: C, cache_ttl: Duration) -> Self {
        Self { http, cache: TtlCache::new(cache_ttl) }
    }

    /// Discover and cache the PDP configuration for the given identifier.
    ///
    /// Fetches the `/.well-known/authzen-configuration` document, validates
    /// that the `policy_decision_point` field matches, and caches the result
    /// for the configured TTL.
    pub async fn discover(&self, pdp_id: &str) -> Result<PdpConfiguration, Error> {
        let url = Self::build_discovery_url(pdp_id)?;
        let resp = self.unauthenticated_get(&url).await?;
        let config: PdpConfiguration =
            serde_json::from_slice(&resp.body).map_err(Error::InvalidResponse)?;

        Self::validate_pdp_match(pdp_id, &config)?;
        self.cache.insert(pdp_id.to_owned(), config.clone()).await;

        Ok(config)
    }

    /// Return the cached PDP configuration, re-fetching if expired.
    pub async fn get_pdp_config(&self, pdp_id: &str) -> Result<PdpConfiguration, Error> {
        if let Some(config) = self.cache.get(pdp_id).await {
            return Ok(config);
        }

        self.discover(pdp_id).await
    }

    /// Remove the cached PDP configuration for the given identifier.
    ///
    /// Returns `true` if an entry was removed, `false` if no entry existed.
    pub async fn invalidate_pdp_config(&self, pdp_id: &str) -> bool {
        let existed = self.cache.get(pdp_id).await.is_some();
        self.cache.invalidate(pdp_id).await;
        existed
    }

    /// Evaluate a single access request (`POST /access/v1/evaluation`,
    /// [AuthZEN §6]).
    ///
    /// The `token` is a Bearer token for authenticating with the PDP.
    ///
    /// [AuthZEN §6]: https://openid.net/specs/authorization-api-1_0.html#section-6
    pub async fn evaluate(
        &self,
        pdp_id: &str,
        token: &str,
        request: &EvaluationRequest,
    ) -> Result<EvaluationResponse, Error> {
        let url = self.resolve_required_endpoint(pdp_id, |c| &c.access_evaluation_endpoint).await?;
        self.post_json(&url, token, request).await
    }

    /// Evaluate a batch of access requests (`POST /access/v1/evaluations`,
    /// [AuthZEN §7]).
    ///
    /// [AuthZEN §7]: https://openid.net/specs/authorization-api-1_0.html#section-7
    pub async fn evaluate_batch(
        &self,
        pdp_id: &str,
        token: &str,
        request: &EvaluationsRequest,
    ) -> Result<EvaluationsResponse, Error> {
        let url = self
            .resolve_optional_endpoint(pdp_id, |c| c.access_evaluations_endpoint.as_ref(), "/access/v1/evaluations")
            .await?;
        self.post_json(&url, token, request).await
    }

    /// Search for authorized subjects (`POST /access/v1/search/subject`,
    /// [AuthZEN §8.4]).
    ///
    /// [AuthZEN §8.4]: https://openid.net/specs/authorization-api-1_0.html#section-8.4
    pub async fn search_subjects(
        &self,
        pdp_id: &str,
        token: &str,
        request: &SubjectSearchRequest,
    ) -> Result<SubjectSearchResponse, Error> {
        let url = self
            .resolve_optional_endpoint(pdp_id, |c| c.search_subject_endpoint.as_ref(), "/access/v1/search/subject")
            .await?;
        self.post_json(&url, token, request).await
    }

    /// Search for accessible resources (`POST /access/v1/search/resource`,
    /// [AuthZEN §8.5]).
    ///
    /// [AuthZEN §8.5]: https://openid.net/specs/authorization-api-1_0.html#section-8.5
    pub async fn search_resources(
        &self,
        pdp_id: &str,
        token: &str,
        request: &ResourceSearchRequest,
    ) -> Result<ResourceSearchResponse, Error> {
        let url = self
            .resolve_optional_endpoint(pdp_id, |c| c.search_resource_endpoint.as_ref(), "/access/v1/search/resource")
            .await?;
        self.post_json(&url, token, request).await
    }

    /// Search for permitted actions (`POST /access/v1/search/action`,
    /// [AuthZEN §8.6]).
    ///
    /// [AuthZEN §8.6]: https://openid.net/specs/authorization-api-1_0.html#section-8.6
    pub async fn search_actions(
        &self,
        pdp_id: &str,
        token: &str,
        request: &ActionSearchRequest,
    ) -> Result<ActionSearchResponse, Error> {
        let url = self
            .resolve_optional_endpoint(pdp_id, |c| c.search_action_endpoint.as_ref(), "/access/v1/search/action")
            .await?;
        self.post_json(&url, token, request).await
    }

    fn validate_pdp_url(pdp_id: &str) -> Result<url::Url, Error> {
        let parsed =
            url::Url::parse(pdp_id).map_err(|e| Error::InvalidPdpUrl(e.to_string()))?;

        if parsed.scheme() != "https" {
            return Err(Error::InvalidPdpUrl(format!(
                "scheme must be https, got {}",
                parsed.scheme()
            )));
        }

        if parsed.query().is_some() || parsed.fragment().is_some() {
            return Err(Error::InvalidPdpUrl(
                "PDP URL must not contain query or fragment".to_owned(),
            ));
        }

        Ok(parsed)
    }

    fn build_discovery_url(pdp_id: &str) -> Result<String, Error> {
        let parsed = Self::validate_pdp_url(pdp_id)?;

        let path = parsed.path().trim_end_matches('/');
        let mut discovery = parsed.clone();
        discovery.set_path(&format!("{}{}", path, AUTHZEN_WELL_KNOWN_PATH));

        Ok(discovery.to_string())
    }

    fn validate_pdp_match(expected: &str, config: &PdpConfiguration) -> Result<(), Error> {
        let expected_normalized = expected.trim_end_matches('/');
        let got_normalized = config.policy_decision_point.trim_end_matches('/');

        if expected_normalized != got_normalized {
            return Err(Error::PdpMismatch {
                expected: expected.to_owned(),
                got: config.policy_decision_point.clone(),
            });
        }

        Ok(())
    }

    /// Resolve a required endpoint URL from cached config.
    async fn resolve_required_endpoint(
        &self,
        pdp_id: &str,
        extract: fn(&PdpConfiguration) -> &String,
    ) -> Result<String, Error> {
        let config =
            self.cache.get(pdp_id).await.ok_or_else(|| Error::NotCached(pdp_id.to_owned()))?;

        Ok(extract(&config).clone())
    }

    /// Resolve an optional endpoint URL from cached config.
    ///
    /// If the PDP metadata does not advertise the endpoint, falls back to
    /// appending `default_path` to the PDP's base URL per [AuthZEN §10.1].
    ///
    /// [AuthZEN §10.1]: https://openid.net/specs/authorization-api-1_0.html#section-10.1
    async fn resolve_optional_endpoint(
        &self,
        pdp_id: &str,
        extract: fn(&PdpConfiguration) -> Option<&String>,
        default_path: &str,
    ) -> Result<String, Error> {
        let config =
            self.cache.get(pdp_id).await.ok_or_else(|| Error::NotCached(pdp_id.to_owned()))?;

        if let Some(url) = extract(&config) {
            return Ok(url.clone());
        }

        // Fall back to PDP base URL + default path (spec §10.1 SHOULD).
        let mut base = Self::validate_pdp_url(&config.policy_decision_point)?;
        let path = base.path().trim_end_matches('/');
        base.set_path(&format!("{}{}", path, default_path));
        Ok(base.to_string())
    }

    /// Make an authenticated HTTP request and return the raw response.
    ///
    /// Automatically includes an `X-Request-ID` header with a UUID v4
    /// value for request correlation ([AuthZEN §10.1.1]).
    ///
    /// [AuthZEN §10.1.1]: https://openid.net/specs/authorization-api-1_0.html#section-10.1.1
    async fn authenticated_request(
        &self,
        method: Method,
        url: &str,
        token: &str,
        body: Option<Vec<u8>>,
    ) -> Result<HttpResponse, Error> {
        let auth = format!("Bearer {}", token);
        let request_id = Uuid::new_v4().to_string();
        let headers = [
            ("authorization", auth.as_str()),
            ("x-request-id", request_id.as_str()),
        ];

        let resp = self.http.request(method, url, &headers, body).await?;

        if resp.status >= 400 {
            return Err(Error::HttpStatus {
                status: resp.status,
                body: String::from_utf8_lossy(&resp.body).into_owned(),
            });
        }

        Ok(resp)
    }

    /// Unauthenticated GET for discovery.
    ///
    /// Includes an `X-Request-ID` header for request correlation.
    async fn unauthenticated_get(&self, url: &str) -> Result<HttpResponse, Error> {
        let request_id = Uuid::new_v4().to_string();
        let headers = [("x-request-id", request_id.as_str())];
        let resp = self.http.request(Method::Get, url, &headers, None).await?;

        if resp.status >= 400 {
            return Err(Error::HttpStatus {
                status: resp.status,
                body: String::from_utf8_lossy(&resp.body).into_owned(),
            });
        }

        Ok(resp)
    }

    /// Authenticated POST with JSON body, deserialize JSON response.
    async fn post_json<T: DeserializeOwned, B: Serialize>(
        &self,
        url: &str,
        token: &str,
        body: &B,
    ) -> Result<T, Error> {
        let bytes = serde_json::to_vec(body).map_err(Error::Serialization)?;
        let resp = self.authenticated_request(Method::Post, url, token, Some(bytes)).await?;
        serde_json::from_slice(&resp.body).map_err(Error::InvalidResponse)
    }
}