biscotti, a new crate for HTTP cookies

TL;DR

biscotti ("cookies", but in Italian) is a new Rust crate to handle HTTP cookies on the server side.
biscotti's API strives to be as faithful as possible to the underlying semantics of HTTP cookies, with a keen eye for edge cases and security:

The first release of biscotti is available on crates.io and its documentation is hosted on docs.rs.
The rest of this article will cover the motivation behind biscotti and show where it differs (and why) from the cookie crate, the de-facto standard for cookies in Rust.

You can discuss this article on r/rust.

Table of Contents

Cookies in Rust

The entirety of the Rust web ecosystem has effectively standardized on one crate for HTTP cookies: cookie.
It was created almost 10 years ago by Alex Crichton, and it has been taken over and maintained by Sergio Benitez (author and maintainer of Rocket) for the past 7 years.

cookie is re-exported by every major web framework: Rocket (obviously), Actix Web, axum, poem and so on.

Why biscotti?

I'm working on a new web framework, Pavex, and I was about to do the same: grab the cookie crate, re-export it as a pavex::cookie module and call it a day.
Then I paused: in the past I had to fight the cookie crate to handle more "advanced" cookie scenarios. Could I craft an API simple enough for the 80% of the use cases, but powerful enough to cover the remaining 20%?

That's how biscotti was born!
It started as a fork of the cookie crate, but the API has diverged significantly since I got started.

Design goals

cookie is a general-purpose crate: it can be used to handle cookies both on the server and on the client side.
That's not the case for biscotti. I deliberately focused on the server side. There is no support, for example, for parsing Set-Cookie headers on the client side in biscotti.

Cookies crash course

Let's start with a quick refresher on how cookies work.
Cookies are a way to attach state to an otherwise stateless protocol, HTTP. The state is stored on the client side, usually by a browser.
The state is passed back and forth between the client and the server using the Cookie and Set-Cookie headers.

The Cookie header is used by clients to send relevant cookies to the server when they issue requests.
The Set-Cookie header, instead, is used by the server to alter the state on the client-side, either by creating new cookies, removing existing ones, or updating their attributes.

Request and response cookies are different

The Cookie and Set-Cookie headers have very different semantics:

You don't see this distinction in the API of the cookie crate. The same Cookie type is used to represent both incoming and outgoing cookies.
Incoming cookies are Cookies with all optional attributes set to None. It is us up to the user to remember that this doesn't imply that, in the cookie jar on the client side, those attributes are actually unset!

biscotti

In biscotti, I chose to encode the distinction in the type system:

You can't mix them up—you can't accidentally send a RequestCookie to the client, or try to set a Path attribute on a RequestCookie.

The differences between request and response cookies don't stop there. They also have different semantics when it comes to cookie collections.

Request cookies

In the Cookie header, you can have multiple cookies with the same name.
Consider the following well-formed GET request:

GET /home HTTP/1.1
Host: example.com
Cookie: name=first; name=second;

This may happen, for example, if the two name cookies were set with a different Path attributes and both paths are relevant for the current request (e.g. / and /home in our example above).
In this scenario, browsers will send both cookies, even if they have the same name.

Depending on what you are trying to do, it might make sense to access the first cookie value, the second cookie value, or all of them!

The cookie crate gives you a CookieJar type to work with cookie collections, regardless of whether they are request or response cookies.
For the purpose of this example, you can think of CookieJar as a HashMap<String, Cookie>. If you insert two cookies with the same name, the second one will overwrite the first one.

Most web frameworks follow this algorithm to parse the Cookie header with cookie:

use cookie::{Cookie, CookieJar};

let cookie_header = "name=first; name=second;";
let mut jar = CookieJar::new();
for cookie in Cookie::split_parse(cookie_header) {
    let cookie = cookie.unwrap();
    jar.add_original(cookie);
}

In our example above, the jar would only contain the last name cookie, with the value second. You can try this out in the Rust playground.
You can find this very code snippet, with minor variations, in axum and poem.
They're not to blame: CookieJar isn't designed to handle multiple cookies with the same name, you can't "fix it" without abandoning CookieJar altogether.

biscotti

biscotti takes a different approach. We have a dedicated type, RequestCookies, to handle incoming cookies as a collection.

use biscotti::{Processor, config::Config, RequestCookies};
  
let header = "name=first; name=second";
let processor: Processor = Config::default().into();
let cookies = RequestCookies::parse_header(header, &processor).unwrap();

RequestCookies is a multivalued map, like HeaderMap in http: you can access the first cookie with a given name, or opt to fetch all values associated with that cookie name.

let value = cookies.get("name").unwrap().value();
assert_eq!(value, "first");
// With `get_all`, you get an iterator over all the cookies with that name.
let all_values: Vec<_> = cookies.get_all("name").unwrap().values().collect();
assert_eq!(all_values, vec!["first", "second"]);

