1use 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
20const 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#[derive(Debug)]
48pub enum ConditionalResult<T> {
49 Modified { data: T, etag: Option<Box<str>> },
51 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 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 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 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 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)), ..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 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(
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 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 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 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 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 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 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 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 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 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 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 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