Skip to main content

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}