Skip to main content

busbar_sf_auth/
credentials.rs

1//! Credentials trait and implementations.
2//!
3//! All credential types implement custom Debug to redact sensitive data.
4
5use crate::error::{Error, ErrorKind, Result};
6
7/// Trait for Salesforce credentials.
8pub trait Credentials: Send + Sync {
9    /// Get the Salesforce instance URL.
10    fn instance_url(&self) -> &str;
11
12    /// Get the access token.
13    fn access_token(&self) -> &str;
14
15    /// Get the API version (e.g., "62.0").
16    fn api_version(&self) -> &str;
17
18    /// Returns true if the credentials appear to be valid (non-empty).
19    fn is_valid(&self) -> bool {
20        !self.instance_url().is_empty() && !self.access_token().is_empty()
21    }
22}
23
24/// Standard Salesforce credentials implementation.
25///
26/// Sensitive fields (access_token, refresh_token) are redacted in Debug output
27/// to prevent accidental exposure in logs.
28#[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    /// Create new credentials with the given values.
52    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    /// Create credentials with a refresh token.
66    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    /// Get the refresh token if available.
72    pub fn refresh_token(&self) -> Option<&str> {
73        self.refresh_token.as_deref()
74    }
75
76    /// Set a new access token (e.g., after refresh).
77    pub fn set_access_token(&mut self, token: impl Into<String>) {
78        self.access_token = token.into();
79    }
80
81    /// Load credentials from environment variables.
82    ///
83    /// Required environment variables:
84    /// - `SF_INSTANCE_URL` or `SALESFORCE_INSTANCE_URL`
85    /// - `SF_ACCESS_TOKEN` or `SALESFORCE_ACCESS_TOKEN`
86    ///
87    /// Optional:
88    /// - `SF_API_VERSION` or `SALESFORCE_API_VERSION` (default: "62.0")
89    /// - `SF_REFRESH_TOKEN` or `SALESFORCE_REFRESH_TOKEN`
90    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    /// Load credentials from SFDX CLI using an org alias or username.
116    ///
117    /// Requires the `sf` CLI to be installed and the org to be authenticated.
118    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    /// Load credentials from an SFDX auth URL.
166    ///
167    /// The SFDX auth URL format is:
168    /// - `force://<client_id>:<client_secret>:<refresh_token>@<instance_url>`
169    /// - `force://<client_id>::<refresh_token>@<instance_url>` (empty client_secret)
170    /// - `force://<client_id>:<client_secret>:<refresh_token>:<username>@<instance_url>` (with username)
171    ///
172    /// The client_secret can be empty (indicated by `::`) for the default Salesforce CLI
173    /// connected app. The username field is optional.
174    ///
175    /// This method will parse the auth URL and use the refresh token to obtain
176    /// an access token from Salesforce.
177    ///
178    /// # Example
179    /// ```no_run
180    /// # use busbar_sf_auth::SalesforceCredentials;
181    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
182    /// let auth_url = std::env::var("SF_AUTH_URL")?;
183    /// let creds = SalesforceCredentials::from_sfdx_auth_url(&auth_url).await?;
184    /// # Ok(())
185    /// # }
186    /// ```
187    pub async fn from_sfdx_auth_url(auth_url: &str) -> Result<Self> {
188        use crate::oauth::{OAuthClient, OAuthConfig};
189
190        // Parse the auth URL
191        // Format: force://<client_id>:<client_secret>:<refresh_token>@<instance_url>
192        // Or with username: force://<client_id>:<client_secret>:<refresh_token>:<username>@<instance_url>
193        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        // Split at @ to separate credentials from instance URL
202        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        // Split credentials into client_id:client_secret:refresh_token[:username]
213        // Username is optional, so we accept 3 or 4 parts
214        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        // The refresh token is in the third position
229        let refresh_token = cred_parts[2];
230        // Username is optional (4th position if present, not used currently)
231
232        // Create OAuth client
233        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        // Build token endpoint URL from instance URL
241        // For localhost/test servers, use the instance_url directly
242        // For Salesforce production/sandbox/scratch orgs, use the appropriate login URL
243        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        // Use refresh token to get access token
256        let token_response = oauth_client
257            .refresh_token(refresh_token, token_url)
258            .await
259            .map_err(|e| {
260                // Enhance error message for expired refresh tokens
261                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        // Create credentials from token response
277        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    /// Change the API version.
289    pub fn with_api_version(mut self, version: impl Into<String>) -> Self {
290        self.api_version = version.into();
291        self
292    }
293
294    /// Get the base REST API URL for this org.
295    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    /// Get the Tooling API URL for this org.
304    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    /// Get the Metadata API URL for this org.
313    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    /// Get the Bulk API 2.0 URL for this org.
322    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        // Should contain [REDACTED]
405        assert!(debug_output.contains("[REDACTED]"));
406
407        // Should NOT contain actual tokens
408        assert!(!debug_output.contains("super_secret_access_token_12345"));
409        assert!(!debug_output.contains("super_secret_refresh_token_67890"));
410
411        // Should still contain non-sensitive data
412        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        // Test parsing with client_secret present
419        // Format: force://<client_id>:<client_secret>:<refresh_token>@<instance_url>
420        let auth_url = "force://client123:secret456:refresh789@https://test.salesforce.com";
421
422        // We can't test the full async function without mocking the OAuth server,
423        // but we can test the parsing logic by extracting it
424        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        // Test parsing with empty client_secret (default Salesforce CLI connected app)
438        // Format: force://<client_id>::<refresh_token>@<instance_url>
439        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], ""); // Empty client_secret
449        assert_eq!(cred_parts[2], "refresh789");
450    }
451
452    #[test]
453    fn test_parse_auth_url_with_username() {
454        // Test parsing with username appended (optional 4th field)
455        // Format: force://<client_id>:<client_secret>:<refresh_token>:<username>@<instance_url>
456        // Note: username cannot contain @ since splitn(2, '@') splits on the first @,
457        // making it the delimiter between credentials and instance_url
458        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        // Test invalid format with too few parts
475        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        // Should have only 2 parts, which is less than the required 3
483        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        // Set up mock OAuth server
496        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        // Custom matcher to verify client_secret is NOT in the request
530        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        // Set up mock OAuth server
541        let mock_server = MockServer::start().await;
542
543        // Verify that client_secret is NOT sent when empty
544        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        // Auth URL with empty client_secret (double colon ::)
558        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        // Set up mock OAuth server - note we can't actually test the sandbox URL selection
575        // without mocking the actual Salesforce endpoint, but we can test that the parsing works
576        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        // Use localhost in the auth URL so it uses the mock server
591        // In production, sandbox URLs would route to test.salesforce.com
592        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        // Set up mock OAuth server
611        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        // Auth URL with username field
626        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        // Auth URL with only 2 parts (missing refresh token)
642        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}