Cookiebox

Cookiebox provides a type safe and flexible approach to managing cookies in Actix web applications with minimal boilerplate. Here is a list of what you will get from using this crate:

  • This crate uses biscotti under the hood, which inherits most of its features.
  • Offers the ability to configure settings on a per cookie basis.
  • Enforces type definitions for deserializing cookie values upon retrieval.
  • Allows customization of both the data type and data serialization.
  • Provides a straightforward and type safe interface for managing cookies.

This crate was inspired by Luca Palmieri's SessionType from his latest book Zero2Prod , his Biscotti cookie crate, and the Actix-session crate.

Note: If you want to skip ahead, click here to jump to the example code, or click here to view the GitHub example.

Why Cookiebox

I'm currently working on building a custom authentication server for my project and have decided to use Biscotti to handle cookies. Biscotti is great, but it's a lower level cookie crate. I needed something a bit more intuitive, a higher-level interface for cookie management. To address this, I decided to take a little segue and implement Cookiebox.

In this article I’ll start by showing you how I initially handled cookies via Biscotti and how it inspired the creation of Cookiebox. Then I’ll walk you through an example of Cookiebox and how it simplifies cookie management.

Biscotti

Let’s start with the basic setup for Biscotti.

use biscotti::config::{CryptoRule, CryptoAlgorithm};
use biscotti::{Key, Processor, ProcessorConfig};
use actix_web::{App, HttpServer};

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    // Set up the processor for the middleware
    let mut cookie_config = ProcessorConfig::default();

    // Set up the rules for encrypted cookies
    let crypto_rule = CryptoRule {
        cookie_names: vec!["__cookie-a".to_string()],
        algorithm: CryptoAlgorithm::Encryption,
        key: Key::generate(),
        fallbacks: vec![],
    };

    cookie_config.crypto_rules.push(crypto_rule);

    let processor: Processor = cookie_config.into();

    HttpServer::new(move || {
        App::new()
            .service(get_cookie)
            .service(add_cookie)
            .app_data(Data::new(processor.clone()))
    })
    .bind(("127.0.0.1", 8080))?
    .run()
    .await
}

The simplest and most straightforward way to use Biscotti is by including the HttpRequest as a parameter.

// A type to deserialize data into
#[derive(Deserialize, Debug)]
pub struct Person {
   pub name: String
}

#[get("get_cookie")]
async fn get_cookie(req: HttpRequest) -> HttpResponse {
    // Extract cookies from the cookie header
    let cookie_collection = process_header(&req);

    let cookie = cookie_collection
        // Using string keys could cause issues here - You may want to leverage the type system to reduce the margin of errors.
        .get("__cookie-a")
        .expect("requested cookie not found in cookie collection");

    // Deserialize cookie data and type cast it to Person
    let cookie_data: Person = serde_json::from_str(cookie.value())
        .expect("Failed to deserialize cookie value");

    HttpResponse::Ok().body(format!("Got - {:?}", cookie_data))
}

#[get("add_cookie")]
async fn add_cookie(req: HttpRequest) -> HttpResponse {
    // Initialize the cookie collection
    let mut cookies = ResponseCookies::new();

    // Fetch the processor from app data
    let processor =
        req.app_data::<web::Data<Processor>>()
            .expect("Processor not found in app data");

    // Serialize data and insert it in cookie_a_add
    let data = json!({
        "name": "Mark",
    });

    // Define the cookie and sets the attributes for it.
    let cookie_a_add = ResponseCookie::new("__cookie-a", data.to_string()).set_path("/").set_domain("127.0.0.1");
    // Create a removal cookie
    let cookie_a_remove = RemovalCookie::new("__cookie-a");

    cookies.insert(cookie_a_add);

    // This does not remove 'cookie_a_add' because biscotti treats `cookie_a_remove` differently.
    // It looks for a cookie named __cookie-a without the path or domain which is not the same as the one with path and domain
    // Another area where it could cause issues if not handled correctly.
    cookies.insert(cookie_a_remove);

    // Transform the cookies
    let cookie_header_value: Vec<String>= cookies.header_values(&processor).collect();

    let mut response = HttpResponse::Ok();

    for cookie in cookie_header_value {
        response.append_header((SET_COOKIE, cookie));
    }

    response.body(format!("added - {:?}", data))
}
// Helper function 
fn process_header(
    req: &HttpRequest,
) -> RequestCookies{
    let processor =
        req.app_data::<web::Data<Processor>>()
            .expect("Processor not found in app data");

    let cookie_header = req
        .headers()
        .get(actix_web::http::header::COOKIE)
        .and_then(|header| header.to_str().ok())
        .expect("Request cookie header is missing or invalid");

    RequestCookies::parse_header(cookie_header, &processor).expect("Failed to process header values")
}

That's all fine and dandy, but it clutters our request handlers. You'd probably want to abstract away the cookie logic and leverage the type system to reduce potential errors