The 80% of the time you can get by with treating RequestCookies as a HashMap<String, RequestCookie>, but you have the flexibility to handle the remaining 20% of the cases when you need to.

Response cookies

A similar issue arises when working with response cookies.
The following response is well-formed:

HTTP/1.1 200 OK
Set-Cookie: name=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT
Set-Cookie: name=value; Path=/home; 

The server is instructing the client to manipulate two cookies with the same name, but different path attributes.
In particular:

This is spec-compliant, and all major browsers will behave as expected here.

With the cookie crate you need to use CookieJar to handle response cookies as well.
The same semantic issues we saw with request cookies apply here as well:

use cookie::{CookieBuilder, CookieJar};

let mut jar = CookieJar::new();
let mut removal_cookie = CookieBuilder::new("name", "").path("/").finish();
removal_cookie.make_removal();
jar.add(removal_cookie);

let new_cookie = CookieBuilder::new("name", "value").path("/home").finish();
jar.add(new_cookie);

In this case, jar will contain a single name cookie, set to value with Path=/home.

assert_eq!(jar.delta().count(), 1);
let set_cookie = jar.delta().next().unwrap();
assert_eq!(set_cookie.to_string(), "name=value; Path=/home");

The removal cookie is lost and won't be sent to the client (Playground link).

biscotti

biscotti has a dedicated type, ResponseCookies, to handle outgoing cookies as a collection.
You can picture it as a HashMap<ResponseCookieId, ResponseCookie>, where ResponseCookieId captures:

As long as the combination of those three values is different, response cookies will be considered to be distinct.

Going back to our example, in biscotti you would do:

use biscotti::{ResponseCookies, RemovalCookie, ResponseCookie};

let mut cookies = ResponseCookies::new();
cookies.insert(RemovalCookie::new("name").set_path("/"));
cookies.insert(ResponseCookie::new("name", "value").set_path("/home"));

We can then obtain the respective Set-Cookie header values:

use maplit::hashset;
use std::collections::HashSet;
use biscotti::{Processor, config::Config};

let processor: Processor = Config::default().into();
let header_values: HashSet<_> = cookies.header_values(&processor).collect();
assert_eq!(header_values, hashset! {
    "name=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT".to_string(),
    "name=value; Path=/home".to_string(),
});

Both Set-Cookie header values are present, as we wanted.

Signed and encrypted cookies

Both cookie and biscotti provide built-in support for signing and encrypting cookies.

In cookie, you opt into signing or encrypting cookies by creating a "child" jar from the main CookieJar:

use cookie::{Cookie, CookieJar, Key, Private};

let key = Key::generate()
let mut jar = CookieJar::new();
let mut child_jar = jar.private(&key);
// This cookie will be encrypted!
child_jar.add(Cookie::new("name", "value"));

The same process is used to verify and decrypt cookies:

let mut jar = CookieJar::new();
let mut child_jar = jar.private(&key);
// This cookie will be decrypted! 
let cookie = child_jar.get("name").unwrap();

biscotti

In biscotti, signing and encryption are managed centrally.
You populate a single Config with the cryptographic setup you want to use, and then you convert it into a Processor:

use biscotti::{Processor, Key, config::{Config, CryptoType}};

let mut config = Config::default();
let rule = CryptoRule {
    cookie_names: vec!["session".to_string()],
    r#type: CryptoType::Signing,
    key: Key::generate(),
    secondary_keys: vec![],
};
config.crypto_rules.push(rule);
let processor: Processor = config.into();

The Processor is a required argument when parsing request cookies or when generating the Set-Cookie header values for response cookies, as you've seen in the code snippets from the previous sections.

use biscotti::{Processor, config::Config, RequestCookies};

let header = "session=unsigned-value";
// `parse_header` will return an error since `session` is expected to be signed
// and it isn't in this case.
let cookies = RequestCookies::parse_header(header, &processor).unwrap();

The rest of the application doesn't need to know about the cryptographic setup, nor does it need access to the cryptographic keys.

In addition to signing and encryption, biscotti also provides built-in support for rotating signing/encryption keys over time via the secondary_keys field in CryptoRule.
Cookies will always be signed/encrypted with the primary key, but the Processor will be also accept cookies that were signed/encrypted with any of the secondary keys. This allows you to rotate keys over time without invalidating all existing cookies.

Conclusion

biscotti, just like cookie, is a framework-agnostic library.
If you are building a web framework, you can use biscotti to handle cookies in your HTTP server. If you need biscotti's features in your axum or poem application, you should be able to integrate it seamlessly.

Keep in mind that biscotti is a new crate—it isn't as battle-tested as cookie and it might have some rough edges. Feel free to open an issue on the GitHub repository if you find any bugs or if you have any suggestions.

I hope you find biscotti useful!

You can discuss this article on r/rust.