Skip to main content

dynamic_config_vault/
auth.rs

1//! Getting a token, and getting another one when it stops working.
2//!
3//! Every Vault auth method ends in the same place: a client token with a lease.
4//! What differs is the credentials handed over and the endpoint they go to, so
5//! that is all [`Auth`] models. The rest — when to log in, when to renew, what
6//! to do about a token that expired mid-request — is the same for all of them
7//! and is handled once, inside the crate.
8//!
9//! # Logging in is lazy
10//!
11//! Building a [`Vault`](crate::Vault) reaches nothing. The first read logs in,
12//! and every read after that reuses the token until it is close to expiry. This
13//! matches the rest of the crate: constructing a source is not I/O, and
14//! configuration that reaches the network on a call nobody expected to block is
15//! how a startup ends up mysteriously slow.
16//!
17//! # Expiry is handled twice, on purpose
18//!
19//! **Before the request**, a token within thirty seconds of its expiry is
20//! renewed — or, if it cannot be, replaced by a fresh login. This is the path
21//! that should normally fire.
22//!
23//! **After the request**, a `403` is treated as *the token stopped working* and
24//! triggers exactly one fresh login and retry. Clocks skew, Vault revokes, a
25//! lease is shorter than it said; the proactive path cannot catch all of that,
26//! and a configuration reader that gives up on the first `403` will eventually
27//! do so at three in the morning.
28//!
29//! Once, not in a loop: if a fresh token also gets `403`, the problem is the
30//! policy rather than the lease, and retrying would only turn a clear failure
31//! into a hang.
32//!
33//! *When* a token is close enough to expiry to replace is [`Cached`]'s
34//! decision, shared
35//! with the Consul and Firestore crates. *Whether to renew or log in again* is
36//! Vault's alone — it is the only one of the three that can extend a lease —
37//! and so it stays here, in this module's `Session`.
38
39use dynamic_config::Error;
40use dynamic_config_store_core::credential::{Cached, Issued};
41
42/// Where a Kubernetes service-account token is mounted, by convention.
43pub const SERVICE_ACCOUNT_TOKEN: &str =
44    dynamic_config_store_core::credential::SERVICE_ACCOUNT_TOKEN;
45
46/// How to obtain a Vault token.
47///
48/// The variants that take a `mount` take the auth method's mount path, which is
49/// its type by default — `approle` for AppRole, `kubernetes` for Kubernetes.
50/// Mounting the same method twice under different paths is ordinary Vault
51/// practice, which is why it is a parameter rather than a constant.
52#[derive(Clone)]
53#[non_exhaustive]
54pub enum Auth {
55    /// A token somebody already obtained.
56    ///
57    /// The simplest thing that works, and the only one that cannot recover on
58    /// its own: there are no credentials here to log in again with. A renewable
59    /// token is still renewed.
60    Token(String),
61
62    /// AppRole: a role id and a secret id.
63    ///
64    /// The usual choice for a service outside Kubernetes.
65    AppRole {
66        /// Mount path, `"approle"` by default.
67        mount: String,
68        /// The role's public half.
69        role_id: String,
70        /// The role's secret half.
71        secret_id: String,
72    },
73
74    /// Kubernetes: the pod's service-account token, plus a Vault role.
75    ///
76    /// The JWT is read from disk at every login rather than once, because the
77    /// kubelet rotates projected service-account tokens and a copy taken at
78    /// startup expires with the pod still running.
79    Kubernetes {
80        /// Mount path, `"kubernetes"` by default.
81        mount: String,
82        /// The Vault role to assume.
83        role: String,
84        /// Where the service-account token is mounted.
85        token_path: String,
86    },
87
88    /// A JWT or OIDC token, with an optional role.
89    Jwt {
90        /// Mount path, `"jwt"` by default.
91        mount: String,
92        /// The Vault role, when the mount does not have a default.
93        role: Option<String>,
94        /// The token to present.
95        jwt: String,
96    },
97
98    /// Username and password against the `userpass` method.
99    Userpass {
100        /// Mount path, `"userpass"` by default.
101        mount: String,
102        /// The user to log in as.
103        username: String,
104        /// Their password.
105        password: String,
106    },
107
108    /// Username and password against an LDAP directory.
109    Ldap {
110        /// Mount path, `"ldap"` by default.
111        mount: String,
112        /// The user to log in as.
113        username: String,
114        /// Their password.
115        password: String,
116    },
117
118    /// A TLS client certificate, with an optional role.
119    ///
120    /// The certificate itself is configured on the HTTP client, not here: build
121    /// a `ureq::Agent` that presents it and hand it over with
122    /// [`Vault::with_agent`](crate::Vault::with_agent). This variant only says
123    /// *log in with it*, which is the part Vault needs told.
124    Certificate {
125        /// Mount path, `"cert"` by default.
126        mount: String,
127        /// The certificate role, when the mount does not pick one by subject.
128        name: Option<String>,
129    },
130}
131
132impl Auth {
133    /// A token somebody already obtained.
134    pub fn token(token: impl Into<String>) -> Self {
135        Self::Token(token.into())
136    }
137
138    /// AppRole, on the default `approle` mount.
139    pub fn app_role(role_id: impl Into<String>, secret_id: impl Into<String>) -> Self {
140        Self::AppRole {
141            mount: "approle".to_owned(),
142            role_id: role_id.into(),
143            secret_id: secret_id.into(),
144        }
145    }
146
147    /// Kubernetes, on the default `kubernetes` mount, reading the pod's own
148    /// service-account token.
149    pub fn kubernetes(role: impl Into<String>) -> Self {
150        Self::Kubernetes {
151            mount: "kubernetes".to_owned(),
152            role: role.into(),
153            token_path: SERVICE_ACCOUNT_TOKEN.to_owned(),
154        }
155    }
156
157    /// A JWT or OIDC token, on the default `jwt` mount.
158    pub fn jwt(jwt: impl Into<String>) -> Self {
159        Self::Jwt {
160            mount: "jwt".to_owned(),
161            role: None,
162            jwt: jwt.into(),
163        }
164    }
165
166    /// Username and password, on the default `userpass` mount.
167    pub fn userpass(username: impl Into<String>, password: impl Into<String>) -> Self {
168        Self::Userpass {
169            mount: "userpass".to_owned(),
170            username: username.into(),
171            password: password.into(),
172        }
173    }
174
175    /// Username and password against LDAP, on the default `ldap` mount.
176    pub fn ldap(username: impl Into<String>, password: impl Into<String>) -> Self {
177        Self::Ldap {
178            mount: "ldap".to_owned(),
179            username: username.into(),
180            password: password.into(),
181        }
182    }
183
184    /// A TLS client certificate, on the default `cert` mount.
185    pub fn certificate() -> Self {
186        Self::Certificate {
187            mount: "cert".to_owned(),
188            name: None,
189        }
190    }
191
192    /// Puts this method on a different mount path.
193    ///
194    /// No effect on [`Auth::Token`], which has no mount.
195    #[must_use]
196    pub fn at_mount(mut self, path: impl Into<String>) -> Self {
197        let path = path.into();
198
199        match &mut self {
200            Self::Token(_) => {}
201            Self::AppRole { mount, .. }
202            | Self::Kubernetes { mount, .. }
203            | Self::Jwt { mount, .. }
204            | Self::Userpass { mount, .. }
205            | Self::Ldap { mount, .. }
206            | Self::Certificate { mount, .. } => *mount = path,
207        }
208
209        self
210    }
211
212    /// Names the role, for the methods that take one.
213    #[must_use]
214    pub fn with_role(mut self, role: impl Into<String>) -> Self {
215        let named = role.into();
216
217        match &mut self {
218            Self::Kubernetes { role, .. } => *role = named,
219            Self::Jwt { role, .. } => *role = Some(named),
220            Self::Certificate { name, .. } => *name = Some(named),
221            _ => {}
222        }
223
224        self
225    }
226
227    /// Reads the service-account token from somewhere other than the
228    /// conventional path.
229    #[must_use]
230    pub fn with_token_path(mut self, path: impl Into<String>) -> Self {
231        if let Self::Kubernetes { token_path, .. } = &mut self {
232            *token_path = path.into();
233        }
234
235        self
236    }
237
238    /// The login endpoint, relative to `/v1`.
239    pub(crate) fn path(&self) -> Option<String> {
240        match self {
241            Self::Token(_) => None,
242            Self::AppRole { mount, .. } => Some(format!("auth/{mount}/login")),
243            Self::Kubernetes { mount, .. } => Some(format!("auth/{mount}/login")),
244            Self::Jwt { mount, .. } => Some(format!("auth/{mount}/login")),
245            Self::Certificate { mount, .. } => Some(format!("auth/{mount}/login")),
246            // These two put the user in the path rather than the body, which is
247            // why they cannot share an arm with the others.
248            Self::Userpass {
249                mount, username, ..
250            } => Some(format!("auth/{mount}/login/{username}")),
251            Self::Ldap {
252                mount, username, ..
253            } => Some(format!("auth/{mount}/login/{username}")),
254        }
255    }
256
257    /// The credentials to POST.
258    ///
259    /// # Errors
260    ///
261    /// If a Kubernetes service-account token cannot be read.
262    pub(crate) fn body(&self) -> Result<serde_json::Value, Error> {
263        Ok(match self {
264            Self::Token(_) => serde_json::json!({}),
265
266            Self::AppRole {
267                role_id, secret_id, ..
268            } => serde_json::json!({ "role_id": role_id, "secret_id": secret_id }),
269
270            Self::Kubernetes {
271                role, token_path, ..
272            } => {
273                // Read per login, not once: the kubelet rotates projected
274                // tokens, and a copy taken at startup expires with the pod
275                // still running.
276                let jwt = std::fs::read_to_string(token_path).map_err(|error| {
277                    Error::remote(format!(
278                        "vault: cannot read the service-account token at {token_path}: {error}"
279                    ))
280                })?;
281
282                serde_json::json!({ "role": role, "jwt": jwt.trim() })
283            }
284
285            Self::Jwt { role, jwt, .. } => match role {
286                Some(role) => serde_json::json!({ "role": role, "jwt": jwt }),
287                None => serde_json::json!({ "jwt": jwt }),
288            },
289
290            Self::Userpass { password, .. } | Self::Ldap { password, .. } => {
291                serde_json::json!({ "password": password })
292            }
293
294            Self::Certificate { name, .. } => match name {
295                Some(name) => serde_json::json!({ "name": name }),
296                None => serde_json::json!({}),
297            },
298        })
299    }
300
301    /// How to name this method in an error.
302    pub(crate) fn describe(&self) -> &'static str {
303        match self {
304            Self::Token(_) => "a supplied token",
305            Self::AppRole { .. } => "approle",
306            Self::Kubernetes { .. } => "kubernetes",
307            Self::Jwt { .. } => "jwt",
308            Self::Userpass { .. } => "userpass",
309            Self::Ldap { .. } => "ldap",
310            Self::Certificate { .. } => "cert",
311        }
312    }
313}
314
315// Debug is hand-written for every type on this page that can hold a secret:
316// a derive prints payloads, and the payloads here are Vault tokens, AppRole
317// secret ids, passwords and JWTs. What IS printed — variant, mount, role,
318// username — is what a person debugging auth actually needs.
319impl std::fmt::Debug for Auth {
320    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
321        match self {
322            Self::Token(_) => f.write_str("Token(***)"),
323            Self::AppRole { mount, role_id, .. } => f
324                .debug_struct("AppRole")
325                .field("mount", mount)
326                .field("role_id", role_id)
327                .finish_non_exhaustive(),
328            Self::Kubernetes {
329                mount,
330                role,
331                token_path,
332            } => f
333                .debug_struct("Kubernetes")
334                .field("mount", mount)
335                .field("role", role)
336                .field("token_path", token_path)
337                .finish(),
338            Self::Jwt { mount, role, .. } => f
339                .debug_struct("Jwt")
340                .field("mount", mount)
341                .field("role", role)
342                .finish_non_exhaustive(),
343            Self::Userpass {
344                mount, username, ..
345            } => f
346                .debug_struct("Userpass")
347                .field("mount", mount)
348                .field("username", username)
349                .finish_non_exhaustive(),
350            Self::Ldap {
351                mount, username, ..
352            } => f
353                .debug_struct("Ldap")
354                .field("mount", mount)
355                .field("username", username)
356                .finish_non_exhaustive(),
357            Self::Certificate { mount, name } => f
358                .debug_struct("Certificate")
359                .field("mount", mount)
360                .field("name", name)
361                .finish(),
362        }
363    }
364}
365
366/// A token and whether it can be renewed.
367///
368/// When it expires is not here: that is the one thing every token-caching
369/// store in this family says the same way, so [`Cached`] keeps it.
370#[derive(Clone)]
371pub(crate) struct Token {
372    pub(crate) secret: String,
373    renewable: bool,
374}
375
376impl Token {
377    pub(crate) fn new(secret: String, renewable: bool) -> Self {
378        Self { secret, renewable }
379    }
380}
381
382impl std::fmt::Debug for Token {
383    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
384        f.debug_struct("Token")
385            .field("secret", &"***")
386            .field("renewable", &self.renewable)
387            .finish()
388    }
389}
390
391/// The current token for one source, and Vault's rule for replacing it.
392///
393/// The rule is Vault's alone: Consul issues login tokens and expects another
394/// login, and Firestore's metadata server cannot extend anything, so those two
395/// hand [`Cached`] a closure that simply obtains. This one first tries to
396/// extend the lease it already has.
397#[derive(Debug, Default)]
398pub(crate) struct Session {
399    held: Cached<Token>,
400}
401
402impl Session {
403    pub(crate) const fn new() -> Self {
404        Self {
405            held: Cached::new(),
406        }
407    }
408
409    /// The token to use, logging in or renewing if it is time.
410    ///
411    /// `login` and `renew` are closures rather than methods so this module
412    /// stays free of HTTP: what it decides is *whether*, not *how*.
413    ///
414    /// # Errors
415    ///
416    /// Whatever logging in reports. A failed *renewal* is not an error: the
417    /// credentials are still here, so falling through to a fresh login is
418    /// strictly better than reporting something the caller can do nothing
419    /// about.
420    pub(crate) fn token(
421        &self,
422        login: impl Fn() -> Result<Issued<Token>, Error>,
423        renew: impl Fn(&str) -> Result<Issued<Token>, Error>,
424    ) -> Result<String, Error> {
425        self.held
426            .get(|current| match current {
427                Some(token) if token.renewable => renew(&token.secret).or_else(|_| login()),
428                // Nothing held, nothing renewable, or a token thrown away by
429                // `invalidate` — all of them mean a fresh login.
430                _ => login(),
431            })
432            .map(|token| token.secret)
433    }
434
435    /// Throws the current token away, so the next request logs in again.
436    pub(crate) fn invalidate(&self) {
437        self.held.invalidate();
438    }
439}
440
441#[cfg(test)]
442mod tests {
443    use std::time::Duration;
444
445    use dynamic_config_store_core::credential::REFRESH_WITHIN;
446
447    use super::*;
448
449    #[test]
450    fn each_method_posts_to_its_own_endpoint() {
451        assert_eq!(Auth::token("t").path(), None, "a token needs no login");
452        assert_eq!(
453            Auth::app_role("r", "s").path().as_deref(),
454            Some("auth/approle/login")
455        );
456        assert_eq!(
457            Auth::userpass("alice", "hunter2").path().as_deref(),
458            Some("auth/userpass/login/alice"),
459            "userpass puts the user in the path, not the body"
460        );
461        assert_eq!(
462            Auth::ldap("alice", "hunter2").path().as_deref(),
463            Some("auth/ldap/login/alice")
464        );
465    }
466
467    #[test]
468    fn a_method_can_be_mounted_anywhere() {
469        assert_eq!(
470            Auth::app_role("r", "s")
471                .at_mount("approle-prod")
472                .path()
473                .as_deref(),
474            Some("auth/approle-prod/login")
475        );
476        assert_eq!(
477            Auth::token("t").at_mount("nowhere").path(),
478            None,
479            "a token has no mount to move"
480        );
481    }
482
483    #[test]
484    fn credentials_go_where_the_method_expects_them() {
485        let body = Auth::app_role("role", "secret").body().unwrap();
486        assert_eq!(body["role_id"], "role");
487        assert_eq!(body["secret_id"], "secret");
488
489        let body = Auth::userpass("alice", "hunter2").body().unwrap();
490        assert_eq!(body["password"], "hunter2");
491        assert!(
492            body.get("username").is_none(),
493            "the username is in the path"
494        );
495
496        let body = Auth::jwt("a.b.c").body().unwrap();
497        assert_eq!(body["jwt"], "a.b.c");
498        assert!(
499            body.get("role").is_none(),
500            "no role unless one was asked for"
501        );
502
503        let body = Auth::jwt("a.b.c").with_role("readers").body().unwrap();
504        assert_eq!(body["role"], "readers");
505    }
506
507    #[test]
508    fn a_missing_service_account_token_says_where_it_looked() {
509        let error = Auth::kubernetes("app")
510            .with_token_path("/no/such/token")
511            .body()
512            .expect_err("there is no token there");
513
514        assert!(error.to_string().contains("/no/such/token"), "{error}");
515    }
516
517    /// A token issued with `lease`, the way `token_from` builds one.
518    fn issued(secret: &str, lease: Option<Duration>, renewable: bool) -> Issued<Token> {
519        Issued {
520            value: Token::new(secret.to_owned(), renewable),
521            ttl: lease,
522        }
523    }
524
525    // When a token is stale, what a lease too large to represent means, that
526    // a login happens once rather than per request, and that `invalidate`
527    // forces another are `dynamic-config-store-core`'s tests now: they were
528    // the same assertions here, in the Consul crate and in the Firestore
529    // crate, over the same code. What stays is what Vault does and the other
530    // two cannot — renew.
531
532    #[test]
533    fn a_stale_renewable_token_is_renewed_rather_than_replaced() {
534        use std::sync::atomic::{AtomicUsize, Ordering};
535
536        let logins = AtomicUsize::new(0);
537        let session = Session::new();
538
539        let expiring = || {
540            logins.fetch_add(1, Ordering::SeqCst);
541
542            Ok(issued("first", Some(REFRESH_WITHIN / 2), true))
543        };
544        let renew = |secret: &str| {
545            assert_eq!(secret, "first", "renewal presents the token it is renewing");
546
547            Ok(issued("renewed", Some(Duration::from_secs(3600)), true))
548        };
549
550        assert_eq!(session.token(expiring, renew).unwrap(), "first");
551        assert_eq!(session.token(expiring, renew).unwrap(), "renewed");
552        assert_eq!(
553            logins.load(Ordering::SeqCst),
554            1,
555            "renewing must not cost a login"
556        );
557    }
558
559    #[test]
560    fn a_failed_renewal_falls_back_to_logging_in_again() {
561        let session = Session::new();
562
563        let login = || Ok(issued("fresh", Some(REFRESH_WITHIN / 2), true));
564        let refuse = |_: &str| Err(Error::remote("the lease is gone"));
565
566        assert_eq!(session.token(login, refuse).unwrap(), "fresh");
567        assert_eq!(
568            session.token(login, refuse).unwrap(),
569            "fresh",
570            "a renewal Vault refuses is not a reason to fail; the credentials are still here"
571        );
572    }
573
574    #[test]
575    fn a_stale_non_renewable_token_goes_straight_to_a_fresh_login() {
576        let session = Session::new();
577
578        let login = || Ok(issued("fresh", Some(REFRESH_WITHIN / 2), false));
579        let renew = |_: &str| panic!("a non-renewable token must not be renewed");
580
581        assert_eq!(session.token(login, renew).unwrap(), "fresh");
582        assert_eq!(session.token(login, renew).unwrap(), "fresh");
583    }
584
585    /// A renewal that fails must not be able to lose the token: the fresh
586    /// login that follows is what the caller ends up presenting, and if
587    /// *that* fails too the previous token has to still be there.
588    #[test]
589    fn a_login_that_fails_after_a_failed_renewal_keeps_the_token_it_had() {
590        let session = Session::new();
591
592        assert_eq!(
593            session
594                .token(
595                    || Ok(issued("first", Some(REFRESH_WITHIN / 2), true)),
596                    |_: &str| panic!("nothing to renew yet"),
597                )
598                .unwrap(),
599            "first"
600        );
601
602        let error = session
603            .token(
604                || Err(Error::auth("the role is gone")),
605                |_: &str| Err(Error::remote("the lease is gone")),
606            )
607            .expect_err("neither renewing nor logging in worked");
608
609        assert!(error.to_string().contains("the role is gone"), "{error}");
610
611        session
612            .token(
613                || panic!("the token that is still held is renewed, not replaced"),
614                |secret: &str| {
615                    assert_eq!(secret, "first");
616
617                    Ok(issued("renewed", Some(Duration::from_secs(3600)), true))
618                },
619            )
620            .unwrap();
621    }
622}