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}