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