Skip to main content

rustauth_core/cookies/
cache.rs

1use base64::engine::general_purpose::URL_SAFE_NO_PAD;
2use base64::Engine;
3use hmac::{Hmac, Mac};
4use serde::de::DeserializeOwned;
5use serde::{Deserialize, Serialize};
6use serde_json::{json, Value};
7use sha2::Sha256;
8
9use crate::crypto::jwt::{sign_jwt, verify_jwt};
10use crate::crypto::JweSecretSource;
11use crate::error::RustAuthError;
12use crate::options::CookieCacheStrategy;
13
14use super::chunked::ChunkedCookieStore;
15use super::types::{AuthCookies, Cookie, CookieOptions};
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18pub struct CookieCachePayload<S, U> {
19    pub session: S,
20    pub user: U,
21    pub updated_at: i64,
22    pub version: String,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26struct CompactCookieEnvelope<S, U> {
27    session: CookieCachePayload<S, U>,
28    expires_at: i64,
29    signature: String,
30}
31
32fn encode_compact_cache<S, U>(
33    payload: &CookieCachePayload<S, U>,
34    secret: &str,
35    max_age: u64,
36) -> Result<String, RustAuthError>
37where
38    S: Serialize,
39    U: Serialize,
40{
41    let expires_at = time::OffsetDateTime::now_utc().unix_timestamp() + max_age as i64;
42    let signed = cache_signature_value(payload, expires_at)?;
43    let signature = hmac_base64url(
44        &serde_json::to_string(&signed).map_err(|error| {
45            RustAuthError::Cookie(format!(
46                "could not serialize cookie cache signature: {error}"
47            ))
48        })?,
49        secret,
50    )?;
51    let envelope = json!({
52        "session": payload,
53        "expires_at": expires_at,
54        "signature": signature,
55    });
56    let json = serde_json::to_vec(&envelope).map_err(|error| {
57        RustAuthError::Cookie(format!(
58            "could not serialize cookie cache envelope: {error}"
59        ))
60    })?;
61
62    Ok(URL_SAFE_NO_PAD.encode(json))
63}
64
65fn decode_compact_cache<S, U>(
66    value: &str,
67    secret: &str,
68) -> Result<Option<CookieCachePayload<S, U>>, RustAuthError>
69where
70    S: DeserializeOwned + Serialize,
71    U: DeserializeOwned + Serialize,
72{
73    let Ok(decoded) = URL_SAFE_NO_PAD.decode(value) else {
74        return Ok(None);
75    };
76    let envelope: CompactCookieEnvelope<S, U> = match serde_json::from_slice(&decoded) {
77        Ok(envelope) => envelope,
78        Err(_) => return Ok(None),
79    };
80    if envelope.expires_at < time::OffsetDateTime::now_utc().unix_timestamp() {
81        return Ok(None);
82    }
83
84    let signed = cache_signature_value(&envelope.session, envelope.expires_at)?;
85    let expected = hmac_base64url(
86        &serde_json::to_string(&signed).map_err(|error| {
87            RustAuthError::Cookie(format!(
88                "could not serialize cookie cache signature: {error}"
89            ))
90        })?,
91        secret,
92    )?;
93    if !crate::crypto::buffer::constant_time_equal(expected, envelope.signature) {
94        return Ok(None);
95    }
96
97    Ok(Some(envelope.session))
98}
99
100fn cache_signature_value<S, U>(
101    payload: &CookieCachePayload<S, U>,
102    expires_at: i64,
103) -> Result<Value, RustAuthError>
104where
105    S: Serialize,
106    U: Serialize,
107{
108    serde_json::to_value(json!({
109        "session": payload.session,
110        "user": payload.user,
111        "updated_at": payload.updated_at,
112        "version": payload.version,
113        "expires_at": expires_at,
114    }))
115    .map_err(|error| RustAuthError::Cookie(error.to_string()))
116}
117
118fn hmac_base64url(value: &str, secret: &str) -> Result<String, RustAuthError> {
119    let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
120        .map_err(|error| RustAuthError::Cookie(error.to_string()))?;
121    mac.update(value.as_bytes());
122    Ok(URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()))
123}
124
125pub fn set_cookie_cache<S, U>(
126    auth_cookies: &AuthCookies,
127    secret: &(impl JweSecretSource + ?Sized),
128    payload: &CookieCachePayload<S, U>,
129    strategy: CookieCacheStrategy,
130    max_age: u64,
131) -> Result<Vec<Cookie>, RustAuthError>
132where
133    S: Serialize,
134    U: Serialize,
135{
136    let data = match strategy {
137        CookieCacheStrategy::Compact => {
138            encode_compact_cache(payload, &secret.current_jwe_secret()?, max_age)?
139        }
140        CookieCacheStrategy::Jwt => {
141            sign_jwt(payload, &secret.current_jwe_secret()?, max_age as i64)?
142        }
143        CookieCacheStrategy::Jwe => encode_jwe_cache(payload, secret, max_age)?,
144    };
145    let mut attributes = auth_cookies.session_data.attributes.clone();
146    attributes.max_age = Some(max_age);
147    let store = ChunkedCookieStore::new(auth_cookies.session_data.name.clone(), attributes, "");
148
149    Ok(store.chunk(&data))
150}
151
152pub fn get_cookie_cache<S, U>(
153    cookie_header: &str,
154    cookie_name: &str,
155    secret: &(impl JweSecretSource + ?Sized),
156    strategy: CookieCacheStrategy,
157    expected_version: Option<&str>,
158) -> Result<Option<CookieCachePayload<S, U>>, RustAuthError>
159where
160    S: DeserializeOwned + Serialize,
161    U: DeserializeOwned + Serialize,
162{
163    let store = ChunkedCookieStore::new(cookie_name, CookieOptions::default(), cookie_header);
164    let Some(data) = store.value() else {
165        return Ok(None);
166    };
167    let Some(payload) = (match strategy {
168        CookieCacheStrategy::Compact => decode_compact_cache(&data, &secret.current_jwe_secret()?)?,
169        CookieCacheStrategy::Jwt => verify_jwt(&data, &secret.current_jwe_secret()?)?,
170        CookieCacheStrategy::Jwe => decode_jwe_cache(&data, secret)?,
171    }) else {
172        return Ok(None);
173    };
174
175    if expected_version.is_some_and(|version| payload.version != version) {
176        return Ok(None);
177    }
178
179    Ok(Some(payload))
180}
181
182#[cfg(feature = "jose")]
183fn encode_jwe_cache<S, U>(
184    payload: &CookieCachePayload<S, U>,
185    secret: &(impl JweSecretSource + ?Sized),
186    max_age: u64,
187) -> Result<String, RustAuthError>
188where
189    S: Serialize,
190    U: Serialize,
191{
192    crate::crypto::symmetric_encode_jwt(payload, secret, max_age)
193}
194
195#[cfg(not(feature = "jose"))]
196fn encode_jwe_cache<S, U>(
197    _payload: &CookieCachePayload<S, U>,
198    _secret: &(impl JweSecretSource + ?Sized),
199    _max_age: u64,
200) -> Result<String, RustAuthError>
201where
202    S: Serialize,
203    U: Serialize,
204{
205    Err(RustAuthError::FeatureDisabled { feature: "jose" })
206}
207
208#[cfg(feature = "jose")]
209fn decode_jwe_cache<S, U>(
210    data: &str,
211    secret: &(impl JweSecretSource + ?Sized),
212) -> Result<Option<CookieCachePayload<S, U>>, RustAuthError>
213where
214    S: DeserializeOwned,
215    U: DeserializeOwned,
216{
217    crate::crypto::symmetric_decode_jwt(data, secret)
218}
219
220#[cfg(not(feature = "jose"))]
221fn decode_jwe_cache<S, U>(
222    _data: &str,
223    _secret: &(impl JweSecretSource + ?Sized),
224) -> Result<Option<CookieCachePayload<S, U>>, RustAuthError>
225where
226    S: DeserializeOwned,
227    U: DeserializeOwned,
228{
229    Err(RustAuthError::FeatureDisabled { feature: "jose" })
230}
231
232#[cfg(all(test, not(feature = "jose")))]
233mod tests {
234    use super::*;
235    use crate::cookies::get_cookies;
236    use crate::options::RustAuthOptions;
237
238    #[derive(Debug, Serialize)]
239    struct TestSession {
240        id: String,
241    }
242
243    #[derive(Debug, Serialize)]
244    struct TestUser {
245        id: String,
246    }
247
248    #[test]
249    fn jwe_cache_strategy_fails_closed_without_jose() -> Result<(), RustAuthError> {
250        let cookies = get_cookies(&RustAuthOptions::default())?;
251        let payload = CookieCachePayload {
252            session: TestSession {
253                id: "session_1".to_owned(),
254            },
255            user: TestUser {
256                id: "user_1".to_owned(),
257            },
258            updated_at: 0,
259            version: "1".to_owned(),
260        };
261
262        let result = set_cookie_cache(
263            &cookies,
264            "secret-a-at-least-32-chars-long!!",
265            &payload,
266            CookieCacheStrategy::Jwe,
267            300,
268        );
269
270        assert!(matches!(
271            result,
272            Err(RustAuthError::FeatureDisabled { feature: "jose" })
273        ));
274        Ok(())
275    }
276}