tsafe-cli 1.0.28

Secrets runtime for developers — inject credentials into processes via exec, never into shell history or .env files
Documentation
//! HashiCorp Vault authentication — token and AppRole.
//!
//! Token lifecycle: the AppRole login call exchanges a role_id + secret_id for
//! a short-lived `client_token`.  That token is returned as a plain `String` and
//! must be used for the duration of the command invocation only.  It is never
//! persisted to disk or to the local tsafe vault.

use super::error::VaultError;

/// Authentication configuration parsed from environment variables or the pull
/// manifest YAML.
///
/// `Token` is the legacy path (static `VAULT_TOKEN` env var).  `AppRole` is
/// the machine-identity path: a role_id + secret_id are exchanged at runtime
/// for a short-lived client token via the Vault AppRole login endpoint.
#[derive(Debug, Clone)]
pub enum VaultAuth {
    Token(String),
    AppRole { role_id: String, secret_id: String },
}

impl VaultAuth {
    /// Resolve auth from environment variables.
    ///
    /// Priority:
    /// 1. `VAULT_ROLE_ID` + `VAULT_SECRET_ID` → AppRole
    /// 2. `VAULT_TOKEN` → Token
    /// 3. Error if neither is set
    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))
            }
        }
    }

    /// Acquire a client token.
    ///
    /// For `Token` auth: returns the static token directly (no HTTP call).
    /// For `AppRole` auth: POSTs to `{vault_url}/v1/auth/approle/login` and
    /// parses `auth.client_token` from the response.  The returned token is
    /// short-lived and must not be cached beyond the current command invocation.
    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)
            }
        }
    }
}

/// Perform the AppRole login handshake.
///
/// Sends `POST {vault_url}/v1/auth/approle/login` with body
/// `{"role_id": "...", "secret_id": "..."}` and extracts `auth.client_token`
/// from the JSON response.
///
/// The `X-Vault-Namespace` header is added when `namespace` is `Some`.
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(),
            )
        })
}

// ── tests ──────────────────────────────────────────────────────────────────────

#[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()
    }

    /// The AppRole login POST body must be `{"role_id":"...","secret_id":"..."}`.
    #[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");
    }

    /// `auth.client_token` is correctly extracted from the login response.
    #[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");
    }

    /// 403 from Vault during AppRole login surfaces as `VaultError::AppRoleAuthFailed`.
    #[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:?}"
        );
    }

    /// When namespace is configured, `X-Vault-Namespace` header is present on the login request.
    #[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");
    }

    /// `VaultAuth::from_env` prefers AppRole credentials over VAULT_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"
                );
            },
        );
    }

    /// `VaultAuth::from_env` falls back to token when only VAULT_TOKEN is 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"
                );
            },
        );
    }

    /// `VaultAuth::from_env` errors when no credentials are present.
    #[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");
            },
        );
    }

    /// `VaultAuth::Token::acquire_token` returns the static token without HTTP.
    #[test]
    fn token_auth_acquire_token_returns_static() {
        let auth = VaultAuth::Token("static-vault-token".into());
        let agent = test_agent();
        // This should not make any HTTP call — any server URL works.
        let token = auth.acquire_token("http://unused", None, &agent).unwrap();
        assert_eq!(token, "static-vault-token");
    }
}