1use crate::error::{Error, ErrorKind, Result};
6
7pub trait Credentials: Send + Sync {
9 fn instance_url(&self) -> &str;
11
12 fn access_token(&self) -> &str;
14
15 fn api_version(&self) -> &str;
17
18 fn is_valid(&self) -> bool {
20 !self.instance_url().is_empty() && !self.access_token().is_empty()
21 }
22}
23
24#[derive(Clone)]
29pub struct SalesforceCredentials {
30 instance_url: String,
31 access_token: String,
32 api_version: String,
33 refresh_token: Option<String>,
34}
35
36impl std::fmt::Debug for SalesforceCredentials {
37 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38 f.debug_struct("SalesforceCredentials")
39 .field("instance_url", &self.instance_url)
40 .field("access_token", &"[REDACTED]")
41 .field("api_version", &self.api_version)
42 .field(
43 "refresh_token",
44 &self.refresh_token.as_ref().map(|_| "[REDACTED]"),
45 )
46 .finish()
47 }
48}
49
50impl SalesforceCredentials {
51 pub fn new(
53 instance_url: impl Into<String>,
54 access_token: impl Into<String>,
55 api_version: impl Into<String>,
56 ) -> Self {
57 Self {
58 instance_url: instance_url.into(),
59 access_token: access_token.into(),
60 api_version: api_version.into(),
61 refresh_token: None,
62 }
63 }
64
65 pub fn with_refresh_token(mut self, refresh_token: impl Into<String>) -> Self {
67 self.refresh_token = Some(refresh_token.into());
68 self
69 }
70
71 pub fn refresh_token(&self) -> Option<&str> {
73 self.refresh_token.as_deref()
74 }
75
76 pub fn set_access_token(&mut self, token: impl Into<String>) {
78 self.access_token = token.into();
79 }
80
81 pub fn from_env() -> Result<Self> {
91 let instance_url = std::env::var("SF_INSTANCE_URL")
92 .or_else(|_| std::env::var("SALESFORCE_INSTANCE_URL"))
93 .map_err(|_| Error::new(ErrorKind::EnvVar("SF_INSTANCE_URL".to_string())))?;
94
95 let access_token = std::env::var("SF_ACCESS_TOKEN")
96 .or_else(|_| std::env::var("SALESFORCE_ACCESS_TOKEN"))
97 .map_err(|_| Error::new(ErrorKind::EnvVar("SF_ACCESS_TOKEN".to_string())))?;
98
99 let api_version = std::env::var("SF_API_VERSION")
100 .or_else(|_| std::env::var("SALESFORCE_API_VERSION"))
101 .unwrap_or_else(|_| busbar_sf_client::DEFAULT_API_VERSION.to_string());
102
103 let refresh_token = std::env::var("SF_REFRESH_TOKEN")
104 .or_else(|_| std::env::var("SALESFORCE_REFRESH_TOKEN"))
105 .ok();
106
107 let mut creds = Self::new(instance_url, access_token, api_version);
108 if let Some(rt) = refresh_token {
109 creds = creds.with_refresh_token(rt);
110 }
111
112 Ok(creds)
113 }
114
115 pub async fn from_sfdx_alias(alias_or_username: &str) -> Result<Self> {
119 use tokio::process::Command;
120
121 let output = Command::new("sf")
122 .args([
123 "org",
124 "display",
125 "--target-org",
126 alias_or_username,
127 "--json",
128 ])
129 .output()
130 .await
131 .map_err(|e| Error::new(ErrorKind::SfdxCli(format!("Failed to run sf CLI: {}", e))))?;
132
133 if !output.status.success() {
134 let stderr = String::from_utf8_lossy(&output.stderr);
135 return Err(Error::new(ErrorKind::SfdxCli(format!(
136 "sf org display failed: {}",
137 stderr
138 ))));
139 }
140
141 let json: serde_json::Value = serde_json::from_slice(&output.stdout)?;
142
143 let result = json.get("result").ok_or_else(|| {
144 Error::new(ErrorKind::SfdxCli("Missing 'result' in output".to_string()))
145 })?;
146
147 let instance_url = result
148 .get("instanceUrl")
149 .and_then(|v| v.as_str())
150 .ok_or_else(|| Error::new(ErrorKind::SfdxCli("Missing instanceUrl".to_string())))?;
151
152 let access_token = result
153 .get("accessToken")
154 .and_then(|v| v.as_str())
155 .ok_or_else(|| Error::new(ErrorKind::SfdxCli("Missing accessToken".to_string())))?;
156
157 let api_version = result
158 .get("apiVersion")
159 .and_then(|v| v.as_str())
160 .unwrap_or(busbar_sf_client::DEFAULT_API_VERSION);
161
162 Ok(Self::new(instance_url, access_token, api_version))
163 }
164
165 pub async fn from_sfdx_auth_url(auth_url: &str) -> Result<Self> {
188 use crate::oauth::{OAuthClient, OAuthConfig};
189
190 if !auth_url.starts_with("force://") {
194 return Err(Error::new(ErrorKind::InvalidInput(
195 "Auth URL must start with force://".to_string(),
196 )));
197 }
198
199 let url = auth_url.strip_prefix("force://").unwrap();
200
201 let parts: Vec<&str> = url.splitn(2, '@').collect();
203 if parts.len() != 2 {
204 return Err(Error::new(ErrorKind::InvalidInput(
205 "Invalid auth URL format: missing @".to_string(),
206 )));
207 }
208
209 let credentials_part = parts[0];
210 let instance_url = parts[1];
211
212 let cred_parts: Vec<&str> = credentials_part.splitn(4, ':').collect();
215 if cred_parts.len() < 3 {
216 return Err(Error::new(ErrorKind::InvalidInput(
217 "Invalid auth URL format: expected client_id:client_secret:refresh_token[:username]"
218 .to_string(),
219 )));
220 }
221
222 let client_id = cred_parts[0];
223 let client_secret = if cred_parts[1].is_empty() {
224 None
225 } else {
226 Some(cred_parts[1].to_string())
227 };
228 let refresh_token = cred_parts[2];
230 let mut config = OAuthConfig::new(client_id);
234 if let Some(secret) = client_secret {
235 config = config.with_secret(secret);
236 }
237
238 let oauth_client = OAuthClient::new(config);
239
240 let token_url = if instance_url.contains("localhost") || instance_url.contains("127.0.0.1")
244 {
245 instance_url
246 } else if instance_url.contains("test.salesforce.com")
247 || instance_url.contains("sandbox")
248 || instance_url.contains(".scratch.")
249 {
250 "https://test.salesforce.com"
251 } else {
252 "https://login.salesforce.com"
253 };
254
255 let token_response = oauth_client
257 .refresh_token(refresh_token, token_url)
258 .await
259 .map_err(|e| {
260 if matches!(&e.kind, ErrorKind::OAuth { error, .. } if error == "invalid_grant") {
262 Error::new(ErrorKind::OAuth {
263 error: "invalid_grant".to_string(),
264 description: format!(
265 "Refresh token expired or invalid. Generate a fresh SF_AUTH_URL using: \
266 `sf org display --verbose --json | jq -r '.result.sfdxAuthUrl'`. \
267 Original error: {}",
268 e
269 ),
270 })
271 } else {
272 e
273 }
274 })?;
275
276 let api_version = busbar_sf_client::DEFAULT_API_VERSION.to_string();
278 let mut creds = Self::new(
279 token_response.instance_url,
280 token_response.access_token,
281 api_version,
282 );
283 creds = creds.with_refresh_token(refresh_token);
284
285 Ok(creds)
286 }
287
288 pub fn with_api_version(mut self, version: impl Into<String>) -> Self {
290 self.api_version = version.into();
291 self
292 }
293
294 pub fn rest_api_url(&self) -> String {
296 format!(
297 "{}/services/data/v{}",
298 self.instance_url.trim_end_matches('/'),
299 self.api_version
300 )
301 }
302
303 pub fn tooling_api_url(&self) -> String {
305 format!(
306 "{}/services/data/v{}/tooling",
307 self.instance_url.trim_end_matches('/'),
308 self.api_version
309 )
310 }
311
312 pub fn metadata_api_url(&self) -> String {
314 format!(
315 "{}/services/Soap/m/{}",
316 self.instance_url.trim_end_matches('/'),
317 self.api_version
318 )
319 }
320
321 pub fn bulk_api_url(&self) -> String {
323 format!(
324 "{}/services/data/v{}/jobs",
325 self.instance_url.trim_end_matches('/'),
326 self.api_version
327 )
328 }
329}
330
331impl Credentials for SalesforceCredentials {
332 fn instance_url(&self) -> &str {
333 &self.instance_url
334 }
335
336 fn access_token(&self) -> &str {
337 &self.access_token
338 }
339
340 fn api_version(&self) -> &str {
341 &self.api_version
342 }
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348
349 #[test]
350 fn test_credentials_new() {
351 let creds =
352 SalesforceCredentials::new("https://test.salesforce.com", "access_token_123", "62.0");
353
354 assert_eq!(creds.instance_url(), "https://test.salesforce.com");
355 assert_eq!(creds.access_token(), "access_token_123");
356 assert_eq!(creds.api_version(), "62.0");
357 assert!(creds.is_valid());
358 }
359
360 #[test]
361 fn test_credentials_with_refresh_token() {
362 let creds =
363 SalesforceCredentials::new("https://test.salesforce.com", "access_token", "62.0")
364 .with_refresh_token("refresh_token_123");
365
366 assert_eq!(creds.refresh_token(), Some("refresh_token_123"));
367 }
368
369 #[test]
370 fn test_api_urls() {
371 let creds = SalesforceCredentials::new("https://na1.salesforce.com", "token", "62.0");
372
373 assert_eq!(
374 creds.rest_api_url(),
375 "https://na1.salesforce.com/services/data/v62.0"
376 );
377 assert_eq!(
378 creds.tooling_api_url(),
379 "https://na1.salesforce.com/services/data/v62.0/tooling"
380 );
381 assert_eq!(
382 creds.bulk_api_url(),
383 "https://na1.salesforce.com/services/data/v62.0/jobs"
384 );
385 }
386
387 #[test]
388 fn test_invalid_credentials() {
389 let creds = SalesforceCredentials::new("", "", "62.0");
390 assert!(!creds.is_valid());
391 }
392
393 #[test]
394 fn test_credentials_debug_redacts_tokens() {
395 let creds = SalesforceCredentials::new(
396 "https://test.salesforce.com",
397 "super_secret_access_token_12345",
398 "62.0",
399 )
400 .with_refresh_token("super_secret_refresh_token_67890");
401
402 let debug_output = format!("{:?}", creds);
403
404 assert!(debug_output.contains("[REDACTED]"));
406
407 assert!(!debug_output.contains("super_secret_access_token_12345"));
409 assert!(!debug_output.contains("super_secret_refresh_token_67890"));
410
411 assert!(debug_output.contains("test.salesforce.com"));
413 assert!(debug_output.contains("62.0"));
414 }
415
416 #[test]
417 fn test_parse_auth_url_with_client_secret() {
418 let auth_url = "force://client123:secret456:refresh789@https://test.salesforce.com";
421
422 let url = auth_url.strip_prefix("force://").unwrap();
425 let parts: Vec<&str> = url.splitn(2, '@').collect();
426 assert_eq!(parts.len(), 2);
427
428 let cred_parts: Vec<&str> = parts[0].splitn(4, ':').collect();
429 assert!(cred_parts.len() >= 3);
430 assert_eq!(cred_parts[0], "client123");
431 assert_eq!(cred_parts[1], "secret456");
432 assert_eq!(cred_parts[2], "refresh789");
433 }
434
435 #[test]
436 fn test_parse_auth_url_without_client_secret() {
437 let auth_url = "force://client123::refresh789@https://test.salesforce.com";
440
441 let url = auth_url.strip_prefix("force://").unwrap();
442 let parts: Vec<&str> = url.splitn(2, '@').collect();
443 assert_eq!(parts.len(), 2);
444
445 let cred_parts: Vec<&str> = parts[0].splitn(4, ':').collect();
446 assert!(cred_parts.len() >= 3);
447 assert_eq!(cred_parts[0], "client123");
448 assert_eq!(cred_parts[1], ""); assert_eq!(cred_parts[2], "refresh789");
450 }
451
452 #[test]
453 fn test_parse_auth_url_with_username() {
454 let auth_url = "force://client123:secret456:refresh789:user@https://test.salesforce.com";
459
460 let url = auth_url.strip_prefix("force://").unwrap();
461 let parts: Vec<&str> = url.splitn(2, '@').collect();
462 assert_eq!(parts.len(), 2);
463
464 let cred_parts: Vec<&str> = parts[0].splitn(4, ':').collect();
465 assert_eq!(cred_parts.len(), 4);
466 assert_eq!(cred_parts[0], "client123");
467 assert_eq!(cred_parts[1], "secret456");
468 assert_eq!(cred_parts[2], "refresh789");
469 assert_eq!(cred_parts[3], "user");
470 }
471
472 #[test]
473 fn test_parse_auth_url_invalid_format() {
474 let auth_url = "force://client123:secret456@https://test.salesforce.com";
476
477 let url = auth_url.strip_prefix("force://").unwrap();
478 let parts: Vec<&str> = url.splitn(2, '@').collect();
479 assert_eq!(parts.len(), 2);
480
481 let cred_parts: Vec<&str> = parts[0].splitn(4, ':').collect();
482 assert_eq!(cred_parts.len(), 2);
484 assert!(
485 cred_parts.len() < 3,
486 "Invalid format should have less than 3 parts"
487 );
488 }
489
490 #[tokio::test]
491 async fn test_from_sfdx_auth_url_with_client_secret() {
492 use wiremock::matchers::{method, path};
493 use wiremock::{Mock, MockServer, ResponseTemplate};
494
495 let mock_server = MockServer::start().await;
497
498 Mock::given(method("POST"))
499 .and(path("/services/oauth2/token"))
500 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
501 "access_token": "test_access_token",
502 "instance_url": "https://na1.salesforce.com",
503 "id": "https://login.salesforce.com/id/00Dxx0000000000EAA/005xx000000000QAAQ",
504 "token_type": "Bearer",
505 "issued_at": "1234567890"
506 })))
507 .mount(&mock_server)
508 .await;
509
510 let auth_url = format!(
511 "force://client123:secret456:refresh789@{}",
512 mock_server.uri()
513 );
514
515 let creds = SalesforceCredentials::from_sfdx_auth_url(&auth_url).await;
516 assert!(creds.is_ok(), "Failed to authenticate: {:?}", creds.err());
517
518 let creds = creds.unwrap();
519 assert_eq!(creds.instance_url(), "https://na1.salesforce.com");
520 assert_eq!(creds.access_token(), "test_access_token");
521 assert_eq!(creds.refresh_token(), Some("refresh789"));
522 }
523
524 #[tokio::test]
525 async fn test_from_sfdx_auth_url_without_client_secret() {
526 use wiremock::matchers::{method, path};
527 use wiremock::{Match, Mock, MockServer, Request, ResponseTemplate};
528
529 struct NoClientSecretMatcher;
531 impl Match for NoClientSecretMatcher {
532 fn matches(&self, request: &Request) -> bool {
533 let body = String::from_utf8_lossy(&request.body);
534 body.contains("client_id=client123")
535 && body.contains("refresh_token=refresh789")
536 && !body.contains("client_secret")
537 }
538 }
539
540 let mock_server = MockServer::start().await;
542
543 Mock::given(method("POST"))
545 .and(path("/services/oauth2/token"))
546 .and(NoClientSecretMatcher)
547 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
548 "access_token": "test_access_token_no_secret",
549 "instance_url": "https://na1.salesforce.com",
550 "id": "https://login.salesforce.com/id/00Dxx0000000000EAA/005xx000000000QAAQ",
551 "token_type": "Bearer",
552 "issued_at": "1234567890"
553 })))
554 .mount(&mock_server)
555 .await;
556
557 let auth_url = format!("force://client123::refresh789@{}", mock_server.uri());
559
560 let creds = SalesforceCredentials::from_sfdx_auth_url(&auth_url).await;
561 assert!(creds.is_ok(), "Failed to authenticate: {:?}", creds.err());
562
563 let creds = creds.unwrap();
564 assert_eq!(creds.instance_url(), "https://na1.salesforce.com");
565 assert_eq!(creds.access_token(), "test_access_token_no_secret");
566 assert_eq!(creds.refresh_token(), Some("refresh789"));
567 }
568
569 #[tokio::test]
570 async fn test_from_sfdx_auth_url_sandbox() {
571 use wiremock::matchers::{method, path};
572 use wiremock::{Mock, MockServer, ResponseTemplate};
573
574 let mock_server = MockServer::start().await;
577
578 Mock::given(method("POST"))
579 .and(path("/services/oauth2/token"))
580 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
581 "access_token": "test_access_token_sandbox",
582 "instance_url": "https://test.salesforce.com",
583 "id": "https://test.salesforce.com/id/00Dxx0000000000EAA/005xx000000000QAAQ",
584 "token_type": "Bearer",
585 "issued_at": "1234567890"
586 })))
587 .mount(&mock_server)
588 .await;
589
590 let auth_url = format!(
593 "force://client123:secret456:refresh789@{}",
594 mock_server.uri()
595 );
596
597 let creds = SalesforceCredentials::from_sfdx_auth_url(&auth_url).await;
598 assert!(creds.is_ok(), "Failed to authenticate: {:?}", creds.err());
599
600 let creds = creds.unwrap();
601 assert_eq!(creds.instance_url(), "https://test.salesforce.com");
602 assert_eq!(creds.access_token(), "test_access_token_sandbox");
603 }
604
605 #[tokio::test]
606 async fn test_from_sfdx_auth_url_with_username() {
607 use wiremock::matchers::{method, path};
608 use wiremock::{Mock, MockServer, ResponseTemplate};
609
610 let mock_server = MockServer::start().await;
612
613 Mock::given(method("POST"))
614 .and(path("/services/oauth2/token"))
615 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
616 "access_token": "test_access_token_with_user",
617 "instance_url": "https://na1.salesforce.com",
618 "id": "https://login.salesforce.com/id/00Dxx0000000000EAA/005xx000000000QAAQ",
619 "token_type": "Bearer",
620 "issued_at": "1234567890"
621 })))
622 .mount(&mock_server)
623 .await;
624
625 let auth_url = format!(
627 "force://client123:secret456:refresh789:username@{}",
628 mock_server.uri()
629 );
630
631 let creds = SalesforceCredentials::from_sfdx_auth_url(&auth_url).await;
632 assert!(creds.is_ok(), "Failed to authenticate: {:?}", creds.err());
633
634 let creds = creds.unwrap();
635 assert_eq!(creds.instance_url(), "https://na1.salesforce.com");
636 assert_eq!(creds.access_token(), "test_access_token_with_user");
637 }
638
639 #[tokio::test]
640 async fn test_from_sfdx_auth_url_invalid_too_few_parts() {
641 let auth_url = "force://client123:secret456@https://test.salesforce.com";
643
644 let creds = SalesforceCredentials::from_sfdx_auth_url(auth_url).await;
645 assert!(creds.is_err());
646 let err = creds.unwrap_err();
647 assert!(err
648 .to_string()
649 .contains("expected client_id:client_secret:refresh_token"));
650 }
651}