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}