diff --git a/src/lib.rs b/src/lib.rs index 3fe46e1..5fb1ad1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,6 +10,7 @@ use rsa::pkcs8::DecodePrivateKey; use regex::Regex; use reqwest::Client; use rsa::pkcs1v15::SigningKey; +use std::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard}; use rsa::signature::{RandomizedSigner, SignatureEncoding}; use rsa::RsaPrivateKey; use serde::{Deserialize, Serialize}; @@ -19,6 +20,7 @@ use thiserror::Error; use time::OffsetDateTime; use url::Url; + /// The default Salesforce production login URL. /// /// Use this value when authenticating against a production Salesforce org. @@ -32,6 +34,8 @@ use url::Url; /// ``` pub const DEFAULT_SALESFORCE_LOGIN_URL: &str = "https://login.salesforce.com"; +const DEFAULT_ACCESS_TOKEN_TTL_SECONDS: i64 = 2 * 60 * 60; + /// Supported Salesforce OAuth authentication flows. /// /// This enum is used by [`SalesforceAuthConfig`] to decide which authentication @@ -306,7 +310,8 @@ impl SalesforceAuthConfig { /// # } /// ``` pub async fn connect(&self) -> Result { - match self.flow { + println!("connect"); + let result = match self.flow { SalesforceAuthFlow::ClientCredentials => { authenticate_client_credentials( required(self.login_url.as_deref(), "login_url")?, @@ -335,8 +340,12 @@ impl SalesforceAuthConfig { self.client_secret.clone(), self.login_url.clone(), )), - } + }; + println!("connect: {:?}", result); + + result } + } /// Raw OAuth token response returned by Salesforce. @@ -371,11 +380,11 @@ struct SalesforceTokenResponse { /// None, /// ); /// -/// assert_eq!(session.access_token, "access-token"); +/// assert_eq!(session.access_token(), "access-token"); /// ``` -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug)] pub struct SalesforceAuthSession { - pub access_token: String, + token: RwLock, /// Salesforce instance URL associated with the authenticated org. pub instance_url: String, @@ -383,15 +392,6 @@ pub struct SalesforceAuthSession { /// Salesforce identity service URL, when returned by Salesforce. pub id: Option, - /// OAuth token type, usually `Bearer`. - pub token_type: Option, - - /// Token issue timestamp, when returned by Salesforce. - pub issued_at: Option, - - /// Salesforce response signature, when returned by Salesforce. - pub signature: Option, - /// OAuth refresh token, when available. pub refresh_token: Option, @@ -405,7 +405,59 @@ pub struct SalesforceAuthSession { pub login_url: Option, } +#[derive(Debug, Clone, PartialEq, Eq)] +struct SalesforceAuthToken { + access_token: String, + + /// OAuth token type, usually `Bearer`. + token_type: Option, + + /// Token issue timestamp, when returned by Salesforce. + issued_at: Option, + + /// Salesforce response signature, when returned by Salesforce. + signature: Option, +} + impl SalesforceAuthSession { + + fn token_read(&self) -> RwLockReadGuard<'_, SalesforceAuthToken> { + self.token + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + fn token_write(&self) -> RwLockWriteGuard<'_, SalesforceAuthToken> { + self.token + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + fn token(&self) -> SalesforceAuthToken { + self.token_read().clone() + } + + pub async fn access_token(&self) -> Result { + if self.is_access_token_expired() { + self.refresh_access_token().await?; + } + + Ok(self.token().access_token) + } + + pub fn token_type(&self) -> Option { + self.token().token_type + } + + pub fn issued_at(&self) -> Option { + self.token().issued_at + } + + pub fn signature(&self) -> Option { + self.token().signature + } + + /// Refreshes this session's access token. /// /// The session must contain `login_url`, `client_id`, and `refresh_token`. @@ -438,14 +490,40 @@ impl SalesforceAuthSession { /// # Ok(()) /// # } /// ``` - pub async fn refresh_access_token(&self) -> Result { - refresh_access_token( + pub async fn refresh_access_token(&self) -> Result<(), SalesforceAuthError> { + let refreshed = refresh_access_token( required(self.login_url.as_deref(), "login_url")?, required(self.client_id.as_deref(), "client_id")?, self.client_secret.as_deref(), required(self.refresh_token.as_deref(), "refresh_token")?, ) - .await + .await?; + + *self.token_write() = refreshed.token(); + + Ok(()) + } + + /// Returns `true` when this session's access token should be refreshed. + /// + /// If `issued_at` is missing or cannot be parsed, the token is treated as + /// expired so callers fail safe and obtain a fresh token. + fn is_access_token_expired(&self) -> bool { + let token = self.token(); + + let Some(issued_at) = token.issued_at.as_deref() else { + return true; + }; + + let Ok(issued_at_millis) = issued_at.parse::() else { + return true; + }; + + let issued_at_seconds = issued_at_millis / 1_000; + let expires_at = issued_at_seconds + i128::from(DEFAULT_ACCESS_TOKEN_TTL_SECONDS); + let now = i128::from(OffsetDateTime::now_utc().unix_timestamp()); + + now >= expires_at } } @@ -556,22 +634,48 @@ pub enum SalesforceAuthError { /// # Ok(()) /// # } /// ``` +/// +/// Configure Salesforce +/// +/// 1. Create the External Client App +/// - Navigate to Setup > External Client App Manager. +/// - Click New External Client App and enter the app name and contact email. +/// - Expand the API (Enable OAuth Settings) section: +/// - Check Enable OAuth. +/// - Check Enable Client Credentials Flow. +/// - Add the Manage user data via APIs (api) scope. Do not add refresh_token or offline_access as these are invalid for this flow. +/// - Click Create and note the Consumer Key (Client ID) and Consumer Secret. +/// +/// 2. Configure Policies and Run As User +/// - In the External Client App Manager, find your new app and click Edit. +/// - Go to the Policies tab. +/// - Under OAuth Flows and External Client App Enhancements: +/// - Ensure Enable Client Credentials Flow is checked. +/// - In the Run As field, select the integration user (a dedicated service account with necessary API permissions). +/// - Save the changes. +/// pub async fn authenticate_client_credentials( login_url: &str, client_id: &str, client_secret: &str, ) -> Result { + println!("authenticate_client_credentials"); + println!("login url {}", login_url); + println!("client Id {}", client_id); + println!("client secret {}", client_secret); + let http_client = Client::builder() .redirect(reqwest::redirect::Policy::none()) .timeout(Duration::from_secs(30)) .build()?; - let token_url = TokenUrl::new(format!( "{}/services/oauth2/token", login_url.trim_end_matches('/') + // "http://localhost:3000" )) .map_err(|error| SalesforceAuthError::OAuth2(error.to_string()))?; + let oauth_client = BasicClient::new(ClientId::new(client_id.to_string())) .set_client_secret(ClientSecret::new(client_secret.to_string())) .set_token_uri(token_url); @@ -579,29 +683,30 @@ pub async fn authenticate_client_credentials( let token_response: StandardTokenResponse = oauth_client .exchange_client_credentials() - .add_scope(Scope::new("api".to_string())) + // .add_scope(Scope::new("api".to_string())) .request_async(&http_client) .await .map_err(|error| SalesforceAuthError::OAuth2(error.to_string()))?; let access_token = token_response.access_token().secret().to_string(); - fetch_salesforce_token_response_from_access_token(login_url, &access_token) - .await - .or_else(|_| { - Ok(SalesforceAuthSession { - access_token, - instance_url: login_url.trim_end_matches('/').to_string(), - id: None, - token_type: Some("Bearer".to_string()), - issued_at: None, - signature: None, - refresh_token: None, - client_id: Some(client_id.to_string()), - client_secret: Some(client_secret.to_string()), - login_url: Some(login_url.to_string()), - }) - }) + println!("asfasf {}", client_id); + + + Ok(SalesforceAuthSession { + token: RwLock::new(SalesforceAuthToken { + access_token: "asdf".to_string(), + token_type: Some("Bearer".to_string()), + issued_at: None, + signature: None, + }), + instance_url: login_url.trim_end_matches('/').to_string(), + id: None, + refresh_token: None, + client_id: Some(client_id.to_string()), + client_secret: Some(client_secret.to_string()), + login_url: Some(login_url.to_string()), + }) } /// Authenticates to Salesforce using the JWT bearer flow. @@ -680,12 +785,14 @@ fn to_session( .ok_or(SalesforceAuthError::MissingRequiredField("instance_url"))?; Ok(SalesforceAuthSession { - access_token: response.access_token, + token: RwLock::new(SalesforceAuthToken { + access_token: response.access_token, + token_type: response.token_type, + issued_at: response.issued_at, + signature: response.signature, + }), instance_url, id: response.id, - token_type: response.token_type, - issued_at: response.issued_at, - signature: response.signature, refresh_token: response.refresh_token.or(refresh_token), client_id, client_secret, @@ -724,12 +831,14 @@ pub fn authenticate_access_token( login_url: Option, ) -> SalesforceAuthSession { SalesforceAuthSession { - access_token: access_token.to_string(), + token: RwLock::new(SalesforceAuthToken { + access_token: access_token.to_string(), + token_type: Some("Bearer".to_string()), + issued_at: None, + signature: None, + }), instance_url: instance_url.trim_end_matches('/').to_string(), id: None, - token_type: Some("Bearer".to_string()), - issued_at: None, - signature: None, refresh_token, client_id, client_secret, @@ -1017,12 +1126,14 @@ async fn fetch_salesforce_token_response_from_access_token( access_token: &str, ) -> Result { Ok(SalesforceAuthSession { - access_token: access_token.to_string(), + token: RwLock::new(SalesforceAuthToken { + access_token: access_token.to_string(), + token_type: Some("Bearer".to_string()), + issued_at: None, + signature: None, + }), instance_url: login_url.trim_end_matches('/').to_string(), id: None, - token_type: Some("Bearer".to_string()), - issued_at: None, - signature: None, refresh_token: None, client_id: None, client_secret: None, @@ -1035,6 +1146,8 @@ fn required<'a>( value: Option<&'a str>, field_name: &'static str, ) -> Result<&'a str, SalesforceAuthError> { + println!("Checking {:?} {:?}", value, field_name); + value.ok_or(SalesforceAuthError::MissingRequiredField(field_name)) } diff --git a/src/main.rs b/src/main.rs index 8a5300e..02489a2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -21,6 +21,8 @@ async fn main() { let url = "https://computing-platform-9537--qa.sandbox.my.salesforce.com/"; let customer_id = "3MVG9xj60O9CjKHpQMaI0gAqe_BibSxfGHYQBII24Bif2Nri7ewsMsz3kEbMr4vch36.q4I.B37EpwOOtg90e"; let customer_secret = "68182180245EEBC7C54804536914BBEA32C0C347D83A67EB43A6225D342C95B9"; + let username = "wvelzeb1@its.jnj.com.im-qa"; + let password = "pi#TU&4wk!1IzN^N"; let sfdx_auth_url = "force://PlatformCLI::5Aep8618kS0Qqf3GEL4qVTX2VSz2qiUtn3Grl0qSlJjRoSGz03Uv86qbbx2MOhcIyj.OTqFKFH.olbk5LdlYEAv@computing-platform-9537--qa.sandbox.my.salesforce.com"; @@ -35,15 +37,22 @@ async fn main() { /// println!("{}", session.access_token); /// # Ok(()) - let config = SalesforceAuthConfig::sfdx_url(sfdx_auth_url); + // let config = SalesforceAuthConfig::sfdx_url(sfdx_auth_url); + let config = SalesforceAuthConfig::client_credentials( + "https://computing-platform-9537--qa.sandbox.my.salesforce.com", + customer_id, + customer_secret, + ); + + println!("Config: {:?}", config); let session = config.connect().await.unwrap(); - println!("{}", session.access_token); + println!("token: {}", session.access_token().await.unwrap()); let mut headers = HeaderMap::new(); - let auth_value = format!("Bearer {}", session.access_token); + let auth_value = format!("Bearer {}", session.access_token().await.unwrap()); headers.insert(AUTHORIZATION, HeaderValue::from_str(&auth_value).unwrap()); //Default header