Skip to main content

hyperdb_api_salesforce/
config.rs

1// Copyright (c) 2026, Salesforce, Inc. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Configuration for Salesforce Data Cloud authentication.
5
6use rsa::pkcs8::DecodePrivateKey;
7use rsa::RsaPrivateKey;
8use url::Url;
9use zeroize::Zeroizing;
10
11use crate::error::{SalesforceAuthError, SalesforceAuthResult};
12
13/// Authentication mode for obtaining an OAuth Access Token from Salesforce.
14#[derive(Clone)]
15pub enum AuthMode {
16    /// Username + password authentication (OAuth password grant).
17    ///
18    /// Requires `client_secret` to be set in the config.
19    Password {
20        /// Salesforce username (email)
21        username: String,
22        /// Salesforce password (may include security token)
23        password: Zeroizing<String>,
24    },
25
26    /// JWT Bearer Token Flow using RSA private key.
27    ///
28    /// This is the recommended mode for server-to-server authentication.
29    /// Does NOT require `client_secret`.  Each call generates a fresh JWT
30    /// assertion, so there is no OAuth Refresh Token to rotate.
31    ///
32    /// See: <https://help.salesforce.com/s/articleView?id=xcloud.remoteaccess_oauth_jwt_flow.htm>
33    PrivateKey {
34        /// Salesforce username (email) that authorized the connected app
35        username: String,
36        /// RSA private key for signing JWT assertions
37        private_key: Box<RsaPrivateKey>,
38    },
39
40    /// OAuth Refresh Token grant.
41    ///
42    /// Uses a long-lived OAuth Refresh Token to obtain short-lived OAuth
43    /// Access Tokens.  Requires `client_secret` to be set in the config.
44    ///
45    /// **Important**: The provider caches the OAuth Access Token and only
46    /// refreshes it when genuinely expired, to avoid unnecessary OAuth
47    /// Refresh Token rotation that would invalidate tokens held by other
48    /// connections.
49    RefreshToken {
50        /// OAuth Refresh Token
51        refresh_token: Zeroizing<String>,
52    },
53}
54
55impl AuthMode {
56    /// Creates a password authentication mode.
57    pub fn password(username: impl Into<String>, password: impl Into<String>) -> Self {
58        AuthMode::Password {
59            username: username.into(),
60            password: Zeroizing::new(password.into()),
61        }
62    }
63
64    /// Creates a private key authentication mode from a PEM-encoded private key.
65    ///
66    /// # Arguments
67    ///
68    /// * `username` - Salesforce username (email) that authorized the connected app
69    /// * `private_key_pem` - RSA private key in PEM format (PKCS#8)
70    ///
71    /// # Example
72    ///
73    /// ```no_run
74    /// use hyperdb_api_salesforce::AuthMode;
75    ///
76    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
77    /// let pem = "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----";
78    /// let mode = AuthMode::private_key("user@example.com", pem)?;
79    /// # Ok(())
80    /// # }
81    /// ```
82    ///
83    /// # Errors
84    ///
85    /// Returns [`SalesforceAuthError::PrivateKey`] if `private_key_pem` is
86    /// not a valid PKCS#8 PEM-encoded RSA private key (malformed PEM
87    /// envelope, wrong algorithm, or corrupted key bytes).
88    pub fn private_key(
89        username: impl Into<String>,
90        private_key_pem: &str,
91    ) -> SalesforceAuthResult<Self> {
92        let private_key = RsaPrivateKey::from_pkcs8_pem(private_key_pem).map_err(|e| {
93            SalesforceAuthError::PrivateKey(format!(
94                "failed to parse private key (expected PKCS#8 PEM format): {e}"
95            ))
96        })?;
97
98        Ok(AuthMode::PrivateKey {
99            username: username.into(),
100            private_key: Box::new(private_key),
101        })
102    }
103
104    /// Creates an OAuth Refresh Token authentication mode.
105    pub fn refresh_token(refresh_token: impl Into<String>) -> Self {
106        AuthMode::RefreshToken {
107            refresh_token: Zeroizing::new(refresh_token.into()),
108        }
109    }
110
111    /// Returns the username if applicable to this auth mode.
112    #[must_use]
113    pub fn username(&self) -> Option<&str> {
114        match self {
115            AuthMode::Password { username, .. } => Some(username),
116            AuthMode::PrivateKey { username, .. } => Some(username),
117            AuthMode::RefreshToken { .. } => None,
118        }
119    }
120}
121
122impl std::fmt::Debug for AuthMode {
123    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124        match self {
125            AuthMode::Password { username, .. } => f
126                .debug_struct("Password")
127                .field("username", username)
128                .field("password", &"[REDACTED]")
129                .finish(),
130            AuthMode::PrivateKey { username, .. } => f
131                .debug_struct("PrivateKey")
132                .field("username", username)
133                .field("private_key", &"[REDACTED]")
134                .finish(),
135            AuthMode::RefreshToken { .. } => f
136                .debug_struct("RefreshToken")
137                .field("refresh_token", &"[REDACTED]")
138                .finish(),
139        }
140    }
141}
142
143/// Configuration for the Salesforce Data Cloud token flow.
144///
145/// Configures how OAuth Access Tokens and DC JWTs are obtained:
146/// - `login_url` + `client_id` + `auth_mode` → OAuth Access Token
147/// - OAuth Access Token + `dataspace` → DC JWT
148///
149/// # Example
150///
151/// ```no_run
152/// use hyperdb_api_salesforce::{SalesforceAuthConfig, AuthMode};
153///
154/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
155/// # let private_key_pem = "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----";
156/// let config = SalesforceAuthConfig::new(
157///     "https://login.salesforce.com",
158///     "3MVG9...", // Connected App Consumer Key
159/// )?
160/// .auth_mode(AuthMode::private_key("user@example.com", &private_key_pem)?)
161/// .dataspace("default");
162/// # Ok(())
163/// # }
164/// ```
165#[derive(Debug, Clone)]
166pub struct SalesforceAuthConfig {
167    /// Salesforce login URL (e.g., "<https://login.salesforce.com>" or custom domain)
168    pub(crate) login_url: Url,
169
170    /// Connected App Consumer Key (`client_id`)
171    pub(crate) client_id: String,
172
173    /// Connected App Consumer Secret (required for Password and `RefreshToken` modes)
174    pub(crate) client_secret: Option<Zeroizing<String>>,
175
176    /// Authentication mode (determines how an OAuth Access Token is obtained)
177    pub(crate) auth_mode: Option<AuthMode>,
178
179    /// Data Cloud dataspace (sent to `/services/a360/token` when
180    /// exchanging the OAuth Access Token for a DC JWT)
181    pub(crate) dataspace: Option<String>,
182
183    /// HTTP request timeout in seconds
184    pub(crate) timeout_secs: u64,
185
186    /// Maximum number of retries for transient failures
187    pub(crate) max_retries: u32,
188}
189
190impl SalesforceAuthConfig {
191    /// Creates a new configuration with the given login URL and client ID.
192    ///
193    /// # Arguments
194    ///
195    /// * `login_url` - Salesforce login URL (e.g., "<https://login.salesforce.com>")
196    /// * `client_id` - Connected App Consumer Key
197    ///
198    /// # Known Login URLs
199    ///
200    /// - Production: `https://login.salesforce.com`
201    /// - Sandbox: `https://test.salesforce.com`
202    /// - Custom domain: `https://mydomain.my.salesforce.com`
203    ///
204    /// # Errors
205    ///
206    /// Returns [`SalesforceAuthError::Config`] if:
207    /// - `login_url` cannot be parsed as a URL (converted from
208    ///   [`url::ParseError`]).
209    /// - The URL scheme is not `http` or `https`.
210    /// - The URL lacks a host component.
211    pub fn new(
212        login_url: impl AsRef<str>,
213        client_id: impl Into<String>,
214    ) -> SalesforceAuthResult<Self> {
215        let login_url = Url::parse(login_url.as_ref())?;
216
217        // Validate the URL has a scheme and host
218        if login_url.scheme() != "https" && login_url.scheme() != "http" {
219            return Err(SalesforceAuthError::Config(
220                "login_url must use http or https scheme".to_string(),
221            ));
222        }
223
224        if login_url.host().is_none() {
225            return Err(SalesforceAuthError::Config(
226                "login_url must have a host".to_string(),
227            ));
228        }
229
230        Ok(SalesforceAuthConfig {
231            login_url,
232            client_id: client_id.into(),
233            client_secret: None,
234            auth_mode: None,
235            dataspace: None,
236            timeout_secs: 30,
237            max_retries: 3,
238        })
239    }
240
241    /// Sets the authentication mode.
242    #[must_use]
243    pub fn auth_mode(mut self, mode: AuthMode) -> Self {
244        self.auth_mode = Some(mode);
245        self
246    }
247
248    #[must_use]
249    /// Sets the client secret (required for Password and `RefreshToken` modes).
250    ///
251    /// **Note**: Client secret is NOT required for `PrivateKey` (JWT Bearer) mode.
252    pub fn client_secret(mut self, secret: impl Into<String>) -> Self {
253        self.client_secret = Some(Zeroizing::new(secret.into()));
254        self
255    }
256
257    #[must_use]
258    /// Sets the Data Cloud dataspace.
259    pub fn dataspace(mut self, dataspace: impl Into<String>) -> Self {
260        self.dataspace = Some(dataspace.into());
261        self
262    }
263
264    /// Sets the HTTP request timeout in seconds (default: 30).
265    #[must_use]
266    pub fn timeout_secs(mut self, secs: u64) -> Self {
267        self.timeout_secs = secs;
268        self
269    }
270
271    /// Sets the maximum number of retries for transient failures (default: 3).
272    #[must_use]
273    pub fn max_retries(mut self, retries: u32) -> Self {
274        self.max_retries = retries;
275        self
276    }
277
278    /// Returns the login URL.
279    #[must_use]
280    pub fn login_url(&self) -> &Url {
281        &self.login_url
282    }
283
284    /// Returns the client ID.
285    #[must_use]
286    pub fn client_id(&self) -> &str {
287        &self.client_id
288    }
289
290    /// Returns the dataspace, if set.
291    #[must_use]
292    pub fn dataspace_value(&self) -> Option<&str> {
293        self.dataspace.as_deref()
294    }
295
296    /// Validates the configuration.
297    pub(crate) fn validate(&self) -> SalesforceAuthResult<()> {
298        let auth_mode = self
299            .auth_mode
300            .as_ref()
301            .ok_or_else(|| SalesforceAuthError::Config("auth_mode is required".to_string()))?;
302
303        match auth_mode {
304            AuthMode::Password { .. } | AuthMode::RefreshToken { .. } => {
305                if self.client_secret.is_none() {
306                    return Err(SalesforceAuthError::Config(
307                        "client_secret is required for Password and RefreshToken auth modes"
308                            .to_string(),
309                    ));
310                }
311            }
312            AuthMode::PrivateKey { .. } => {
313                if self.client_secret.is_some() {
314                    tracing::warn!(
315                        "client_secret is set but not used for PrivateKey (JWT Bearer) mode"
316                    );
317                }
318            }
319        }
320
321        Ok(())
322    }
323}
324
325/// Known Salesforce login URL patterns for validation/warnings.
326#[expect(
327    dead_code,
328    reason = "retained for upcoming login URL warning surface; keep wired up so it stays compiled"
329)]
330pub(crate) fn is_known_salesforce_host(host: &str) -> bool {
331    let patterns = ["login.salesforce.com", "test.salesforce.com"];
332
333    let suffix_patterns = [
334        ".my.salesforce.com",
335        ".my.site.com",
336        ".sandbox.my.salesforce.com",
337    ];
338
339    if patterns.contains(&host) {
340        return true;
341    }
342
343    for suffix in suffix_patterns {
344        if host.ends_with(suffix) {
345            return true;
346        }
347    }
348
349    // Test/development patterns
350    if host.starts_with("login.test") && host.ends_with(".pc-rnd.salesforce.com") {
351        return true;
352    }
353
354    false
355}