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#[derive(Clone, PartialEq, Eq, Hash)]
16pub struct SessionToken([u8; 32]);
17
18impl SessionToken {
19 pub fn generate() -> Self {
21 let mut bytes = [0u8; 32];
22 rand::rng().fill_bytes(&mut bytes);
23 Self(bytes)
24 }
25
26 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct SessionData {
104 pub id: SessionId,
106 #[serde(skip_serializing)]
107 pub(crate) token_hash: String,
108 pub user_id: String,
110 pub ip_address: String,
112 pub user_agent: String,
114 pub device_name: String,
116 pub device_type: String,
118 pub fingerprint: String,
120 pub data: serde_json::Value,
122 pub created_at: DateTime<Utc>,
124 pub last_active_at: DateTime<Utc>,
126 pub expires_at: DateTime<Utc>,
128}
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133
134 #[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 #[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}