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#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
10pub struct SessionId(String);
11
12impl SessionId {
13 pub fn new() -> Self {
15 Self(ulid::Ulid::new().to_string())
16 }
17
18 pub fn from_raw(s: impl Into<String>) -> Self {
20 Self(s.into())
21 }
22
23 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#[derive(Clone, PartialEq, Eq, Hash)]
56pub struct SessionToken([u8; 32]);
57
58impl SessionToken {
59 pub fn generate() -> Self {
61 let mut bytes = [0u8; 32];
62 rand::rng().fill_bytes(&mut bytes);
63 Self(bytes)
64 }
65
66 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct SessionData {
143 pub id: SessionId,
145 pub(crate) token_hash: String,
146 pub user_id: String,
148 pub ip_address: String,
150 pub user_agent: String,
152 pub device_name: String,
154 pub device_type: String,
156 pub fingerprint: String,
158 pub data: serde_json::Value,
160 pub created_at: DateTime<Utc>,
162 pub last_active_at: DateTime<Utc>,
164 pub expires_at: DateTime<Utc>,
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171
172 #[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 #[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}