Let's try to implement typed cookies and cookie header extraction logic as a separate component. You can go about this in many different ways but I will show you something close to what I wrote prior to building Cookiebox.

use std::future::{ready, Ready};
use std::marker::PhantomData;
use actix_web::dev::Payload;
use actix_web::http::header::SET_COOKIE;
use actix_web::{FromRequest, HttpResponseBuilder};
use biscotti::time::Duration;
use biscotti::{Processor, RemovalCookie, RequestCookie, RequestCookies, ResponseCookie, ResponseCookies};
use actix_web::{get, HttpRequest, HttpResponse, web,web::Data};
use serde::Deserialize;
use serde_json::json;

///////////////////////////////// 
//         Typed Cookies
/////////////////////////////////

// A type to deserialize data into
#[derive(Deserialize, Debug)]
pub struct Person {
   pub name: String
}

// A cookie type state for managing cookie states
pub struct TypedCookie<'c, CookieType> {
    pub cookie: Option<RequestCookie<'c>>,
    pub cookie_type: PhantomData<CookieType>,
}

// Custom cookie type
pub struct CookieA;

// Define constant values for our custom cookie type to ensure consistency when working on cookieA
impl CookieA {
    const COOKIE_NAME: &'static str = "__cookie-a";
}

impl<'c> TypedCookie<'c, CookieA> {
    pub fn get_person(&self) -> Result<Person, String> {
        // Deserialize cookie data 
        if let Some(cookie) = &self.cookie {
            let cookie_data: Person = serde_json::from_str(cookie.value())
                .expect("Failed to deserialize cookie value");
            return Ok(cookie_data);
        }
        Err("No request cookie data".to_string())
    } 
    pub fn remove_cookie(&self) -> RemovalCookie {
        RemovalCookie::new(CookieA::COOKIE_NAME)
    }
    pub fn add_cookie(&self, name: String) -> ResponseCookie {
        let data = json!({
            "name": name
        });

        ResponseCookie::new(CookieA::COOKIE_NAME, data.to_string())
            .set_max_age(Duration::seconds(60 * 2))
    }
}

impl<'c> FromRequest for TypedCookie<'c, CookieA>{
    type Error = actix_web::error::Error;
    type Future = Ready<Result<TypedCookie<'c, CookieA>, Self::Error>>;

    fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future {
        let cookies_request = process_header_pre(req);
        if cookies_request.is_err() {
            return ready(Ok(TypedCookie {
                    cookie: None,
                    cookie_type: PhantomData::<CookieA>,
                }))
        }
        match cookies_request.unwrap().get(CookieA::COOKIE_NAME) {
            Some(cookie) => {
            // A quick and dirty fix for lifetime issue here
            let cookie = RequestCookie::new(cookie.name().to_string(), cookie.value().to_string());
            ready(Ok(TypedCookie {
                    cookie: Some(cookie),
                    cookie_type: PhantomData::<CookieA>,
                }))
            },
            None => ready(Err(actix_web::error::ErrorBadRequest("CookieA not found")))
        }

    }
}
///////////////////////////////// 
//         Helper Methods
/////////////////////////////////

// Extracts and parses header values from the request
fn process_header_pre(
    req: &HttpRequest,
) -> Result<RequestCookies, String> {

    let processor =
        req.app_data::<web::Data<Processor>>()
            .expect("Processor not found in app data");

    let cookie_header = req
        .headers()
        .get(actix_web::http::header::COOKIE)
        .and_then(|header| header.to_str().ok());

    if let Some(cookie_header) = cookie_header {
        Ok(RequestCookies::parse_header(cookie_header, &processor).expect("Failed to process header values"))
    } else {
        Err("No cookie header found".to_string())
    }
}

// This transforms all cookies and sets them to the `Set-Cookie` header
fn process_header_post(req: HttpRequest, cookies: ResponseCookies, response: &mut HttpResponseBuilder) {
    // Fetch the processor from app data - we need that to transform the cookies before sending it off to the caller
    let processor =
        req.app_data::<web::Data<Processor>>()
            .expect("Processor not found in app data");

    // Transform the cookies
    let cookie_header_value: Vec<String>= cookies.header_values(&processor).collect();

    for cookie in cookie_header_value {
        response.append_header((SET_COOKIE, cookie));
    }
}

This is a lot better. Our cookie logic is contained in the type state implementation of the generic state type. Let's see how this cleans up our request handlers.

///////////////////////////////// 
//        Request Handler
/////////////////////////////////

// Look ma! My request handlers are a lot cleaner now

// FromRequest did most of the heavy lifting here
#[get("get_cookie")]
async fn get_cookie(cookie_a: TypedCookie<'_, CookieA>) -> HttpResponse {
    let person = cookie_a.get_person().map_err(|e| eprint!("Failure caused by:\n\n{e}"));
    HttpResponse::Ok().body(format!("Got - {:?}", person))
}

