oauth2_broker/http.rs
1//! Transport primitives for OAuth token exchanges.
2//!
3//! The module exposes [`TokenHttpClient`] alongside [`ResponseMetadata`] and
4//! [`ResponseMetadataSlot`] so downstream crates can integrate custom HTTP clients
5//! without losing the broker's instrumentation hooks. Implementations call
6//! [`ResponseMetadataSlot::take`] before dispatching a request and
7//! [`ResponseMetadataSlot::store`] once an HTTP status or retry hint is known,
8//! enabling `map_request_error` to classify failures with consistent metadata.
9
10// std
11use std::ops::Deref;
12// crates.io
13use oauth2::{AsyncHttpClient, HttpClientError, HttpRequest, HttpResponse};
14#[cfg(feature = "reqwest")] use reqwest::header::{HeaderMap, RETRY_AFTER};
15#[cfg(feature = "reqwest")] use time::format_description::well_known::Rfc2822;
16// self
17use crate::_prelude::*;
18
19/// Abstraction over HTTP transports capable of executing OAuth token exchanges while
20/// publishing response metadata to the broker's instrumentation pipeline.
21///
22/// The trait acts as the broker's only dependency on an HTTP stack. Callers provide
23/// an implementation (typically behind `Arc<T>` where `T: TokenHttpClient`) and the broker
24/// requests short-lived [`AsyncHttpClient`] handles that each carry a clone of a
25/// [`ResponseMetadataSlot`]. Implementations must be `Send + Sync + 'static` so they
26/// can be shared across broker instances without additional wrappers, and the handles
27/// they return must own whatever state is required so their request futures remain
28/// `Send` for the lifetime of the in-flight operation. This lets facade callers box
29/// the async blocks without worrying about borrowed transports.
30pub trait TokenHttpClient
31where
32 Self: 'static + Send + Sync,
33{
34 /// Concrete error emitted by the underlying transport.
35 type TransportError: 'static + Send + Sync + StdError;
36
37 /// [`AsyncHttpClient`] handle tied to a [`ResponseMetadataSlot`].
38 ///
39 /// Each handle must satisfy `Send + Sync` so broker futures can hop executors without
40 /// cloning transports unnecessarily. The request future returned by
41 /// [`AsyncHttpClient::call`] must also be `Send` so the facade's boxed futures
42 /// inherit the same guarantee.
43 type Handle: for<'c> AsyncHttpClient<
44 'c,
45 Error = HttpClientError<Self::TransportError>,
46 Future: 'c + Send,
47 >
48 + 'static
49 + Send
50 + Sync;
51
52 /// Builds an [`AsyncHttpClient`] handle that records outcomes in `slot`.
53 ///
54 /// # Metadata Contract
55 ///
56 /// - Call [`ResponseMetadataSlot::take`] before submitting the HTTP request so stale
57 /// information never leaks across retries.
58 /// - Once an HTTP response (successful or erroneous) provides status headers, save them with
59 /// [`ResponseMetadataSlot::store`].
60 /// - Never retain the slot clone beyond the lifetime of the returned handle; the handle itself
61 /// enforces borrowing rules for the transport.
62 fn with_metadata(&self, slot: ResponseMetadataSlot) -> Self::Handle;
63}
64
65/// Captures metadata from the most recent HTTP response for downstream error mapping.
66///
67/// Additional metadata fields may be added in future releases, so downstream code
68/// should construct values using field names instead of struct update syntax.
69#[derive(Clone, Debug, Default)]
70pub struct ResponseMetadata {
71 /// HTTP status code returned by the token endpoint, if available.
72 pub status: Option<u16>,
73 /// Retry-After hint expressed as a relative duration.
74 pub retry_after: Option<Duration>,
75}
76
77/// Thread-safe slot for sharing [`ResponseMetadata`] between transport and error layers.
78///
79/// The broker creates a fresh slot for each token request and reads the captured
80/// metadata immediately after `oauth2` resolves. Transport implementations borrow
81/// the slot just long enough to call [`store`](ResponseMetadataSlot::store) and must
82/// keep ownership with the broker.
83#[derive(Clone, Debug, Default)]
84pub struct ResponseMetadataSlot(Arc<Mutex<Option<ResponseMetadata>>>);
85impl ResponseMetadataSlot {
86 /// Stores new metadata for the current request.
87 pub fn store(&self, meta: ResponseMetadata) {
88 *self.0.lock() = Some(meta);
89 }
90
91 /// Returns the captured metadata, if any, consuming it from the slot.
92 ///
93 /// Custom HTTP clients should invoke this helper before performing a request to
94 /// ensure traces from prior attempts never leak into the new invocation.
95 pub fn take(&self) -> Option<ResponseMetadata> {
96 self.0.lock().take()
97 }
98}
99
100/// Thin wrapper around [`ReqwestClient`] so shared HTTP behavior lives in one place.
101/// Token requests should not follow redirects, matching OAuth 2.0 guidance that token
102/// endpoints return results directly instead of delegating to another URI. Configure
103/// any custom [`ReqwestClient`] to disable redirect following, because the broker
104/// passes this client into the `oauth2` crate when it builds the facade layer.
105#[cfg(feature = "reqwest")]
106#[derive(Clone, Default)]
107pub struct ReqwestHttpClient(pub ReqwestClient);
108#[cfg(feature = "reqwest")]
109impl ReqwestHttpClient {
110 /// Wraps an existing reqwest [`ReqwestClient`].
111 pub fn with_client(client: ReqwestClient) -> Self {
112 Self(client)
113 }
114
115 /// Builds an instrumented HTTP client that captures response metadata.
116 pub(crate) fn instrumented(&self, slot: ResponseMetadataSlot) -> InstrumentedHandle {
117 InstrumentedHandle::new(self.0.clone(), slot)
118 }
119}
120#[cfg(feature = "reqwest")]
121impl AsRef<ReqwestClient> for ReqwestHttpClient {
122 fn as_ref(&self) -> &ReqwestClient {
123 &self.0
124 }
125}
126#[cfg(feature = "reqwest")]
127impl Deref for ReqwestHttpClient {
128 type Target = ReqwestClient;
129
130 fn deref(&self) -> &Self::Target {
131 &self.0
132 }
133}
134
135#[cfg(feature = "reqwest")]
136/// Instrumented adapter that implements [`AsyncHttpClient`] for reqwest.
137pub(crate) struct InstrumentedHttpClient {
138 client: ReqwestClient,
139 slot: ResponseMetadataSlot,
140}
141#[cfg(feature = "reqwest")]
142impl InstrumentedHttpClient {
143 fn new(client: ReqwestClient, slot: ResponseMetadataSlot) -> Self {
144 Self { client, slot }
145 }
146}
147
148#[cfg(feature = "reqwest")]
149/// Public handle returned by [`ReqwestHttpClient`] that satisfies [`TokenHttpClient`].
150#[derive(Clone)]
151pub struct InstrumentedHandle(Arc<InstrumentedHttpClient>);
152#[cfg(feature = "reqwest")]
153impl InstrumentedHandle {
154 fn new(client: ReqwestClient, slot: ResponseMetadataSlot) -> Self {
155 Self(Arc::new(InstrumentedHttpClient::new(client, slot)))
156 }
157}
158#[cfg(feature = "reqwest")]
159impl<'c> AsyncHttpClient<'c> for InstrumentedHandle {
160 type Error = HttpClientError<ReqwestError>;
161 type Future =
162 Pin<Box<dyn Future<Output = Result<HttpResponse, Self::Error>> + 'c + Send + Sync>>;
163
164 fn call(&'c self, request: HttpRequest) -> Self::Future {
165 let client = Arc::clone(&self.0);
166
167 Box::pin(async move {
168 client.slot.take();
169
170 let response = client
171 .client
172 .execute(request.try_into().map_err(Box::new)?)
173 .await
174 .map_err(Box::new)?;
175 let status = response.status();
176 let headers = response.headers().to_owned();
177 let retry_after = parse_retry_after(&headers);
178
179 client.slot.store(ResponseMetadata { status: Some(status.as_u16()), retry_after });
180
181 let mut response_new =
182 HttpResponse::new(response.bytes().await.map_err(Box::new)?.to_vec());
183
184 *response_new.status_mut() = status;
185 *response_new.headers_mut() = headers;
186
187 Ok(response_new)
188 })
189 }
190}
191#[cfg(feature = "reqwest")]
192impl TokenHttpClient for ReqwestHttpClient {
193 type Handle = InstrumentedHandle;
194 type TransportError = ReqwestError;
195
196 fn with_metadata(&self, slot: ResponseMetadataSlot) -> Self::Handle {
197 self.instrumented(slot)
198 }
199}
200
201#[cfg(feature = "reqwest")]
202fn parse_retry_after(headers: &HeaderMap) -> Option<Duration> {
203 let value = headers.get(RETRY_AFTER)?;
204 let raw = value.to_str().ok()?.trim();
205
206 if let Ok(secs) = raw.parse::<u64>() {
207 return Some(Duration::seconds(secs as i64));
208 }
209 if let Ok(moment) = OffsetDateTime::parse(raw, &Rfc2822) {
210 let delta = moment - OffsetDateTime::now_utc();
211
212 if delta.is_positive() {
213 return Some(delta);
214 }
215 }
216
217 None
218}