strand_bui_backend_session/lib.rs
1// Copyright (C) The Strand-Braid Authors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Backend session management for the BUI (Browser User Interface) used by [Strand
5//! Camera](https://strawlab.org/strand-cam) and
6//! [Braid](https://strawlab.org/braid).
7//!
8//! This crate provides HTTP session management functionality for web-based user
9//! interfaces, including cookie handling, authentication tokens, and request/response
10//! processing. It's designed to work with browser-based frontends that communicate
11//! with Rust backend services.
12//!
13//! # Features
14//!
15//! - HTTP session management with automatic cookie handling
16//! - Support for pre-shared authentication tokens
17//! - Async/await support for all HTTP operations
18//! - Integration with the Strand Camera and Braid ecosystems
19//!
20//! # Examples
21//!
22//! ```rust,no_run
23//! use strand_bui_backend_session::{HttpSession, create_session};
24//! use std::sync::{Arc, RwLock};
25//!
26//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
27//! // Create a cookie store for session management
28//! let jar = Arc::new(RwLock::new(cookie_store::CookieStore::new(None)));
29//!
30//! // Server information with authentication token
31//! let server_info = strand_bui_backend_session_types::BuiServerAddrInfo::new(
32//! "127.0.0.1:8080".parse()?,
33//! strand_bui_backend_session_types::AccessToken::NoToken
34//! );
35//!
36//! // Create an authenticated session
37//! let mut session = create_session(&server_info, jar).await?;
38//!
39//! // Make requests using the session
40//! let response = session.get("api/status").await?;
41//! # Ok(())
42//! # }
43//! ```
44
45// Copyright 2016-2025 Andrew D. Straw.
46//
47// Licensed under the Apache License, Version 2.0
48// <http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
49// <http://opensource.org/licenses/MIT>, at your option. This file may not be
50// copied, modified, or distributed except according to those terms.
51
52#![warn(missing_docs)]
53#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))]
54
55use http::{HeaderValue, header::ACCEPT};
56use std::{
57 net::SocketAddr,
58 sync::{Arc, RwLock},
59};
60use strand_bui_backend_session_types::{AccessToken, BuiServerAddrInfo};
61use thiserror::Error;
62
63const SET_COOKIE: &str = "set-cookie";
64const COOKIE: &str = "cookie";
65
66/// Type alias for the HTTP body type used throughout the crate.
67///
68/// This uses Axum's body type for compatibility with the web framework.
69pub type MyBody = axum::body::Body;
70
71/// Errors that can occur during HTTP session operations.
72#[derive(Error, Debug)]
73pub enum Error {
74 /// A wrapped error from the hyper HTTP client crate.
75 #[error("hyper error `{0}`")]
76 Hyper(#[from] hyper::Error),
77 /// A wrapped error from the hyper-util HTTP utilities crate.
78 #[error("hyper-util error `{0}`")]
79 HyperUtil(#[from] hyper_util::client::legacy::Error),
80 /// The HTTP request was not successful.
81 ///
82 /// This error occurs when the server returns a non-success status code
83 /// (anything other than 2xx).
84 #[error("request not successful. status code: `{0}`")]
85 RequestFailed(http::StatusCode),
86}
87
88/// An HTTP session for communicating with a single server.
89///
90/// This struct manages cookies, authentication, and provides methods for making
91/// HTTP requests to a specific server. All requests made through this session
92/// will automatically include appropriate cookies and authentication tokens.
93#[derive(Clone, Debug)]
94pub struct HttpSession {
95 /// The base URI for all requests made by this session
96 base_uri: hyper::Uri,
97 /// Thread-safe cookie store for managing session cookies
98 jar: Arc<RwLock<cookie_store::CookieStore>>,
99}
100
101/// Creates an authenticated `HttpSession` by making an initial request with the provided token.
102///
103/// This function establishes a session with the server by making an initial authenticated
104/// request, which typically results in the server setting session cookies that will be
105/// used for subsequent requests.
106///
107/// # Arguments
108///
109/// * `server_info` - Server address and authentication information
110/// * `jar` - Thread-safe cookie store for managing session cookies
111///
112/// # Returns
113///
114/// An authenticated `HttpSession` ready for making requests, or an error if the
115/// initial authentication request fails.
116///
117/// # Examples
118///
119/// ```rust,no_run
120/// use strand_bui_backend_session::create_session;
121/// use strand_bui_backend_session_types::AccessToken;
122/// use std::sync::{Arc, RwLock};
123///
124/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
125/// let jar = Arc::new(RwLock::new(cookie_store::CookieStore::new(None)));
126/// let server_info = strand_bui_backend_session_types::BuiServerAddrInfo::new(
127/// "127.0.0.1:8080".parse()?,
128/// AccessToken::PreSharedToken("secret123".to_string())
129/// );
130///
131/// let session = create_session(&server_info, jar).await?;
132/// # Ok(())
133/// # }
134/// ```
135#[tracing::instrument(level = "debug", skip(server_info, jar))]
136pub async fn create_session(
137 server_info: &strand_bui_backend_session_types::BuiServerAddrInfo,
138 jar: Arc<RwLock<cookie_store::CookieStore>>,
139) -> Result<HttpSession, Error> {
140 let base_uri = format!("http://{}/", server_info.addr());
141 let mut base = HttpSession::new(&base_uri, jar);
142 base.get_with_token("", server_info.token()).await?;
143 Ok(base)
144}
145
146impl HttpSession {
147 /// Creates a new HTTP session for the specified base URI.
148 ///
149 /// # Arguments
150 ///
151 /// * `base_uri` - The base URI for all requests (must end with "/")
152 /// * `jar` - Thread-safe cookie store for managing session cookies
153 ///
154 /// # Panics
155 ///
156 /// Panics if the base URI cannot be parsed or doesn't end with "/".
157 fn new(base_uri: &str, jar: Arc<RwLock<cookie_store::CookieStore>>) -> Self {
158 let base_uri: hyper::Uri = base_uri.parse().expect("failed to parse uri");
159 if let Some(pq) = base_uri.path_and_query() {
160 assert_eq!(pq.path(), "/");
161 assert!(pq.query().is_none());
162 }
163 Self { base_uri, jar }
164 }
165
166 /// Constructs a full URI from a relative path and optional access token.
167 ///
168 /// # Arguments
169 ///
170 /// * `rel` - Relative path to append to the base URI
171 /// * `token` - Optional access token to include as a query parameter
172 ///
173 /// # Returns
174 ///
175 /// A complete URI ready for making HTTP requests.
176 fn get_rel_uri(&self, rel: &str, token: Option<&AccessToken>) -> hyper::Uri {
177 let token = if let Some(tok1) = token {
178 match tok1 {
179 AccessToken::NoToken => None,
180 AccessToken::PreSharedToken(t) => Some(t),
181 }
182 } else {
183 None
184 };
185
186 let pq: String = if let Some(token) = token {
187 format!("/{rel}?token={token}")
188 } else {
189 format!("/{rel}")
190 };
191 let pqs: &str = &pq;
192
193 let pq: http::uri::PathAndQuery = std::convert::TryFrom::try_from(pqs).unwrap();
194
195 http::uri::Builder::new()
196 .scheme(self.base_uri.scheme().unwrap().clone())
197 .authority(self.base_uri.authority().unwrap().clone())
198 .path_and_query(pq)
199 .build()
200 .expect("build url")
201 }
202
203 /// Internal method for making HTTP requests with full control over parameters.
204 ///
205 /// # Arguments
206 ///
207 /// * `rel` - Relative path for the request
208 /// * `token` - Optional access token
209 /// * `accepts` - Array of Accept header values
210 /// * `method` - HTTP method to use
211 /// * `body` - Request body
212 async fn inner_req(
213 &mut self,
214 rel: &str,
215 token: Option<&AccessToken>,
216 accepts: &[HeaderValue],
217 method: http::Method,
218 body: axum::body::Body,
219 ) -> Result<hyper::Response<hyper::body::Incoming>, Error> {
220 let uri = self.get_rel_uri(rel, token);
221
222 let mut req = hyper::Request::new(body);
223 *req.method_mut() = method;
224 *req.uri_mut() = uri;
225 for accept in accepts.iter() {
226 req.headers_mut().insert(ACCEPT, (*accept).clone());
227 }
228 let response = self.make_request(req).await?;
229 Ok(response)
230 }
231
232 /// Makes a GET request to the specified relative path.
233 ///
234 /// This method automatically includes session cookies and handles authentication.
235 ///
236 /// # Arguments
237 ///
238 /// * `rel` - Relative path to request (e.g., "api/status")
239 ///
240 /// # Returns
241 ///
242 /// The HTTP response from the server, or an error if the request fails.
243 ///
244 /// # Examples
245 ///
246 /// ```rust,no_run
247 /// # async fn example(mut session: strand_bui_backend_session::HttpSession) -> Result<(), Box<dyn std::error::Error>> {
248 /// let response = session.get("api/status").await?;
249 /// println!("Status: {}", response.status());
250 /// # Ok(())
251 /// # }
252 /// ```
253 pub async fn get(
254 &mut self,
255 rel: &str,
256 ) -> Result<hyper::Response<hyper::body::Incoming>, Error> {
257 self.inner_req(rel, None, &[], http::Method::GET, axum::body::Body::empty())
258 .await
259 }
260
261 /// Makes an HTTP request with custom Accept headers and method.
262 ///
263 /// This method provides more control over the HTTP request, allowing you to
264 /// specify Accept headers and HTTP method.
265 ///
266 /// # Arguments
267 ///
268 /// * `rel` - Relative path to request
269 /// * `accepts` - Array of Accept header values to include
270 /// * `method` - HTTP method to use (GET, POST, PUT, etc.)
271 /// * `body` - Request body
272 ///
273 /// # Returns
274 ///
275 /// The HTTP response from the server, or an error if the request fails.
276 pub async fn req_accepts(
277 &mut self,
278 rel: &str,
279 accepts: &[HeaderValue],
280 method: http::Method,
281 body: axum::body::Body,
282 ) -> Result<hyper::Response<hyper::body::Incoming>, Error> {
283 self.inner_req(rel, None, accepts, method, body).await
284 }
285
286 /// Makes a GET request with an authentication token.
287 ///
288 /// This method is used internally for authenticated requests, typically
289 /// during session establishment.
290 ///
291 /// # Arguments
292 ///
293 /// * `rel` - Relative path to request
294 /// * `token` - Access token for authentication
295 async fn get_with_token(
296 &mut self,
297 rel: &str,
298 token: &AccessToken,
299 ) -> Result<hyper::Response<hyper::body::Incoming>, Error> {
300 self.inner_req(
301 rel,
302 Some(token),
303 &[],
304 http::Method::GET,
305 axum::body::Body::empty(),
306 )
307 .await
308 }
309
310 /// Makes a POST request to the specified relative path.
311 ///
312 /// This method automatically includes session cookies and sets the
313 /// Content-Type header to "application/json".
314 ///
315 /// # Arguments
316 ///
317 /// * `rel` - Relative path to post to (e.g., "api/submit")
318 /// * `body` - Request body containing the data to post
319 ///
320 /// # Returns
321 ///
322 /// The HTTP response from the server, or an error if the request fails.
323 ///
324 /// # Examples
325 ///
326 /// ```rust,no_run
327 /// # async fn example(mut session: strand_bui_backend_session::HttpSession) -> Result<(), Box<dyn std::error::Error>> {
328 /// let body = axum::body::Body::from(r#"{"key": "value"}"#);
329 /// let response = session.post("api/data", body).await?;
330 /// # Ok(())
331 /// # }
332 /// ```
333 #[tracing::instrument(skip_all)]
334 pub async fn post(
335 &mut self,
336 rel: &str,
337 body: MyBody,
338 ) -> Result<hyper::Response<hyper::body::Incoming>, Error> {
339 let uri = self.get_rel_uri(rel, None);
340
341 let mut req = hyper::Request::new(body);
342 *req.method_mut() = hyper::Method::POST;
343 *req.uri_mut() = uri;
344 self.make_request(req).await
345 }
346
347 /// Internal method that actually executes HTTP requests.
348 ///
349 /// This method handles cookie management, sets appropriate headers,
350 /// and processes the response including cookie updates.
351 ///
352 /// # Arguments
353 ///
354 /// * `req` - The complete HTTP request to execute
355 ///
356 /// # Returns
357 ///
358 /// The HTTP response, or an error if the request fails or returns
359 /// a non-success status code.
360 #[tracing::instrument(skip_all)]
361 async fn make_request(
362 &mut self,
363 mut req: hyper::Request<MyBody>,
364 ) -> Result<hyper::Response<hyper::body::Incoming>, Error> {
365 let client =
366 hyper_util::client::legacy::Client::builder(hyper_util::rt::TokioExecutor::new())
367 .build_http();
368
369 tracing::trace!("building request");
370 let url = url::Url::parse(req.uri().to_string().as_ref()).unwrap();
371 {
372 let jar = self.jar.read().unwrap();
373 for (cookie_name, cookie_value) in jar.get_request_values(&url) {
374 let cookie = cookie_store::RawCookie::new(cookie_name, cookie_value);
375 tracing::trace!("adding cookie {}", cookie);
376 req.headers_mut().insert(
377 COOKIE,
378 hyper::header::HeaderValue::from_str(&cookie.to_string()).unwrap(),
379 );
380 }
381 }
382
383 req.headers_mut().insert(
384 http::header::CONTENT_TYPE,
385 hyper::header::HeaderValue::from_str("application/json").unwrap(),
386 );
387
388 tracing::debug!("making request {:?}", req);
389 let response = client.request(req).await.map_err(|e| {
390 tracing::error!("encountered error {e}: {e:?}");
391 Error::from(e)
392 })?;
393
394 tracing::debug!("handling response {:?}", response);
395 let response = handle_response(&url, self.jar.clone(), response)?;
396 let status_code = response.status();
397 if !status_code.is_success() {
398 use http_body_util::BodyExt;
399 let body_bytes = response.into_body().collect().await.unwrap().to_bytes();
400 let body_str = std::string::String::from_utf8_lossy(body_bytes.as_ref());
401 tracing::error!("response {status_code:?}: \"{body_str}\"");
402 return Err(Error::RequestFailed(status_code));
403 }
404 Ok(response)
405 }
406}
407
408/// Processes HTTP response headers to extract and store cookies.
409///
410/// This function examines the response for Set-Cookie headers and updates
411/// the cookie store accordingly. It's called automatically by the session
412/// to maintain cookie state across requests.
413///
414/// # Arguments
415///
416/// * `url` - The URL that generated this response (for cookie domain matching)
417/// * `jar` - Thread-safe cookie store to update
418/// * `response` - The HTTP response to process
419///
420/// # Returns
421///
422/// The same HTTP response, or an error if cookie processing fails.
423fn handle_response(
424 url: &url::Url,
425 jar: Arc<RwLock<cookie_store::CookieStore>>,
426 mut response: hyper::Response<hyper::body::Incoming>,
427) -> Result<hyper::Response<hyper::body::Incoming>, Error> {
428 tracing::trace!("starting to handle cookies in response {:?}", response);
429
430 use hyper::header::Entry::*;
431 match response.headers_mut().entry(SET_COOKIE) {
432 Occupied(e) => {
433 let (_key, drain) = e.remove_entry_mult();
434 let mut jar = jar.write().unwrap();
435 jar.store_response_cookies(
436 drain.map(|cookie_raw| {
437 cookie_store::RawCookie::parse(cookie_raw.to_str().unwrap().to_string())
438 .unwrap()
439 }),
440 url,
441 );
442 }
443 Vacant(_) => {}
444 }
445
446 tracing::trace!("done handling cookies in response {:?}", response);
447 Ok(response)
448}
449
450#[test]
451fn test_serialized_cookie_store() {
452 // Test that we can upgrade `cookie_store` crate without invalidating on-disk stored cookies.
453
454 // Load CookieStore from json like we have previously saved to disk.
455 let serialized_json1 = r#"[{"raw_cookie":"abc=def; Expires="#;
456 let expires = chrono::Utc::now()
457 .checked_add_signed(chrono::Duration::days(365))
458 .unwrap();
459 let expires_str1 = expires.format("%a, %d %b %Y %H:%M:%S GMT").to_string();
460 // e.g. "Thu, 20 Nov 2025 12:19:29 GMT"
461 let serialized_json2 =
462 r#"","path":["/",false],"domain":{"HostOnly":"127.0.0.1"},"expires":{"AtUtc":""#;
463 // e.g. "2025-11-20T12:19:29Z"
464 let expires_str2 = expires.format("%Y-%m-%dT%H:%M:%SZ").to_string();
465 let serialized_json3 = r#""}}]"#;
466 let serialized_json = format!(
467 "{}{}{}{}{}",
468 serialized_json1, expires_str1, serialized_json2, expires_str2, serialized_json3
469 );
470 let loaded: cookie_store::CookieStore = serde_json::from_str(&serialized_json).unwrap();
471
472 // What do we expect?
473 let expected = {
474 let mut expected = cookie_store::CookieStore::new(None);
475 let cookie_str1 = "abc=def; Expires=";
476 let cookie_str = format!("{}{}", cookie_str1, expires_str1);
477 let request_url = "http://127.0.0.1/".try_into().unwrap();
478
479 let cookie = cookie_store::Cookie::parse(cookie_str, &request_url).unwrap();
480 expected.insert(cookie, &request_url).unwrap();
481 expected
482 };
483
484 // Since CookieStore does not implement PartialEq, we convert to json values
485 // and compare those.
486 let loaded_json: serde_json::Value = serde_json::to_value(&loaded).unwrap();
487 let expected_json: serde_json::Value = serde_json::to_value(&expected).unwrap();
488 assert_eq!(&loaded_json, &expected_json);
489}
490
491/// Builds a list of HTTP URIs for the server address.
492pub fn build_urls(bui_server_info: &BuiServerAddrInfo) -> std::io::Result<Vec<http::Uri>> {
493 let query = match &bui_server_info.token() {
494 AccessToken::NoToken => "".to_string(),
495 AccessToken::PreSharedToken(tok) => format!("?token={tok}"),
496 };
497 Ok(expand_unspecified_addr(bui_server_info.addr())?
498 .into_iter()
499 .map(|specified_addr| {
500 let addr = specified_addr.addr();
501 http::uri::Builder::new()
502 .scheme("http")
503 .authority(format!("{}:{}", addr.ip(), addr.port()))
504 .path_and_query(format!("/{query}"))
505 .build()
506 .unwrap()
507 })
508 .collect())
509}
510
511/// A newtype wrapping a [SocketAddr] which ensures that it is specified.
512#[derive(Debug, PartialEq, Clone, serde::Serialize)]
513#[serde(transparent)]
514pub struct SpecifiedSocketAddr(SocketAddr);
515
516impl std::fmt::Display for SpecifiedSocketAddr {
517 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
518 std::fmt::Display::fmt(&self.0, f)
519 }
520}
521
522impl SpecifiedSocketAddr {
523 fn make_err() -> std::io::Error {
524 std::io::ErrorKind::AddrNotAvailable.into()
525 }
526 /// Creates a new `SpecifiedSocketAddr` from a `SocketAddr`.
527 pub fn new(addr: SocketAddr) -> std::io::Result<Self> {
528 if addr.ip().is_unspecified() {
529 return Err(Self::make_err());
530 }
531 Ok(Self(addr))
532 }
533 /// Get the underlying IP address of the socket.
534 pub fn ip(&self) -> std::net::IpAddr {
535 self.0.ip()
536 }
537 /// Get the underlying socket address.
538 pub fn addr(&self) -> &std::net::SocketAddr {
539 &self.0
540 }
541}
542
543impl<'de> serde::Deserialize<'de> for SpecifiedSocketAddr {
544 fn deserialize<D>(deserializer: D) -> std::result::Result<SpecifiedSocketAddr, D::Error>
545 where
546 D: serde::Deserializer<'de>,
547 {
548 let addr: SocketAddr = std::net::SocketAddr::deserialize(deserializer)?;
549 SpecifiedSocketAddr::new(addr).map_err(|_e| serde::de::Error::custom(Self::make_err()))
550 }
551}
552
553/// Expands an unspecified address into a list of specified addresses.
554fn expand_unspecified_addr(addr: &SocketAddr) -> std::io::Result<Vec<SpecifiedSocketAddr>> {
555 if addr.ip().is_unspecified() {
556 expand_unspecified_ip(addr.ip())?
557 .into_iter()
558 .map(|ip| SpecifiedSocketAddr::new(SocketAddr::new(ip, addr.port())))
559 .collect()
560 } else {
561 Ok(vec![SpecifiedSocketAddr::new(*addr).unwrap()])
562 }
563}
564
565fn expand_unspecified_ip(ip: std::net::IpAddr) -> std::io::Result<Vec<std::net::IpAddr>> {
566 if ip.is_unspecified() {
567 // Get all interfaces if IP is unspecified.
568 Ok(if_addrs::get_if_addrs()?
569 .iter()
570 .filter_map(|x| {
571 let this_ip = x.addr.ip();
572 // Take only IP addresses from correct family.
573 if ip.is_ipv4() == this_ip.is_ipv4() {
574 Some(this_ip)
575 } else {
576 None
577 }
578 })
579 .collect())
580 } else {
581 Ok(vec![ip])
582 }
583}