Skip to main content

codoseo_web/auth/
session.rs

1//! Random tokens, their hashes, and the session cookie.
2//!
3//! Spec section 8: sessions are random IDs in HttpOnly / Secure / SameSite=Lax cookies, stored
4//! hashed. The same 32-byte random token and SHA-256 hash serve magic links.
5
6use axum::http::{HeaderMap, HeaderValue, header};
7use base64::Engine;
8use base64::engine::general_purpose::URL_SAFE_NO_PAD;
9use sha2::{Digest, Sha256};
10use uuid::Uuid;
11
12use crate::error::AppError;
13use crate::state::AppState;
14
15pub const SESSION_COOKIE: &str = "codoseo_session";
16pub const SESSION_TTL: time::Duration = time::Duration::days(30);
17
18/// 32 random bytes as URL-safe base64 (43 characters).
19pub fn random_token() -> String {
20    let mut bytes = [0u8; 32];
21    rand::fill(&mut bytes);
22    URL_SAFE_NO_PAD.encode(bytes)
23}
24
25/// What gets stored instead of the token itself.
26pub fn hash(token: &str) -> Vec<u8> {
27    Sha256::digest(token.as_bytes()).to_vec()
28}
29
30/// Reads one cookie from the request.
31pub fn cookie(headers: &HeaderMap, name: &str) -> Option<String> {
32    headers
33        .get_all(header::COOKIE)
34        .iter()
35        .filter_map(|v| v.to_str().ok())
36        .flat_map(|v| v.split(';'))
37        .filter_map(|pair| pair.trim().split_once('='))
38        .find(|(k, _)| *k == name)
39        .map(|(_, v)| v.to_owned())
40}
41
42/// A `Set-Cookie` value. `max_age` of zero clears the cookie.
43pub fn set_cookie(name: &str, value: &str, max_age_secs: i64, secure: bool) -> HeaderValue {
44    let secure = if secure { "; Secure" } else { "" };
45    HeaderValue::from_str(&format!(
46        "{name}={value}; Path=/; Max-Age={max_age_secs}; HttpOnly; SameSite=Lax{secure}"
47    ))
48    .expect("cookie values are base64 or empty")
49}
50
51/// Creates a session for the account and returns the `Set-Cookie` header value.
52pub async fn start(state: &AppState, account_id: Uuid) -> Result<HeaderValue, AppError> {
53    let token = random_token();
54    codoseo_store::auth::create_session(&state.pool, account_id, &hash(&token), SESSION_TTL)
55        .await?;
56    codoseo_store::accounts::touch_login(&state.pool, account_id).await?;
57    Ok(set_cookie(
58        SESSION_COOKIE,
59        &token,
60        SESSION_TTL.whole_seconds(),
61        state.config.secure_cookies(),
62    ))
63}
64
65#[cfg(test)]
66mod tests {
67    use super::*;
68
69    #[test]
70    fn tokens_are_random_and_hash_to_32_bytes() {
71        let a = random_token();
72        let b = random_token();
73        assert_ne!(a, b);
74        assert_eq!(a.len(), 43);
75        assert_eq!(hash(&a).len(), 32);
76        assert_eq!(hash(&a), hash(&a));
77    }
78
79    #[test]
80    fn reads_a_cookie_among_others() {
81        let mut h = HeaderMap::new();
82        h.insert(
83            header::COOKIE,
84            HeaderValue::from_static("theme=dark; codoseo_session=abc; x=1"),
85        );
86        assert_eq!(cookie(&h, SESSION_COOKIE).as_deref(), Some("abc"));
87        assert_eq!(cookie(&h, "missing"), None);
88    }
89
90    #[test]
91    fn cookie_flags() {
92        let v = set_cookie("s", "v", 60, true);
93        let s = v.to_str().unwrap();
94        assert!(s.contains("HttpOnly") && s.contains("SameSite=Lax") && s.contains("Secure"));
95        assert!(
96            !set_cookie("s", "v", 60, false)
97                .to_str()
98                .unwrap()
99                .contains("Secure")
100        );
101    }
102}