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::private_key(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",
221            ));
222        }
223
224        if login_url.host().is_none() {
225            return Err(SalesforceAuthError::config("login_url must have a host"));
226        }
227
228        Ok(SalesforceAuthConfig {
229            login_url,
230            client_id: client_id.into(),
231            client_secret: None,
232            auth_mode: None,
233            dataspace: None,
234            timeout_secs: 30,
235            max_retries: 3,
236        })
237    }
238
239    /// Sets the authentication mode.
240    #[must_use]
241    pub fn auth_mode(mut self, mode: AuthMode) -> Self {
242        self.auth_mode = Some(mode);
243        self
244    }
245
246    #[must_use]
247    /// Sets the client secret (required for Password and `RefreshToken` modes).
248    ///
249    /// **Note**: Client secret is NOT required for `PrivateKey` (JWT Bearer) mode.
250    pub fn client_secret(mut self, secret: impl Into<String>) -> Self {
251        self.client_secret = Some(Zeroizing::new(secret.into()));
252        self
253    }
254
255    #[must_use]
256    /// Sets the Data Cloud dataspace.
257    pub fn dataspace(mut self, dataspace: impl Into<String>) -> Self {
258        self.dataspace = Some(dataspace.into());
259        self
260    }
261
262    /// Sets the HTTP request timeout in seconds (default: 30).
263    #[must_use]
264    pub fn timeout_secs(mut self, secs: u64) -> Self {
265        self.timeout_secs = secs;
266        self
267    }
268
269    /// Sets the maximum number of retries for transient failures (default: 3).
270    #[must_use]
271    pub fn max_retries(mut self, retries: u32) -> Self {
272        self.max_retries = retries;
273        self
274    }
275
276    /// Returns the login URL.
277    #[must_use]
278    pub fn login_url(&self) -> &Url {
279        &self.login_url
280    }
281
282    /// Returns the client ID.
283    #[must_use]
284    pub fn client_id(&self) -> &str {
285        &self.client_id
286    }
287
288    /// Returns the dataspace, if set.
289    #[must_use]
290    pub fn dataspace_value(&self) -> Option<&str> {
291        self.dataspace.as_deref()
292    }
293
294    /// Validates the configuration.
295    pub(crate) fn validate(&self) -> SalesforceAuthResult<()> {
296        let auth_mode = self
297            .auth_mode
298            .as_ref()
299            .ok_or_else(|| SalesforceAuthError::config("auth_mode is required"))?;
300
301        match auth_mode {
302            AuthMode::Password { .. } | AuthMode::RefreshToken { .. } => {
303                if self.client_secret.is_none() {
304                    return Err(SalesforceAuthError::config(
305                        "client_secret is required for Password and RefreshToken auth modes",
306                    ));
307                }
308            }
309            AuthMode::PrivateKey { .. } => {
310                if self.client_secret.is_some() {
311                    tracing::warn!(
312                        "client_secret is set but not used for PrivateKey (JWT Bearer) mode"
313                    );
314                }
315            }
316        }
317
318        Ok(())
319    }
320}
321
322/// Known Salesforce login URL patterns for validation/warnings.
323#[expect(
324    dead_code,
325    reason = "retained for upcoming login URL warning surface; keep wired up so it stays compiled"
326)]
327pub(crate) fn is_known_salesforce_host(host: &str) -> bool {
328    let patterns = ["login.salesforce.com", "test.salesforce.com"];
329
330    let suffix_patterns = [
331        ".my.salesforce.com",
332        ".my.site.com",
333        ".sandbox.my.salesforce.com",
334    ];
335
336    if patterns.contains(&host) {
337        return true;
338    }
339
340    for suffix in suffix_patterns {
341        if host.ends_with(suffix) {
342            return true;
343        }
344    }
345
346    // Test/development patterns
347    if host.starts_with("login.test") && host.ends_with(".pc-rnd.salesforce.com") {
348        return true;
349    }
350
351    false
352}