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 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#[derive(Clone, PartialEq, Eq, Hash)]
61pub struct SessionToken([u8; 32]);
62
63impl SessionToken {
64 pub fn generate() -> Self {
66 let mut bytes = [0u8; 32];
67 rand::rng().fill_bytes(&mut bytes);
68 Self(bytes)
69 }
70
71 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct SessionData {
149 pub id: SessionId,
151 pub(crate) token_hash: String,
152 pub user_id: String,
154 pub ip_address: String,
156 pub user_agent: String,
158 pub device_name: String,
160 pub device_type: String,
162 pub fingerprint: String,
164 pub data: serde_json::Value,
166 pub created_at: DateTime<Utc>,
168 pub last_active_at: DateTime<Utc>,
170 pub expires_at: DateTime<Utc>,
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177
178 #[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 #[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}