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}