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