Skip to main content

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}