modo/auth/session/token.rs
1//! [`SessionToken`] — opaque 32-byte cryptographic session token.
2//!
3//! The raw bytes are never transmitted; the hex-encoded value goes in the
4//! signed cookie, and only the SHA-256 hash is stored in the database.
5//! `Debug` and `Display` both redact the value as `"****"`.
6
7use std::fmt;
8
9/// A cryptographically random 32-byte session token.
10///
11/// The raw bytes are never transmitted; only the hex-encoded form is written
12/// to the signed cookie, and the SHA-256 hash is stored in the database so
13/// that a stolen database cannot be used to forge cookies.
14///
15/// `Debug` and `Display` both redact the value as `"****"` to prevent
16/// accidental logging.
17#[derive(Clone, PartialEq, Eq, Hash)]
18pub struct SessionToken([u8; 32]);
19
20impl SessionToken {
21 /// Generate a new random session token.
22 pub fn generate() -> Self {
23 let mut bytes = [0u8; 32];
24 rand::fill(&mut bytes);
25 Self(bytes)
26 }
27
28 /// Decode a session token from a 64-character lowercase hex string.
29 ///
30 /// # Errors
31 ///
32 /// Returns `Err` if the string is not exactly 64 characters or contains
33 /// non-hexadecimal characters.
34 pub fn from_hex(s: &str) -> Result<Self, &'static str> {
35 if s.len() != 64 {
36 return Err("token must be 64 hex characters");
37 }
38 let mut bytes = [0u8; 32];
39 for (i, chunk) in s.as_bytes().chunks(2).enumerate() {
40 let hi = hex_digit(chunk[0]).ok_or("invalid hex character")?;
41 let lo = hex_digit(chunk[1]).ok_or("invalid hex character")?;
42 bytes[i] = (hi << 4) | lo;
43 }
44 Ok(Self(bytes))
45 }
46
47 /// Encode the token as a 64-character lowercase hex string.
48 ///
49 /// This is the value written into the session cookie.
50 pub fn as_hex(&self) -> String {
51 crate::encoding::hex::encode(&self.0)
52 }
53
54 /// Compute the SHA-256 hash of the token and return it as a 64-character
55 /// lowercase hex string.
56 ///
57 /// This hash is what is stored in `sessions.token_hash`. Storing only
58 /// the hash ensures that a read of the database cannot be used to impersonate
59 /// users.
60 pub fn hash(&self) -> String {
61 crate::encoding::hex::sha256(self.0)
62 }
63
64 /// Expose the raw token as a 64-character hex string.
65 ///
66 /// This intentionally breaks the redaction guarantee and is meant only for
67 /// JWT `jti` round-tripping inside the crate. Do not use for logging.
68 pub fn expose(&self) -> String {
69 self.as_hex()
70 }
71
72 /// Reconstruct a `SessionToken` from a 64-character hex string (the value
73 /// previously returned by [`expose`](Self::expose)).
74 ///
75 /// Returns `None` if the string is not a valid 64-character hex encoding.
76 pub fn from_raw(s: &str) -> Option<Self> {
77 Self::from_hex(s).ok()
78 }
79}
80
81impl fmt::Debug for SessionToken {
82 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83 write!(f, "SessionToken(****)")
84 }
85}
86
87impl fmt::Display for SessionToken {
88 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89 f.write_str("****")
90 }
91}
92
93fn hex_digit(b: u8) -> Option<u8> {
94 match b {
95 b'0'..=b'9' => Some(b - b'0'),
96 b'a'..=b'f' => Some(b - b'a' + 10),
97 b'A'..=b'F' => Some(b - b'A' + 10),
98 _ => None,
99 }
100}