1use dynamic_config::Error;
40use dynamic_config_store_core::credential::{Cached, Issued};
41
42pub const SERVICE_ACCOUNT_TOKEN: &str =
44 dynamic_config_store_core::credential::SERVICE_ACCOUNT_TOKEN;
45
46#[derive(Clone)]
53#[non_exhaustive]
54pub enum Auth {
55 Token(String),
61
62 AppRole {
66 mount: String,
68 role_id: String,
70 secret_id: String,
72 },
73
74 Kubernetes {
80 mount: String,
82 role: String,
84 token_path: String,
86 },
87
88 Jwt {
90 mount: String,
92 role: Option<String>,
94 jwt: String,
96 },
97
98 Userpass {
100 mount: String,
102 username: String,
104 password: String,
106 },
107
108 Ldap {
110 mount: String,
112 username: String,
114 password: String,
116 },
117
118 Certificate {
125 mount: String,
127 name: Option<String>,
129 },
130}
131
132impl Auth {
133 pub fn token(token: impl Into<String>) -> Self {
135 Self::Token(token.into())
136 }
137
138 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 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 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 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 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 pub fn certificate() -> Self {
186 Self::Certificate {
187 mount: "cert".to_owned(),
188 name: None,
189 }
190 }
191
192 #[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 #[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 #[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 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 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 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 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 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
315impl 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#[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#[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 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 _ => login(),
431 })
432 .map(|token| token.secret)
433 }
434
435 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 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 #[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 #[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}