use crate::error::{Error, ErrorKind, Result};
pub trait Credentials: Send + Sync {
fn instance_url(&self) -> &str;
fn access_token(&self) -> &str;
fn api_version(&self) -> &str;
fn is_valid(&self) -> bool {
!self.instance_url().is_empty() && !self.access_token().is_empty()
}
}
#[derive(Clone)]
pub struct SalesforceCredentials {
instance_url: String,
access_token: String,
api_version: String,
refresh_token: Option<String>,
}
impl std::fmt::Debug for SalesforceCredentials {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SalesforceCredentials")
.field("instance_url", &self.instance_url)
.field("access_token", &"[REDACTED]")
.field("api_version", &self.api_version)
.field(
"refresh_token",
&self.refresh_token.as_ref().map(|_| "[REDACTED]"),
)
.finish()
}
}
impl SalesforceCredentials {
pub fn new(
instance_url: impl Into<String>,
access_token: impl Into<String>,
api_version: impl Into<String>,
) -> Self {
Self {
instance_url: instance_url.into(),
access_token: access_token.into(),
api_version: api_version.into(),
refresh_token: None,
}
}
pub fn with_refresh_token(mut self, refresh_token: impl Into<String>) -> Self {
self.refresh_token = Some(refresh_token.into());
self
}
pub fn refresh_token(&self) -> Option<&str> {
self.refresh_token.as_deref()
}
pub fn set_access_token(&mut self, token: impl Into<String>) {
self.access_token = token.into();
}
pub fn from_env() -> Result<Self> {
let instance_url = std::env::var("SF_INSTANCE_URL")
.or_else(|_| std::env::var("SALESFORCE_INSTANCE_URL"))
.map_err(|_| Error::new(ErrorKind::EnvVar("SF_INSTANCE_URL".to_string())))?;
let access_token = std::env::var("SF_ACCESS_TOKEN")
.or_else(|_| std::env::var("SALESFORCE_ACCESS_TOKEN"))
.map_err(|_| Error::new(ErrorKind::EnvVar("SF_ACCESS_TOKEN".to_string())))?;
let api_version = std::env::var("SF_API_VERSION")
.or_else(|_| std::env::var("SALESFORCE_API_VERSION"))
.unwrap_or_else(|_| busbar_sf_client::DEFAULT_API_VERSION.to_string());
let refresh_token = std::env::var("SF_REFRESH_TOKEN")
.or_else(|_| std::env::var("SALESFORCE_REFRESH_TOKEN"))
.ok();
let mut creds = Self::new(instance_url, access_token, api_version);
if let Some(rt) = refresh_token {
creds = creds.with_refresh_token(rt);
}
Ok(creds)
}
pub async fn from_sfdx_alias(alias_or_username: &str) -> Result<Self> {
use tokio::process::Command;
let output = Command::new("sf")
.args([
"org",
"display",
"--target-org",
alias_or_username,
"--json",
])
.output()
.await
.map_err(|e| Error::new(ErrorKind::SfdxCli(format!("Failed to run sf CLI: {}", e))))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(Error::new(ErrorKind::SfdxCli(format!(
"sf org display failed: {}",
stderr
))));
}
let json: serde_json::Value = serde_json::from_slice(&output.stdout)?;
let result = json.get("result").ok_or_else(|| {
Error::new(ErrorKind::SfdxCli("Missing 'result' in output".to_string()))
})?;
let instance_url = result
.get("instanceUrl")
.and_then(|v| v.as_str())
.ok_or_else(|| Error::new(ErrorKind::SfdxCli("Missing instanceUrl".to_string())))?;
let access_token = result
.get("accessToken")
.and_then(|v| v.as_str())
.ok_or_else(|| Error::new(ErrorKind::SfdxCli("Missing accessToken".to_string())))?;
let api_version = result
.get("apiVersion")
.and_then(|v| v.as_str())
.unwrap_or(busbar_sf_client::DEFAULT_API_VERSION);
Ok(Self::new(instance_url, access_token, api_version))
}
pub async fn from_sfdx_auth_url(auth_url: &str) -> Result<Self> {
use crate::oauth::{OAuthClient, OAuthConfig};
if !auth_url.starts_with("force://") {
return Err(Error::new(ErrorKind::InvalidInput(
"Auth URL must start with force://".to_string(),
)));
}
let url = auth_url.strip_prefix("force://").unwrap();
let parts: Vec<&str> = url.splitn(2, '@').collect();
if parts.len() != 2 {
return Err(Error::new(ErrorKind::InvalidInput(
"Invalid auth URL format: missing @".to_string(),
)));
}
let credentials_part = parts[0];
let instance_url = parts[1];
let cred_parts: Vec<&str> = credentials_part.splitn(4, ':').collect();
if cred_parts.len() < 3 {
return Err(Error::new(ErrorKind::InvalidInput(
"Invalid auth URL format: expected client_id:client_secret:refresh_token[:username]"
.to_string(),
)));
}
let client_id = cred_parts[0];
let client_secret = if cred_parts[1].is_empty() {
None
} else {
Some(cred_parts[1].to_string())
};
let refresh_token = cred_parts[2];
let mut config = OAuthConfig::new(client_id);
if let Some(secret) = client_secret {
config = config.with_secret(secret);
}
let oauth_client = OAuthClient::new(config);
let token_url = if instance_url.contains("localhost") || instance_url.contains("127.0.0.1")
{
instance_url
} else if instance_url.contains("test.salesforce.com")
|| instance_url.contains("sandbox")
|| instance_url.contains(".scratch.")
{
"https://test.salesforce.com"
} else {
"https://login.salesforce.com"
};
let token_response = oauth_client
.refresh_token(refresh_token, token_url)
.await
.map_err(|e| {
if matches!(&e.kind, ErrorKind::OAuth { error, .. } if error == "invalid_grant") {
Error::new(ErrorKind::OAuth {
error: "invalid_grant".to_string(),
description: format!(
"Refresh token expired or invalid. Generate a fresh SF_AUTH_URL using: \
`sf org display --verbose --json | jq -r '.result.sfdxAuthUrl'`. \
Original error: {}",
e
),
})
} else {
e
}
})?;
let api_version = busbar_sf_client::DEFAULT_API_VERSION.to_string();
let mut creds = Self::new(
token_response.instance_url,
token_response.access_token,
api_version,
);
creds = creds.with_refresh_token(refresh_token);
Ok(creds)
}
pub fn with_api_version(mut self, version: impl Into<String>) -> Self {
self.api_version = version.into();
self
}
pub fn rest_api_url(&self) -> String {
format!(
"{}/services/data/v{}",
self.instance_url.trim_end_matches('/'),
self.api_version
)
}
pub fn tooling_api_url(&self) -> String {
format!(
"{}/services/data/v{}/tooling",
self.instance_url.trim_end_matches('/'),
self.api_version
)
}
pub fn metadata_api_url(&self) -> String {
format!(
"{}/services/Soap/m/{}",
self.instance_url.trim_end_matches('/'),
self.api_version
)
}
pub fn bulk_api_url(&self) -> String {
format!(
"{}/services/data/v{}/jobs",
self.instance_url.trim_end_matches('/'),
self.api_version
)
}
}
impl Credentials for SalesforceCredentials {
fn instance_url(&self) -> &str {
&self.instance_url
}
fn access_token(&self) -> &str {
&self.access_token
}
fn api_version(&self) -> &str {
&self.api_version
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_credentials_new() {
let creds =
SalesforceCredentials::new("https://test.salesforce.com", "access_token_123", "62.0");
assert_eq!(creds.instance_url(), "https://test.salesforce.com");
assert_eq!(creds.access_token(), "access_token_123");
assert_eq!(creds.api_version(), "62.0");
assert!(creds.is_valid());
}
#[test]
fn test_credentials_with_refresh_token() {
let creds =
SalesforceCredentials::new("https://test.salesforce.com", "access_token", "62.0")
.with_refresh_token("refresh_token_123");
assert_eq!(creds.refresh_token(), Some("refresh_token_123"));
}
#[test]
fn test_api_urls() {
let creds = SalesforceCredentials::new("https://na1.salesforce.com", "token", "62.0");
assert_eq!(
creds.rest_api_url(),
"https://na1.salesforce.com/services/data/v62.0"
);
assert_eq!(
creds.tooling_api_url(),
"https://na1.salesforce.com/services/data/v62.0/tooling"
);
assert_eq!(
creds.bulk_api_url(),
"https://na1.salesforce.com/services/data/v62.0/jobs"
);
}
#[test]
fn test_invalid_credentials() {
let creds = SalesforceCredentials::new("", "", "62.0");
assert!(!creds.is_valid());
}
#[test]
fn test_credentials_debug_redacts_tokens() {
let creds = SalesforceCredentials::new(
"https://test.salesforce.com",
"super_secret_access_token_12345",
"62.0",
)
.with_refresh_token("super_secret_refresh_token_67890");
let debug_output = format!("{:?}", creds);
assert!(debug_output.contains("[REDACTED]"));
assert!(!debug_output.contains("super_secret_access_token_12345"));
assert!(!debug_output.contains("super_secret_refresh_token_67890"));
assert!(debug_output.contains("test.salesforce.com"));
assert!(debug_output.contains("62.0"));
}
#[test]
fn test_parse_auth_url_with_client_secret() {
let auth_url = "force://client123:secret456:refresh789@https://test.salesforce.com";
let url = auth_url.strip_prefix("force://").unwrap();
let parts: Vec<&str> = url.splitn(2, '@').collect();
assert_eq!(parts.len(), 2);
let cred_parts: Vec<&str> = parts[0].splitn(4, ':').collect();
assert!(cred_parts.len() >= 3);
assert_eq!(cred_parts[0], "client123");
assert_eq!(cred_parts[1], "secret456");
assert_eq!(cred_parts[2], "refresh789");
}
#[test]
fn test_parse_auth_url_without_client_secret() {
let auth_url = "force://client123::refresh789@https://test.salesforce.com";
let url = auth_url.strip_prefix("force://").unwrap();
let parts: Vec<&str> = url.splitn(2, '@').collect();
assert_eq!(parts.len(), 2);
let cred_parts: Vec<&str> = parts[0].splitn(4, ':').collect();
assert!(cred_parts.len() >= 3);
assert_eq!(cred_parts[0], "client123");
assert_eq!(cred_parts[1], ""); assert_eq!(cred_parts[2], "refresh789");
}
#[test]
fn test_parse_auth_url_with_username() {
let auth_url = "force://client123:secret456:refresh789:user@https://test.salesforce.com";
let url = auth_url.strip_prefix("force://").unwrap();
let parts: Vec<&str> = url.splitn(2, '@').collect();
assert_eq!(parts.len(), 2);
let cred_parts: Vec<&str> = parts[0].splitn(4, ':').collect();
assert_eq!(cred_parts.len(), 4);
assert_eq!(cred_parts[0], "client123");
assert_eq!(cred_parts[1], "secret456");
assert_eq!(cred_parts[2], "refresh789");
assert_eq!(cred_parts[3], "user");
}
#[test]
fn test_parse_auth_url_invalid_format() {
let auth_url = "force://client123:secret456@https://test.salesforce.com";
let url = auth_url.strip_prefix("force://").unwrap();
let parts: Vec<&str> = url.splitn(2, '@').collect();
assert_eq!(parts.len(), 2);
let cred_parts: Vec<&str> = parts[0].splitn(4, ':').collect();
assert_eq!(cred_parts.len(), 2);
assert!(
cred_parts.len() < 3,
"Invalid format should have less than 3 parts"
);
}
#[tokio::test]
async fn test_from_sfdx_auth_url_with_client_secret() {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/services/oauth2/token"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": "test_access_token",
"instance_url": "https://na1.salesforce.com",
"id": "https://login.salesforce.com/id/00Dxx0000000000EAA/005xx000000000QAAQ",
"token_type": "Bearer",
"issued_at": "1234567890"
})))
.mount(&mock_server)
.await;
let auth_url = format!(
"force://client123:secret456:refresh789@{}",
mock_server.uri()
);
let creds = SalesforceCredentials::from_sfdx_auth_url(&auth_url).await;
assert!(creds.is_ok(), "Failed to authenticate: {:?}", creds.err());
let creds = creds.unwrap();
assert_eq!(creds.instance_url(), "https://na1.salesforce.com");
assert_eq!(creds.access_token(), "test_access_token");
assert_eq!(creds.refresh_token(), Some("refresh789"));
}
#[tokio::test]
async fn test_from_sfdx_auth_url_without_client_secret() {
use wiremock::matchers::{method, path};
use wiremock::{Match, Mock, MockServer, Request, ResponseTemplate};
struct NoClientSecretMatcher;
impl Match for NoClientSecretMatcher {
fn matches(&self, request: &Request) -> bool {
let body = String::from_utf8_lossy(&request.body);
body.contains("client_id=client123")
&& body.contains("refresh_token=refresh789")
&& !body.contains("client_secret")
}
}
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/services/oauth2/token"))
.and(NoClientSecretMatcher)
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": "test_access_token_no_secret",
"instance_url": "https://na1.salesforce.com",
"id": "https://login.salesforce.com/id/00Dxx0000000000EAA/005xx000000000QAAQ",
"token_type": "Bearer",
"issued_at": "1234567890"
})))
.mount(&mock_server)
.await;
let auth_url = format!("force://client123::refresh789@{}", mock_server.uri());
let creds = SalesforceCredentials::from_sfdx_auth_url(&auth_url).await;
assert!(creds.is_ok(), "Failed to authenticate: {:?}", creds.err());
let creds = creds.unwrap();
assert_eq!(creds.instance_url(), "https://na1.salesforce.com");
assert_eq!(creds.access_token(), "test_access_token_no_secret");
assert_eq!(creds.refresh_token(), Some("refresh789"));
}
#[tokio::test]
async fn test_from_sfdx_auth_url_sandbox() {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/services/oauth2/token"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": "test_access_token_sandbox",
"instance_url": "https://test.salesforce.com",
"id": "https://test.salesforce.com/id/00Dxx0000000000EAA/005xx000000000QAAQ",
"token_type": "Bearer",
"issued_at": "1234567890"
})))
.mount(&mock_server)
.await;
let auth_url = format!(
"force://client123:secret456:refresh789@{}",
mock_server.uri()
);
let creds = SalesforceCredentials::from_sfdx_auth_url(&auth_url).await;
assert!(creds.is_ok(), "Failed to authenticate: {:?}", creds.err());
let creds = creds.unwrap();
assert_eq!(creds.instance_url(), "https://test.salesforce.com");
assert_eq!(creds.access_token(), "test_access_token_sandbox");
}
#[tokio::test]
async fn test_from_sfdx_auth_url_with_username() {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/services/oauth2/token"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": "test_access_token_with_user",
"instance_url": "https://na1.salesforce.com",
"id": "https://login.salesforce.com/id/00Dxx0000000000EAA/005xx000000000QAAQ",
"token_type": "Bearer",
"issued_at": "1234567890"
})))
.mount(&mock_server)
.await;
let auth_url = format!(
"force://client123:secret456:refresh789:username@{}",
mock_server.uri()
);
let creds = SalesforceCredentials::from_sfdx_auth_url(&auth_url).await;
assert!(creds.is_ok(), "Failed to authenticate: {:?}", creds.err());
let creds = creds.unwrap();
assert_eq!(creds.instance_url(), "https://na1.salesforce.com");
assert_eq!(creds.access_token(), "test_access_token_with_user");
}
#[tokio::test]
async fn test_from_sfdx_auth_url_invalid_too_few_parts() {
let auth_url = "force://client123:secret456@https://test.salesforce.com";
let creds = SalesforceCredentials::from_sfdx_auth_url(auth_url).await;
assert!(creds.is_err());
let err = creds.unwrap_err();
assert!(err
.to_string()
.contains("expected client_id:client_secret:refresh_token"));
}
}