Skip to main content

openidauthzen/
client.rs

1//! AuthZEN client for discovery, access evaluation, and search.
2//!
3//! [`AuthZenClient`] is the single entry point for PEPs interacting with
4//! a PDP. It handles metadata discovery, caches
5//! [`PdpConfiguration`] per PDP identifier, and provides typed methods
6//! for every endpoint defined by the [Authorization API 1.0][authzen].
7//!
8//! [authzen]: https://openid.net/specs/authorization-api-1_0.html
9
10use std::time::Duration;
11
12use serde::Serialize;
13use serde::de::DeserializeOwned;
14use uuid::Uuid;
15
16use crate::cache::TtlCache;
17use crate::error::Error;
18use crate::evaluation::{
19    EvaluationRequest, EvaluationResponse, EvaluationsRequest, EvaluationsResponse,
20};
21use crate::http::{HttpClient, HttpResponse, Method};
22use crate::search::{
23    ActionSearchRequest, ActionSearchResponse, ResourceSearchRequest, ResourceSearchResponse,
24    SubjectSearchRequest, SubjectSearchResponse,
25};
26
27const AUTHZEN_WELL_KNOWN_PATH: &str = "/.well-known/authzen-configuration";
28
29/// PDP metadata returned by the `/.well-known/authzen-configuration`
30/// discovery endpoint ([AuthZEN §9]).
31///
32/// This document allows PEPs to discover a PDP's available endpoints
33/// and capabilities.
34///
35/// [AuthZEN §9]: https://openid.net/specs/authorization-api-1_0.html#section-9
36#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
37pub struct PdpConfiguration {
38    /// The PDP's identifier URL (HTTPS). Must match the identifier used
39    /// to construct the well-known URL. Required.
40    pub policy_decision_point: String,
41    /// URL of the single access evaluation endpoint. Required.
42    pub access_evaluation_endpoint: String,
43    /// URL of the batch access evaluations endpoint.
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub access_evaluations_endpoint: Option<String>,
46    /// URL of the subject search endpoint.
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub search_subject_endpoint: Option<String>,
49    /// URL of the resource search endpoint.
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub search_resource_endpoint: Option<String>,
52    /// URL of the action search endpoint.
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub search_action_endpoint: Option<String>,
55    /// Capability URNs advertised by the PDP.
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub capabilities: Option<Vec<String>>,
58    /// A signed JWT containing the metadata, per [AuthZEN §9].
59    /// When present, signed values take precedence over unsigned fields.
60    ///
61    /// [AuthZEN §9]: https://openid.net/specs/authorization-api-1_0.html#section-9
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub signed_metadata: Option<String>,
64}
65
66/// A client for interacting with an AuthZEN PDP.
67///
68/// Handles discovery, access evaluation, and search. Caches
69/// [`PdpConfiguration`] per PDP identifier with a configurable TTL.
70pub struct AuthZenClient<C: HttpClient> {
71    http: C,
72    cache: TtlCache<PdpConfiguration>,
73}
74
75impl<C: HttpClient> AuthZenClient<C> {
76    /// Create a new client.
77    ///
78    /// `cache_ttl` controls how long a discovered [`PdpConfiguration`]
79    /// is reused before re-fetching from the well-known endpoint.
80    #[must_use]
81    pub fn new(http: C, cache_ttl: Duration) -> Self {
82        Self { http, cache: TtlCache::new(cache_ttl) }
83    }
84
85    /// Discover and cache the PDP configuration for the given identifier.
86    ///
87    /// Fetches the `/.well-known/authzen-configuration` document, validates
88    /// that the `policy_decision_point` field matches, and caches the result
89    /// for the configured TTL.
90    pub async fn discover(&self, pdp_id: &str) -> Result<PdpConfiguration, Error> {
91        let url = Self::build_discovery_url(pdp_id)?;
92        let resp = self.unauthenticated_get(&url).await?;
93        let config: PdpConfiguration =
94            serde_json::from_slice(&resp.body).map_err(Error::InvalidResponse)?;
95
96        Self::validate_pdp_match(pdp_id, &config)?;
97        self.cache.insert(pdp_id.to_owned(), config.clone()).await;
98
99        Ok(config)
100    }
101
102    /// Return the cached PDP configuration, re-fetching if expired.
103    pub async fn get_pdp_config(&self, pdp_id: &str) -> Result<PdpConfiguration, Error> {
104        if let Some(config) = self.cache.get(pdp_id).await {
105            return Ok(config);
106        }
107
108        self.discover(pdp_id).await
109    }
110
111    /// Remove the cached PDP configuration for the given identifier.
112    ///
113    /// Returns `true` if an entry was removed, `false` if no entry existed.
114    pub async fn invalidate_pdp_config(&self, pdp_id: &str) -> bool {
115        let existed = self.cache.get(pdp_id).await.is_some();
116        self.cache.invalidate(pdp_id).await;
117        existed
118    }
119
120    /// Evaluate a single access request (`POST /access/v1/evaluation`,
121    /// [AuthZEN §6]).
122    ///
123    /// The `token` is a Bearer token for authenticating with the PDP.
124    ///
125    /// [AuthZEN §6]: https://openid.net/specs/authorization-api-1_0.html#section-6
126    pub async fn evaluate(
127        &self,
128        pdp_id: &str,
129        token: &str,
130        request: &EvaluationRequest,
131    ) -> Result<EvaluationResponse, Error> {
132        let url = self.resolve_required_endpoint(pdp_id, |c| &c.access_evaluation_endpoint).await?;
133        self.post_json(&url, token, request).await
134    }
135
136    /// Evaluate a batch of access requests (`POST /access/v1/evaluations`,
137    /// [AuthZEN §7]).
138    ///
139    /// [AuthZEN §7]: https://openid.net/specs/authorization-api-1_0.html#section-7
140    pub async fn evaluate_batch(
141        &self,
142        pdp_id: &str,
143        token: &str,
144        request: &EvaluationsRequest,
145    ) -> Result<EvaluationsResponse, Error> {
146        let url = self
147            .resolve_optional_endpoint(pdp_id, |c| c.access_evaluations_endpoint.as_ref(), "/access/v1/evaluations")
148            .await?;
149        self.post_json(&url, token, request).await
150    }
151
152    /// Search for authorized subjects (`POST /access/v1/search/subject`,
153    /// [AuthZEN §8.4]).
154    ///
155    /// [AuthZEN §8.4]: https://openid.net/specs/authorization-api-1_0.html#section-8.4
156    pub async fn search_subjects(
157        &self,
158        pdp_id: &str,
159        token: &str,
160        request: &SubjectSearchRequest,
161    ) -> Result<SubjectSearchResponse, Error> {
162        let url = self
163            .resolve_optional_endpoint(pdp_id, |c| c.search_subject_endpoint.as_ref(), "/access/v1/search/subject")
164            .await?;
165        self.post_json(&url, token, request).await
166    }
167
168    /// Search for accessible resources (`POST /access/v1/search/resource`,
169    /// [AuthZEN §8.5]).
170    ///
171    /// [AuthZEN §8.5]: https://openid.net/specs/authorization-api-1_0.html#section-8.5
172    pub async fn search_resources(
173        &self,
174        pdp_id: &str,
175        token: &str,
176        request: &ResourceSearchRequest,
177    ) -> Result<ResourceSearchResponse, Error> {
178        let url = self
179            .resolve_optional_endpoint(pdp_id, |c| c.search_resource_endpoint.as_ref(), "/access/v1/search/resource")
180            .await?;
181        self.post_json(&url, token, request).await
182    }
183
184    /// Search for permitted actions (`POST /access/v1/search/action`,
185    /// [AuthZEN §8.6]).
186    ///
187    /// [AuthZEN §8.6]: https://openid.net/specs/authorization-api-1_0.html#section-8.6
188    pub async fn search_actions(
189        &self,
190        pdp_id: &str,
191        token: &str,
192        request: &ActionSearchRequest,
193    ) -> Result<ActionSearchResponse, Error> {
194        let url = self
195            .resolve_optional_endpoint(pdp_id, |c| c.search_action_endpoint.as_ref(), "/access/v1/search/action")
196            .await?;
197        self.post_json(&url, token, request).await
198    }
199
200    fn validate_pdp_url(pdp_id: &str) -> Result<url::Url, Error> {
201        let parsed =
202            url::Url::parse(pdp_id).map_err(|e| Error::InvalidPdpUrl(e.to_string()))?;
203
204        if parsed.scheme() != "https" {
205            return Err(Error::InvalidPdpUrl(format!(
206                "scheme must be https, got {}",
207                parsed.scheme()
208            )));
209        }
210
211        if parsed.query().is_some() || parsed.fragment().is_some() {
212            return Err(Error::InvalidPdpUrl(
213                "PDP URL must not contain query or fragment".to_owned(),
214            ));
215        }
216
217        Ok(parsed)
218    }
219
220    fn build_discovery_url(pdp_id: &str) -> Result<String, Error> {
221        let parsed = Self::validate_pdp_url(pdp_id)?;
222
223        let path = parsed.path().trim_end_matches('/');
224        let mut discovery = parsed.clone();
225        discovery.set_path(&format!("{}{}", path, AUTHZEN_WELL_KNOWN_PATH));
226
227        Ok(discovery.to_string())
228    }
229
230    fn validate_pdp_match(expected: &str, config: &PdpConfiguration) -> Result<(), Error> {
231        let expected_normalized = expected.trim_end_matches('/');
232        let got_normalized = config.policy_decision_point.trim_end_matches('/');
233
234        if expected_normalized != got_normalized {
235            return Err(Error::PdpMismatch {
236                expected: expected.to_owned(),
237                got: config.policy_decision_point.clone(),
238            });
239        }
240
241        Ok(())
242    }
243
244    /// Resolve a required endpoint URL from cached config.
245    async fn resolve_required_endpoint(
246        &self,
247        pdp_id: &str,
248        extract: fn(&PdpConfiguration) -> &String,
249    ) -> Result<String, Error> {
250        let config =
251            self.cache.get(pdp_id).await.ok_or_else(|| Error::NotCached(pdp_id.to_owned()))?;
252
253        Ok(extract(&config).clone())
254    }
255
256    /// Resolve an optional endpoint URL from cached config.
257    ///
258    /// If the PDP metadata does not advertise the endpoint, falls back to
259    /// appending `default_path` to the PDP's base URL per [AuthZEN §10.1].
260    ///
261    /// [AuthZEN §10.1]: https://openid.net/specs/authorization-api-1_0.html#section-10.1
262    async fn resolve_optional_endpoint(
263        &self,
264        pdp_id: &str,
265        extract: fn(&PdpConfiguration) -> Option<&String>,
266        default_path: &str,
267    ) -> Result<String, Error> {
268        let config =
269            self.cache.get(pdp_id).await.ok_or_else(|| Error::NotCached(pdp_id.to_owned()))?;
270
271        if let Some(url) = extract(&config) {
272            return Ok(url.clone());
273        }
274
275        // Fall back to PDP base URL + default path (spec §10.1 SHOULD).
276        let mut base = Self::validate_pdp_url(&config.policy_decision_point)?;
277        let path = base.path().trim_end_matches('/');
278        base.set_path(&format!("{}{}", path, default_path));
279        Ok(base.to_string())
280    }
281
282    /// Make an authenticated HTTP request and return the raw response.
283    ///
284    /// Automatically includes an `X-Request-ID` header with a UUID v4
285    /// value for request correlation ([AuthZEN §10.1.1]).
286    ///
287    /// [AuthZEN §10.1.1]: https://openid.net/specs/authorization-api-1_0.html#section-10.1.1
288    async fn authenticated_request(
289        &self,
290        method: Method,
291        url: &str,
292        token: &str,
293        body: Option<Vec<u8>>,
294    ) -> Result<HttpResponse, Error> {
295        let auth = format!("Bearer {}", token);
296        let request_id = Uuid::new_v4().to_string();
297        let headers = [
298            ("authorization", auth.as_str()),
299            ("x-request-id", request_id.as_str()),
300        ];
301
302        let resp = self.http.request(method, url, &headers, body).await?;
303
304        if resp.status >= 400 {
305            return Err(Error::HttpStatus {
306                status: resp.status,
307                body: String::from_utf8_lossy(&resp.body).into_owned(),
308            });
309        }
310
311        Ok(resp)
312    }
313
314    /// Unauthenticated GET for discovery.
315    ///
316    /// Includes an `X-Request-ID` header for request correlation.
317    async fn unauthenticated_get(&self, url: &str) -> Result<HttpResponse, Error> {
318        let request_id = Uuid::new_v4().to_string();
319        let headers = [("x-request-id", request_id.as_str())];
320        let resp = self.http.request(Method::Get, url, &headers, None).await?;
321
322        if resp.status >= 400 {
323            return Err(Error::HttpStatus {
324                status: resp.status,
325                body: String::from_utf8_lossy(&resp.body).into_owned(),
326            });
327        }
328
329        Ok(resp)
330    }
331
332    /// Authenticated POST with JSON body, deserialize JSON response.
333    async fn post_json<T: DeserializeOwned, B: Serialize>(
334        &self,
335        url: &str,
336        token: &str,
337        body: &B,
338    ) -> Result<T, Error> {
339        let bytes = serde_json::to_vec(body).map_err(Error::Serialization)?;
340        let resp = self.authenticated_request(Method::Post, url, token, Some(bytes)).await?;
341        serde_json::from_slice(&resp.body).map_err(Error::InvalidResponse)
342    }
343}