#[get("add_cookie")]
async fn add_cookie(req: HttpRequest, cookie_a: TypedCookie<'_, CookieA>) -> HttpResponse {
    // Initialize the cookie collection
    let mut cookies = ResponseCookies::new();

    // Define the cookie and sets the attributes for it.
    let cookie_a_add = cookie_a.add_cookie("Mark".to_string());

    cookies.insert(cookie_a_add);

    let mut response = HttpResponse::Ok();

    // Transform cookies and set them to `Set-Cookie` 
    process_header_post(req, cookies, &mut response);

    response.body(format!("added - Mark"))
}

Great! We have typed cookies and reduced the amount of code in our request handlers. If we refactor further and implement middleware, it will declutter the code even more. If you notice the code for add_cookie, remove_cookie, and get_cookie can also be abstracted away. The main differences are the return types, serialization, and attributes. If you continue down this path, you’ll eventually end up with something very close to CookieBox.

Cookiebox

Now that we've seen how typed cookies are implemented with biscotti, let's take a look at how cookiebox simplifies cookie management with minimal setup.

Start by adding the CookieMiddleware to the App instance.

use cookiebox::{CookieMiddleware, Processor, ProcessorConfig, Key, config::{CryptoRule, CryptoAlgorithm)};
use actix_web::{App, HttpServer};

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    // Set up the processor for the middleware
    let mut cookie_config = ProcessorConfig::default();

    // Set up the rules for encrypted cookies
    let crypto_rule = CryptoRule {
        cookie_names: vec!["__cookie-a".to_string()],
        algorithm: CryptoAlgorithm::Encryption,
        key: Key::generate(),
        fallbacks: vec![],
    };

    cookie_config.crypto_rules.push(crypto_rule);

    let processor: Processor = cookie_config.into();

    HttpServer::new(|| {
        App::new()
            // The middleware handles the extraction and transformation the cookies from the request handler
            .wrap(CookieMiddleware::new(processor.clone()))
            .service(get_cookie)
            .service(add_cookie)
    })
    .bind(("127.0.0.1", 8080))?
    .run()
    .await
}

Now, define and configure your cookies.

use actix_web::{get, HttpMessage, HttpResponse};
use cookiebox::cookies::{Cookie, CookieName, IncomingConfig, OutgoingConfig};
use cookiebox::{Attributes, SameSite};
use cookiebox::cookiebox_macros::{cookie, FromRequest};
use serde::{Deserialize, Serialize};
use serde_json::json;

// Data Types 
#[derive(Serialize, Deserialize, Debug)]
pub struct CookieData {
    pub data: String,
}

//Define cookies
#[cookie(name = "__cookie-a")]
pub struct CookieA;
#[cookie(name = "__cookie-b")]
pub struct CookieB;

// Cookie types configuration
//
// Cookie A
// This generic type parameter would give Cookie type get and get_all
impl IncomingConfig for CookieA {
    type Get = String;
}

// Cookie B
// This generic type parameter would give Cookie type get, get_all, insert, and remove.
impl IncomingConfig for CookieB {
    type Get = CookieData;
}
impl OutgoingConfig for CookieB {
    type Insert = (String, i32);

    // Customize the serialization method
    fn serialize(values: Self::Insert) -> serde_json::Value {
        json!({
            "data": format!("Name: {} - Age: {}", values.0, values.1)
        })
    }
    // Configure attributes for cookie
    fn attributes<'c>() -> Attributes<'c> {
        Attributes::new().same_site(SameSite::Lax).http_only(true)
    }
}

Implement FromRequest for the assorted cookie collection via the derive macro.

#[derive(FromRequest)]
pub struct CookieCollection<'c> {
    cookie_a: Cookie<'c, CookieA>,
    cookie_b: Cookie<'c, CookieB>,
}

That's it! You can now use the cookie collection in the request handlers like so

// Request handler from the app
#[get("/add-cookie")]
async fn add_cookie(cookies_collection: CookieCollection<'_>) -> HttpResponse {
    // Insert the cookie with the defined attributes and custom serialization method
    cookies_collection.cookie_b.insert(("Scarlet".to_string(), 27));

    HttpResponse::Ok().finish()
}

#[get("/get-cookie")]
async fn get_cookie(cookies_collection: CookieCollection<'_>) -> HttpResponse {
    // This returns a Ok(String) if found, otherwise Err(CookieBoxError)
    cookies_collection.cookie_b.get().expect("Unable to get cookie data");

    HttpResponse::Ok().finish()
}

If you would like to see an example, please click here.

Conclusion

Well, that's about it. If you find cookiebox helpful, please consider giving it a star. If you stumble upon a bug or have any suggestions, feel free to open an issue on GitHub. Thanks for reading!