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 async fn revoke_session(&self, revoke_refresh: bool, login_url: &str) -> Result<()> {
130 use crate::oauth::{OAuthClient, OAuthConfig};
131
132 let token = if revoke_refresh {
134 self.refresh_token.as_ref().ok_or_else(|| {
135 Error::new(ErrorKind::InvalidInput(
136 "Cannot revoke refresh token: no refresh token available".to_string(),
137 ))
138 })?
139 } else {
140 &self.access_token
141 };
142
143 let config = OAuthConfig::new("revoke_client");
145 let client = OAuthClient::new(config);
146
147 client.revoke_token(token, login_url).await
148 }
149
150 pub fn from_env() -> Result<Self> {
160 let instance_url = std::env::var("SF_INSTANCE_URL")
161 .or_else(|_| std::env::var("SALESFORCE_INSTANCE_URL"))
162 .map_err(|_| Error::new(ErrorKind::EnvVar("SF_INSTANCE_URL".to_string())))?;
163
164 let access_token = std::env::var("SF_ACCESS_TOKEN")
165 .or_else(|_| std::env::var("SALESFORCE_ACCESS_TOKEN"))
166 .map_err(|_| Error::new(ErrorKind::EnvVar("SF_ACCESS_TOKEN".to_string())))?;
167
168 let api_version = std::env::var("SF_API_VERSION")
169 .or_else(|_| std::env::var("SALESFORCE_API_VERSION"))
170 .unwrap_or_else(|_| busbar_sf_client::DEFAULT_API_VERSION.to_string());
171
172 let refresh_token = std::env::var("SF_REFRESH_TOKEN")
173 .or_else(|_| std::env::var("SALESFORCE_REFRESH_TOKEN"))
174 .ok();
175
176 let mut creds = Self::new(instance_url, access_token, api_version);
177 if let Some(rt) = refresh_token {
178 creds = creds.with_refresh_token(rt);
179 }
180
181 Ok(creds)
182 }
183
184 pub async fn from_sfdx_alias(alias_or_username: &str) -> Result<Self> {
188 use tokio::process::Command;
189
190 let output = Command::new("sf")
191 .args([
192 "org",
193 "display",
194 "--target-org",
195 alias_or_username,
196 "--json",
197 ])
198 .output()
199 .await
200 .map_err(|e| Error::new(ErrorKind::SfdxCli(format!("Failed to run sf CLI: {}", e))))?;
201
202 if !output.status.success() {
203 let stderr = String::from_utf8_lossy(&output.stderr);
204 return Err(Error::new(ErrorKind::SfdxCli(format!(
205 "sf org display failed: {}",
206 stderr
207 ))));
208 }
209
210 let json: serde_json::Value = serde_json::from_slice(&output.stdout)?;
211
212 let result = json.get("result").ok_or_else(|| {
213 Error::new(ErrorKind::SfdxCli("Missing 'result' in output".to_string()))
214 })?;
215
216 let instance_url = result
217 .get("instanceUrl")
218 .and_then(|v| v.as_str())
219 .ok_or_else(|| Error::new(ErrorKind::SfdxCli("Missing instanceUrl".to_string())))?;
220
221 let access_token = result
222 .get("accessToken")
223 .and_then(|v| v.as_str())
224 .ok_or_else(|| Error::new(ErrorKind::SfdxCli("Missing accessToken".to_string())))?;
225
226 let api_version = result
227 .get("apiVersion")
228 .and_then(|v| v.as_str())
229 .unwrap_or(busbar_sf_client::DEFAULT_API_VERSION);
230
231 Ok(Self::new(instance_url, access_token, api_version))
232 }
233
234 pub async fn from_sfdx_auth_url(auth_url: &str) -> Result<Self> {
257 use crate::oauth::{OAuthClient, OAuthConfig};
258
259 if !auth_url.starts_with("force://") {
263 return Err(Error::new(ErrorKind::InvalidInput(
264 "Auth URL must start with force://".to_string(),
265 )));
266 }
267
268 let url = auth_url.strip_prefix("force://").unwrap();
269
270 let parts: Vec<&str> = url.splitn(2, '@').collect();
272 if parts.len() != 2 {
273 return Err(Error::new(ErrorKind::InvalidInput(
274 "Invalid auth URL format: missing @".to_string(),
275 )));
276 }
277
278 let credentials_part = parts[0];
279 let instance_url = parts[1];
280
281 let cred_parts: Vec<&str> = credentials_part.splitn(4, ':').collect();
284 if cred_parts.len() < 3 {
285 return Err(Error::new(ErrorKind::InvalidInput(
286 "Invalid auth URL format: expected client_id:client_secret:refresh_token[:username]"
287 .to_string(),
288 )));
289 }
290
291 let client_id = cred_parts[0];
292 let client_secret = if cred_parts[1].is_empty() {
293 None
294 } else {
295 Some(cred_parts[1].to_string())
296 };
297 let refresh_token = cred_parts[2];
299 let mut config = OAuthConfig::new(client_id);
303 if let Some(secret) = client_secret {
304 config = config.with_secret(secret);
305 }
306
307 let oauth_client = OAuthClient::new(config);
308
309 let token_url = if instance_url.contains("localhost") || instance_url.contains("127.0.0.1")
313 {
314 instance_url
315 } else if instance_url.contains("test.salesforce.com")
316 || instance_url.contains("sandbox")
317 || instance_url.contains(".scratch.")
318 {
319 "https://test.salesforce.com"
320 } else {
321 "https://login.salesforce.com"
322 };
323
324 let token_response = oauth_client
326 .refresh_token(refresh_token, token_url)
327 .await
328 .map_err(|e| {
329 if matches!(&e.kind, ErrorKind::OAuth { error, .. } if error == "invalid_grant") {
331 Error::new(ErrorKind::OAuth {
332 error: "invalid_grant".to_string(),
333 description: format!(
334 "Refresh token expired or invalid. Generate a fresh SF_AUTH_URL using: \
335 `sf org display --verbose --json | jq -r '.result.sfdxAuthUrl'`. \
336 Original error: {}",
337 e
338 ),
339 })
340 } else {
341 e
342 }
343 })?;
344
345 let api_version = busbar_sf_client::DEFAULT_API_VERSION.to_string();
347 let mut creds = Self::new(
348 token_response.instance_url,
349 token_response.access_token,
350 api_version,
351 );
352 creds = creds.with_refresh_token(refresh_token);
353
354 Ok(creds)
355 }
356
357 pub fn with_api_version(mut self, version: impl Into<String>) -> Self {
359 self.api_version = version.into();
360 self
361 }
362
363 pub fn rest_api_url(&self) -> String {
365 format!(
366 "{}/services/data/v{}",
367 self.instance_url.trim_end_matches('/'),
368 self.api_version
369 )
370 }
371
372 pub fn tooling_api_url(&self) -> String {
374 format!(
375 "{}/services/data/v{}/tooling",
376 self.instance_url.trim_end_matches('/'),
377 self.api_version
378 )
379 }
380
381 pub fn metadata_api_url(&self) -> String {
383 format!(
384 "{}/services/Soap/m/{}",
385 self.instance_url.trim_end_matches('/'),
386 self.api_version
387 )
388 }
389
390 pub fn bulk_api_url(&self) -> String {
392 format!(
393 "{}/services/data/v{}/jobs",
394 self.instance_url.trim_end_matches('/'),
395 self.api_version
396 )
397 }
398}
399
400impl Credentials for SalesforceCredentials {
401 fn instance_url(&self) -> &str {
402 &self.instance_url
403 }
404
405 fn access_token(&self) -> &str {
406 &self.access_token
407 }
408
409 fn api_version(&self) -> &str {
410 &self.api_version
411 }
412}
413
414#[cfg(test)]
415mod tests {
416 use super::*;
417
418 #[test]
419 fn test_credentials_new() {
420 let creds =
421 SalesforceCredentials::new("https://test.salesforce.com", "access_token_123", "62.0");
422
423 assert_eq!(creds.instance_url(), "https://test.salesforce.com");
424 assert_eq!(creds.access_token(), "access_token_123");
425 assert_eq!(creds.api_version(), "62.0");
426 assert!(creds.is_valid());
427 }
428
429 #[test]
430 fn test_credentials_with_refresh_token() {
431 let creds =
432 SalesforceCredentials::new("https://test.salesforce.com", "access_token", "62.0")
433 .with_refresh_token("refresh_token_123");
434
435 assert_eq!(creds.refresh_token(), Some("refresh_token_123"));
436 }
437
438 #[test]
439 fn test_api_urls() {
440 let creds = SalesforceCredentials::new("https://na1.salesforce.com", "token", "62.0");
441
442 assert_eq!(
443 creds.rest_api_url(),
444 "https://na1.salesforce.com/services/data/v62.0"
445 );
446 assert_eq!(
447 creds.tooling_api_url(),
448 "https://na1.salesforce.com/services/data/v62.0/tooling"
449 );
450 assert_eq!(
451 creds.bulk_api_url(),
452 "https://na1.salesforce.com/services/data/v62.0/jobs"
453 );
454 }
455
456 #[test]
457 fn test_invalid_credentials() {
458 let creds = SalesforceCredentials::new("", "", "62.0");
459 assert!(!creds.is_valid());
460 }
461
462 #[test]
463 fn test_credentials_debug_redacts_tokens() {
464 let creds = SalesforceCredentials::new(
465 "https://test.salesforce.com",
466 "super_secret_access_token_12345",
467 "62.0",
468 )
469 .with_refresh_token("super_secret_refresh_token_67890");
470
471 let debug_output = format!("{:?}", creds);
472
473 assert!(debug_output.contains("[REDACTED]"));
475
476 assert!(!debug_output.contains("super_secret_access_token_12345"));
478 assert!(!debug_output.contains("super_secret_refresh_token_67890"));
479
480 assert!(debug_output.contains("test.salesforce.com"));
482 assert!(debug_output.contains("62.0"));
483 }
484
485 #[test]
486 fn test_parse_auth_url_with_client_secret() {
487 let auth_url = "force://client123:secret456:refresh789@https://test.salesforce.com";
490
491 let url = auth_url.strip_prefix("force://").unwrap();
494 let parts: Vec<&str> = url.splitn(2, '@').collect();
495 assert_eq!(parts.len(), 2);
496
497 let cred_parts: Vec<&str> = parts[0].splitn(4, ':').collect();
498 assert!(cred_parts.len() >= 3);
499 assert_eq!(cred_parts[0], "client123");
500 assert_eq!(cred_parts[1], "secret456");
501 assert_eq!(cred_parts[2], "refresh789");
502 }
503
504 #[test]
505 fn test_parse_auth_url_without_client_secret() {
506 let auth_url = "force://client123::refresh789@https://test.salesforce.com";
509
510 let url = auth_url.strip_prefix("force://").unwrap();
511 let parts: Vec<&str> = url.splitn(2, '@').collect();
512 assert_eq!(parts.len(), 2);
513
514 let cred_parts: Vec<&str> = parts[0].splitn(4, ':').collect();
515 assert!(cred_parts.len() >= 3);
516 assert_eq!(cred_parts[0], "client123");
517 assert_eq!(cred_parts[1], ""); assert_eq!(cred_parts[2], "refresh789");
519 }
520
521 #[test]
522 fn test_parse_auth_url_with_username() {
523 let auth_url = "force://client123:secret456:refresh789:user@https://test.salesforce.com";
528
529 let url = auth_url.strip_prefix("force://").unwrap();
530 let parts: Vec<&str> = url.splitn(2, '@').collect();
531 assert_eq!(parts.len(), 2);
532
533 let cred_parts: Vec<&str> = parts[0].splitn(4, ':').collect();
534 assert_eq!(cred_parts.len(), 4);
535 assert_eq!(cred_parts[0], "client123");
536 assert_eq!(cred_parts[1], "secret456");
537 assert_eq!(cred_parts[2], "refresh789");
538 assert_eq!(cred_parts[3], "user");
539 }
540
541 #[test]
542 fn test_parse_auth_url_invalid_format() {
543 let auth_url = "force://client123:secret456@https://test.salesforce.com";
545
546 let url = auth_url.strip_prefix("force://").unwrap();
547 let parts: Vec<&str> = url.splitn(2, '@').collect();
548 assert_eq!(parts.len(), 2);
549
550 let cred_parts: Vec<&str> = parts[0].splitn(4, ':').collect();
551 assert_eq!(cred_parts.len(), 2);
553 assert!(
554 cred_parts.len() < 3,
555 "Invalid format should have less than 3 parts"
556 );
557 }
558
559 #[tokio::test]
560 async fn test_from_sfdx_auth_url_with_client_secret() {
561 use wiremock::matchers::{method, path};
562 use wiremock::{Mock, MockServer, ResponseTemplate};
563
564 let mock_server = MockServer::start().await;
566
567 Mock::given(method("POST"))
568 .and(path("/services/oauth2/token"))
569 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
570 "access_token": "test_access_token",
571 "instance_url": "https://na1.salesforce.com",
572 "id": "https://login.salesforce.com/id/00Dxx0000000000EAA/005xx000000000QAAQ",
573 "token_type": "Bearer",
574 "issued_at": "1234567890"
575 })))
576 .mount(&mock_server)
577 .await;
578
579 let auth_url = format!(
580 "force://client123:secret456:refresh789@{}",
581 mock_server.uri()
582 );
583
584 let creds = SalesforceCredentials::from_sfdx_auth_url(&auth_url).await;
585 assert!(creds.is_ok(), "Failed to authenticate: {:?}", creds.err());
586
587 let creds = creds.unwrap();
588 assert_eq!(creds.instance_url(), "https://na1.salesforce.com");
589 assert_eq!(creds.access_token(), "test_access_token");
590 assert_eq!(creds.refresh_token(), Some("refresh789"));
591 }
592
593 #[tokio::test]
594 async fn test_from_sfdx_auth_url_without_client_secret() {
595 use wiremock::matchers::{method, path};
596 use wiremock::{Match, Mock, MockServer, Request, ResponseTemplate};
597
598 struct NoClientSecretMatcher;
600 impl Match for NoClientSecretMatcher {
601 fn matches(&self, request: &Request) -> bool {
602 let body = String::from_utf8_lossy(&request.body);
603 body.contains("client_id=client123")
604 && body.contains("refresh_token=refresh789")
605 && !body.contains("client_secret")
606 }
607 }
608
609 let mock_server = MockServer::start().await;
611
612 Mock::given(method("POST"))
614 .and(path("/services/oauth2/token"))
615 .and(NoClientSecretMatcher)
616 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
617 "access_token": "test_access_token_no_secret",
618 "instance_url": "https://na1.salesforce.com",
619 "id": "https://login.salesforce.com/id/00Dxx0000000000EAA/005xx000000000QAAQ",
620 "token_type": "Bearer",
621 "issued_at": "1234567890"
622 })))
623 .mount(&mock_server)
624 .await;
625
626 let auth_url = format!("force://client123::refresh789@{}", mock_server.uri());
628
629 let creds = SalesforceCredentials::from_sfdx_auth_url(&auth_url).await;
630 assert!(creds.is_ok(), "Failed to authenticate: {:?}", creds.err());
631
632 let creds = creds.unwrap();
633 assert_eq!(creds.instance_url(), "https://na1.salesforce.com");
634 assert_eq!(creds.access_token(), "test_access_token_no_secret");
635 assert_eq!(creds.refresh_token(), Some("refresh789"));
636 }
637
638 #[tokio::test]
639 async fn test_from_sfdx_auth_url_sandbox() {
640 use wiremock::matchers::{method, path};
641 use wiremock::{Mock, MockServer, ResponseTemplate};
642
643 let mock_server = MockServer::start().await;
646
647 Mock::given(method("POST"))
648 .and(path("/services/oauth2/token"))
649 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
650 "access_token": "test_access_token_sandbox",
651 "instance_url": "https://test.salesforce.com",
652 "id": "https://test.salesforce.com/id/00Dxx0000000000EAA/005xx000000000QAAQ",
653 "token_type": "Bearer",
654 "issued_at": "1234567890"
655 })))
656 .mount(&mock_server)
657 .await;
658
659 let auth_url = format!(
662 "force://client123:secret456:refresh789@{}",
663 mock_server.uri()
664 );
665
666 let creds = SalesforceCredentials::from_sfdx_auth_url(&auth_url).await;
667 assert!(creds.is_ok(), "Failed to authenticate: {:?}", creds.err());
668
669 let creds = creds.unwrap();
670 assert_eq!(creds.instance_url(), "https://test.salesforce.com");
671 assert_eq!(creds.access_token(), "test_access_token_sandbox");
672 }
673
674 #[tokio::test]
675 async fn test_from_sfdx_auth_url_with_username() {
676 use wiremock::matchers::{method, path};
677 use wiremock::{Mock, MockServer, ResponseTemplate};
678
679 let mock_server = MockServer::start().await;
681
682 Mock::given(method("POST"))
683 .and(path("/services/oauth2/token"))
684 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
685 "access_token": "test_access_token_with_user",
686 "instance_url": "https://na1.salesforce.com",
687 "id": "https://login.salesforce.com/id/00Dxx0000000000EAA/005xx000000000QAAQ",
688 "token_type": "Bearer",
689 "issued_at": "1234567890"
690 })))
691 .mount(&mock_server)
692 .await;
693
694 let auth_url = format!(
696 "force://client123:secret456:refresh789:username@{}",
697 mock_server.uri()
698 );
699
700 let creds = SalesforceCredentials::from_sfdx_auth_url(&auth_url).await;
701 assert!(creds.is_ok(), "Failed to authenticate: {:?}", creds.err());
702
703 let creds = creds.unwrap();
704 assert_eq!(creds.instance_url(), "https://na1.salesforce.com");
705 assert_eq!(creds.access_token(), "test_access_token_with_user");
706 }
707
708 #[tokio::test]
709 async fn test_from_sfdx_auth_url_invalid_too_few_parts() {
710 let auth_url = "force://client123:secret456@https://test.salesforce.com";
712
713 let creds = SalesforceCredentials::from_sfdx_auth_url(auth_url).await;
714 assert!(creds.is_err());
715 let err = creds.unwrap_err();
716 assert!(err
717 .to_string()
718 .contains("expected client_id:client_secret:refresh_token"));
719 }
720
721 #[tokio::test]
722 async fn test_revoke_session_access_token() {
723 use wiremock::matchers::{body_string_contains, method, path};
724 use wiremock::{Mock, MockServer, ResponseTemplate};
725
726 let mock_server = MockServer::start().await;
727
728 Mock::given(method("POST"))
730 .and(path("/services/oauth2/revoke"))
731 .and(body_string_contains("token=test_access_token"))
732 .respond_with(ResponseTemplate::new(200))
733 .mount(&mock_server)
734 .await;
735
736 let creds =
737 SalesforceCredentials::new("https://na1.salesforce.com", "test_access_token", "62.0");
738
739 let result = creds.revoke_session(false, &mock_server.uri()).await;
740 assert!(result.is_ok(), "Revoking access token should succeed");
741 }
742
743 #[tokio::test]
744 async fn test_revoke_session_refresh_token() {
745 use wiremock::matchers::{body_string_contains, method, path};
746 use wiremock::{Mock, MockServer, ResponseTemplate};
747
748 let mock_server = MockServer::start().await;
749
750 Mock::given(method("POST"))
752 .and(path("/services/oauth2/revoke"))
753 .and(body_string_contains("token=test_refresh_token"))
754 .respond_with(ResponseTemplate::new(200))
755 .mount(&mock_server)
756 .await;
757
758 let creds =
759 SalesforceCredentials::new("https://na1.salesforce.com", "test_access_token", "62.0")
760 .with_refresh_token("test_refresh_token");
761
762 let result = creds.revoke_session(true, &mock_server.uri()).await;
763 assert!(
764 result.is_ok(),
765 "Revoking refresh token should succeed: {:?}",
766 result.err()
767 );
768 }
769
770 #[tokio::test]
771 async fn test_revoke_session_no_refresh_token() {
772 let creds =
773 SalesforceCredentials::new("https://na1.salesforce.com", "test_access_token", "62.0");
774
775 let result = creds
777 .revoke_session(true, "https://login.salesforce.com")
778 .await;
779
780 assert!(result.is_err(), "Should fail when no refresh token exists");
781 let err = result.unwrap_err();
782 assert!(
783 matches!(err.kind, ErrorKind::InvalidInput(_)),
784 "Should return InvalidInput error"
785 );
786 }
787}