Skip to main content

solti_model/
auth.rs

1//! # Bearer token
2//!
3//! [`Token`] wraps one bearer secret.
4//! It can be created, generated, or loaded from an environment variable or file.
5//!
6//! This module does not choose an authentication topology.
7//! It does not persist or rotate secrets.
8
9use std::fmt;
10use std::path::Path;
11
12use base64::Engine;
13use base64::engine::general_purpose::URL_SAFE_NO_PAD;
14use subtle::ConstantTimeEq;
15
16use crate::error::{ModelError, ModelResult};
17
18/// Prefix on generated tokens.
19const GENERATED_PREFIX: &str = "solti_agt_";
20
21/// Entropy of a generated token, in bytes (256 bits).
22const GENERATED_ENTROPY_BYTES: usize = 32;
23
24/// Validated bearer token.
25///
26/// `Debug` output is redacted.
27/// Use [`Self::verify`] for inbound checks.
28/// Use [`Self::expose`] only when the raw value is required.
29///
30/// ## Example
31///
32/// ```
33/// use solti_model::Token;
34///
35/// let token = Token::new("secret").unwrap();
36///
37/// assert!(token.verify("secret"));
38/// assert!(!token.verify("other"));
39/// assert_eq!(format!("{token:?}"), "Token(***redacted***)");
40/// ```
41#[derive(Clone)]
42pub struct Token(String);
43
44impl Token {
45    /// Wraps a raw token.
46    ///
47    /// Surrounding whitespace is trimmed.
48    ///
49    /// # Errors
50    ///
51    /// Returns [`ModelError::Invalid`] when the trimmed value is empty.
52    ///
53    /// ## Example
54    ///
55    /// ```
56    /// use solti_model::Token;
57    ///
58    /// let token = Token::new("  secret\n").unwrap();
59    /// assert_eq!(token.expose(), "secret");
60    /// ```
61    pub fn new(token: impl Into<String>) -> ModelResult<Self> {
62        Self::checked(token.into())
63    }
64
65    /// Generates a random token.
66    ///
67    /// The token uses 256 bits from the operating system entropy source.
68    /// It uses unpadded base64url and starts with `solti_agt_`.
69    /// The token is not persisted.
70    ///
71    /// # Errors
72    ///
73    /// Returns [`ModelError::Invalid`] when the entropy source is unavailable.
74    ///
75    /// ## Example
76    ///
77    /// ```
78    /// use solti_model::Token;
79    ///
80    /// let token = Token::generate()?;
81    /// assert!(token.expose().starts_with("solti_agt_"));
82    /// assert!(token.verify(token.expose()));
83    /// # Ok::<(), solti_model::ModelError>(())
84    /// ```
85    pub fn generate() -> ModelResult<Self> {
86        let mut buf = [0u8; GENERATED_ENTROPY_BYTES];
87        getrandom::fill(&mut buf).map_err(|error| {
88            ModelError::Invalid(format!("OS entropy source unavailable: {error}").into())
89        })?;
90        Ok(Self(format!(
91            "{GENERATED_PREFIX}{}",
92            URL_SAFE_NO_PAD.encode(buf)
93        )))
94    }
95
96    /// Reads a token from an environment variable.
97    ///
98    /// # Errors
99    ///
100    /// Returns [`ModelError::Invalid`] when the variable is absent or the value is empty.
101    ///
102    /// ## Example
103    ///
104    /// ```rust,no_run
105    /// use solti_model::Token;
106    ///
107    /// let token = Token::from_env("SOLTI_AGENT_TOKEN")?;
108    /// # Ok::<(), solti_model::ModelError>(())
109    /// ```
110    pub fn from_env(var: &str) -> ModelResult<Self> {
111        let raw = std::env::var(var)
112            .map_err(|_| ModelError::Invalid(format!("token env var `{var}` is not set").into()))?;
113        Self::checked(raw)
114    }
115
116    /// Reads a token from a UTF-8 file.
117    ///
118    /// Surrounding whitespace is trimmed.
119    ///
120    /// # Errors
121    ///
122    /// Returns [`ModelError::Invalid`] when the file cannot be read or the value is empty.
123    ///
124    /// ## Example
125    ///
126    /// ```rust,no_run
127    /// use solti_model::Token;
128    ///
129    /// let token = Token::from_file("/run/secrets/solti-agent-token")?;
130    /// # Ok::<(), solti_model::ModelError>(())
131    /// ```
132    pub fn from_file(path: impl AsRef<Path>) -> ModelResult<Self> {
133        let path = path.as_ref();
134        let raw = std::fs::read_to_string(path).map_err(|e| {
135            ModelError::Invalid(format!("read token file `{}`: {e}", path.display()).into())
136        })?;
137        Self::checked(raw)
138    }
139
140    fn checked(raw: String) -> ModelResult<Self> {
141        let trimmed = raw.trim();
142        if trimmed.is_empty() {
143            return Err(ModelError::Invalid("token must not be empty".into()));
144        }
145        Ok(Self(trimmed.to_string()))
146    }
147
148    /// Returns the raw token.
149    ///
150    /// Inbound verification should use [`Self::verify`].
151    pub fn expose(&self) -> &str {
152        &self.0
153    }
154
155    /// Verifies a candidate value.
156    ///
157    /// The comparison is constant-time for equal-length strings.
158    /// A length mismatch returns `false`.
159    ///
160    /// ## Example
161    ///
162    /// ```
163    /// use solti_model::Token;
164    ///
165    /// let token = Token::new("secret").unwrap();
166    /// assert!(token.verify("secret"));
167    /// assert!(!token.verify("Secret"));
168    /// ```
169    pub fn verify(&self, candidate: &str) -> bool {
170        self.0.as_bytes().ct_eq(candidate.as_bytes()).into()
171    }
172}
173
174impl fmt::Debug for Token {
175    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
176        f.write_str("Token(***redacted***)")
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    #[test]
185    fn new_trims_whitespace() {
186        assert_eq!(Token::new("  abc\n").unwrap().expose(), "abc");
187    }
188
189    #[test]
190    fn checked_rejects_empty() {
191        assert!(Token::new("").is_err());
192        assert!(Token::new(" \t\n").is_err());
193        assert!(Token::from_env("__definitely_unset_var__").is_err());
194    }
195
196    #[test]
197    fn verify_matches_only_exact() {
198        let t = Token::new("s3cr3t-value").unwrap();
199        assert!(t.verify("s3cr3t-value"));
200        assert!(!t.verify("s3cr3t-valuE"));
201        assert!(!t.verify("s3cr3t"));
202        assert!(!t.verify("s3cr3t-value-extra"));
203    }
204
205    #[test]
206    fn debug_is_redacted() {
207        let t = Token::new("super-secret").unwrap();
208        assert_eq!(format!("{t:?}"), "Token(***redacted***)");
209        assert!(!format!("{t:?}").contains("super-secret"));
210    }
211
212    #[test]
213    fn generate_is_prefixed_unique_and_self_verifying() {
214        let a = Token::generate().unwrap();
215        let b = Token::generate().unwrap();
216        assert!(a.expose().starts_with("solti_agt_"));
217        assert_ne!(a.expose(), b.expose(), "two generated tokens must differ");
218        assert!(a.verify(a.expose()));
219        assert!(!a.verify(b.expose()));
220        assert_eq!(a.expose().len(), "solti_agt_".len() + 43);
221    }
222}