Skip to main content

cloudillo_core/
request.rs

1// SPDX-FileCopyrightText: Szilárd Hajba
2// SPDX-License-Identifier: LGPL-3.0-or-later
3
4//! Request client implementation
5
6use futures::TryStreamExt;
7use futures_core::stream::Stream;
8use http_body_util::{BodyExt, Empty, Full, StreamBody, combinators::BoxBody};
9use hyper::http::StatusCode;
10use hyper::{Method, body::Body, body::Bytes};
11use hyper_rustls::{HttpsConnector, HttpsConnectorBuilder};
12use hyper_util::client::legacy::{Client, connect::HttpConnector};
13use hyper_util::rt::TokioExecutor;
14use serde::{Deserialize, Serialize, de::DeserializeOwned};
15use std::borrow::Cow;
16use std::sync::Arc;
17use std::time::Duration;
18use tokio::io::AsyncRead;
19use tokio::time::timeout;
20
21/// Default HTTP request timeout (10 seconds)
22const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
23
24use crate::prelude::*;
25use cloudillo_types::action_types::CreateAction;
26use cloudillo_types::auth_adapter::AuthAdapter;
27use cloudillo_types::validation::{id_tag_to_ascii, validate_id_tag};
28
29fn to_boxed<B>(body: B) -> BoxBody<Bytes, Error>
30where
31	B: Body<Data = Bytes> + Send + Sync + 'static,
32	B::Error: Send + 'static,
33{
34	body.map_err(|_err| Error::NetworkError("body stream error".into())).boxed()
35}
36
37#[derive(Deserialize)]
38struct TokenData {
39	token: Box<str>,
40}
41
42#[derive(Deserialize)]
43struct TokenRes {
44	data: TokenData,
45}
46
47/// Result of a conditional GET request
48#[derive(Debug)]
49pub enum ConditionalResult<T> {
50	/// 200 OK - new data with new etag
51	Modified { data: T, etag: Option<Box<str>> },
52	/// 304 Not Modified - etag unchanged
53	NotModified,
54}
55
56#[derive(Debug, Clone)]
57pub struct Request {
58	pub auth_adapter: Arc<dyn AuthAdapter>,
59	client: Client<HttpsConnector<HttpConnector>, BoxBody<Bytes, Error>>,
60	proxy_tokens: Arc<crate::ProxyTokenCache>,
61}
62
63impl Request {
64	pub fn new(
65		auth_adapter: Arc<dyn AuthAdapter>,
66		proxy_tokens: Arc<crate::ProxyTokenCache>,
67	) -> ClResult<Self> {
68		let client = HttpsConnectorBuilder::new()
69			.with_native_roots()
70			.map_err(|_| Error::ConfigError("no native root CA certificates found".into()))?
71			.https_only()
72			.enable_http1()
73			.build();
74
75		Ok(Request {
76			auth_adapter,
77			client: Client::builder(TokioExecutor::new()).build(client),
78			proxy_tokens,
79		})
80	}
81
82	/// Execute an HTTP request with timeout wrapper
83	async fn timed_request(
84		&self,
85		req: hyper::Request<BoxBody<Bytes, Error>>,
86	) -> ClResult<hyper::Response<hyper::body::Incoming>> {
87		timeout(REQUEST_TIMEOUT, self.client.request(req))
88			.await
89			.map_err(|_| Error::Timeout)?
90			.map_err(Error::from)
91	}
92
93	/// Validate an `id_tag` and return its A-label for URL construction.
94	///
95	/// `id_tag` ultimately originates from attacker-controllable action-token
96	/// `iss`/`aud` fields, so without this guard a crafted value could inject a
97	/// path, query, fragment, userinfo or port into the request target (SSRF /
98	/// request smuggling). [`validate_id_tag`] forbids `/`, `@`, `:`, whitespace,
99	/// `_` and uppercase, leaving only a canonical DNS-style name; the A-label
100	/// returned here is pure LDH ASCII by construction.
101	///
102	/// Stored id_tags are UTS #46 U-labels, so an IDN identity must be punycoded
103	/// before it goes on the wire — DNS and TLS only speak the A-label.
104	fn host_for(id_tag: &str) -> ClResult<Cow<'_, str>> {
105		if !validate_id_tag(id_tag) {
106			return Err(Error::ValidationError(format!(
107				"invalid id_tag for federation request: {id_tag}"
108			)));
109		}
110		id_tag_to_ascii(id_tag)
111	}
112
113	/// Collect response body with timeout
114	async fn collect_body(body: hyper::body::Incoming) -> ClResult<Bytes> {
115		timeout(REQUEST_TIMEOUT, body.collect())
116			.await
117			.map_err(|_| Error::Timeout)?
118			.map_err(|_| Error::NetworkError("body collection error".into()))
119			.map(http_body_util::Collected::to_bytes)
120	}
121
122	// NOTE: Despite the name, this returns the **HS256 access token** the
123	// remote signs back, NOT the PROXY action token (the PROXY token is a
124	// short-lived bearer for the access-token endpoint only). Most callers
125	// should go through `get_or_mint_proxy_token` to benefit from caching
126	// and the 401-retry path.
127	pub async fn create_proxy_token(
128		&self,
129		tn_id: TnId,
130		id_tag: &str,
131		subject: Option<&str>,
132	) -> ClResult<Box<str>> {
133		let host = Self::host_for(id_tag)?;
134		let auth_token = self
135			.auth_adapter
136			.create_action_token(
137				tn_id,
138				CreateAction {
139					typ: "PROXY".into(),
140					audience_tag: Some(id_tag.into()),
141					expires_at: Some(Timestamp::from_now(60)), // 1 min
142					..Default::default()
143				},
144			)
145			.await?;
146		let req = hyper::Request::builder()
147			.method(Method::GET)
148			.uri(format!(
149				"https://cl-o.{}/api/auth/access-token?token={}{}",
150				host,
151				auth_token,
152				if let Some(subject) = subject {
153					format!("&subject={}", subject)
154				} else {
155					String::new()
156				}
157			))
158			.body(to_boxed(Empty::new()))?;
159		let res = self.timed_request(req).await?;
160		if !res.status().is_success() {
161			return Err(Error::PermissionDenied);
162		}
163		let parsed: TokenRes = serde_json::from_slice(&Self::collect_body(res.into_body()).await?)?;
164
165		Ok(parsed.data.token)
166	}
167
168	/// Returns a cached access token, minting one via `create_proxy_token`
169	/// and inserting on miss. Callers that hit 401/403 should call
170	/// `self.proxy_tokens.invalidate(tn_id, id_tag)` and retry once.
171	async fn get_or_mint_proxy_token(&self, tn_id: TnId, id_tag: &str) -> ClResult<Box<str>> {
172		if let Some(token) = self.proxy_tokens.get(tn_id, id_tag) {
173			return Ok(token);
174		}
175		let token = self.create_proxy_token(tn_id, id_tag, None).await?;
176		self.proxy_tokens.insert(tn_id, id_tag, token.clone());
177		Ok(token)
178	}
179
180	pub async fn get_bin(
181		&self,
182		tn_id: TnId,
183		id_tag: &str,
184		path: &str,
185		auth: bool,
186	) -> ClResult<Bytes> {
187		let host = Self::host_for(id_tag)?;
188		let mut attempt = 0u8;
189		loop {
190			let req = hyper::Request::builder()
191				.method(Method::GET)
192				.uri(format!("https://cl-o.{}/api{}", host, path));
193			let req = if auth {
194				req.header(
195					"Authorization",
196					format!("Bearer {}", self.get_or_mint_proxy_token(tn_id, id_tag).await?),
197				)
198			} else {
199				req
200			};
201			let req = req.body(to_boxed(Empty::new()))?;
202			let res = self.timed_request(req).await?;
203			debug!(status = %res.status(), "federated GET response");
204			match res.status() {
205				StatusCode::OK => return Self::collect_body(res.into_body()).await,
206				StatusCode::NOT_FOUND => return Err(Error::NotFound),
207				StatusCode::GONE => return Err(Error::Gone),
208				StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN if auth && attempt == 0 => {
209					debug!(id_tag = %id_tag, path = %path,
210						"auth rejected, refreshing cached token and retrying");
211					self.proxy_tokens.invalidate(tn_id, id_tag);
212					attempt += 1;
213				}
214				StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => {
215					return Err(Error::PermissionDenied);
216				}
217				code => {
218					return Err(Error::NetworkError(format!("unexpected HTTP status: {}", code)));
219				}
220			}
221		}
222	}
223
224	//pub async fn get_stream(&self, id_tag: &str, path: &str) -> ClResult<BodyDataStream<hyper::body::Incoming>> {
225	//pub async fn get_stream(&self, id_tag: &str, path: &str) -> ClResult<BodyDataStream<ClResult<Bytes>>> {
226	//pub async fn get_stream(&self, id_tag: &str, path: &str) -> ClResult<impl Stream<Item = ClResult<Bytes>>> {
227	//pub async fn get_stream(&self, id_tag: &str, path: &str) -> ClResult<TokioIo<BodyDataStream<hyper::body::Incoming>>> {
228	//pub async fn get_stream(&self, id_tag: &str, path: &str) -> ClResult<StreamReader<BodyDataStream<hyper::body::Incoming>, Bytes>> {
229	pub async fn get_stream(
230		&self,
231		tn_id: TnId,
232		id_tag: &str,
233		path: &str,
234		auth: bool,
235	) -> ClResult<impl AsyncRead + Send + Unpin + use<>> {
236		let host = Self::host_for(id_tag)?;
237		let mut attempt = 0u8;
238		loop {
239			let req = hyper::Request::builder()
240				.method(Method::GET)
241				.uri(format!("https://cl-o.{}/api{}", host, path));
242			let req = if auth {
243				let token = self.get_or_mint_proxy_token(tn_id, id_tag).await?;
244				debug!("Got proxy token (len={})", token.len());
245				req.header("Authorization", format!("Bearer {}", token))
246			} else {
247				req
248			};
249			let req = req.body(to_boxed(Empty::new()))?;
250			let res = self.timed_request(req).await?;
251			match res.status() {
252				StatusCode::OK => {
253					let stream = res.into_body().into_data_stream().map_err(std::io::Error::other);
254					return Ok(tokio_util::io::StreamReader::new(stream));
255				}
256				StatusCode::NOT_FOUND => return Err(Error::NotFound),
257				StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN if auth && attempt == 0 => {
258					debug!(id_tag = %id_tag, path = %path,
259						"auth rejected on stream, refreshing cached token and retrying");
260					self.proxy_tokens.invalidate(tn_id, id_tag);
261					attempt += 1;
262				}
263				StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => {
264					return Err(Error::PermissionDenied);
265				}
266				code => {
267					return Err(Error::NetworkError(format!("unexpected HTTP status: {}", code)));
268				}
269			}
270		}
271	}
272
273	pub async fn get<Res>(&self, tn_id: TnId, id_tag: &str, path: &str) -> ClResult<Res>
274	where
275		Res: DeserializeOwned,
276	{
277		let res = self.get_bin(tn_id, id_tag, path, true).await?;
278		let parsed: Res = serde_json::from_slice(&res)?;
279		Ok(parsed)
280	}
281
282	pub async fn get_noauth<Res>(&self, tn_id: TnId, id_tag: &str, path: &str) -> ClResult<Res>
283	where
284		Res: DeserializeOwned,
285	{
286		let res = self.get_bin(tn_id, id_tag, path, false).await?;
287		let parsed: Res = serde_json::from_slice(&res)?;
288		Ok(parsed)
289	}
290
291	/// Make a public GET request without authentication or tenant context
292	pub async fn get_public<Res>(&self, id_tag: &str, path: &str) -> ClResult<Res>
293	where
294		Res: DeserializeOwned,
295	{
296		let host = Self::host_for(id_tag)?;
297		let req = hyper::Request::builder()
298			.method(Method::GET)
299			.uri(format!("https://cl-o.{}/api{}", host, path))
300			.body(to_boxed(Empty::new()))?;
301		let res = self.timed_request(req).await?;
302		match res.status() {
303			StatusCode::OK => {
304				let bytes = Self::collect_body(res.into_body()).await?;
305				let parsed: Res = serde_json::from_slice(&bytes)?;
306				Ok(parsed)
307			}
308			StatusCode::NOT_FOUND => Err(Error::NotFound),
309			StatusCode::FORBIDDEN => Err(Error::PermissionDenied),
310			code => Err(Error::NetworkError(format!("unexpected HTTP status: {}", code))),
311		}
312	}
313
314	/// Make a conditional GET request with If-None-Match header for etag support
315	///
316	/// Returns `ConditionalResult::NotModified` if server returns 304,
317	/// or `ConditionalResult::Modified` with data and new etag if content changed.
318	pub async fn get_conditional<Res>(
319		&self,
320		id_tag: &str,
321		path: &str,
322		etag: Option<&str>,
323	) -> ClResult<ConditionalResult<Res>>
324	where
325		Res: DeserializeOwned,
326	{
327		let host = Self::host_for(id_tag)?;
328		let mut builder = hyper::Request::builder()
329			.method(Method::GET)
330			.uri(format!("https://cl-o.{}/api{}", host, path));
331
332		// Add If-None-Match header if we have an etag
333		if let Some(etag) = etag {
334			builder = builder.header("If-None-Match", etag);
335		}
336
337		let req = builder.body(to_boxed(Empty::new()))?;
338		let res = self.timed_request(req).await?;
339
340		match res.status() {
341			StatusCode::NOT_MODIFIED => Ok(ConditionalResult::NotModified),
342			StatusCode::OK => {
343				// Extract ETag from response headers
344				let new_etag = res
345					.headers()
346					.get("etag")
347					.and_then(|v| v.to_str().ok())
348					.map(|s| s.trim_matches('"').into());
349
350				let bytes = Self::collect_body(res.into_body()).await?;
351				let parsed: Res = serde_json::from_slice(&bytes)?;
352				Ok(ConditionalResult::Modified { data: parsed, etag: new_etag })
353			}
354			StatusCode::NOT_FOUND => Err(Error::NotFound),
355			StatusCode::FORBIDDEN => Err(Error::PermissionDenied),
356			code => Err(Error::NetworkError(format!("unexpected HTTP status: {}", code))),
357		}
358	}
359
360	/// Make a public POST request without authentication or tenant context
361	pub async fn post_public<Req, Res>(&self, id_tag: &str, path: &str, data: &Req) -> ClResult<Res>
362	where
363		Req: Serialize,
364		Res: DeserializeOwned,
365	{
366		let host = Self::host_for(id_tag)?;
367		let json_data = serde_json::to_vec(data)?;
368		let req = hyper::Request::builder()
369			.method(Method::POST)
370			.uri(format!("https://cl-o.{}/api{}", host, path))
371			.header("Content-Type", "application/json")
372			.body(to_boxed(Full::from(json_data)))?;
373		let res = self.timed_request(req).await?;
374		match res.status() {
375			StatusCode::OK | StatusCode::CREATED => {
376				let bytes = Self::collect_body(res.into_body()).await?;
377				let parsed: Res = serde_json::from_slice(&bytes)?;
378				Ok(parsed)
379			}
380			StatusCode::NOT_FOUND => Err(Error::NotFound),
381			StatusCode::FORBIDDEN => Err(Error::PermissionDenied),
382			StatusCode::UNPROCESSABLE_ENTITY => Err(Error::ValidationError(
383				"IDP registration failed - validation error".to_string(),
384			)),
385			code => Err(Error::NetworkError(format!("unexpected HTTP status: {}", code))),
386		}
387	}
388
389	/// Unauthenticated POST to a remote tenant.
390	///
391	/// Callers (e.g. IDP registration/resend, action federation inbox) authenticate
392	/// via signed tokens carried in the request body, not via an `Authorization`
393	/// header — `tn_id` is therefore unused at the transport layer.
394	pub async fn post_bin(
395		&self,
396		_tn_id: TnId,
397		id_tag: &str,
398		path: &str,
399		data: Bytes,
400	) -> ClResult<Bytes> {
401		let host = Self::host_for(id_tag)?;
402		let req = hyper::Request::builder()
403			.method(Method::POST)
404			.uri(format!("https://cl-o.{}/api{}", host, path))
405			.header("Content-Type", "application/json")
406			.body(to_boxed(Full::from(data)))?;
407		let res = self.timed_request(req).await?;
408		Self::collect_body(res.into_body()).await
409	}
410
411	/// Unauthenticated streaming POST to a remote tenant. See [`Self::post_bin`]
412	/// for why no `Authorization` header is attached.
413	pub async fn post_stream<S>(
414		&self,
415		_tn_id: TnId,
416		id_tag: &str,
417		path: &str,
418		stream: S,
419	) -> ClResult<Bytes>
420	where
421		S: Stream<Item = Result<hyper::body::Frame<Bytes>, hyper::Error>> + Send + Sync + 'static,
422	{
423		let host = Self::host_for(id_tag)?;
424		let req = hyper::Request::builder()
425			.method(Method::POST)
426			.uri(format!("https://cl-o.{}/api{}", host, path))
427			.body(to_boxed(StreamBody::new(stream)))?;
428		let res = self.timed_request(req).await?;
429		Self::collect_body(res.into_body()).await
430	}
431
432	pub async fn post<Res>(
433		&self,
434		tn_id: TnId,
435		id_tag: &str,
436		path: &str,
437		data: &impl Serialize,
438	) -> ClResult<Res>
439	where
440		Res: DeserializeOwned,
441	{
442		let res = self.post_bin(tn_id, id_tag, path, serde_json::to_vec(data)?.into()).await?;
443		let parsed: Res = serde_json::from_slice(&res)?;
444		Ok(parsed)
445	}
446
447	/// Authenticated JSON POST to a remote tenant. Mirrors [`Self::get_bin`]:
448	/// attaches a cached proxy token in the `Authorization` header and retries
449	/// once on 401/403 after invalidating the cached token. Branches on
450	/// response status so callers see a typed `Error` (NotFound / Gone /
451	/// PermissionDenied / NetworkError) instead of a downstream JSON-
452	/// deserialization failure when the upstream returns an error envelope.
453	///
454	/// Hard-codes `Content-Type: application/json`; for binary authed POSTs
455	/// add a separate helper that takes an explicit content-type parameter.
456	pub async fn post_json_authed(
457		&self,
458		tn_id: TnId,
459		id_tag: &str,
460		path: &str,
461		data: Bytes,
462	) -> ClResult<Bytes> {
463		let host = Self::host_for(id_tag)?;
464		let mut attempt = 0u8;
465		loop {
466			let token = self.get_or_mint_proxy_token(tn_id, id_tag).await?;
467			let req = hyper::Request::builder()
468				.method(Method::POST)
469				.uri(format!("https://cl-o.{}/api{}", host, path))
470				.header("Content-Type", "application/json")
471				.header("Authorization", format!("Bearer {}", token))
472				.body(to_boxed(Full::from(data.clone())))?;
473			let res = self.timed_request(req).await?;
474			debug!(status = %res.status(), "federated POST response");
475			match res.status() {
476				StatusCode::NOT_FOUND => return Err(Error::NotFound),
477				StatusCode::GONE => return Err(Error::Gone),
478				StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN if attempt == 0 => {
479					debug!(id_tag = %id_tag, path = %path,
480						"auth rejected on POST, refreshing cached token and retrying");
481					self.proxy_tokens.invalidate(tn_id, id_tag);
482					attempt += 1;
483				}
484				StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => {
485					return Err(Error::PermissionDenied);
486				}
487				code if code.is_success() => {
488					// Accept any 2xx — the wire shape is up to the caller.
489					// `204 No Content` returns empty bytes; the typed
490					// `post_authed` wrapper will surface that as a JSON parse
491					// error, which is acceptable for callers that opted in.
492					return Self::collect_body(res.into_body()).await;
493				}
494				code => {
495					return Err(Error::NetworkError(format!("unexpected HTTP status: {}", code)));
496				}
497			}
498		}
499	}
500
501	/// Typed JSON wrapper around [`Self::post_json_authed`].
502	pub async fn post_authed<Res>(
503		&self,
504		tn_id: TnId,
505		id_tag: &str,
506		path: &str,
507		data: &impl Serialize,
508	) -> ClResult<Res>
509	where
510		Res: DeserializeOwned,
511	{
512		let res = self
513			.post_json_authed(tn_id, id_tag, path, serde_json::to_vec(data)?.into())
514			.await?;
515		let parsed: Res = serde_json::from_slice(&res)?;
516		Ok(parsed)
517	}
518}
519
520#[cfg(test)]
521mod tests {
522	use super::*;
523
524	#[test]
525	fn host_for_accepts_valid() {
526		assert_eq!(Request::host_for("alice").unwrap(), "alice");
527		assert_eq!(Request::host_for("home.w9.hu").unwrap(), "home.w9.hu");
528		assert_eq!(Request::host_for("user-name-123").unwrap(), "user-name-123");
529	}
530
531	#[test]
532	fn host_for_punycodes_idn() {
533		// Storage holds the U-label; the wire needs the A-label.
534		assert_eq!(Request::host_for("münchen.example.com").unwrap(), "xn--mnchen-3ya.example.com");
535	}
536
537	#[test]
538	fn host_for_rejects_url_injection() {
539		// Each of these would corrupt the `https://cl-o.{id_tag}/api...` target
540		// if it were allowed through.
541		for bad in [
542			"alice/../../etc",
543			"alice/admin",
544			"alice@evil.com",
545			"alice:8080",
546			"alice evil",
547			"alice?x=1",
548			"alice#frag",
549			"alice_123",
550			"Alice.Example.com",
551			"",
552		] {
553			let err = Request::host_for(bad);
554			assert!(matches!(err, Err(Error::ValidationError(_))), "expected reject for {bad:?}");
555		}
556	}
557}
558
559// vim: ts=4