Skip to main content

modo_session/
types.rs

1use chrono::{DateTime, Utc};
2use rand::RngCore;
3use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5use std::fmt::{self, Write};
6
7modo::ulid_id!(SessionId);
8
9/// Opaque session token (32 random bytes).
10///
11/// Stored in the browser cookie as a 64-character hex string; only the SHA-256
12/// hash is persisted to the database so a compromised DB row cannot be replayed.
13/// `Debug` and `Display` output are redacted (`****`) to prevent accidental
14/// logging of the raw token.
15#[derive(Clone, PartialEq, Eq, Hash)]
16pub struct SessionToken([u8; 32]);
17
18impl SessionToken {
19    /// Generate a cryptographically random 32-byte token.
20    pub fn generate() -> Self {
21        let mut bytes = [0u8; 32];
22        rand::rng().fill_bytes(&mut bytes);
23        Self(bytes)
24    }
25
26    /// Decode a token from a 64-character lowercase hex string.
27    ///
28    /// Returns `Err` if `s` is not exactly 64 hex characters.
29    pub fn from_hex(s: &str) -> Result<Self, &'static str> {
30        if s.len() != 64 {
31            return Err("token must be 64 hex characters");
32        }
33        let mut bytes = [0u8; 32];
34        for (i, chunk) in s.as_bytes().chunks(2).enumerate() {
35            let hi = hex_digit(chunk[0]).ok_or("invalid hex character")?;
36            let lo = hex_digit(chunk[1]).ok_or("invalid hex character")?;
37            bytes[i] = (hi << 4) | lo;
38        }
39        Ok(Self(bytes))
40    }
41
42    /// Encode the raw token bytes as a 64-character lowercase hex string.
43    pub fn as_hex(&self) -> String {
44        let mut s = String::with_capacity(64);
45        for b in &self.0 {
46            write!(s, "{b:02x}").expect("writing to String cannot fail");
47        }
48        s
49    }
50
51    /// Compute the SHA-256 hash of the token as a 64-character lowercase hex string.
52    ///
53    /// This is the value stored in the database; the raw token is never persisted.
54    pub fn hash(&self) -> String {
55        let digest = Sha256::digest(self.0);
56        let mut s = String::with_capacity(64);
57        for b in digest {
58            write!(s, "{b:02x}").expect("writing to String cannot fail");
59        }
60        s
61    }
62}
63
64impl fmt::Debug for SessionToken {
65    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66        write!(f, "SessionToken(****)")
67    }
68}
69
70impl fmt::Display for SessionToken {
71    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72        f.write_str("****")
73    }
74}
75
76impl Serialize for SessionToken {
77    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
78        serializer.serialize_str(&self.as_hex())
79    }
80}
81
82impl<'de> Deserialize<'de> for SessionToken {
83    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
84        let s = String::deserialize(deserializer)?;
85        Self::from_hex(&s).map_err(serde::de::Error::custom)
86    }
87}
88
89fn hex_digit(b: u8) -> Option<u8> {
90    match b {
91        b'0'..=b'9' => Some(b - b'0'),
92        b'a'..=b'f' => Some(b - b'a' + 10),
93        b'A'..=b'F' => Some(b - b'A' + 10),
94        _ => None,
95    }
96}
97
98/// Full session record loaded from the database.
99///
100/// Returned by [`crate::SessionManager::current`] and
101/// [`crate::SessionManager::list_my_sessions`].
102#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct SessionData {
104    /// Unique session identifier (ULID).
105    pub id: SessionId,
106    #[serde(skip_serializing)]
107    pub(crate) token_hash: String,
108    /// ID of the authenticated user.
109    pub user_id: String,
110    /// Client IP address at session creation time.
111    pub ip_address: String,
112    /// Raw `User-Agent` header value.
113    pub user_agent: String,
114    /// Human-readable device name derived from the User-Agent (e.g. `"Chrome on macOS"`).
115    pub device_name: String,
116    /// Device category: `"desktop"`, `"mobile"`, or `"tablet"`.
117    pub device_type: String,
118    /// SHA-256 fingerprint of stable request headers used for hijack detection.
119    pub fingerprint: String,
120    /// Arbitrary JSON payload attached to the session.
121    pub data: serde_json::Value,
122    /// Timestamp when the session was created.
123    pub created_at: DateTime<Utc>,
124    /// Timestamp of the last activity (updated on touch).
125    pub last_active_at: DateTime<Utc>,
126    /// Timestamp after which the session is considered expired.
127    pub expires_at: DateTime<Utc>,
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    // --- SessionId tests ---
135
136    #[test]
137    fn session_id_generates_unique() {
138        let a = SessionId::new();
139        let b = SessionId::new();
140        assert_ne!(a, b);
141    }
142
143    #[test]
144    fn session_id_ulid_format() {
145        let id = SessionId::new();
146        assert_eq!(id.as_str().len(), 26);
147    }
148
149    #[test]
150    fn session_id_display_from_str_roundtrip() {
151        let id = SessionId::new();
152        let s = id.to_string();
153        let parsed: SessionId = s.parse().unwrap();
154        assert_eq!(id, parsed);
155    }
156
157    #[test]
158    fn session_id_from_raw() {
159        let id = SessionId::from_raw("test-id");
160        assert_eq!(id.as_str(), "test-id");
161    }
162
163    // --- SessionToken tests ---
164
165    #[test]
166    fn session_token_generates_64_hex() {
167        let token = SessionToken::generate();
168        let hex = token.as_hex();
169        assert_eq!(hex.len(), 64);
170        assert!(hex.chars().all(|c| c.is_ascii_hexdigit()));
171    }
172
173    #[test]
174    fn session_token_unique() {
175        let a = SessionToken::generate();
176        let b = SessionToken::generate();
177        assert_ne!(a, b);
178    }
179
180    #[test]
181    fn session_token_from_hex_roundtrip() {
182        let token = SessionToken::generate();
183        let hex = token.as_hex();
184        let parsed = SessionToken::from_hex(&hex).unwrap();
185        assert_eq!(token, parsed);
186    }
187
188    #[test]
189    fn session_token_from_hex_rejects_wrong_length() {
190        assert!(SessionToken::from_hex("abcd").is_err());
191    }
192
193    #[test]
194    fn session_token_from_hex_rejects_non_hex() {
195        let bad = "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz";
196        assert!(SessionToken::from_hex(bad).is_err());
197    }
198
199    #[test]
200    fn session_token_hash_returns_64_hex() {
201        let token = SessionToken::generate();
202        let h = token.hash();
203        assert_eq!(h.len(), 64);
204        assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
205    }
206
207    #[test]
208    fn session_token_hash_deterministic() {
209        let token = SessionToken::generate();
210        assert_eq!(token.hash(), token.hash());
211    }
212
213    #[test]
214    fn session_token_hash_differs_from_hex() {
215        let token = SessionToken::generate();
216        assert_ne!(token.hash(), token.as_hex());
217    }
218
219    #[test]
220    fn session_token_debug_is_redacted() {
221        let token = SessionToken::generate();
222        let dbg = format!("{token:?}");
223        assert_eq!(dbg, "SessionToken(****)");
224        assert!(!dbg.contains(&token.as_hex()));
225    }
226}