Skip to main content

dynamic_config_consul/
auth.rs

1//! Getting an ACL token, and getting another one when it stops working.
2//!
3//! Consul's story is shorter than Vault's. A token is either handed to the
4//! process — the usual case, through `CONSUL_HTTP_TOKEN` — or obtained by
5//! presenting a bearer token to an *auth method*, which is how a workload in
6//! Kubernetes proves who it is without a secret to distribute.
7//!
8//! There is no renewal. Consul issues login tokens with an expiry and expects
9//! you to log in again, so that is what happens: a token close to expiry is
10//! replaced, and a `403` replaces one early. Both paths exist for the same
11//! reason they do in the Vault crate — the proactive one should normally fire,
12//! and the reactive one covers clock skew and tokens revoked out from under a
13//! running process.
14
15use std::sync::Mutex;
16use std::time::{Duration, Instant};
17
18use dynamic_config::Error;
19
20/// How close to expiry a token may get before it is refreshed.
21///
22/// One name and one value across the three token-caching store crates, on
23/// purpose. The margin is also the only cushion against clock skew: expiry
24/// is computed from a *local* `Instant` plus a *server-reported* TTL, so any
25/// disagreement between the server's issue time and our receipt time eats
26/// into it. A minute absorbs the skew a real fleet actually has.
27const REFRESH_WITHIN: Duration = Duration::from_secs(60);
28
29/// Where a Kubernetes service-account token is mounted, by convention.
30pub const SERVICE_ACCOUNT_TOKEN: &str = "/var/run/secrets/kubernetes.io/serviceaccount/token";
31
32/// How to obtain a Consul ACL token.
33#[derive(Clone)]
34#[non_exhaustive]
35pub enum Auth {
36    /// No token at all.
37    ///
38    /// Correct for a Consul with ACLs disabled, and for a `default` policy that
39    /// allows reads — both of which are ordinary in development.
40    Anonymous,
41
42    /// A token somebody already obtained, usually `CONSUL_HTTP_TOKEN`.
43    ///
44    /// The only variant that cannot recover on its own: there are no
45    /// credentials here to log in again with.
46    Token(String),
47
48    /// A bearer token presented to an auth method.
49    ///
50    /// Consul calls the endpoint `/v1/acl/login`; the method decides what a
51    /// valid bearer token looks like — a Kubernetes service-account JWT, an
52    /// OIDC id token, a JWT signed by something Consul trusts.
53    Login {
54        /// The auth method's name, as configured in Consul.
55        method: String,
56        /// Where the bearer token comes from.
57        bearer: Bearer,
58        /// Consul's `Meta`, attached to the issued token for auditing.
59        meta: Vec<(String, String)>,
60    },
61}
62
63/// Where a bearer token comes from.
64#[derive(Clone)]
65#[non_exhaustive]
66pub enum Bearer {
67    /// A literal token.
68    Literal(String),
69    /// A file, re-read at every login.
70    ///
71    /// This is what a Kubernetes projected service-account token needs: the
72    /// kubelet rotates it, and a copy taken at startup expires with the pod
73    /// still running.
74    File(String),
75}
76
77impl Auth {
78    /// A token somebody already obtained.
79    pub fn token(token: impl Into<String>) -> Self {
80        Self::Token(token.into())
81    }
82
83    /// `CONSUL_HTTP_TOKEN`, if it is set and not empty.
84    ///
85    /// Returns [`Auth::Anonymous`] when it is not, because a Consul with ACLs
86    /// disabled is a perfectly ordinary thing to point this at, and failing
87    /// would make the convenience useless in exactly that case.
88    #[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    /// Kubernetes: the pod's service-account token, presented to `method`.
97    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    /// A JWT or OIDC token presented to `method`.
106    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    /// Reads the bearer token from somewhere other than the conventional path.
115    #[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    /// Adds a `Meta` entry, which Consul attaches to the issued token.
125    #[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    /// The body to POST to `/v1/acl/login`, or `None` when there is no login.
135    ///
136    /// # Errors
137    ///
138    /// If a bearer token file cannot be read.
139    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    /// How to name this method in an error.
174    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/// A token and when it expires.
184#[derive(Clone)]
185pub(crate) struct Token {
186    pub(crate) secret: String,
187    /// `None` for a token Consul did not put an expiry on.
188    expires: Option<Instant>,
189}
190
191impl Token {
192    pub(crate) fn new(secret: String, ttl: Option<Duration>) -> Self {
193        Self {
194            secret,
195            // `checked_add` because the TTL comes from the agent: one answering
196            // with a nonsense `ExpirationTTL` would otherwise panic the process
197            // on the arithmetic. Too large to represent is treated as no
198            // expiry, which is what a number that large means anyway.
199            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
210// Debug is hand-written for every type on this page that can hold a secret:
211// a derive prints payloads, and the payloads here are ACL tokens. What IS
212// printed — variant names, method names, expiry — is what a person debugging
213// auth actually needs.
214impl 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            // The path is not a secret — the token *behind* it is, and it is
233            // never held here.
234            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/// The current token for one source.
249#[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    /// The token to present, logging in again if it is time.
262    ///
263    /// # Errors
264    ///
265    /// Whatever logging in reports.
266    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    /// Throws the current token away, so the next request logs in again.
283    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        // Consul with ACLs off is an ordinary thing to point this at, so the
339        // convenience has to work there.
340        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        // An agent answering with nonsense must not be able to panic the
348        // process on `Instant + Duration`.
349        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}