1use std::sync::Mutex;
16use std::time::{Duration, Instant};
17
18use dynamic_config::Error;
19
20const REFRESH_WITHIN: Duration = Duration::from_secs(60);
28
29pub const SERVICE_ACCOUNT_TOKEN: &str = "/var/run/secrets/kubernetes.io/serviceaccount/token";
31
32#[derive(Clone)]
34#[non_exhaustive]
35pub enum Auth {
36 Anonymous,
41
42 Token(String),
47
48 Login {
54 method: String,
56 bearer: Bearer,
58 meta: Vec<(String, String)>,
60 },
61}
62
63#[derive(Clone)]
65#[non_exhaustive]
66pub enum Bearer {
67 Literal(String),
69 File(String),
75}
76
77impl Auth {
78 pub fn token(token: impl Into<String>) -> Self {
80 Self::Token(token.into())
81 }
82
83 #[must_use]
89 pub fn from_environment() -> Self {
90 match std::env::var("CONSUL_HTTP_TOKEN") {
91 Ok(token) if !token.is_empty() => Self::Token(token),
92 _ => Self::Anonymous,
93 }
94 }
95
96 pub fn kubernetes(method: impl Into<String>) -> Self {
98 Self::Login {
99 method: method.into(),
100 bearer: Bearer::File(SERVICE_ACCOUNT_TOKEN.to_owned()),
101 meta: Vec::new(),
102 }
103 }
104
105 pub fn jwt(method: impl Into<String>, token: impl Into<String>) -> Self {
107 Self::Login {
108 method: method.into(),
109 bearer: Bearer::Literal(token.into()),
110 meta: Vec::new(),
111 }
112 }
113
114 #[must_use]
116 pub fn with_bearer_file(mut self, path: impl Into<String>) -> Self {
117 if let Self::Login { bearer, .. } = &mut self {
118 *bearer = Bearer::File(path.into());
119 }
120
121 self
122 }
123
124 #[must_use]
126 pub fn with_meta(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
127 if let Self::Login { meta, .. } = &mut self {
128 meta.push((name.into(), value.into()));
129 }
130
131 self
132 }
133
134 pub(crate) fn login_body(&self) -> Result<Option<serde_json::Value>, Error> {
140 let Self::Login {
141 method,
142 bearer,
143 meta,
144 } = self
145 else {
146 return Ok(None);
147 };
148
149 let token = match bearer {
150 Bearer::Literal(token) => token.clone(),
151 Bearer::File(path) => std::fs::read_to_string(path)
152 .map_err(|error| {
153 Error::remote(format!(
154 "consul: cannot read the bearer token at {path}: {error}"
155 ))
156 })?
157 .trim()
158 .to_owned(),
159 };
160
161 let meta: serde_json::Map<String, serde_json::Value> = meta
162 .iter()
163 .map(|(name, value)| (name.clone(), serde_json::Value::from(value.clone())))
164 .collect();
165
166 Ok(Some(serde_json::json!({
167 "AuthMethod": method,
168 "BearerToken": token,
169 "Meta": meta,
170 })))
171 }
172
173 pub(crate) fn describe(&self) -> String {
175 match self {
176 Self::Anonymous => "no token".to_owned(),
177 Self::Token(_) => "a supplied token".to_owned(),
178 Self::Login { method, .. } => format!("auth method `{method}`"),
179 }
180 }
181}
182
183#[derive(Clone)]
185pub(crate) struct Token {
186 pub(crate) secret: String,
187 expires: Option<Instant>,
189}
190
191impl Token {
192 pub(crate) fn new(secret: String, ttl: Option<Duration>) -> Self {
193 Self {
194 secret,
195 expires: ttl.and_then(|ttl| Instant::now().checked_add(ttl)),
200 }
201 }
202
203 fn is_stale(&self) -> bool {
204 self.expires.is_some_and(|expires| {
205 expires.saturating_duration_since(Instant::now()) < REFRESH_WITHIN
206 })
207 }
208}
209
210impl std::fmt::Debug for Auth {
215 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
216 match self {
217 Self::Anonymous => f.write_str("Anonymous"),
218 Self::Token(_) => f.write_str("Token(***)"),
219 Self::Login { method, meta, .. } => f
220 .debug_struct("Login")
221 .field("method", method)
222 .field("meta", meta)
223 .finish_non_exhaustive(),
224 }
225 }
226}
227
228impl std::fmt::Debug for Bearer {
229 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
230 match self {
231 Self::Literal(_) => f.write_str("Literal(***)"),
232 Self::File(path) => f.debug_tuple("File").field(path).finish(),
235 }
236 }
237}
238
239impl std::fmt::Debug for Token {
240 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
241 f.debug_struct("Token")
242 .field("secret", &"***")
243 .field("expires", &self.expires)
244 .finish()
245 }
246}
247
248#[derive(Debug, Default)]
250pub(crate) struct Session {
251 token: Mutex<Option<Token>>,
252}
253
254impl Session {
255 pub(crate) const fn new() -> Self {
256 Self {
257 token: Mutex::new(None),
258 }
259 }
260
261 pub(crate) fn token(&self, login: impl Fn() -> Result<Token, Error>) -> Result<String, Error> {
267 let mut slot = self.lock();
268
269 if let Some(token) = slot.as_ref() {
270 if !token.is_stale() {
271 return Ok(token.secret.clone());
272 }
273 }
274
275 let fresh = login()?;
276 let secret = fresh.secret.clone();
277 *slot = Some(fresh);
278
279 Ok(secret)
280 }
281
282 pub(crate) fn invalidate(&self) {
284 *self.lock() = None;
285 }
286
287 fn lock(&self) -> std::sync::MutexGuard<'_, Option<Token>> {
288 self.token
289 .lock()
290 .unwrap_or_else(std::sync::PoisonError::into_inner)
291 }
292}
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297
298 #[test]
299 fn a_supplied_token_needs_no_login() {
300 assert!(Auth::token("t").login_body().unwrap().is_none());
301 assert!(Auth::Anonymous.login_body().unwrap().is_none());
302 }
303
304 #[test]
305 fn a_login_presents_its_bearer_token_to_a_named_method() {
306 let body = Auth::jwt("kubernetes", "a.b.c")
307 .login_body()
308 .unwrap()
309 .expect("this one logs in");
310
311 assert_eq!(body["AuthMethod"], "kubernetes");
312 assert_eq!(body["BearerToken"], "a.b.c");
313 }
314
315 #[test]
316 fn meta_is_carried_through_for_the_audit_log() {
317 let body = Auth::jwt("kubernetes", "a.b.c")
318 .with_meta("pod", "myapp-7f9")
319 .login_body()
320 .unwrap()
321 .unwrap();
322
323 assert_eq!(body["Meta"]["pod"], "myapp-7f9");
324 }
325
326 #[test]
327 fn a_missing_bearer_file_says_where_it_looked() {
328 let error = Auth::kubernetes("kubernetes")
329 .with_bearer_file("/no/such/token")
330 .login_body()
331 .expect_err("there is no token there");
332
333 assert!(error.to_string().contains("/no/such/token"), "{error}");
334 }
335
336 #[test]
337 fn an_unset_environment_variable_is_anonymous_rather_than_an_error() {
338 std::env::remove_var("CONSUL_HTTP_TOKEN");
341
342 assert!(matches!(Auth::from_environment(), Auth::Anonymous));
343 }
344
345 #[test]
346 fn a_ttl_too_large_to_represent_is_treated_as_no_expiry() {
347 assert!(!Token::new("t".to_owned(), Some(Duration::from_nanos(u64::MAX))).is_stale());
350 }
351
352 #[test]
353 fn a_token_with_no_expiry_is_never_stale() {
354 assert!(!Token::new("t".to_owned(), None).is_stale());
355 }
356
357 #[test]
358 fn a_token_near_its_expiry_is_stale() {
359 assert!(!Token::new("t".to_owned(), Some(Duration::from_secs(3600))).is_stale());
360 assert!(Token::new("t".to_owned(), Some(REFRESH_WITHIN / 2)).is_stale());
361 }
362
363 #[test]
364 fn a_session_logs_in_once_and_then_reuses_the_token() {
365 use std::sync::atomic::{AtomicUsize, Ordering};
366
367 let logins = AtomicUsize::new(0);
368 let session = Session::new();
369
370 let login = || {
371 logins.fetch_add(1, Ordering::SeqCst);
372
373 Ok(Token::new(
374 "token".to_owned(),
375 Some(Duration::from_secs(3600)),
376 ))
377 };
378
379 assert_eq!(session.token(login).unwrap(), "token");
380 assert_eq!(session.token(login).unwrap(), "token");
381 assert_eq!(logins.load(Ordering::SeqCst), 1);
382
383 session.invalidate();
384
385 assert_eq!(session.token(login).unwrap(), "token");
386 assert_eq!(
387 logins.load(Ordering::SeqCst),
388 2,
389 "a 403 must be able to force a fresh login"
390 );
391 }
392}