Skip to main content

topcoat_session/
token.rs

1mod hash;
2mod store;
3
4pub use hash::*;
5pub use store::*;
6
7use sha2::Digest;
8
9/// A session token: 32 bytes of cryptographically secure randomness, held by
10/// the client as its proof of a session.
11///
12/// The raw token only ever travels between the client and the [`TokenStore`].
13/// Applications persist its [`hash`](Self::hash) instead, so a leaked session
14/// database never contains a credential a client could present.
15#[derive(Clone)]
16pub struct Token([u8; 32]);
17
18impl Token {
19    /// Creates a token from raw bytes, typically inside a [`TokenStore`] that
20    /// has deserialized a client-presented token.
21    #[must_use]
22    pub fn new(bytes: [u8; 32]) -> Self {
23        Self(bytes)
24    }
25
26    /// Generates a fresh random token.
27    ///
28    /// # Panics
29    ///
30    /// Panics when the platform's source of cryptographically secure
31    /// randomness fails.
32    #[must_use]
33    pub fn random() -> Self {
34        let mut bytes = [0u8; 32];
35        getrandom::fill(&mut bytes).expect("the session token randomness source failed");
36        Self::new(bytes)
37    }
38
39    /// Parses a token from its URL-safe base64 [`encode`](Self::encode)d form.
40    ///
41    /// # Errors
42    ///
43    /// Returns a [`DecodeError`] when `s` is not valid base64 or does not
44    /// decode to exactly 32 bytes.
45    pub fn decode(s: &str) -> Result<Self, DecodeError> {
46        use base64::{DecodeSliceError, Engine as _, engine::general_purpose::URL_SAFE};
47        let mut bytes = [0u8; 32];
48        let num_bytes = URL_SAFE
49            .decode_slice(s, &mut bytes)
50            .map_err(|error| match error {
51                DecodeSliceError::OutputSliceTooSmall => DecodeError::Length,
52                DecodeSliceError::DecodeError(error) => error.into(),
53            })?;
54        if num_bytes != bytes.len() {
55            return Err(DecodeError::Length);
56        }
57        Ok(Self::new(bytes))
58    }
59
60    /// Encodes the token as URL-safe base64, for a [`TokenStore`] to send to
61    /// the client.
62    #[must_use]
63    pub fn encode(&self) -> String {
64        use base64::{Engine as _, engine::general_purpose::URL_SAFE};
65        URL_SAFE.encode(self.0)
66    }
67
68    /// Returns the [`TokenHash`] identifying this token's session, safe for
69    /// the application to persist.
70    #[must_use]
71    pub fn hash(&self) -> TokenHash {
72        let mut hasher = sha2::Sha256::new();
73        hasher.update(self.0);
74        TokenHash::new(hasher.finalize().0)
75    }
76
77    /// Exposes the raw token bytes.
78    ///
79    /// Only a [`TokenStore`] serializing the token for the client should need
80    /// this; never persist the raw bytes server-side.
81    #[must_use]
82    pub fn dangerous_as_array(&self) -> &[u8; 32] {
83        &self.0
84    }
85}
86
87impl std::fmt::Debug for Token {
88    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        f.debug_struct(stringify!(Token)).finish()
90    }
91}
92
93/// The reason a [`Token::decode`] call rejected its input.
94#[derive(Debug, thiserror::Error)]
95pub enum DecodeError {
96    #[error("base64 decoding failed")]
97    Base64(#[from] base64::DecodeError),
98    #[error("invalid number of bytes in token")]
99    Length,
100}
101
102#[cfg(test)]
103mod tests {
104    use base64::{Engine as _, engine::general_purpose::URL_SAFE};
105
106    use super::*;
107
108    #[test]
109    fn encode_then_decode_round_trips() {
110        let token = Token::random();
111        let decoded = Token::decode(&token.encode()).expect("an encoded token decodes back");
112        assert_eq!(decoded.dangerous_as_array(), token.dangerous_as_array());
113    }
114
115    #[test]
116    fn decode_accept_correct_length() {
117        let encoded = URL_SAFE.encode([0u8; 32]);
118        assert_eq!(
119            Token::decode(&encoded).unwrap().dangerous_as_array(),
120            &[0u8; 32]
121        );
122    }
123
124    #[test]
125    fn decode_rejects_length_too_short() {
126        let encoded = URL_SAFE.encode([0u8; 31]);
127        assert!(matches!(Token::decode(&encoded), Err(DecodeError::Length)));
128    }
129
130    #[test]
131    fn decode_rejects_length_too_long() {
132        let encoded = URL_SAFE.encode([0u8; 33]);
133        assert!(matches!(Token::decode(&encoded), Err(DecodeError::Length)));
134    }
135
136    #[test]
137    fn decode_rejects_invalid_base64() {
138        assert!(matches!(
139            Token::decode("not*valid*base64"),
140            Err(DecodeError::Base64(_))
141        ));
142    }
143}