Skip to main content

mas_axum_utils/
csrf.rs

1// Copyright 2025, 2026 Element Creations Ltd.
2// Copyright 2024, 2025 New Vector Ltd.
3// Copyright 2022-2024 The Matrix.org Foundation C.I.C.
4//
5// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
6// Please see LICENSE files in the repository root for full details.
7
8use base64ct::{Base64UrlUnpadded, Encoding};
9use chrono::{DateTime, Duration, Utc};
10use mas_data_model::Clock;
11use rand::{
12    Rng, RngExt,
13    distr::{Distribution as _, StandardUniform},
14};
15use serde::{Deserialize, Serialize};
16use serde_with::{TimestampSeconds, serde_as};
17use thiserror::Error;
18
19use crate::cookies::{CookieDecodeError, CookieJar};
20
21/// Failed to validate CSRF token
22#[derive(Debug, Error)]
23pub enum CsrfError {
24    /// The token in the form did not match the token in the cookie
25    #[error("CSRF token mismatch")]
26    Mismatch,
27
28    /// The token in the form did not match the token in the cookie
29    #[error("Missing CSRF cookie")]
30    Missing,
31
32    /// Failed to decode the token
33    #[error("could not decode CSRF cookie")]
34    DecodeCookie(#[from] CookieDecodeError),
35
36    /// The token expired
37    #[error("CSRF token expired")]
38    Expired,
39
40    /// Failed to decode the token
41    #[error("could not decode CSRF token")]
42    Decode(#[from] base64ct::Error),
43}
44
45/// A CSRF token
46#[serde_as]
47#[derive(Serialize, Deserialize, Debug)]
48pub struct CsrfToken {
49    #[serde_as(as = "TimestampSeconds<i64>")]
50    expiration: DateTime<Utc>,
51    token: [u8; 32],
52}
53
54impl CsrfToken {
55    /// Create a new token from a defined value valid for a specified duration
56    fn new(token: [u8; 32], now: DateTime<Utc>, ttl: Duration) -> Self {
57        let expiration = now + ttl;
58        Self { expiration, token }
59    }
60
61    /// Generate a new random token valid for a specified duration
62    fn generate(now: DateTime<Utc>, mut rng: impl RngExt, ttl: Duration) -> Self {
63        let token = StandardUniform.sample(&mut rng);
64        Self::new(token, now, ttl)
65    }
66
67    /// Generate a new token with the same value but an up to date expiration
68    fn refresh(self, now: DateTime<Utc>, ttl: Duration) -> Self {
69        Self::new(self.token, now, ttl)
70    }
71
72    /// Get the value to include in HTML forms
73    #[must_use]
74    pub fn form_value(&self) -> String {
75        Base64UrlUnpadded::encode_string(&self.token[..])
76    }
77
78    /// Verifies that the value got from an HTML form matches this token
79    ///
80    /// # Errors
81    ///
82    /// Returns an error if the value in the form does not match this token
83    pub fn verify_form_value(&self, form_value: &str) -> Result<(), CsrfError> {
84        let form_value = Base64UrlUnpadded::decode_vec(form_value)?;
85        if self.token[..] == form_value {
86            Ok(())
87        } else {
88            Err(CsrfError::Mismatch)
89        }
90    }
91
92    fn verify_expiration(self, now: DateTime<Utc>) -> Result<Self, CsrfError> {
93        if now < self.expiration {
94            Ok(self)
95        } else {
96            Err(CsrfError::Expired)
97        }
98    }
99}
100
101// A CSRF-protected form
102#[derive(Deserialize)]
103pub struct ProtectedForm<T> {
104    csrf: String,
105
106    #[serde(flatten)]
107    inner: T,
108}
109
110pub trait CsrfExt {
111    /// Get the current CSRF token out of the cookie jar, generating a new one
112    /// if necessary
113    fn csrf_token<C, R>(self, clock: &C, rng: R) -> (CsrfToken, Self)
114    where
115        R: Rng,
116        C: Clock;
117
118    /// Verify that the given CSRF-protected form is valid, returning the inner
119    /// value
120    ///
121    /// # Errors
122    ///
123    /// Returns an error if the CSRF cookie is missing or if the value in the
124    /// form is invalid
125    fn verify_form<C, T>(&self, clock: &C, form: ProtectedForm<T>) -> Result<T, CsrfError>
126    where
127        C: Clock;
128}
129
130impl CsrfExt for CookieJar {
131    fn csrf_token<C, R>(self, clock: &C, rng: R) -> (CsrfToken, Self)
132    where
133        R: Rng,
134        C: Clock,
135    {
136        let now = clock.now();
137        let maybe_token = match self.load::<CsrfToken>("csrf") {
138            Ok(Some(token)) => {
139                let token = token.verify_expiration(now);
140
141                // If the token is expired, just ignore it
142                token.ok()
143            }
144            Ok(None) => None,
145            Err(e) => {
146                tracing::warn!("Failed to decode CSRF cookie: {}", e);
147                None
148            }
149        };
150
151        let token = maybe_token.map_or_else(
152            || CsrfToken::generate(now, rng, Duration::try_hours(1).unwrap()),
153            |token| token.refresh(now, Duration::try_hours(1).unwrap()),
154        );
155
156        let jar = self.save("csrf", &token, false);
157        (token, jar)
158    }
159
160    fn verify_form<C, T>(&self, clock: &C, form: ProtectedForm<T>) -> Result<T, CsrfError>
161    where
162        C: Clock,
163    {
164        let token: CsrfToken = self.load("csrf")?.ok_or(CsrfError::Missing)?;
165        let token = token.verify_expiration(clock.now())?;
166        token.verify_form_value(&form.csrf)?;
167        Ok(form.inner)
168    }
169}