Skip to main content

asjeeves_csrf/
form_authenticity_token.rs

1//! # Form Authenticity Token
2//! A randomized base64 string useable for CSRF protection.
3//! See: [Cross-site request forgery](https://en.wikipedia.org/wiki/Cross-site_request_forgery)
4//!
5//! ## Randomization used
6//! As of 2025.07.17 The from_entropy() function of the rand crate behaves as such:
7//!     - Automatic seeding and reseeding via OsRng (for Linux this is the getrandom syscall, or /dev/urandom)
8//!     - Algorithm used: ChaCha (20 rounds)
9//!     - Does not zero memory on exit. (No protection for internal memory state)
10
11use std::{borrow::Cow, convert::Infallible, fmt};
12
13use axum::{
14    extract::{FromRequestParts, OptionalFromRequestParts},
15    http::HeaderValue,
16};
17use axum_extra::extract::CookieJar;
18use base64ct::{Base64Url, Encoding};
19use cookie::{Cookie, SameSite};
20
21use rand_core::RngCore;
22
23use tracing::instrument;
24
25pub const COOKIE_NAME: &str = "csrf_token";
26
27// Recommended length (128 bits / 16 bytes) for the token.
28const TOKEN_LEN: usize = 32;
29
30#[derive(Clone, Debug, PartialEq)]
31pub struct FormAuthenticityToken(Box<str>);
32
33impl FormAuthenticityToken {
34    /// Generates a random token.
35    /// # Examples
36    ///
37    /// ```
38    /// use asjeeves_csrf::FormAuthenticityToken;
39    /// use rand_chacha::ChaCha20Rng;
40    /// use rand_core::SeedableRng;
41    ///
42    /// let mut rng = ChaCha20Rng::from_seed(Default::default());
43    ///
44    /// let fat = FormAuthenticityToken::generate(&mut rng);
45    ///
46    /// ```
47    #[instrument]
48    pub fn generate<R>(rng: &mut R) -> Self
49    where
50        R: fmt::Debug + RngCore,
51    {
52        // Allocate a 32 byte array.
53        let mut token_bytes = [0u8; TOKEN_LEN];
54
55        rng.fill_bytes(&mut token_bytes);
56
57        let token = Base64Url::encode_string(&token_bytes);
58
59        Self(token.into_boxed_str())
60    }
61
62    /// Returns a cookie with the token formatted for csrf
63    /// Secure; SameSite=Strict; HttpOnly; Path=/
64    pub fn csrf_cookie<'a>(&self) -> Cookie<'a> {
65        Cookie::build((COOKIE_NAME, self.to_string()))
66            .http_only(true)
67            .partitioned(true)
68            .same_site(SameSite::Strict)
69            .secure(true)
70            .path("/")
71            .build()
72    }
73
74    pub fn as_str(&self) -> &str {
75        self.0.as_ref()
76    }
77}
78
79impl AsRef<str> for FormAuthenticityToken {
80    fn as_ref(&self) -> &str {
81        self.0.as_ref()
82    }
83}
84
85impl fmt::Display for FormAuthenticityToken {
86    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87        write!(f, "{}", self.0.as_ref())
88    }
89}
90
91impl<'a> From<&Cookie<'a>> for FormAuthenticityToken {
92    fn from(c: &Cookie<'a>) -> Self {
93        let s = String::from(c.value());
94
95        Self(s.into_boxed_str())
96    }
97}
98
99impl From<HeaderValue> for FormAuthenticityToken {
100    fn from(value: HeaderValue) -> Self {
101        let bytes: &[u8] = value.as_bytes();
102
103        let s: Cow<'_, str> = String::from_utf8_lossy(bytes);
104
105        Self(s.into())
106    }
107}
108
109impl<S> OptionalFromRequestParts<S> for FormAuthenticityToken
110where
111    S: Send + Sync,
112{
113    type Rejection = Infallible;
114
115    async fn from_request_parts(
116        parts: &mut axum::http::request::Parts,
117        state: &S,
118    ) -> Result<Option<Self>, Self::Rejection> {
119        let jar: CookieJar = CookieJar::from_request_parts(parts, state).await?;
120        let fat: Option<Self> = jar.get(COOKIE_NAME).map(Self::from);
121
122        Ok(fat)
123    }
124}
125
126#[cfg(test)]
127pub mod test {
128    use super::*;
129    use rand_chacha::ChaCha20Rng;
130    use rand_core::SeedableRng;
131
132    pub const FAT_ONE: &'static str = "mjdEUEVgY57GcLehfUkrJz4HewqWvvWLp3YHeeVEVG4=";
133    pub const FAT_TWO: &'static str = "AA7-yHxXSewRV5EuDhcfYN6eU0E0iBmi3pnxQMWaQkw=";
134
135    #[test]
136    fn it_should_create_a_token_and_cookie() {
137        let mut rng = ChaCha20Rng::seed_from_u64(1);
138
139        let fat_one = FormAuthenticityToken::generate(&mut rng);
140        let fat_two = FormAuthenticityToken::generate(&mut rng);
141
142        let ec_one = format!(
143            "{}={}; HttpOnly; SameSite=Strict; Partitioned; Secure; Path=/",
144            COOKIE_NAME, FAT_ONE
145        );
146        let ec_two = format!(
147            "{}={}; HttpOnly; SameSite=Strict; Partitioned; Secure; Path=/",
148            COOKIE_NAME, FAT_TWO
149        );
150
151        assert_eq!(FAT_ONE, fat_one.as_str());
152        assert_eq!(FAT_ONE, fat_one.as_ref());
153        assert_eq!(ec_one, fat_one.csrf_cookie().to_string());
154
155        assert_eq!(FAT_TWO, fat_two.as_str());
156        assert_eq!(FAT_TWO, fat_two.as_ref());
157        assert_eq!(ec_two, fat_two.csrf_cookie().to_string());
158    }
159}