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