use dynamic_config::Error;
use dynamic_config_store_core::credential::{Cached, Issued};
pub const SERVICE_ACCOUNT_TOKEN: &str =
dynamic_config_store_core::credential::SERVICE_ACCOUNT_TOKEN;
#[derive(Clone)]
#[non_exhaustive]
pub enum Auth {
Token(String),
AppRole {
mount: String,
role_id: String,
secret_id: String,
},
Kubernetes {
mount: String,
role: String,
token_path: String,
},
Jwt {
mount: String,
role: Option<String>,
jwt: String,
},
Userpass {
mount: String,
username: String,
password: String,
},
Ldap {
mount: String,
username: String,
password: String,
},
Certificate {
mount: String,
name: Option<String>,
},
}
impl Auth {
pub fn token(token: impl Into<String>) -> Self {
Self::Token(token.into())
}
pub fn app_role(role_id: impl Into<String>, secret_id: impl Into<String>) -> Self {
Self::AppRole {
mount: "approle".to_owned(),
role_id: role_id.into(),
secret_id: secret_id.into(),
}
}
pub fn kubernetes(role: impl Into<String>) -> Self {
Self::Kubernetes {
mount: "kubernetes".to_owned(),
role: role.into(),
token_path: SERVICE_ACCOUNT_TOKEN.to_owned(),
}
}
pub fn jwt(jwt: impl Into<String>) -> Self {
Self::Jwt {
mount: "jwt".to_owned(),
role: None,
jwt: jwt.into(),
}
}
pub fn userpass(username: impl Into<String>, password: impl Into<String>) -> Self {
Self::Userpass {
mount: "userpass".to_owned(),
username: username.into(),
password: password.into(),
}
}
pub fn ldap(username: impl Into<String>, password: impl Into<String>) -> Self {
Self::Ldap {
mount: "ldap".to_owned(),
username: username.into(),
password: password.into(),
}
}
pub fn certificate() -> Self {
Self::Certificate {
mount: "cert".to_owned(),
name: None,
}
}
#[must_use]
pub fn at_mount(mut self, path: impl Into<String>) -> Self {
let path = path.into();
match &mut self {
Self::Token(_) => {}
Self::AppRole { mount, .. }
| Self::Kubernetes { mount, .. }
| Self::Jwt { mount, .. }
| Self::Userpass { mount, .. }
| Self::Ldap { mount, .. }
| Self::Certificate { mount, .. } => *mount = path,
}
self
}
#[must_use]
pub fn with_role(mut self, role: impl Into<String>) -> Self {
let named = role.into();
match &mut self {
Self::Kubernetes { role, .. } => *role = named,
Self::Jwt { role, .. } => *role = Some(named),
Self::Certificate { name, .. } => *name = Some(named),
_ => {}
}
self
}
#[must_use]
pub fn with_token_path(mut self, path: impl Into<String>) -> Self {
if let Self::Kubernetes { token_path, .. } = &mut self {
*token_path = path.into();
}
self
}
pub(crate) fn path(&self) -> Option<String> {
match self {
Self::Token(_) => None,
Self::AppRole { mount, .. } => Some(format!("auth/{mount}/login")),
Self::Kubernetes { mount, .. } => Some(format!("auth/{mount}/login")),
Self::Jwt { mount, .. } => Some(format!("auth/{mount}/login")),
Self::Certificate { mount, .. } => Some(format!("auth/{mount}/login")),
Self::Userpass {
mount, username, ..
} => Some(format!("auth/{mount}/login/{username}")),
Self::Ldap {
mount, username, ..
} => Some(format!("auth/{mount}/login/{username}")),
}
}
pub(crate) fn body(&self) -> Result<serde_json::Value, Error> {
Ok(match self {
Self::Token(_) => serde_json::json!({}),
Self::AppRole {
role_id, secret_id, ..
} => serde_json::json!({ "role_id": role_id, "secret_id": secret_id }),
Self::Kubernetes {
role, token_path, ..
} => {
let jwt = std::fs::read_to_string(token_path).map_err(|error| {
Error::remote(format!(
"vault: cannot read the service-account token at {token_path}: {error}"
))
})?;
serde_json::json!({ "role": role, "jwt": jwt.trim() })
}
Self::Jwt { role, jwt, .. } => match role {
Some(role) => serde_json::json!({ "role": role, "jwt": jwt }),
None => serde_json::json!({ "jwt": jwt }),
},
Self::Userpass { password, .. } | Self::Ldap { password, .. } => {
serde_json::json!({ "password": password })
}
Self::Certificate { name, .. } => match name {
Some(name) => serde_json::json!({ "name": name }),
None => serde_json::json!({}),
},
})
}
pub(crate) fn describe(&self) -> &'static str {
match self {
Self::Token(_) => "a supplied token",
Self::AppRole { .. } => "approle",
Self::Kubernetes { .. } => "kubernetes",
Self::Jwt { .. } => "jwt",
Self::Userpass { .. } => "userpass",
Self::Ldap { .. } => "ldap",
Self::Certificate { .. } => "cert",
}
}
}
impl std::fmt::Debug for Auth {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Token(_) => f.write_str("Token(***)"),
Self::AppRole { mount, role_id, .. } => f
.debug_struct("AppRole")
.field("mount", mount)
.field("role_id", role_id)
.finish_non_exhaustive(),
Self::Kubernetes {
mount,
role,
token_path,
} => f
.debug_struct("Kubernetes")
.field("mount", mount)
.field("role", role)
.field("token_path", token_path)
.finish(),
Self::Jwt { mount, role, .. } => f
.debug_struct("Jwt")
.field("mount", mount)
.field("role", role)
.finish_non_exhaustive(),
Self::Userpass {
mount, username, ..
} => f
.debug_struct("Userpass")
.field("mount", mount)
.field("username", username)
.finish_non_exhaustive(),
Self::Ldap {
mount, username, ..
} => f
.debug_struct("Ldap")
.field("mount", mount)
.field("username", username)
.finish_non_exhaustive(),
Self::Certificate { mount, name } => f
.debug_struct("Certificate")
.field("mount", mount)
.field("name", name)
.finish(),
}
}
}
#[derive(Clone)]
pub(crate) struct Token {
pub(crate) secret: String,
renewable: bool,
}
impl Token {
pub(crate) fn new(secret: String, renewable: bool) -> Self {
Self { secret, renewable }
}
}
impl std::fmt::Debug for Token {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Token")
.field("secret", &"***")
.field("renewable", &self.renewable)
.finish()
}
}
#[derive(Debug, Default)]
pub(crate) struct Session {
held: Cached<Token>,
}
impl Session {
pub(crate) const fn new() -> Self {
Self {
held: Cached::new(),
}
}
pub(crate) fn token(
&self,
login: impl Fn() -> Result<Issued<Token>, Error>,
renew: impl Fn(&str) -> Result<Issued<Token>, Error>,
) -> Result<String, Error> {
self.held
.get(|current| match current {
Some(token) if token.renewable => renew(&token.secret).or_else(|_| login()),
_ => login(),
})
.map(|token| token.secret)
}
pub(crate) fn invalidate(&self) {
self.held.invalidate();
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use dynamic_config_store_core::credential::REFRESH_WITHIN;
use super::*;
#[test]
fn each_method_posts_to_its_own_endpoint() {
assert_eq!(Auth::token("t").path(), None, "a token needs no login");
assert_eq!(
Auth::app_role("r", "s").path().as_deref(),
Some("auth/approle/login")
);
assert_eq!(
Auth::userpass("alice", "hunter2").path().as_deref(),
Some("auth/userpass/login/alice"),
"userpass puts the user in the path, not the body"
);
assert_eq!(
Auth::ldap("alice", "hunter2").path().as_deref(),
Some("auth/ldap/login/alice")
);
}
#[test]
fn a_method_can_be_mounted_anywhere() {
assert_eq!(
Auth::app_role("r", "s")
.at_mount("approle-prod")
.path()
.as_deref(),
Some("auth/approle-prod/login")
);
assert_eq!(
Auth::token("t").at_mount("nowhere").path(),
None,
"a token has no mount to move"
);
}
#[test]
fn credentials_go_where_the_method_expects_them() {
let body = Auth::app_role("role", "secret").body().unwrap();
assert_eq!(body["role_id"], "role");
assert_eq!(body["secret_id"], "secret");
let body = Auth::userpass("alice", "hunter2").body().unwrap();
assert_eq!(body["password"], "hunter2");
assert!(
body.get("username").is_none(),
"the username is in the path"
);
let body = Auth::jwt("a.b.c").body().unwrap();
assert_eq!(body["jwt"], "a.b.c");
assert!(
body.get("role").is_none(),
"no role unless one was asked for"
);
let body = Auth::jwt("a.b.c").with_role("readers").body().unwrap();
assert_eq!(body["role"], "readers");
}
#[test]
fn a_missing_service_account_token_says_where_it_looked() {
let error = Auth::kubernetes("app")
.with_token_path("/no/such/token")
.body()
.expect_err("there is no token there");
assert!(error.to_string().contains("/no/such/token"), "{error}");
}
fn issued(secret: &str, lease: Option<Duration>, renewable: bool) -> Issued<Token> {
Issued {
value: Token::new(secret.to_owned(), renewable),
ttl: lease,
}
}
#[test]
fn a_stale_renewable_token_is_renewed_rather_than_replaced() {
use std::sync::atomic::{AtomicUsize, Ordering};
let logins = AtomicUsize::new(0);
let session = Session::new();
let expiring = || {
logins.fetch_add(1, Ordering::SeqCst);
Ok(issued("first", Some(REFRESH_WITHIN / 2), true))
};
let renew = |secret: &str| {
assert_eq!(secret, "first", "renewal presents the token it is renewing");
Ok(issued("renewed", Some(Duration::from_secs(3600)), true))
};
assert_eq!(session.token(expiring, renew).unwrap(), "first");
assert_eq!(session.token(expiring, renew).unwrap(), "renewed");
assert_eq!(
logins.load(Ordering::SeqCst),
1,
"renewing must not cost a login"
);
}
#[test]
fn a_failed_renewal_falls_back_to_logging_in_again() {
let session = Session::new();
let login = || Ok(issued("fresh", Some(REFRESH_WITHIN / 2), true));
let refuse = |_: &str| Err(Error::remote("the lease is gone"));
assert_eq!(session.token(login, refuse).unwrap(), "fresh");
assert_eq!(
session.token(login, refuse).unwrap(),
"fresh",
"a renewal Vault refuses is not a reason to fail; the credentials are still here"
);
}
#[test]
fn a_stale_non_renewable_token_goes_straight_to_a_fresh_login() {
let session = Session::new();
let login = || Ok(issued("fresh", Some(REFRESH_WITHIN / 2), false));
let renew = |_: &str| panic!("a non-renewable token must not be renewed");
assert_eq!(session.token(login, renew).unwrap(), "fresh");
assert_eq!(session.token(login, renew).unwrap(), "fresh");
}
#[test]
fn a_login_that_fails_after_a_failed_renewal_keeps_the_token_it_had() {
let session = Session::new();
assert_eq!(
session
.token(
|| Ok(issued("first", Some(REFRESH_WITHIN / 2), true)),
|_: &str| panic!("nothing to renew yet"),
)
.unwrap(),
"first"
);
let error = session
.token(
|| Err(Error::auth("the role is gone")),
|_: &str| Err(Error::remote("the lease is gone")),
)
.expect_err("neither renewing nor logging in worked");
assert!(error.to_string().contains("the role is gone"), "{error}");
session
.token(
|| panic!("the token that is still held is renewed, not replaced"),
|secret: &str| {
assert_eq!(secret, "first");
Ok(issued("renewed", Some(Duration::from_secs(3600)), true))
},
)
.unwrap();
}
}