├── .gitignore ├── src ├── extract.rs ├── service │ ├── future.rs │ └── mod.rs ├── signed.rs ├── private.rs └── lib.rs ├── examples ├── hello_world.rs ├── counter.rs ├── signed_private.rs └── counter-extractor.rs ├── LICENSE ├── .pre-commit.sh ├── Cargo.toml ├── README.md └── .github └── workflows └── ci.yml /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | Cargo.lock 3 | -------------------------------------------------------------------------------- /src/extract.rs: -------------------------------------------------------------------------------- 1 | use crate::Cookies; 2 | use axum_core::extract::FromRequestParts; 3 | use http::{request::Parts, StatusCode}; 4 | 5 | impl FromRequestParts for Cookies 6 | where 7 | S: Sync + Send, 8 | { 9 | type Rejection = (http::StatusCode, &'static str); 10 | 11 | async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { 12 | parts.extensions.get::().cloned().ok_or(( 13 | StatusCode::INTERNAL_SERVER_ERROR, 14 | "Can't extract cookies. Is `CookieManagerLayer` enabled?", 15 | )) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /examples/hello_world.rs: -------------------------------------------------------------------------------- 1 | use axum::{routing::get, Router}; 2 | use std::net::SocketAddr; 3 | use tower_cookies::{Cookie, CookieManagerLayer, Cookies}; 4 | 5 | #[tokio::main] 6 | async fn main() { 7 | let app = Router::new() 8 | .route("/", get(handler)) 9 | .layer(CookieManagerLayer::new()); 10 | 11 | let addr = SocketAddr::from(([127, 0, 0, 1], 3000)); 12 | let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); 13 | axum::serve(listener, app.into_make_service()) 14 | .await 15 | .unwrap(); 16 | } 17 | 18 | async fn handler(cookies: Cookies) -> &'static str { 19 | cookies.add(Cookie::new("hello_world", "hello_world")); 20 | 21 | "Check your cookies." 22 | } 23 | -------------------------------------------------------------------------------- /examples/counter.rs: -------------------------------------------------------------------------------- 1 | use axum::{routing::get, Router}; 2 | use std::net::SocketAddr; 3 | use tower_cookies::{Cookie, CookieManagerLayer, Cookies}; 4 | 5 | const COOKIE_NAME: &str = "visited"; 6 | 7 | #[tokio::main] 8 | async fn main() { 9 | let app = Router::new() 10 | .route("/", get(handler)) 11 | .layer(CookieManagerLayer::new()); 12 | 13 | let addr = SocketAddr::from(([127, 0, 0, 1], 3000)); 14 | let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); 15 | axum::serve(listener, app.into_make_service()) 16 | .await 17 | .unwrap(); 18 | } 19 | 20 | async fn handler(cookies: Cookies) -> String { 21 | let visited = cookies 22 | .get(COOKIE_NAME) 23 | .and_then(|c| c.value().parse().ok()) 24 | .unwrap_or(0); 25 | if visited > 10 { 26 | cookies.remove(Cookie::new(COOKIE_NAME, "")); 27 | "Counter has been reset".into() 28 | } else { 29 | cookies.add(Cookie::new(COOKIE_NAME, (visited + 1).to_string())); 30 | format!("You've been here {} times before", visited) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Imbolc 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.pre-commit.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | set -eu 4 | 5 | # Linking the script as the pre-commit hook 6 | SCRIPT_PATH=$(realpath "$0") 7 | HOOK_PATH=$(git rev-parse --git-dir)/hooks/pre-commit 8 | 9 | if [ "$(realpath "$HOOK_PATH")" != "$SCRIPT_PATH" ]; then 10 | printf "Link this script as the git pre-commit hook to avoid further manual running? (y/N): " 11 | read -r link_hook 12 | case "$link_hook" in 13 | [Yy]) 14 | ln -sf "$SCRIPT_PATH" "$HOOK_PATH" 15 | ;; 16 | esac 17 | fi 18 | 19 | set -x 20 | 21 | # Install tools 22 | cargo clippy --version >/dev/null 2>&1 || rustup component add clippy 23 | cargo shear --version >/dev/null 2>&1 || cargo install --locked cargo-shear 24 | cargo sort --version >/dev/null 2>&1 || cargo install --locked cargo-sort 25 | typos --version >/dev/null 2>&1 || cargo install --locked typos-cli 26 | 27 | rustup toolchain list | grep -q 'nightly' || rustup toolchain install nightly 28 | cargo +nightly fmt --version >/dev/null 2>&1 || rustup component add rustfmt --toolchain nightly 29 | 30 | # Checks 31 | typos . 32 | cargo shear 33 | cargo +nightly fmt -- --check 34 | cargo sort -c 35 | cargo rustdoc --all-features -- -D warnings 36 | cargo test --all-features --all-targets 37 | cargo test --doc 38 | cargo clippy --all-features --all-targets -- -D warnings 39 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | authors = ["imbolc"] 3 | categories = ["web-programming"] 4 | description = "Cookie manager middleware for tower." 5 | edition = "2021" 6 | rust-version = "1.70" 7 | homepage = "https://github.com/imbolc/tower-cookies" 8 | keywords = ["axum", "cookie", "cookies", "tower"] 9 | license = "MIT" 10 | name = "tower-cookies" 11 | readme = "README.md" 12 | repository = "https://github.com/imbolc/tower-cookies" 13 | version = "0.11.0" 14 | 15 | [package.metadata.docs.rs] 16 | all-features = true 17 | rustdoc-args = ["--cfg", "docsrs"] 18 | 19 | [features] 20 | default = ["axum-core"] 21 | signed = ["cookie/signed"] 22 | private = ["cookie/secure"] 23 | key-expansion = ["cookie/key-expansion"] 24 | 25 | [dependencies] 26 | axum-core = { version = "0.5", optional = true } 27 | cookie = { version = "0.18", features = ["percent-encode"] } 28 | futures-util = "0.3" 29 | http = "1.0" 30 | parking_lot = "0.12" 31 | pin-project-lite = "0.2" 32 | tower-layer = "0.3" 33 | tower-service = "0.3" 34 | 35 | [dev-dependencies] 36 | axum = "0.8" 37 | http-body-util = "0.1" 38 | tokio = { version = "1", features = ["rt-multi-thread"] } 39 | tower = "0.5" 40 | 41 | [[example]] 42 | name = "counter" 43 | required-features = ["axum-core"] 44 | 45 | [[example]] 46 | name = "hello_world" 47 | required-features = ["axum-core"] 48 | 49 | [[example]] 50 | name = "signed_private" 51 | required-features = ["axum-core", "signed", "private"] 52 | -------------------------------------------------------------------------------- /src/service/future.rs: -------------------------------------------------------------------------------- 1 | //! [`Future`] types. 2 | 3 | use crate::Cookies; 4 | use futures_util::ready; 5 | use http::{header, HeaderValue, Response}; 6 | use pin_project_lite::pin_project; 7 | use std::{ 8 | future::Future, 9 | pin::Pin, 10 | task::{Context, Poll}, 11 | }; 12 | 13 | pin_project! { 14 | /// Response future for [`CookieManager`]. 15 | #[derive(Debug)] 16 | pub struct ResponseFuture { 17 | #[pin] 18 | pub(crate) future: F, 19 | pub(crate) cookies: Cookies, 20 | } 21 | } 22 | 23 | impl Future for ResponseFuture 24 | where 25 | F: Future, E>>, 26 | { 27 | type Output = F::Output; 28 | 29 | fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { 30 | let this = self.project(); 31 | let mut res = ready!(this.future.poll(cx)?); 32 | 33 | let mut cookies = this.cookies.inner.lock(); 34 | if cookies.changed { 35 | let values: Vec<_> = cookies 36 | .jar() 37 | .delta() 38 | .filter_map(|c| HeaderValue::from_str(&c.to_string()).ok()) 39 | .collect(); 40 | let headers = res.headers_mut(); 41 | for value in values { 42 | headers.append(header::SET_COOKIE, value); 43 | } 44 | } 45 | 46 | Poll::Ready(Ok(res)) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /examples/signed_private.rs: -------------------------------------------------------------------------------- 1 | //! The counter-example using private / signed cookies instead of raw ones 2 | //! Can be run by: `cargo run --all-features --example signed_private` 3 | use axum::{routing::get, Router}; 4 | use std::net::SocketAddr; 5 | use std::sync::OnceLock; 6 | use tower_cookies::{Cookie, CookieManagerLayer, Cookies, Key}; 7 | 8 | const COOKIE_NAME: &str = "visited_private"; 9 | static KEY: OnceLock = OnceLock::new(); 10 | 11 | #[tokio::main] 12 | async fn main() { 13 | let my_key: &[u8] = &[0; 64]; // Your real key must be cryptographically random 14 | KEY.set(Key::from(my_key)).ok(); 15 | 16 | let app = Router::new() 17 | .route("/", get(handler)) 18 | .layer(CookieManagerLayer::new()); 19 | 20 | let addr = SocketAddr::from(([127, 0, 0, 1], 3000)); 21 | let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); 22 | axum::serve(listener, app.into_make_service()) 23 | .await 24 | .unwrap(); 25 | } 26 | 27 | async fn handler(cookies: Cookies) -> String { 28 | let key = KEY.get().unwrap(); 29 | let private_cookies = cookies.private(key); // You can use `cookies.signed` as well 30 | 31 | let visited = private_cookies 32 | .get(COOKIE_NAME) 33 | .and_then(|c| c.value().parse().ok()) 34 | .unwrap_or(0); 35 | if visited > 10 { 36 | cookies.remove(Cookie::new(COOKIE_NAME, "")); 37 | "Counter has been reset".into() 38 | } else { 39 | private_cookies.add(Cookie::new(COOKIE_NAME, (visited + 1).to_string())); 40 | format!("You've been here {} times before", visited) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /examples/counter-extractor.rs: -------------------------------------------------------------------------------- 1 | //! The example illustrates accessing cookies from an 2 | //! [`axum_core::extract::FromRequest::from_request`] implementation. 3 | //! The behavior is the same as `examples/counter.rs` but cookies leveraging is moved into an 4 | //! extractor. 5 | use axum::{routing::get, Router}; 6 | use axum_core::extract::FromRequestParts; 7 | use http::request::Parts; 8 | use std::net::SocketAddr; 9 | use tower_cookies::{Cookie, CookieManagerLayer, Cookies}; 10 | 11 | const COOKIE_NAME: &str = "visited"; 12 | 13 | struct Counter(usize); 14 | 15 | impl FromRequestParts for Counter 16 | where 17 | S: Send + Sync, 18 | { 19 | type Rejection = (http::StatusCode, &'static str); 20 | 21 | async fn from_request_parts(req: &mut Parts, state: &S) -> Result { 22 | let cookies = Cookies::from_request_parts(req, state).await?; 23 | 24 | let visited = cookies 25 | .get(COOKIE_NAME) 26 | .and_then(|c| c.value().parse().ok()) 27 | .unwrap_or(0) 28 | + 1; 29 | cookies.add(Cookie::new(COOKIE_NAME, visited.to_string())); 30 | 31 | Ok(Counter(visited)) 32 | } 33 | } 34 | 35 | #[tokio::main] 36 | async fn main() { 37 | let app = Router::new() 38 | .route("/", get(handler)) 39 | .layer(CookieManagerLayer::new()); 40 | 41 | let addr = SocketAddr::from(([127, 0, 0, 1], 3000)); 42 | let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); 43 | axum::serve(listener, app.into_make_service()) 44 | .await 45 | .unwrap(); 46 | } 47 | 48 | async fn handler(counter: Counter) -> String { 49 | format!("You have visited this page {} times", counter.0) 50 | } 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![License](https://img.shields.io/crates/l/tower-cookies.svg)](https://choosealicense.com/licenses/mit/) 2 | [![Crates.io](https://img.shields.io/crates/v/tower-cookies.svg)](https://crates.io/crates/tower-cookies) 3 | [![Docs.rs](https://docs.rs/tower-cookies/badge.svg)](https://docs.rs/tower-cookies) 4 | 5 | # tower-cookies 6 | 7 | A cookie manager middleware built on top of [tower]. 8 | 9 | ## Example 10 | 11 | With [axum]: 12 | 13 | ```rust,no_run 14 | use axum::{routing::get, Router}; 15 | use std::net::SocketAddr; 16 | use tower_cookies::{Cookie, CookieManagerLayer, Cookies}; 17 | 18 | #[tokio::main] 19 | async fn main() { 20 | let app = Router::new() 21 | .route("/", get(handler)) 22 | .layer(CookieManagerLayer::new()); 23 | 24 | let addr = SocketAddr::from(([127, 0, 0, 1], 3000)); 25 | let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); 26 | axum::serve(listener, app.into_make_service()) 27 | .await 28 | .unwrap(); 29 | } 30 | 31 | async fn handler(cookies: Cookies) -> &'static str { 32 | cookies.add(Cookie::new("hello_world", "hello_world")); 33 | 34 | "Check your cookies." 35 | } 36 | ``` 37 | 38 | A complete CRUD cookie example in [examples/counter.rs][example] 39 | 40 | [axum]: https://crates.io/crates/axum 41 | [tower]: https://crates.io/crates/tower 42 | [example]: https://github.com/imbolc/tower-cookies/blob/main/examples/counter.rs 43 | 44 | ## Contributing 45 | 46 | Please run [.pre-commit.sh] before sending a PR, it will check everything. 47 | 48 | ## License 49 | 50 | This project is licensed under the [MIT license][license]. 51 | 52 | [.pre-commit.sh]: 53 | https://github.com/imbolc/tower-cookies/blob/main/.pre-commit.sh 54 | [license]: https://github.com/imbolc/tower-cookies/blob/main/LICENSE 55 | -------------------------------------------------------------------------------- /src/service/mod.rs: -------------------------------------------------------------------------------- 1 | //! Middleware to use [`Cookies`]. 2 | 3 | use self::future::ResponseFuture; 4 | use crate::Cookies; 5 | use http::{header, Request, Response}; 6 | use std::task::{Context, Poll}; 7 | use tower_layer::Layer; 8 | use tower_service::Service; 9 | 10 | pub mod future; 11 | 12 | /// Middleware to use [`Cookies`]. 13 | #[derive(Clone, Debug)] 14 | pub struct CookieManager { 15 | inner: S, 16 | } 17 | 18 | impl CookieManager { 19 | /// Create a new cookie manager. 20 | pub fn new(inner: S) -> Self { 21 | Self { inner } 22 | } 23 | } 24 | 25 | impl Service> for CookieManager 26 | where 27 | S: Service, Response = Response>, 28 | { 29 | type Response = S::Response; 30 | type Error = S::Error; 31 | type Future = ResponseFuture; 32 | 33 | #[inline] 34 | fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { 35 | self.inner.poll_ready(cx) 36 | } 37 | 38 | fn call(&mut self, mut req: Request) -> Self::Future { 39 | let value = req 40 | .headers() 41 | .get_all(header::COOKIE) 42 | .iter() 43 | .cloned() 44 | .collect(); 45 | let cookies = Cookies::new(value); 46 | req.extensions_mut().insert(cookies.clone()); 47 | 48 | ResponseFuture { 49 | future: self.inner.call(req), 50 | cookies, 51 | } 52 | } 53 | } 54 | 55 | /// Layer to apply [`CookieManager`] middleware. 56 | #[derive(Clone, Debug, Default)] 57 | pub struct CookieManagerLayer { 58 | _priv: (), 59 | } 60 | 61 | impl CookieManagerLayer { 62 | /// Create a new cookie manager layer. 63 | pub fn new() -> Self { 64 | Self { _priv: () } 65 | } 66 | } 67 | 68 | impl Layer for CookieManagerLayer { 69 | type Service = CookieManager; 70 | 71 | fn layer(&self, inner: S) -> Self::Service { 72 | CookieManager { inner } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - main 6 | - ci 7 | pull_request: 8 | 9 | jobs: 10 | rustfmt: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | 16 | - name: Nightly Rust 17 | uses: dtolnay/rust-toolchain@master 18 | with: 19 | toolchain: nightly 20 | components: rustfmt 21 | 22 | - name: Rustfmt 23 | run: cargo +nightly fmt -- --check 24 | 25 | clippy: 26 | runs-on: ubuntu-latest 27 | steps: 28 | - name: Checkout 29 | uses: actions/checkout@v4 30 | 31 | - name: Stable Rust 32 | uses: dtolnay/rust-toolchain@master 33 | with: 34 | toolchain: stable 35 | components: clippy 36 | 37 | - name: Clippy 38 | run: cargo clippy --all-targets --all-features -- -D warnings 39 | 40 | rustdoc: 41 | runs-on: ubuntu-latest 42 | steps: 43 | - name: Checkout 44 | uses: actions/checkout@v4 45 | 46 | - name: Stable Rust 47 | uses: dtolnay/rust-toolchain@master 48 | with: 49 | toolchain: stable 50 | 51 | - name: Rustdoc 52 | run: cargo rustdoc --all-features -- -D warnings 53 | 54 | test: 55 | runs-on: ubuntu-latest 56 | steps: 57 | - name: Checkout 58 | uses: actions/checkout@v4 59 | 60 | - name: Stable Rust 61 | uses: dtolnay/rust-toolchain@master 62 | with: 63 | toolchain: stable 64 | 65 | - name: Test all targets 66 | run: cargo test --all-targets 67 | 68 | - name: Test docs 69 | run: cargo test --doc 70 | 71 | typos: 72 | runs-on: ubuntu-latest 73 | steps: 74 | - name: Checkout 75 | uses: actions/checkout@v4 76 | 77 | - name: Check typos 78 | uses: crate-ci/typos@master 79 | with: 80 | files: . 81 | 82 | cargo_sort: 83 | runs-on: ubuntu-latest 84 | steps: 85 | - name: Checkout 86 | uses: actions/checkout@v4 87 | 88 | - name: Stable Rust 89 | uses: dtolnay/rust-toolchain@master 90 | with: 91 | toolchain: stable 92 | 93 | - name: Install cargo-sort 94 | run: cargo install --locked cargo-sort 95 | 96 | - name: Check `Cargo.toml` sort 97 | run: cargo sort -c 98 | 99 | shear: 100 | runs-on: ubuntu-latest 101 | steps: 102 | - name: Checkout 103 | uses: actions/checkout@v4 104 | 105 | - name: Stable Rust 106 | uses: dtolnay/rust-toolchain@master 107 | with: 108 | toolchain: stable 109 | 110 | - name: Install `cargo-shear` 111 | run: cargo install --locked cargo-shear 112 | 113 | - name: Check unused Cargo dependencies 114 | run: cargo shear 115 | -------------------------------------------------------------------------------- /src/signed.rs: -------------------------------------------------------------------------------- 1 | use crate::Cookies; 2 | use cookie::{Cookie, Key}; 3 | 4 | /// A child cookie jar that authenticates its cookies. 5 | /// It signs all the cookies added to it and verifies cookies retrieved from it. 6 | /// Any cookies stored in `SignedCookies` are provided integrity and authenticity. In other 7 | /// words, clients cannot tamper with the contents of a cookie nor can they fabricate cookie 8 | /// values, but the data is visible in plaintext. 9 | pub struct SignedCookies<'a> { 10 | cookies: Cookies, 11 | key: &'a Key, 12 | } 13 | 14 | impl<'a> SignedCookies<'a> { 15 | /// Creates an instance of `SignedCookies` with parent `cookies` and key `key`. This method is 16 | /// typically called indirectly via the `signed` method of [`Cookies`]. 17 | pub(crate) fn new(cookies: &Cookies, key: &'a Key) -> Self { 18 | Self { 19 | cookies: cookies.clone(), 20 | key, 21 | } 22 | } 23 | 24 | /// Adds cookie to the parent jar. The cookie’s value is signed assuring integrity and 25 | /// authenticity. 26 | pub fn add(&self, cookie: Cookie<'static>) { 27 | let mut inner = self.cookies.inner.lock(); 28 | inner.changed = true; 29 | inner.jar().signed_mut(self.key).add(cookie); 30 | } 31 | 32 | /// Returns `Cookie` with the `name` and verifies the authenticity and integrity of the 33 | /// cookie’s value, returning a `Cookie` with the authenticated value. If the cookie cannot be 34 | /// found, or the cookie fails to verify, None is returned. 35 | pub fn get(&self, name: &str) -> Option> { 36 | let mut inner = self.cookies.inner.lock(); 37 | inner.jar().signed(self.key).get(name) 38 | } 39 | 40 | /// Removes the `cookie` from the parent jar. 41 | /// 42 | /// **To properly generate the removal cookie, `cookie` must contain the same `path` and 43 | /// `domain` as the cookie that was initially set.** In particular, this means that passing a 44 | /// cookie from a browser to this method won't work because browsers don't set the cookie's 45 | /// `path` attribute. 46 | pub fn remove(&self, cookie: Cookie<'static>) { 47 | self.cookies.remove(cookie); 48 | } 49 | } 50 | 51 | #[cfg(all(test, feature = "signed"))] 52 | mod tests { 53 | use crate::Cookies; 54 | use cookie::{Cookie, Key}; 55 | 56 | #[test] 57 | fn get_absent() { 58 | let key = Key::generate(); 59 | let cookies = Cookies::new(vec![]); 60 | assert_eq!(cookies.signed(&key).get("foo"), None); 61 | } 62 | 63 | #[test] 64 | fn add_get_signed() { 65 | let key = Key::generate(); 66 | let cookies = Cookies::new(vec![]); 67 | let cookie = Cookie::new("foo", "bar"); 68 | let signed = cookies.signed(&key); 69 | signed.add(cookie.clone()); 70 | assert_eq!(signed.get("foo").unwrap(), cookie); 71 | } 72 | 73 | #[test] 74 | fn add_signed_get_raw() { 75 | let key = Key::generate(); 76 | let cookies = Cookies::new(vec![]); 77 | let cookie = Cookie::new("foo", "bar"); 78 | cookies.signed(&key).add(cookie.clone()); 79 | assert_ne!(cookies.get("foo").unwrap(), cookie); 80 | } 81 | 82 | #[test] 83 | fn add_raw_get_signed() { 84 | let key = Key::generate(); 85 | let cookies = Cookies::new(vec![]); 86 | let cookie = Cookie::new("foo", "bar"); 87 | cookies.add(cookie); 88 | assert_eq!(cookies.signed(&key).get("foo"), None); 89 | } 90 | 91 | #[test] 92 | fn messed_keys() { 93 | let key1 = Key::generate(); 94 | let key2 = Key::generate(); 95 | let cookies = Cookies::new(vec![]); 96 | let cookie = Cookie::new("foo", "bar"); 97 | cookies.signed(&key1).add(cookie); 98 | assert_eq!(cookies.signed(&key2).get("foo"), None); 99 | } 100 | 101 | #[test] 102 | fn remove() { 103 | let key = Key::generate(); 104 | let cookies = Cookies::new(vec![]); 105 | let signed = cookies.signed(&key); 106 | signed.add(Cookie::new("foo", "bar")); 107 | let cookie = signed.get("foo").unwrap(); 108 | signed.remove(cookie); 109 | assert!(signed.get("foo").is_none()); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/private.rs: -------------------------------------------------------------------------------- 1 | use crate::Cookies; 2 | use cookie::{Cookie, Key}; 3 | 4 | /// A cookie jar that provides authenticated encryption for its cookies. 5 | /// 6 | /// A _private_ child jar signs and encrypts all the cookies added to it and 7 | /// verifies and decrypts cookies retrieved from it. Any cookies stored in 8 | /// `PrivateCookies` are simultaneously assured confidentiality, integrity, and 9 | /// authenticity. In other words, clients cannot discover nor tamper with the 10 | /// contents of a cookie, nor can they fabricate cookie data. 11 | pub struct PrivateCookies<'a> { 12 | cookies: Cookies, 13 | key: &'a Key, 14 | } 15 | 16 | impl<'a> PrivateCookies<'a> { 17 | /// Creates an instance of `PrivateCookies` with parent `cookies` and key `key`. 18 | /// This method is typically called indirectly via the `private` 19 | /// method of [`Cookies`]. 20 | pub(crate) fn new(cookies: &Cookies, key: &'a Key) -> Self { 21 | Self { 22 | cookies: cookies.clone(), 23 | key, 24 | } 25 | } 26 | 27 | /// Adds `cookie` to the parent jar. The cookie's value is encrypted with 28 | /// authenticated encryption assuring confidentiality, integrity, and 29 | /// authenticity. 30 | pub fn add(&self, cookie: Cookie<'static>) { 31 | let mut inner = self.cookies.inner.lock(); 32 | inner.changed = true; 33 | inner.jar().private_mut(self.key).add(cookie); 34 | } 35 | 36 | /// Returns a reference to the `Cookie` inside this jar with the name `name` 37 | /// and authenticates and decrypts the cookie's value, returning a `Cookie` 38 | /// with the decrypted value. If the cookie cannot be found, or the cookie 39 | /// fails to authenticate or decrypt, `None` is returned. 40 | pub fn get(&self, name: &str) -> Option> { 41 | let mut inner = self.cookies.inner.lock(); 42 | inner.jar().private(self.key).get(name) 43 | } 44 | 45 | /// Removes the `cookie` from the parent jar. 46 | /// 47 | /// **To properly generate the removal cookie, `cookie` must contain the same `path` and 48 | /// `domain` as the cookie that was initially set.** In particular, this means that passing a 49 | /// cookie from a browser to this method won't work because browsers don't set the cookie's 50 | /// `path` attribute. 51 | pub fn remove(&self, cookie: Cookie<'static>) { 52 | self.cookies.remove(cookie); 53 | } 54 | } 55 | 56 | #[cfg(all(test, feature = "private"))] 57 | mod tests { 58 | use crate::Cookies; 59 | use cookie::{Cookie, Key}; 60 | 61 | #[test] 62 | fn get_absent() { 63 | let key = Key::generate(); 64 | let cookies = Cookies::new(vec![]); 65 | assert_eq!(cookies.private(&key).get("foo"), None); 66 | } 67 | 68 | #[test] 69 | fn add_get_private() { 70 | let key = Key::generate(); 71 | let cookies = Cookies::new(vec![]); 72 | let cookie = Cookie::new("foo", "bar"); 73 | let private = cookies.private(&key); 74 | private.add(cookie.clone()); 75 | assert_eq!(private.get("foo").unwrap(), cookie); 76 | } 77 | 78 | #[test] 79 | fn add_private_get_raw() { 80 | let key = Key::generate(); 81 | let cookies = Cookies::new(vec![]); 82 | let cookie = Cookie::new("foo", "bar"); 83 | cookies.private(&key).add(cookie.clone()); 84 | assert_ne!(cookies.get("foo").unwrap(), cookie); 85 | } 86 | 87 | #[test] 88 | fn add_raw_get_private() { 89 | let key = Key::generate(); 90 | let cookies = Cookies::new(vec![]); 91 | let cookie = Cookie::new("foo", "bar"); 92 | cookies.add(cookie); 93 | assert_eq!(cookies.private(&key).get("foo"), None); 94 | } 95 | 96 | #[test] 97 | fn messed_keys() { 98 | let key1 = Key::generate(); 99 | let key2 = Key::generate(); 100 | let cookies = Cookies::new(vec![]); 101 | let cookie = Cookie::new("foo", "bar"); 102 | cookies.private(&key1).add(cookie); 103 | assert_eq!(cookies.private(&key2).get("foo"), None); 104 | } 105 | 106 | #[test] 107 | fn remove() { 108 | let key = Key::generate(); 109 | let cookies = Cookies::new(vec![]); 110 | let private = cookies.private(&key); 111 | private.add(Cookie::new("foo", "bar")); 112 | let cookie = private.get("foo").unwrap(); 113 | private.remove(cookie); 114 | assert!(private.get("foo").is_none()); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![cfg_attr(docsrs, feature(doc_auto_cfg))] 2 | #![doc = include_str!("../README.md")] 3 | #![warn(clippy::all, missing_docs, nonstandard_style, future_incompatible)] 4 | #![forbid(unsafe_code)] 5 | #![cfg_attr(docsrs, feature(doc_cfg))] 6 | 7 | use cookie::CookieJar; 8 | use http::HeaderValue; 9 | use parking_lot::Mutex; 10 | use std::sync::Arc; 11 | 12 | #[doc(inline)] 13 | pub use self::service::{CookieManager, CookieManagerLayer}; 14 | 15 | #[cfg(feature = "signed")] 16 | pub use self::signed::SignedCookies; 17 | 18 | #[cfg(feature = "private")] 19 | pub use self::private::PrivateCookies; 20 | 21 | #[cfg(any(feature = "signed", feature = "private"))] 22 | pub use cookie::Key; 23 | 24 | pub use cookie::Cookie; 25 | 26 | #[doc(inline)] 27 | pub use cookie; 28 | 29 | #[cfg(feature = "axum-core")] 30 | #[cfg_attr(docsrs, doc(cfg(feature = "axum-core")))] 31 | mod extract; 32 | 33 | #[cfg(feature = "signed")] 34 | mod signed; 35 | 36 | #[cfg(feature = "private")] 37 | mod private; 38 | 39 | pub mod service; 40 | 41 | /// A parsed on-demand cookie jar. 42 | #[derive(Clone, Debug, Default)] 43 | pub struct Cookies { 44 | inner: Arc>, 45 | } 46 | 47 | impl Cookies { 48 | fn new(headers: Vec) -> Self { 49 | let inner = Inner { 50 | headers, 51 | ..Default::default() 52 | }; 53 | Self { 54 | inner: Arc::new(Mutex::new(inner)), 55 | } 56 | } 57 | 58 | /// Adds [`Cookie`] to this jar. If a [`Cookie`] with the same name already exists, it is 59 | /// replaced with provided cookie. 60 | pub fn add(&self, cookie: Cookie<'static>) { 61 | let mut inner = self.inner.lock(); 62 | inner.changed = true; 63 | inner.jar().add(cookie); 64 | } 65 | 66 | /// Returns the [`Cookie`] with the given name. Returns [`None`] if it doesn't exist. 67 | pub fn get(&self, name: &str) -> Option> { 68 | let mut inner = self.inner.lock(); 69 | inner.jar().get(name).cloned() 70 | } 71 | 72 | /// Removes [`Cookie`] from this jar. 73 | /// 74 | /// **To properly generate the removal cookie, `cookie` must contain the same `path` and 75 | /// `domain` as the cookie that was initially set.** In particular, this means that passing a 76 | /// cookie from a browser to this method won't work because browsers don't set the cookie's 77 | /// `path` attribute. 78 | pub fn remove(&self, cookie: Cookie<'static>) { 79 | let mut inner = self.inner.lock(); 80 | inner.changed = true; 81 | inner.jar().remove(cookie); 82 | } 83 | 84 | /// Returns all the [`Cookie`]s present in this jar. 85 | /// 86 | /// This method collects [`Cookie`]s into a vector instead of iterating through them to 87 | /// minimize the mutex locking time. 88 | pub fn list(&self) -> Vec> { 89 | let mut inner = self.inner.lock(); 90 | inner.jar().iter().cloned().collect() 91 | } 92 | 93 | /// Returns a child [`SignedCookies`] jar for interactions with signed by the `key` cookies. 94 | /// 95 | /// # Example: 96 | /// ``` 97 | /// use cookie::{Cookie, Key}; 98 | /// use tower_cookies::Cookies; 99 | /// 100 | /// let cookies = Cookies::default(); 101 | /// let key = Key::generate(); 102 | /// let signed = cookies.signed(&key); 103 | /// 104 | /// let foo = Cookie::new("foo", "bar"); 105 | /// signed.add(foo.clone()); 106 | /// 107 | /// assert_eq!(signed.get("foo"), Some(foo.clone())); 108 | /// assert_ne!(cookies.get("foo"), Some(foo)); 109 | /// ``` 110 | #[cfg(feature = "signed")] 111 | pub fn signed<'a>(&self, key: &'a cookie::Key) -> SignedCookies<'a> { 112 | SignedCookies::new(self, key) 113 | } 114 | 115 | /// Returns a child [`PrivateCookies`] jar for encrypting and decrypting cookies. 116 | /// 117 | /// # Example: 118 | /// ``` 119 | /// use cookie::{Cookie, Key}; 120 | /// use tower_cookies::Cookies; 121 | /// 122 | /// let cookies = Cookies::default(); 123 | /// let key = Key::generate(); 124 | /// let private = cookies.private(&key); 125 | /// 126 | /// let foo = Cookie::new("foo", "bar"); 127 | /// private.add(foo.clone()); 128 | /// 129 | /// assert_eq!(private.get("foo"), Some(foo.clone())); 130 | /// assert_ne!(cookies.get("foo"), Some(foo)); 131 | /// ``` 132 | #[cfg(feature = "private")] 133 | pub fn private<'a>(&self, key: &'a cookie::Key) -> PrivateCookies<'a> { 134 | PrivateCookies::new(self, key) 135 | } 136 | } 137 | 138 | #[derive(Debug, Default)] 139 | struct Inner { 140 | headers: Vec, 141 | jar: Option, 142 | changed: bool, 143 | } 144 | 145 | impl Inner { 146 | fn jar(&mut self) -> &mut CookieJar { 147 | if self.jar.is_none() { 148 | let mut jar = CookieJar::new(); 149 | for header in &self.headers { 150 | if let Ok(header_str) = std::str::from_utf8(header.as_bytes()) { 151 | for cookie_str in header_str.split(';') { 152 | if let Ok(cookie) = cookie::Cookie::parse_encoded(cookie_str.to_owned()) { 153 | jar.add_original(cookie); 154 | } 155 | } 156 | } 157 | } 158 | self.jar = Some(jar); 159 | } 160 | self.jar.as_mut().unwrap() 161 | } 162 | } 163 | 164 | #[cfg(all(test, feature = "axum-core"))] 165 | mod tests { 166 | use crate::{CookieManagerLayer, Cookies}; 167 | use axum::{body::Body, routing::get, Router}; 168 | use cookie::Cookie; 169 | use http::{header, Request}; 170 | use http_body_util::BodyExt; 171 | use tower::ServiceExt; 172 | 173 | fn app() -> Router { 174 | Router::new() 175 | .route( 176 | "/list", 177 | get(|cookies: Cookies| async move { 178 | let mut items = cookies 179 | .list() 180 | .iter() 181 | .map(|c| format!("{}={}", c.name(), c.value())) 182 | .collect::>(); 183 | items.sort(); 184 | items.join(", ") 185 | }), 186 | ) 187 | .route( 188 | "/add", 189 | get(|cookies: Cookies| async move { 190 | cookies.add(Cookie::new("baz", "3")); 191 | cookies.add(Cookie::new("spam", "4")); 192 | }), 193 | ) 194 | .route( 195 | "/remove", 196 | get(|cookies: Cookies| async move { 197 | cookies.remove(Cookie::new("foo", "")); 198 | }), 199 | ) 200 | .layer(CookieManagerLayer::new()) 201 | } 202 | 203 | async fn body_string(body: Body) -> String { 204 | let bytes = body.collect().await.unwrap().to_bytes(); 205 | String::from_utf8_lossy(&bytes).into() 206 | } 207 | 208 | #[tokio::test] 209 | async fn read_cookies() { 210 | let req = Request::builder() 211 | .uri("/list") 212 | .header(header::COOKIE, "foo=1; bar=2") 213 | .body(Body::empty()) 214 | .unwrap(); 215 | let res = app().oneshot(req).await.unwrap(); 216 | assert_eq!(body_string(res.into_body()).await, "bar=2, foo=1"); 217 | } 218 | 219 | #[tokio::test] 220 | async fn read_multi_header_cookies() { 221 | let req = Request::builder() 222 | .uri("/list") 223 | .header(header::COOKIE, "foo=1") 224 | .header(header::COOKIE, "bar=2") 225 | .body(Body::empty()) 226 | .unwrap(); 227 | let res = app().oneshot(req).await.unwrap(); 228 | assert_eq!(body_string(res.into_body()).await, "bar=2, foo=1"); 229 | } 230 | 231 | #[tokio::test] 232 | async fn add_cookies() { 233 | let req = Request::builder() 234 | .uri("/add") 235 | .header(header::COOKIE, "foo=1; bar=2") 236 | .body(Body::empty()) 237 | .unwrap(); 238 | let res = app().oneshot(req).await.unwrap(); 239 | let mut hdrs: Vec<_> = res.headers().get_all(header::SET_COOKIE).iter().collect(); 240 | hdrs.sort(); 241 | assert_eq!(hdrs, ["baz=3", "spam=4"]); 242 | } 243 | 244 | #[tokio::test] 245 | async fn remove_cookies() { 246 | let req = Request::builder() 247 | .uri("/remove") 248 | .header(header::COOKIE, "foo=1; bar=2") 249 | .body(Body::empty()) 250 | .unwrap(); 251 | let res = app().oneshot(req).await.unwrap(); 252 | let mut hdrs = res.headers().get_all(header::SET_COOKIE).iter(); 253 | let hdr = hdrs.next().unwrap().to_str().unwrap(); 254 | assert!(hdr.starts_with("foo=; Max-Age=0")); 255 | assert_eq!(hdrs.next(), None); 256 | } 257 | } 258 | --------------------------------------------------------------------------------