Skip to main content

ccwire_oauth/
secret.rs

1//! [`Secret`]: a token that never prints.
2
3use alloc::string::String;
4use core::fmt;
5
6use serde::{Deserialize, Deserializer};
7use zeroize::Zeroize;
8
9/// An access or refresh token.
10///
11/// Its [`Debug`](fmt::Debug) and [`Display`](fmt::Display) print
12/// `<redacted>`, it has no `Serialize`, and its own buffer is zeroed when it
13/// is dropped. [`Secret::expose`] is the only way to read it.
14///
15/// Zeroing is best effort, not a guarantee that no copy is left in memory.
16/// Each clone is a separate buffer, zeroed when that clone drops. The
17/// client zeroes the response bodies it reads, but not the copies made
18/// outside it: the request body handed to reqwest (and its TLS and HTTP/2
19/// buffers), reqwest's received chunks, the `String` a caller passes to
20/// [`Secret::new`], and whatever a caller does with [`Secret::expose`].
21///
22/// Equality (`==`) is a plain string comparison, not constant-time: fine
23/// for the compare-and-swap a refresh needs, not for checking a token an
24/// untrusted party sent.
25///
26/// ```
27/// use ccwire_oauth::Secret;
28///
29/// let token = Secret::new("not-a-real-token");
30/// assert_eq!(format!("{token} {token:?}"), "<redacted> Secret(<redacted>)");
31/// assert_eq!(token.expose(), "not-a-real-token");
32/// ```
33#[derive(Clone, Default, PartialEq, Eq)]
34pub struct Secret(String);
35
36impl Secret {
37    /// Wraps a token.
38    pub fn new(token: impl Into<String>) -> Self {
39        Self(token.into())
40    }
41
42    /// The token itself. Send it, compare it or store it; don't log it.
43    pub fn expose(&self) -> &str {
44        &self.0
45    }
46
47    /// Whether the token is the empty string.
48    pub fn is_empty(&self) -> bool {
49        self.0.is_empty()
50    }
51}
52
53impl From<String> for Secret {
54    fn from(token: String) -> Self {
55        Self(token)
56    }
57}
58
59impl From<&str> for Secret {
60    fn from(token: &str) -> Self {
61        Self(token.into())
62    }
63}
64
65impl Drop for Secret {
66    fn drop(&mut self) {
67        self.0.zeroize();
68    }
69}
70
71impl fmt::Debug for Secret {
72    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73        f.write_str("Secret(<redacted>)")
74    }
75}
76
77impl fmt::Display for Secret {
78    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
79        f.write_str("<redacted>")
80    }
81}
82
83impl<'de> Deserialize<'de> for Secret {
84    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
85        String::deserialize(deserializer).map(Self)
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92
93    #[test]
94    fn never_prints() {
95        let s = Secret::new("abc123");
96        assert_eq!(format!("{s}"), "<redacted>");
97        assert_eq!(format!("{s:?}"), "Secret(<redacted>)");
98        assert_eq!(format!("{:?}", Some(s.clone())), "Some(Secret(<redacted>))");
99        assert_eq!(s.expose(), "abc123");
100    }
101
102    #[test]
103    fn deserializes_from_a_string() {
104        let s: Secret = serde_json::from_str(r#""tok""#).unwrap();
105        assert_eq!(s.expose(), "tok");
106        assert!(serde_json::from_str::<Secret>("1").is_err());
107    }
108}