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