Skip to main content

rustlavel_vault/
auth.rs

1//! Proving who you are to Vault.
2//!
3//! Every secret in this package is behind a token, and a token has to come from
4//! somewhere. The methods here are the four an application actually uses: a
5//! token handed in by an operator, an AppRole for a service, a Kubernetes
6//! service account for a pod, and a username and password for a person.
7//!
8//! What they have in common is that the credential is worth more than anything
9//! it unlocks, so none of these types print theirs. What they do not have in
10//! common is the shape of the exchange — a token is already a token, and the
11//! rest have to be traded for one — which is why [`Login`] describes the
12//! request rather than performing it.
13
14use crate::client::VaultClient;
15use crate::error::VaultError;
16use rustlavel_core::Result;
17use crate::lease::Lease;
18use rustlavel_core::Json;
19use std::time::Duration;
20
21/// The path a service account's token is mounted at inside a Kubernetes pod.
22pub const KUBERNETES_TOKEN_PATH: &str = "/var/run/secrets/kubernetes.io/serviceaccount/token";
23
24/// How a login method asks for a token.
25///
26/// Two shapes, because Vault has two: most methods trade a credential at an
27/// endpoint, and one — a token you were simply given — is already the answer
28/// and only needs its lease looked up.
29pub enum LoginRequest {
30    /// `POST path` with `body`; the token comes back as `auth.client_token`.
31    Endpoint { path: String, body: Json },
32    /// The token is in hand. Its TTL and renewability still have to be read
33    /// from the server, because nothing about the string says either.
34    Existing { token: String },
35}
36
37/// A way of obtaining a Vault token.
38///
39/// Deliberately synchronous and object-safe: the fallible part of a login is
40/// building the request — reading a service-account file that may not be
41/// mounted — and doing that here means the failure names the file rather than
42/// arriving later as a bare 400 from Vault.
43pub trait Login {
44    fn request(&self) -> Result<LoginRequest>;
45}
46
47impl VaultClient {
48    /// Log in, keep the token, and return the lease it came with.
49    ///
50    /// The lease is the point: a service that logs in once at boot and never
51    /// looks at the lease again will stop working somewhere between an hour and
52    /// a month later, at a time nobody chose.
53    pub async fn login(&self, method: &dyn Login) -> Result<Lease> {
54        match method.request()? {
55            LoginRequest::Endpoint { path, body } => {
56                let response = self.post(&path, body).await?;
57                let token = response.auth.get("client_token").and_then(Json::as_str).map(str::to_string).ok_or_else(|| {
58                    VaultError::Malformed {
59                        path: path.clone(),
60                        message: "the login succeeded but carried no auth.client_token".into(),
61                    }
62                })?;
63                self.set_token(token);
64                Ok(response.lease)
65            }
66            LoginRequest::Existing { token } => {
67                self.set_token(token);
68                self.lookup_self().await
69            }
70        }
71    }
72
73    /// What the current token's lease is, according to the server.
74    ///
75    /// Its TTL lives in `data.ttl` rather than the `lease_duration` every other
76    /// response uses, so this cannot go through `VaultResponse::lease`.
77    pub async fn lookup_self(&self) -> Result<Lease> {
78        let response = self.get("auth/token/lookup-self").await?;
79        let data = &response.data;
80
81        let seconds = data.get("ttl").and_then(Json::as_i64).unwrap_or(0).max(0);
82        let renewable = data.get("renewable").and_then(Json::as_bool).unwrap_or(false);
83
84        Ok(Lease::new(String::new(), Duration::from_secs(seconds as u64), renewable))
85    }
86}
87
88/// A token handed to the process, by an operator or by an orchestrator.
89///
90/// Still worth a login rather than a bare `set_token`: it costs one round trip
91/// and tells the process whether the token expires in ten minutes or never,
92/// which is the difference between a renewal loop and an outage.
93pub struct Token {
94    token: String,
95}
96
97impl Token {
98    pub fn new(token: impl Into<String>) -> Token {
99        Token { token: token.into() }
100    }
101}
102
103impl Login for Token {
104    fn request(&self) -> Result<LoginRequest> {
105        Ok(LoginRequest::Existing { token: self.token.clone() })
106    }
107}
108
109impl std::fmt::Debug for Token {
110    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111        f.debug_struct("Token").field("token", &"<redacted>").finish()
112    }
113}
114
115/// The usual way a service authenticates: a role id and a secret id.
116///
117/// The two halves are meant to travel separately — the role id baked into the
118/// deployment, the secret id delivered at start-up and often single-use — so
119/// that neither on its own is enough.
120pub struct AppRole {
121    mount: String,
122    role_id: String,
123    secret_id: String,
124}
125
126impl AppRole {
127    pub fn new(role_id: impl Into<String>, secret_id: impl Into<String>) -> AppRole {
128        AppRole {
129            mount: "auth/approle".into(),
130            role_id: role_id.into(),
131            secret_id: secret_id.into(),
132        }
133    }
134
135    /// For a method enabled somewhere other than `auth/approle`.
136    pub fn mount(mut self, mount: impl Into<String>) -> AppRole {
137        self.mount = normalise(mount);
138        self
139    }
140}
141
142impl Login for AppRole {
143    fn request(&self) -> Result<LoginRequest> {
144        Ok(LoginRequest::Endpoint {
145            path: format!("{}/login", self.mount),
146            body: Json::object([
147                ("role_id", Json::from(self.role_id.as_str())),
148                ("secret_id", Json::from(self.secret_id.as_str())),
149            ]),
150        })
151    }
152}
153
154/// The role id is an identifier, not a credential, and seeing it is how you
155/// work out which role a pod is using. The secret id is the credential.
156impl std::fmt::Debug for AppRole {
157    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
158        f.debug_struct("AppRole")
159            .field("mount", &self.mount)
160            .field("role_id", &self.role_id)
161            .field("secret_id", &"<redacted>")
162            .finish()
163    }
164}
165
166/// A pod authenticating as its own service account.
167///
168/// The JWT is read at login rather than at construction: the kubelet rotates
169/// the projected token on disk, and a copy taken at start-up is stale by the
170/// time a long-lived process needs to log in again.
171pub struct Kubernetes {
172    mount: String,
173    role: String,
174    token_path: String,
175}
176
177impl Kubernetes {
178    pub fn new(role: impl Into<String>) -> Kubernetes {
179        Kubernetes {
180            mount: "auth/kubernetes".into(),
181            role: role.into(),
182            token_path: KUBERNETES_TOKEN_PATH.into(),
183        }
184    }
185
186    pub fn mount(mut self, mount: impl Into<String>) -> Kubernetes {
187        self.mount = normalise(mount);
188        self
189    }
190
191    /// Where to read the service-account JWT.
192    ///
193    /// Overridable so a test does not need a cluster, and because a projected
194    /// volume with an audience can be mounted anywhere.
195    pub fn token_path(mut self, path: impl Into<String>) -> Kubernetes {
196        self.token_path = path.into();
197        self
198    }
199}
200
201impl Login for Kubernetes {
202    fn request(&self) -> Result<LoginRequest> {
203        let jwt = std::fs::read_to_string(&self.token_path).map_err(|error| {
204            VaultError::Malformed {
205                path: self.token_path.clone(),
206                message: format!(
207                    "cannot read the service-account token: {error}. \
208                     Outside a pod, point `token_path` at a JWT."
209                ),
210            }
211        })?;
212
213        Ok(LoginRequest::Endpoint {
214            path: format!("{}/login", self.mount),
215            body: Json::object([
216                ("role", Json::from(self.role.as_str())),
217                ("jwt", Json::from(jwt.trim())),
218            ]),
219        })
220    }
221}
222
223/// The JWT is never held on this type, so there is nothing here to leak.
224impl std::fmt::Debug for Kubernetes {
225    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
226        f.debug_struct("Kubernetes")
227            .field("mount", &self.mount)
228            .field("role", &self.role)
229            .field("token_path", &self.token_path)
230            .finish()
231    }
232}
233
234/// A person's username and password.
235///
236/// Rarely right for a service — a password does not rotate itself and cannot be
237/// scoped to one deployment — but it is what a developer has on a laptop.
238pub struct UserPass {
239    mount: String,
240    username: String,
241    password: String,
242}
243
244impl UserPass {
245    pub fn new(username: impl Into<String>, password: impl Into<String>) -> UserPass {
246        UserPass {
247            mount: "auth/userpass".into(),
248            username: username.into(),
249            password: password.into(),
250        }
251    }
252
253    pub fn mount(mut self, mount: impl Into<String>) -> UserPass {
254        self.mount = normalise(mount);
255        self
256    }
257}
258
259impl Login for UserPass {
260    fn request(&self) -> Result<LoginRequest> {
261        Ok(LoginRequest::Endpoint {
262            // The username is a path segment here, not a field — the one
263            // endpoint in this file that is not addressed by `…/login` alone.
264            path: format!("{}/login/{}", self.mount, self.username),
265            body: Json::object([("password", Json::from(self.password.as_str()))]),
266        })
267    }
268}
269
270impl std::fmt::Debug for UserPass {
271    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
272        f.debug_struct("UserPass")
273            .field("mount", &self.mount)
274            .field("username", &self.username)
275            .field("password", &"<redacted>")
276            .finish()
277    }
278}
279
280/// Accept `approle`, `auth/approle` and `auth/approle/` as the same mount.
281///
282/// Vault's own CLI takes the bare name and the UI shows the trailing slash, so
283/// a caller copying either would otherwise post to `auth/auth/approle/login`
284/// and get a 404 that says nothing about why.
285fn normalise(mount: impl Into<String>) -> String {
286    let mount = mount.into();
287    let trimmed = mount.trim_matches('/');
288    if trimmed.starts_with("auth/") { trimmed.to_string() } else { format!("auth/{trimmed}") }
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294    use rustlavel_client::{Fake, FakeResponse};
295
296    /// The body OpenBao 2.6.2 returns from `auth/approle/login`.
297    fn login_response() -> FakeResponse {
298        FakeResponse::json(
299            Json::parse(
300                r#"{"lease_id":"","renewable":false,"lease_duration":0,"data":null,
301                    "auth":{"client_token":"s.K550TB79WPxslNsUjh51Y6z3",
302                            "accessor":"ZaY7ukg1a53506PweIujWibJ",
303                            "policies":["default"],"lease_duration":3600,
304                            "renewable":true,"token_type":"service"}}"#,
305            )
306            .unwrap(),
307        )
308    }
309
310    fn vault(fake: Fake) -> VaultClient {
311        VaultClient::new("https://vault.test:8200").faking(fake)
312    }
313
314    #[tokio::test]
315    async fn an_approle_login_sets_the_token_and_returns_its_lease() {
316        let client = vault(Fake::new().on("auth/approle/login", login_response()));
317
318        let lease = client.login(&AppRole::new("role-uuid", "secret-uuid")).await.unwrap();
319
320        assert_eq!(client.token().as_str(), ("s.K550TB79WPxslNsUjh51Y6z3"));
321        assert_eq!(lease.duration(), Duration::from_secs(3600));
322        assert!(lease.renewable());
323
324        let fake = client.fake().unwrap();
325        fake.assert_sent("vault.test:8200/v1/auth/approle/login");
326        let sent = fake.recorded()[0].json().unwrap();
327        assert_eq!(sent.get("role_id").unwrap().as_str(), Some("role-uuid"));
328        assert_eq!(sent.get("secret_id").unwrap().as_str(), Some("secret-uuid"));
329    }
330
331    #[tokio::test]
332    async fn an_approle_can_be_mounted_anywhere() {
333        let client = vault(Fake::new().on("login", login_response()));
334
335        // All three spellings an operator might copy out of the CLI or the UI.
336        for mount in ["approle-prod", "auth/approle-prod", "/auth/approle-prod/"] {
337            client.login(&AppRole::new("r", "s").mount(mount)).await.unwrap();
338        }
339
340        for request in client.fake().unwrap().recorded() {
341            assert!(
342                request.url.ends_with("/v1/auth/approle-prod/login"),
343                "mount was not normalised: {}",
344                request.url
345            );
346        }
347    }
348
349    #[tokio::test]
350    async fn a_userpass_login_puts_the_username_in_the_path() {
351        let client = vault(Fake::new().fallback(login_response()));
352
353        client.login(&UserPass::new("ada", "hunter2")).await.unwrap();
354
355        let sent = &client.fake().unwrap().recorded()[0];
356        assert!(sent.url.ends_with("/v1/auth/userpass/login/ada"), "{}", sent.url);
357        assert_eq!(sent.json().unwrap().get("password").unwrap().as_str(), Some("hunter2"));
358        // The username is a path segment, so it must not also be a field.
359        assert!(sent.json().unwrap().get("username").is_none());
360    }
361
362    #[tokio::test]
363    async fn a_kubernetes_login_sends_the_service_account_jwt() {
364        let path = std::env::temp_dir().join(format!("rustlavel-vault-k8s-jwt-sends-{}", std::process::id()));
365        std::fs::write(&path, "eyJhbGciOi.pod.jwt\n").unwrap();
366
367        let client = vault(Fake::new().fallback(login_response()));
368        client
369            .login(
370                &Kubernetes::new("myapp").token_path(path.to_string_lossy().into_owned()),
371            )
372            .await
373            .unwrap();
374
375        let sent = &client.fake().unwrap().recorded()[0];
376        assert!(sent.url.ends_with("/v1/auth/kubernetes/login"), "{}", sent.url);
377        let body = sent.json().unwrap();
378        assert_eq!(body.get("role").unwrap().as_str(), Some("myapp"));
379        // The trailing newline the kubelet writes would be sent verbatim and
380        // rejected by the API server as an invalid token.
381        assert_eq!(body.get("jwt").unwrap().as_str(), Some("eyJhbGciOi.pod.jwt"));
382
383        std::fs::remove_file(&path).unwrap();
384    }
385
386    #[test]
387    fn a_missing_service_account_file_says_what_to_do_about_it() {
388        // `LoginRequest` holds the credential, so it has no `Debug` and cannot
389        // be `unwrap_err`ed — which is the point.
390        let request = Kubernetes::new("myapp")
391            .token_path("/nowhere/rustlavel-vault/serviceaccount/token")
392            .request();
393        let Err(error) = request else { panic!("reading a missing JWT should fail") };
394        let error = error.to_string();
395
396        assert!(error.contains("service-account token"), "{error}");
397        assert!(error.contains("token_path"), "{error}");
398    }
399
400    #[tokio::test]
401    async fn a_token_login_still_asks_the_server_how_long_it_has() {
402        // The shape `auth/token/lookup-self` returns: the TTL is in `data.ttl`,
403        // not the `lease_duration` every other endpoint uses, and reading the
404        // wrong one hands back a lease that claims to be eternal.
405        let lookup = FakeResponse::json(
406            Json::parse(
407                r#"{"lease_id":"","renewable":false,"lease_duration":0,
408                    "data":{"id":"s.given","ttl":3600,"renewable":true,
409                            "policies":["default"],"creation_ttl":3600},
410                    "auth":null}"#,
411            )
412            .unwrap(),
413        );
414        let client = vault(Fake::new().on("auth/token/lookup-self", lookup));
415
416        let lease = client.login(&Token::new("s.given")).await.unwrap();
417
418        assert_eq!(client.token().as_str(), ("s.given"));
419        assert_eq!(lease.duration(), Duration::from_secs(3600));
420        assert!(lease.renewable());
421        assert!(!lease.never_expires());
422        // The lookup has to be authenticated by the token it is asking about.
423        let sent = &client.fake().unwrap().recorded()[0];
424        assert_eq!(sent.headers.get("x-vault-token"), Some("s.given"));
425    }
426
427    #[tokio::test]
428    async fn a_root_token_reads_back_as_one_that_never_expires() {
429        // OpenBao reports a root token as ttl 0 and renewable false; a renewal
430        // loop that cannot tell that from an expired lease will spin.
431        let lookup = FakeResponse::json(
432            Json::parse(
433                r#"{"data":{"id":"root-token","ttl":0,"renewable":false,"policies":["root"]}}"#,
434            )
435            .unwrap(),
436        );
437        let client = vault(Fake::new().fallback(lookup));
438
439        let lease = client.login(&Token::new("root-token")).await.unwrap();
440
441        assert!(lease.never_expires());
442        assert!(!lease.should_renew());
443        assert!(!lease.is_expired());
444    }
445
446    #[tokio::test]
447    async fn a_rejected_login_keeps_vaults_own_explanation() {
448        let refused =
449            FakeResponse::text(r#"{"errors":["invalid role or secret ID"]}"#).status(400);
450        let client = vault(Fake::new().fallback(refused));
451
452        let error = client.login(&AppRole::new("r", "wrong")).await.unwrap_err();
453
454        assert!(error.to_string().contains("invalid role or secret ID"), "{error}");
455        // A failed login must not leave a token behind.
456        assert!(!client.has_token());
457    }
458
459    #[tokio::test]
460    async fn a_login_that_returns_no_token_is_a_failure_not_a_silent_success() {
461        let client = vault(Fake::new().fallback(FakeResponse::json(Json::object([
462            ("auth", Json::Null),
463        ]))));
464
465        let error = client.login(&AppRole::new("r", "s")).await.unwrap_err().to_string();
466
467        assert!(error.contains("auth.client_token"), "{error}");
468    }
469
470    #[test]
471    fn no_login_method_prints_its_secret() {
472        // These are exactly the values a developer prints while debugging a
473        // failing login, and a secret id in a scrollback has to be revoked.
474        assert!(!format!("{:?}", Token::new("s.tok")).contains("s.tok"));
475        assert!(!format!("{:?}", AppRole::new("role", "sid")).contains("sid"));
476        assert!(!format!("{:?}", UserPass::new("ada", "hunter2")).contains("hunter2"));
477
478        // The role id is an identifier, not a credential, and is how you tell
479        // which role a pod is using.
480        assert!(format!("{:?}", AppRole::new("role-uuid", "sid")).contains("role-uuid"));
481        assert!(format!("{:?}", UserPass::new("ada", "p")).contains("ada"));
482    }
483}