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}