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};
14use reqwest::{
15	Client, Error as ReqwestError,
16	header::{HeaderMap, RETRY_AFTER},
17};
18use time::format_description::well_known::Rfc2822;
19// self
20use crate::_prelude::*;
21
22/// Abstraction over HTTP transports capable of executing OAuth token exchanges while
23/// publishing response metadata to the broker's instrumentation pipeline.
24///
25/// The trait acts as the broker's only dependency on an HTTP stack. Callers provide
26/// an implementation (typically behind `Arc<T>` where `T: TokenHttpClient`) and the broker
27/// requests short-lived [`AsyncHttpClient`] handles that each carry a clone of a
28/// [`ResponseMetadataSlot`]. Implementations must be `Send + Sync + 'static` so they
29/// can be shared across broker instances without additional wrappers, and the handles
30/// they return must own whatever state is required so their request futures remain
31/// `Send` for the lifetime of the in-flight operation. This lets facade callers box
32/// the async blocks without worrying about borrowed transports.
33pub trait TokenHttpClient: Send + Sync + 'static {
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 [`Client`] 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 [`Client`] to disable redirect following, because the broker
104/// passes this client into the `oauth2` crate when it builds the facade layer.
105#[derive(Clone, Default)]
106pub struct ReqwestHttpClient(pub Client);
107impl ReqwestHttpClient {
108	/// Wraps an existing reqwest [`Client`].
109	pub fn with_client(client: Client) -> Self {
110		Self(client)
111	}
112
113	/// Builds an instrumented HTTP client that captures response metadata.
114	pub(crate) fn instrumented(&self, slot: ResponseMetadataSlot) -> InstrumentedHandle {
115		InstrumentedHandle::new(self.0.clone(), slot)
116	}
117}
118impl AsRef<Client> for ReqwestHttpClient {
119	fn as_ref(&self) -> &Client {
120		&self.0
121	}
122}
123impl Deref for ReqwestHttpClient {
124	type Target = Client;
125
126	fn deref(&self) -> &Self::Target {
127		&self.0
128	}
129}
130
131/// Instrumented adapter that implements [`AsyncHttpClient`] for reqwest.
132pub(crate) struct InstrumentedHttpClient {
133	client: Client,
134	slot: ResponseMetadataSlot,
135}
136impl InstrumentedHttpClient {
137	fn new(client: Client, slot: ResponseMetadataSlot) -> Self {
138		Self { client, slot }
139	}
140}
141
142/// Public handle returned by [`ReqwestHttpClient`] that satisfies [`TokenHttpClient`].
143#[derive(Clone)]
144pub struct InstrumentedHandle(Arc<InstrumentedHttpClient>);
145impl InstrumentedHandle {
146	fn new(client: Client, slot: ResponseMetadataSlot) -> Self {
147		Self(Arc::new(InstrumentedHttpClient::new(client, slot)))
148	}
149}
150impl<'c> AsyncHttpClient<'c> for InstrumentedHandle {
151	type Error = HttpClientError<ReqwestError>;
152	type Future =
153		Pin<Box<dyn Future<Output = Result<HttpResponse, Self::Error>> + 'c + Send + Sync>>;
154
155	fn call(&'c self, request: HttpRequest) -> Self::Future {
156		let client = Arc::clone(&self.0);
157
158		Box::pin(async move {
159			client.slot.take();
160
161			let response = client
162				.client
163				.execute(request.try_into().map_err(Box::new)?)
164				.await
165				.map_err(Box::new)?;
166			let status = response.status();
167			let headers = response.headers().to_owned();
168			let retry_after = parse_retry_after(&headers);
169
170			client.slot.store(ResponseMetadata { status: Some(status.as_u16()), retry_after });
171
172			let mut response_new =
173				HttpResponse::new(response.bytes().await.map_err(Box::new)?.to_vec());
174
175			*response_new.status_mut() = status;
176			*response_new.headers_mut() = headers;
177
178			Ok(response_new)
179		})
180	}
181}
182impl TokenHttpClient for ReqwestHttpClient {
183	type Handle = InstrumentedHandle;
184	type TransportError = ReqwestError;
185
186	fn with_metadata(&self, slot: ResponseMetadataSlot) -> Self::Handle {
187		self.instrumented(slot)
188	}
189}
190
191fn parse_retry_after(headers: &HeaderMap) -> Option<Duration> {
192	let value = headers.get(RETRY_AFTER)?;
193	let raw = value.to_str().ok()?.trim();
194
195	if let Ok(secs) = raw.parse::<u64>() {
196		return Some(Duration::seconds(secs as i64));
197	}
198	if let Ok(moment) = OffsetDateTime::parse(raw, &Rfc2822) {
199		let delta = moment - OffsetDateTime::now_utc();
200
201		if delta.is_positive() {
202			return Some(delta);
203		}
204	}
205
206	None
207}