use super::{SecretMetadata, VaultClient, VaultClientInfo, VaultConfig, VaultError, VaultType};
use async_trait::async_trait;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use tracing::{debug, info};
#[derive(Debug, Deserialize)]
struct AuthResponse {
#[serde(rename = "accessToken")]
access_token: String,
#[serde(rename = "expiresIn")]
expires_in: u64,
#[serde(rename = "tokenType")]
token_type: String,
}
#[derive(Debug, Serialize)]
struct AuthRequest {
#[serde(rename = "clientId")]
client_id: String,
#[serde(rename = "clientSecret")]
client_secret: String,
}
#[derive(Debug, Deserialize)]
struct SecretResponse {
secret: SecretData,
}
#[derive(Debug, Deserialize)]
struct SecretData {
#[serde(rename = "secretKey")]
secret_key: String,
#[serde(rename = "secretValue")]
secret_value: String,
#[serde(rename = "secretComment")]
secret_comment: Option<String>,
version: Option<u32>,
#[serde(rename = "createdAt")]
created_at: Option<String>,
#[serde(rename = "updatedAt")]
updated_at: Option<String>,
}
#[derive(Debug, Deserialize)]
struct SecretsListResponse {
secrets: Vec<SecretListItem>,
}
#[derive(Debug, Deserialize)]
struct SecretListItem {
#[serde(rename = "secretKey")]
secret_key: String,
#[allow(dead_code)]
version: Option<u32>,
}
#[derive(Debug, Serialize)]
struct CreateSecretRequest {
#[serde(rename = "secretKey")]
secret_key: String,
#[serde(rename = "secretValue")]
secret_value: String,
#[serde(rename = "secretComment")]
secret_comment: Option<String>,
#[serde(rename = "workspaceId")]
workspace_id: String,
environment: String,
#[serde(rename = "secretPath")]
secret_path: String,
}
#[derive(Debug, Clone)]
struct TokenInfo {
token: String,
expires_at: std::time::Instant,
}
pub struct InfisicalClient {
config: VaultConfig,
client: Client,
client_id: String,
client_secret: String,
workspace_id: Option<String>,
environment: String,
secret_path: String,
token_info: Arc<RwLock<Option<TokenInfo>>>,
}
impl InfisicalClient {
pub async fn new(config: VaultConfig) -> Result<Self, VaultError> {
let client_id = std::env::var("INFISICAL_UNIVERSAL_AUTH_CLIENT_ID").map_err(|_| {
VaultError::ConfigError(
"INFISICAL_UNIVERSAL_AUTH_CLIENT_ID environment variable not set".to_string(),
)
})?;
let client_secret =
std::env::var("INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET").map_err(|_| {
VaultError::ConfigError(
"INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET environment variable not set"
.to_string(),
)
})?;
let workspace_id = std::env::var("INFISICAL_PROJECT_ID").ok();
let environment = config
.environment
.clone()
.unwrap_or_else(|| "dev".to_string());
let secret_path =
std::env::var("INFISICAL_SECRET_PATH").unwrap_or_else(|_| "/".to_string());
let client = Client::builder()
.timeout(std::time::Duration::from_secs(config.timeout_seconds))
.build()
.map_err(|e| {
VaultError::NetworkError(format!("Failed to create HTTP client: {}", e))
})?;
let infisical_client = Self {
config,
client,
client_id,
client_secret,
workspace_id,
environment,
secret_path,
token_info: Arc::new(RwLock::new(None)),
};
infisical_client.authenticate().await?;
Ok(infisical_client)
}
fn base_url(&self) -> String {
self.config
.base_url
.as_ref()
.unwrap_or(&"https://app.infisical.com".to_string())
.clone()
}
async fn get_access_token(&self) -> Result<String, VaultError> {
let token_info = self.token_info.read().await;
if let Some(info) = token_info.as_ref() {
if info.expires_at > std::time::Instant::now() + std::time::Duration::from_secs(300) {
return Ok(info.token.clone());
}
}
drop(token_info);
self.authenticate().await?;
let token_info = self.token_info.read().await;
token_info
.as_ref()
.map(|info| info.token.clone())
.ok_or_else(|| {
VaultError::AuthenticationFailed("Failed to obtain access token".to_string())
})
}
fn parse_datetime(&self, datetime_str: &str) -> Option<chrono::DateTime<chrono::Utc>> {
chrono::DateTime::parse_from_rfc3339(datetime_str)
.map(|dt| dt.with_timezone(&chrono::Utc))
.ok()
}
}
#[async_trait]
impl VaultClient for InfisicalClient {
async fn authenticate(&self) -> Result<(), VaultError> {
let auth_url = format!("{}/api/v1/auth/universal-auth/login", self.base_url());
let auth_request = AuthRequest {
client_id: self.client_id.clone(),
client_secret: self.client_secret.clone(),
};
debug!("Authenticating with Infisical at {}", auth_url);
let response = self
.client
.post(&auth_url)
.json(&auth_request)
.send()
.await
.map_err(|e| {
VaultError::NetworkError(format!("Authentication request failed: {}", e))
})?;
if !response.status().is_success() {
let status = response.status();
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(VaultError::AuthenticationFailed(format!(
"Authentication failed with status {}: {}",
status, error_text
)));
}
let auth_response: AuthResponse = response.json().await.map_err(|e| {
VaultError::InvalidResponse(format!("Failed to parse auth response: {}", e))
})?;
if auth_response.token_type.to_lowercase() != "bearer" {
return Err(VaultError::AuthenticationFailed(format!(
"Unexpected token type: {} (expected: Bearer)",
auth_response.token_type
)));
}
let expires_at =
std::time::Instant::now() + std::time::Duration::from_secs(auth_response.expires_in);
let token_info = TokenInfo {
token: auth_response.access_token,
expires_at,
};
let mut token_guard = self.token_info.write().await;
*token_guard = Some(token_info);
info!("Successfully authenticated with Infisical");
Ok(())
}
async fn get_secret(&self, name: &str) -> Result<String, VaultError> {
let (value, _) = self.get_secret_with_metadata(name).await?;
Ok(value)
}
async fn get_secret_with_metadata(
&self,
name: &str,
) -> Result<(String, SecretMetadata), VaultError> {
let token = self.get_access_token().await?;
let mut secret_url = format!(
"{}/api/v3/secrets/raw/{}?environment={}&secretPath={}",
self.base_url(),
urlencoding::encode(name),
urlencoding::encode(&self.environment),
urlencoding::encode(&self.secret_path)
);
if let Some(workspace_id) = &self.workspace_id {
secret_url = format!("{}&workspaceId={}", secret_url, workspace_id);
}
debug!("Fetching secret '{}' from Infisical", name);
let response = self
.client
.get(&secret_url)
.header("Authorization", format!("Bearer {}", token))
.send()
.await
.map_err(|e| VaultError::NetworkError(format!("Secret request failed: {}", e)))?;
if response.status() == 404 {
return Err(VaultError::SecretNotFound(name.to_string()));
}
if !response.status().is_success() {
let status = response.status();
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
if status == 401 {
return Err(VaultError::AuthenticationFailed(
"Access token expired or invalid".to_string(),
));
} else if status == 403 {
return Err(VaultError::AccessDenied(format!(
"Access denied to secret '{}'",
name
)));
}
return Err(VaultError::NetworkError(format!(
"Secret request failed with status {}: {}",
status, error_text
)));
}
let secret_response: SecretResponse = response.json().await.map_err(|e| {
VaultError::InvalidResponse(format!("Failed to parse secret response: {}", e))
})?;
let secret = secret_response.secret;
let mut tags = HashMap::new();
if let Some(comment) = &secret.secret_comment {
if !comment.is_empty() {
tags.insert("comment".to_string(), comment.clone());
}
}
if let Some(version) = secret.version {
tags.insert("version".to_string(), version.to_string());
}
let metadata = SecretMetadata {
name: secret.secret_key.clone(),
version: secret.version.map(|v| v.to_string()),
created_at: secret.created_at.and_then(|s| self.parse_datetime(&s)),
updated_at: secret.updated_at.and_then(|s| self.parse_datetime(&s)),
tags,
};
debug!("Successfully retrieved secret '{}'", name);
Ok((secret.secret_value, metadata))
}
async fn list_secrets(&self) -> Result<Vec<String>, VaultError> {
let token = self.get_access_token().await?;
let mut list_url = format!(
"{}/api/v3/secrets?environment={}&secretPath={}",
self.base_url(),
urlencoding::encode(&self.environment),
urlencoding::encode(&self.secret_path)
);
if let Some(workspace_id) = &self.workspace_id {
list_url = format!("{}&workspaceId={}", list_url, workspace_id);
}
debug!("Listing secrets from Infisical");
let response = self
.client
.get(&list_url)
.header("Authorization", format!("Bearer {}", token))
.send()
.await
.map_err(|e| VaultError::NetworkError(format!("List secrets request failed: {}", e)))?;
if !response.status().is_success() {
let status = response.status();
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
if status == 401 {
return Err(VaultError::AuthenticationFailed(
"Access token expired or invalid".to_string(),
));
} else if status == 403 {
return Err(VaultError::AccessDenied(
"Access denied to list secrets".to_string(),
));
}
return Err(VaultError::NetworkError(format!(
"List secrets failed with status {}: {}",
status, error_text
)));
}
let secrets_response: SecretsListResponse = response.json().await.map_err(|e| {
VaultError::InvalidResponse(format!("Failed to parse secrets list response: {}", e))
})?;
let secret_names: Vec<String> = secrets_response
.secrets
.into_iter()
.map(|secret| {
secret.secret_key
})
.collect();
debug!("Successfully listed {} secrets", secret_names.len());
Ok(secret_names)
}
async fn set_secret(&self, name: &str, value: &str) -> Result<(), VaultError> {
self.set_secret_with_comment(name, value, None).await
}
async fn delete_secret(&self, name: &str) -> Result<(), VaultError> {
let token = self.get_access_token().await?;
let mut delete_url = format!(
"{}/api/v3/secrets/{}?environment={}&secretPath={}",
self.base_url(),
urlencoding::encode(name),
urlencoding::encode(&self.environment),
urlencoding::encode(&self.secret_path)
);
if let Some(workspace_id) = &self.workspace_id {
delete_url = format!("{}&workspaceId={}", delete_url, workspace_id);
}
debug!("Deleting secret '{}' from Infisical", name);
let response = self
.client
.delete(&delete_url)
.header("Authorization", format!("Bearer {}", token))
.send()
.await
.map_err(|e| {
VaultError::NetworkError(format!("Delete secret request failed: {}", e))
})?;
if response.status() == 404 {
return Err(VaultError::SecretNotFound(name.to_string()));
}
if !response.status().is_success() {
let status = response.status();
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
if status == 401 {
return Err(VaultError::AuthenticationFailed(
"Access token expired or invalid".to_string(),
));
} else if status == 403 {
return Err(VaultError::AccessDenied(format!(
"Access denied to delete secret '{}'",
name
)));
}
return Err(VaultError::NetworkError(format!(
"Delete secret failed with status {}: {}",
status, error_text
)));
}
info!("Successfully deleted secret '{}'", name);
Ok(())
}
async fn is_authenticated(&self) -> bool {
let token_info = self.token_info.read().await;
if let Some(info) = token_info.as_ref() {
info.expires_at > std::time::Instant::now()
} else {
false
}
}
fn client_info(&self) -> VaultClientInfo {
VaultClientInfo {
name: "Infisical Client".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
vault_type: VaultType::Infisical,
read_only: false,
}
}
}
impl InfisicalClient {
pub async fn set_secret_with_comment(
&self,
name: &str,
value: &str,
comment: Option<&str>,
) -> Result<(), VaultError> {
let workspace_id = self.workspace_id.as_ref().ok_or_else(|| {
VaultError::ConfigError("Workspace ID required for creating secrets".to_string())
})?;
let token = self.get_access_token().await?;
let create_url = format!(
"{}/api/v3/secrets/{}",
self.base_url(),
urlencoding::encode(name)
);
let create_request = CreateSecretRequest {
secret_key: name.to_string(),
secret_value: value.to_string(),
secret_comment: comment.map(|c| c.to_string()),
workspace_id: workspace_id.clone(),
environment: self.environment.clone(),
secret_path: self.secret_path.clone(),
};
debug!("Creating/updating secret '{}' in Infisical", name);
let response = self
.client
.post(&create_url)
.header("Authorization", format!("Bearer {}", token))
.json(&create_request)
.send()
.await
.map_err(|e| {
VaultError::NetworkError(format!("Create secret request failed: {}", e))
})?;
if !response.status().is_success() {
let status = response.status();
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
if status == 401 {
return Err(VaultError::AuthenticationFailed(
"Access token expired or invalid".to_string(),
));
} else if status == 403 {
return Err(VaultError::AccessDenied(format!(
"Access denied to create secret '{}'",
name
)));
}
return Err(VaultError::NetworkError(format!(
"Create secret failed with status {}: {}",
status, error_text
)));
}
info!("Successfully created/updated secret '{}'", name);
Ok(())
}
pub async fn get_secret_version(
&self,
name: &str,
version: u32,
) -> Result<(String, SecretMetadata), VaultError> {
let token = self.get_access_token().await?;
let mut secret_url = format!(
"{}/api/v3/secrets/raw/{}?environment={}&secretPath={}&version={}",
self.base_url(),
urlencoding::encode(name),
urlencoding::encode(&self.environment),
urlencoding::encode(&self.secret_path),
version
);
if let Some(workspace_id) = &self.workspace_id {
secret_url = format!("{}&workspaceId={}", secret_url, workspace_id);
}
debug!(
"Fetching secret '{}' version {} from Infisical",
name, version
);
let response = self
.client
.get(&secret_url)
.header("Authorization", format!("Bearer {}", token))
.send()
.await
.map_err(|e| VaultError::NetworkError(format!("Secret request failed: {}", e)))?;
if response.status() == 404 {
return Err(VaultError::SecretNotFound(format!("{}:v{}", name, version)));
}
if !response.status().is_success() {
let status = response.status();
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
if status == 401 {
return Err(VaultError::AuthenticationFailed(
"Access token expired or invalid".to_string(),
));
} else if status == 403 {
return Err(VaultError::AccessDenied(format!(
"Access denied to secret '{}' version {}",
name, version
)));
}
return Err(VaultError::NetworkError(format!(
"Secret request failed with status {}: {}",
status, error_text
)));
}
let secret_response: SecretResponse = response.json().await.map_err(|e| {
VaultError::InvalidResponse(format!("Failed to parse secret response: {}", e))
})?;
let secret = secret_response.secret;
let mut tags = HashMap::new();
if let Some(comment) = &secret.secret_comment {
if !comment.is_empty() {
tags.insert("comment".to_string(), comment.clone());
}
}
tags.insert("version".to_string(), version.to_string());
let metadata = SecretMetadata {
name: secret.secret_key.clone(),
version: Some(version.to_string()),
created_at: secret.created_at.and_then(|s| self.parse_datetime(&s)),
updated_at: secret.updated_at.and_then(|s| self.parse_datetime(&s)),
tags,
};
debug!(
"Successfully retrieved secret '{}' version {}",
name, version
);
Ok((secret.secret_value, metadata))
}
pub async fn list_secret_versions(&self, name: &str) -> Result<Vec<u32>, VaultError> {
match self.get_secret_with_metadata(name).await {
Ok((_, metadata)) => {
if let Some(version_str) = metadata.version {
if let Ok(version) = version_str.parse::<u32>() {
return Ok(vec![version]);
}
}
Ok(vec![1]) }
Err(_) => Ok(vec![]), }
}
pub async fn get_secret_comment(&self, name: &str) -> Result<Option<String>, VaultError> {
let (_, metadata) = self.get_secret_with_metadata(name).await?;
Ok(metadata.tags.get("comment").cloned())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_auth_request_serialization() {
let request = AuthRequest {
client_id: "test-client".to_string(),
client_secret: "test-secret".to_string(),
};
let json = serde_json::to_string(&request).unwrap();
assert!(json.contains("clientId"));
assert!(json.contains("clientSecret"));
}
#[test]
fn test_secret_response_deserialization() {
let json = r#"{
"secret": {
"secretKey": "TEST_KEY",
"secretValue": "test-value",
"secretComment": "Test comment",
"version": 1,
"createdAt": "2023-01-01T00:00:00.000Z",
"updatedAt": "2023-01-01T00:00:00.000Z"
}
}"#;
let response: SecretResponse = serde_json::from_str(json).unwrap();
assert_eq!(response.secret.secret_key, "TEST_KEY");
assert_eq!(response.secret.secret_value, "test-value");
assert_eq!(response.secret.version, Some(1));
}
}