1use crate::client::VaultClient;
15use crate::error::VaultError;
16use rustlavel_core::Result;
17use crate::lease::Lease;
18use rustlavel_core::Json;
19use std::time::Duration;
20
21pub const KUBERNETES_TOKEN_PATH: &str = "/var/run/secrets/kubernetes.io/serviceaccount/token";
23
24pub enum LoginRequest {
30 Endpoint { path: String, body: Json },
32 Existing { token: String },
35}
36
37pub trait Login {
44 fn request(&self) -> Result<LoginRequest>;
45}
46
47impl VaultClient {
48 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 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
88pub 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
115pub 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 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
154impl 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
166pub 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 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
223impl 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
234pub 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 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
280fn 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 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 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 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 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 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 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 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 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 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 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 assert!(format!("{:?}", AppRole::new("role-uuid", "sid")).contains("role-uuid"));
481 assert!(format!("{:?}", UserPass::new("ada", "p")).contains("ada"));
482 }
483}