use super::error::VaultError;
#[derive(Debug, Clone)]
pub enum VaultAuth {
Token(String),
AppRole { role_id: String, secret_id: String },
}
impl VaultAuth {
pub fn from_env() -> Result<Self, VaultError> {
let role_id = std::env::var("VAULT_ROLE_ID").ok();
let secret_id = std::env::var("VAULT_SECRET_ID").ok();
match (role_id, secret_id) {
(Some(rid), Some(sid)) if !rid.is_empty() && !sid.is_empty() => {
Ok(VaultAuth::AppRole {
role_id: rid,
secret_id: sid,
})
}
_ => {
let token = std::env::var("VAULT_TOKEN").map_err(|_| {
VaultError::Config(
"no Vault credentials found — set VAULT_TOKEN for token auth, \
or VAULT_ROLE_ID + VAULT_SECRET_ID for AppRole auth"
.into(),
)
})?;
if token.is_empty() {
return Err(VaultError::Config("VAULT_TOKEN is set but empty".into()));
}
Ok(VaultAuth::Token(token))
}
}
}
pub fn acquire_token(
&self,
vault_url: &str,
namespace: Option<&str>,
agent: &ureq::Agent,
) -> Result<String, VaultError> {
match self {
VaultAuth::Token(t) => Ok(t.clone()),
VaultAuth::AppRole { role_id, secret_id } => {
approle_login(vault_url, role_id, secret_id, namespace, agent)
}
}
}
}
pub(crate) fn approle_login(
vault_url: &str,
role_id: &str,
secret_id: &str,
namespace: Option<&str>,
agent: &ureq::Agent,
) -> Result<String, VaultError> {
let login_url = format!("{vault_url}/v1/auth/approle/login");
let body = serde_json::json!({
"role_id": role_id,
"secret_id": secret_id,
});
let mut req = agent.post(&login_url);
if let Some(ns) = namespace {
req = req.set("X-Vault-Namespace", ns);
}
let resp = req
.send_json(&body)
.map_err(|e| match e {
ureq::Error::Status(403, resp) => {
let body = resp.into_string().unwrap_or_default();
VaultError::AppRoleAuthFailed(format!("403 Forbidden — {body}"))
}
ureq::Error::Status(status, resp) => {
let body = resp.into_string().unwrap_or_default();
VaultError::Http {
status,
message: body,
}
}
ureq::Error::Transport(t) => VaultError::Transport(t.to_string()),
})?
.into_json::<serde_json::Value>()
.map_err(|e| VaultError::Transport(e.to_string()))?;
resp["auth"]["client_token"]
.as_str()
.map(|s| s.to_string())
.ok_or_else(|| {
VaultError::AppRoleAuthFailed(
"Vault AppRole login response missing 'auth.client_token'".into(),
)
})
}
#[cfg(test)]
mod tests {
use super::*;
fn test_agent() -> ureq::Agent {
ureq::AgentBuilder::new()
.timeout_connect(std::time::Duration::from_secs(5))
.timeout(std::time::Duration::from_secs(10))
.build()
}
#[test]
fn approle_auth_request_shape() {
let mut server = mockito::Server::new();
let role_id = "my-role-id";
let secret_id = "my-secret-id";
let _m = server
.mock("POST", "/v1/auth/approle/login")
.match_header(
"content-type",
mockito::Matcher::Regex("application/json".into()),
)
.match_body(mockito::Matcher::Json(serde_json::json!({
"role_id": role_id,
"secret_id": secret_id,
})))
.with_status(200)
.with_header("Content-Type", "application/json")
.with_body(r#"{"auth":{"client_token":"hvs.test-token","renewable":true,"ttl":3600}}"#)
.create();
let agent = test_agent();
let result = approle_login(&server.url(), role_id, secret_id, None, &agent);
assert!(result.is_ok(), "expected ok, got: {result:?}");
assert_eq!(result.unwrap(), "hvs.test-token");
}
#[test]
fn approle_auth_token_extracted() {
let mut server = mockito::Server::new();
let _m = server
.mock("POST", "/v1/auth/approle/login")
.with_status(200)
.with_header("Content-Type", "application/json")
.with_body(
r#"{"auth":{"client_token":"hvs.CAESX-extracted-token","renewable":true,"ttl":7200}}"#,
)
.create();
let agent = test_agent();
let token = approle_login(&server.url(), "role", "secret", None, &agent).unwrap();
assert_eq!(token, "hvs.CAESX-extracted-token");
}
#[test]
fn approle_auth_failure_surfaces_clear_error() {
let mut server = mockito::Server::new();
let _m = server
.mock("POST", "/v1/auth/approle/login")
.with_status(403)
.with_header("Content-Type", "application/json")
.with_body(r#"{"errors":["invalid role ID"]}"#)
.create();
let agent = test_agent();
let result = approle_login(&server.url(), "bad-role", "bad-secret", None, &agent);
assert!(
matches!(result, Err(VaultError::AppRoleAuthFailed(_))),
"expected AppRoleAuthFailed, got: {result:?}"
);
}
#[test]
fn namespace_header_is_present_on_approle_login() {
let mut server = mockito::Server::new();
let _m = server
.mock("POST", "/v1/auth/approle/login")
.match_header("X-Vault-Namespace", "team-alpha")
.with_status(200)
.with_header("Content-Type", "application/json")
.with_body(r#"{"auth":{"client_token":"hvs.ns-token","renewable":true,"ttl":3600}}"#)
.create();
let agent = test_agent();
let result = approle_login(&server.url(), "role", "secret", Some("team-alpha"), &agent);
assert!(
result.is_ok(),
"expected ok with namespace header, got: {result:?}"
);
assert_eq!(result.unwrap(), "hvs.ns-token");
}
#[test]
fn from_env_prefers_approle_over_token() {
temp_env::with_vars(
[
("VAULT_ROLE_ID", Some("r-123")),
("VAULT_SECRET_ID", Some("s-456")),
("VAULT_TOKEN", Some("old-token")),
],
|| {
let auth = VaultAuth::from_env().unwrap();
assert!(
matches!(auth, VaultAuth::AppRole { .. }),
"expected AppRole auth when both env vars are set"
);
},
);
}
#[test]
fn from_env_falls_back_to_token() {
temp_env::with_vars(
[
("VAULT_ROLE_ID", None::<&str>),
("VAULT_SECRET_ID", None::<&str>),
("VAULT_TOKEN", Some("static-token")),
],
|| {
let auth = VaultAuth::from_env().unwrap();
assert!(
matches!(auth, VaultAuth::Token(ref t) if t == "static-token"),
"expected Token auth when only VAULT_TOKEN is set"
);
},
);
}
#[test]
fn from_env_errors_when_no_credentials() {
temp_env::with_vars(
[
("VAULT_ROLE_ID", None::<&str>),
("VAULT_SECRET_ID", None::<&str>),
("VAULT_TOKEN", None::<&str>),
],
|| {
let result = VaultAuth::from_env();
assert!(result.is_err(), "expected error when no credentials set");
},
);
}
#[test]
fn token_auth_acquire_token_returns_static() {
let auth = VaultAuth::Token("static-vault-token".into());
let agent = test_agent();
let token = auth.acquire_token("http://unused", None, &agent).unwrap();
assert_eq!(token, "static-vault-token");
}
}