From 512b60d9f4358a9861c473bb6ba7ef9a13b99f94 Mon Sep 17 00:00:00 2001 From: Wim Velzeboer Date: Mon, 21 Sep 2026 21:09:09 +0100 Subject: [PATCH] Working SFDX Auth URL and Client Credentials --- src/credentials/access_token.rs | 87 +++ src/credentials/client_credentials.rs | 105 +++ src/credentials/jwt_bearer.rs | 192 +++++ src/credentials/mod.rs | 251 ++++++ src/credentials/sfdx_auth_url.rs | 131 ++++ src/error.rs | 42 + src/lib.rs | 1015 +------------------------ src/main.rs | 15 +- src/salesforce_auth_token.rs | 57 ++ src/salesforce_token_response.rs | 14 + 10 files changed, 911 insertions(+), 998 deletions(-) create mode 100644 src/credentials/access_token.rs create mode 100644 src/credentials/client_credentials.rs create mode 100644 src/credentials/jwt_bearer.rs create mode 100644 src/credentials/mod.rs create mode 100644 src/credentials/sfdx_auth_url.rs create mode 100644 src/error.rs create mode 100644 src/salesforce_auth_token.rs create mode 100644 src/salesforce_token_response.rs diff --git a/src/credentials/access_token.rs b/src/credentials/access_token.rs new file mode 100644 index 0000000..f9c426c --- /dev/null +++ b/src/credentials/access_token.rs @@ -0,0 +1,87 @@ +use crate::credentials::{required, SalesforceAuthFlow, SalesforceCredentials}; +use crate::error::SalesforceAuthError; +use crate::salesforce_token_response::SalesforceTokenResponse; +use crate::SalesforceAuthSession; + +impl SalesforceCredentials { + /// Creates a configuration for using an existing Salesforce access token. + /// + /// If `refresh_token`, `client_id`, `client_secret`, and `login_url` are + /// supplied, the resulting [`SalesforceAuthSession`] can later refresh its + /// access token with [`SalesforceAuthSession::refresh_access_token`]. + /// + /// # Examples + /// + /// ```rust + /// use rustsf_auth::{SalesforceCredentials, SalesforceAuthFlow}; + /// + /// let config = SalesforceCredentials::access_token( + /// "access-token", + /// "https://example.my.salesforce.com", + /// Some("refresh-token".to_string()), + /// Some("client-id".to_string()), + /// Some("client-secret".to_string()), + /// Some("https://login.salesforce.com".to_string()), + /// ); + /// + /// assert_eq!(config.flow, SalesforceAuthFlow::AccessToken); + /// assert_eq!(config.access_token.as_deref(), Some("access-token")); + /// ``` + pub fn access_token( + access_token: impl Into, + instance_url: impl Into, + refresh_token: Option, + client_id: Option, + client_secret: Option, + login_url: Option, + ) -> Self { + Self { + flow: SalesforceAuthFlow::AccessToken, + login_url, + client_id, + client_secret, + username: None, + private_key_pem: None, + access_token: Some(access_token.into()), + refresh_token, + instance_url: Some(instance_url.into()), + } + } + + /// Creates a Salesforce authentication session from an existing access token. + /// + /// This function does not validate the access token with Salesforce. It simply + /// wraps the token and related metadata in a [`SalesforceAuthSession`]. + /// + /// # Examples + /// + /// ```rust + /// use rustsf_auth::authenticate_access_token; + /// + /// let session = authenticate_access_token( + /// "access-token", + /// "https://example.my.salesforce.com/", + /// Some("refresh-token".to_string()), + /// Some("client-id".to_string()), + /// Some("client-secret".to_string()), + /// Some("https://login.salesforce.com".to_string()), + /// ); + /// + /// assert_eq!(session.access_token, "access-token"); + /// assert_eq!(session.instance_url, "https://example.my.salesforce.com"); + /// ``` + pub(crate) fn connect_access_token(&self) -> Result { + let access_token = required(self.access_token.as_deref(), "access_token")?; + let login_url = required(self.login_url.as_deref(), "login_url")?; + + Ok(SalesforceTokenResponse { + access_token: access_token.to_string(), + instance_url: Some(login_url.trim_end_matches('/').to_string()), + id: None, + token_type: Some("Bearer".to_string()), + issued_at: None, + signature: None, + refresh_token: None, + }) + } +} \ No newline at end of file diff --git a/src/credentials/client_credentials.rs b/src/credentials/client_credentials.rs new file mode 100644 index 0000000..4bbe0e6 --- /dev/null +++ b/src/credentials/client_credentials.rs @@ -0,0 +1,105 @@ +use crate::credentials::{http_client, required, SalesforceAuthFlow, SalesforceCredentials}; +use crate::salesforce_token_response::SalesforceTokenResponse; +use crate::SalesforceAuthError; + +impl SalesforceCredentials { + /// Creates a configuration for the OAuth 2.0 client mod flow. + /// + /// # Examples + /// + /// ```rust + /// use rustsf_auth::{SalesforceCredentials, SalesforceAuthFlow}; + /// + /// let config = SalesforceCredentials::client_credentials( + /// "https://login.salesforce.com", + /// "client-id", + /// "client-secret", + /// ); + /// + /// assert_eq!(config.flow, SalesforceAuthFlow::ClientCredentials); + /// assert_eq!(config.client_id.as_deref(), Some("client-id")); + /// ``` + pub fn client_credentials( + login_url: impl Into, + client_id: impl Into, + client_secret: impl Into, + ) -> Self { + Self { + flow: SalesforceAuthFlow::ClientCredentials, + login_url: Some(login_url.into()), + client_id: Some(client_id.into()), + client_secret: Some(client_secret.into()), + username: None, + private_key_pem: None, + access_token: None, + refresh_token: None, + instance_url: None, + } + } + + /// Authenticates to Salesforce using the OAuth 2.0 client mod flow. + /// + /// This method sends a client mod token request to: + /// + /// `{login_url}/services/oauth2/token` + /// + /// # Errors + /// + /// Returns [`SalesforceAuthError`] if the OAuth client cannot be built, the HTTP + /// request fails, or Salesforce rejects the token request. + /// + /// # Examples + /// + /// ```rust,no_run + /// + /// use rustsf_auth::authenticate_client_credentials; + /// # async fn example() -> Result<(), rustsf_auth::SalesforceAuthError> { + /// let session = authenticate_client_credentials( + /// "https://login.salesforce.com", + /// "client-id", + /// "client-secret", + /// ).await?; + /// + /// println!("{}", session.access_token); + /// # 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(crate) async fn connect_client_credentials(&self) -> Result { + + let client_id = required(self.client_id.as_deref(), "client_id")?; + let client_secret = required(self.client_secret.as_deref(), "client_secret")?; + + Ok(http_client()? + .post(self.token_url()?.url().clone()) + .form(&[ + ("grant_type", "client_credentials"), + ("client_id", client_id), + ("client_secret", client_secret), + ]) + .send() + .await? + .error_for_status()? + .json::() + .await?) + } +} \ No newline at end of file diff --git a/src/credentials/jwt_bearer.rs b/src/credentials/jwt_bearer.rs new file mode 100644 index 0000000..2ec5144 --- /dev/null +++ b/src/credentials/jwt_bearer.rs @@ -0,0 +1,192 @@ +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; + +use crate::credentials::{http_client, required, SalesforceAuthFlow, SalesforceCredentials}; +use crate::error::SalesforceAuthError; +use crate::salesforce_token_response::SalesforceTokenResponse; + +use rsa::pkcs1::DecodeRsaPrivateKey; +use rsa::pkcs8::DecodePrivateKey; +use rsa::pkcs1v15::SigningKey; +use rsa::signature::{RandomizedSigner, SignatureEncoding}; +use rsa::RsaPrivateKey; +use serde::{Deserialize, Serialize}; +use sha2::Sha256; +use std::time::Duration; +use time::OffsetDateTime; +use crate::salesforce_token_endpoint; + +#[derive(Debug, Serialize)] +struct JwtHeader<'a> { + alg: &'a str, + typ: &'a str, +} + +#[derive(Debug, Serialize)] +struct JwtClaims<'a> { + iss: &'a str, + sub: &'a str, + aud: &'a str, + exp: i64, +} + +impl SalesforceCredentials { + /// Creates a configuration for the Salesforce JWT bearer flow. + /// + /// The private key must be an RSA private key in PEM format. It should + /// correspond to the certificate configured on the Salesforce connected app. + /// + /// # Examples + /// + /// ```rust + /// use rustsf_auth::{SalesforceCredentials, SalesforceAuthFlow}; + /// + /// let config = SalesforceCredentials::jwt_bearer( + /// "https://login.salesforce.com", + /// "client-id", + /// "user@example.com", + /// "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----", + /// ); + /// + /// assert_eq!(config.flow, SalesforceAuthFlow::JwtBearer); + /// assert_eq!(config.username.as_deref(), Some("user@example.com")); + /// ``` + pub fn jwt_bearer( + login_url: impl Into, + client_id: impl Into, + username: impl Into, + private_key_pem: impl Into, + ) -> Self { + Self { + flow: SalesforceAuthFlow::JwtBearer, + login_url: Some(login_url.into()), + client_id: Some(client_id.into()), + client_secret: None, + username: Some(username.into()), + private_key_pem: Some(private_key_pem.into()), + access_token: None, + refresh_token: None, + instance_url: None, + } + } + + /// Authenticates to Salesforce using the JWT bearer flow. + /// + /// This function creates a signed RS256 JWT assertion and exchanges it for a + /// Salesforce access token. + /// + /// # Errors + /// + /// Returns [`SalesforceAuthError`] if the private key cannot be parsed, the JWT + /// cannot be signed, the token URL is invalid, the HTTP request fails, or + /// Salesforce rejects the assertion. + /// + /// # Examples + /// + /// ```rust,no_run + /// use rustsf_auth::authenticate_jwt_bearer; + /// + /// # async fn example() -> Result<(), rustsf_auth::SalesforceAuthError> { + /// let private_key_pem = "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----"; + /// + /// let session = authenticate_jwt_bearer( + /// "https://login.salesforce.com", + /// "client-id", + /// "user@example.com", + /// private_key_pem, + /// ).await?; + /// + /// println!("{}", session.access_token); + /// # Ok(()) + /// # } + /// ``` + pub(crate) async fn connect_jwt(&self) -> Result { + let login_url = required(self.login_url.as_deref(), "login_url")?; + let client_id = required(self.client_id.as_deref(), "client_id")?; + let username = required(self.username.as_deref(), "username")?; + let private_key_pem = required(self.private_key_pem.as_deref(), "private_key_pem")?; + + let assertion = create_salesforce_jwt_assertion(login_url, client_id, username, private_key_pem)?; + + Ok(http_client()? + .post(salesforce_token_endpoint(login_url.trim_end_matches('/'))) + .form(&[ + ("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer"), + ("assertion", assertion.as_str()), + ]) + .send() + .await? + .error_for_status()? + .json::() + .await?) + } + +} + + + +/// Creates a signed Salesforce JWT bearer assertion. +/// +/// The returned string is a compact JWT signed with RS256. It is intended for +/// the Salesforce OAuth 2.0 JWT bearer grant. +/// +/// # Errors +/// +/// Returns [`SalesforceAuthError::Jwt`] if the private key cannot be parsed, +/// the JWT claims cannot be serialized, or signing fails. +/// +/// # Examples +/// +/// ```rust,no_run +/// use rustsf_auth::create_salesforce_jwt_assertion; +/// +/// let private_key_pem = "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----"; +/// +/// let assertion = create_salesforce_jwt_assertion( +/// "https://login.salesforce.com", +/// "client-id", +/// "user@example.com", +/// private_key_pem, +/// )?; +/// +/// assert_eq!(assertion.split('.').count(), 3); +/// # Ok::<(), rustsf_auth::SalesforceAuthError>(()) +/// ``` +pub fn create_salesforce_jwt_assertion( + login_url: &str, + client_id: &str, + username: &str, + private_key_pem: &str, +) -> Result { + let header = JwtHeader { + alg: "RS256", + typ: "JWT", + }; + + let claims = JwtClaims { + iss: client_id, + sub: username, + aud: login_url.trim_end_matches('/'), + exp: OffsetDateTime::now_utc().unix_timestamp() + 180, + }; + + let encoded_header = encode_json_base64_url(&header)?; + let encoded_claims = encode_json_base64_url(&claims)?; + let signing_input = format!("{encoded_header}.{encoded_claims}"); + + let private_key = RsaPrivateKey::from_pkcs8_pem(private_key_pem) + .or_else(|_| RsaPrivateKey::from_pkcs1_pem(private_key_pem)) + .map_err(|error| SalesforceAuthError::Jwt(error.to_string()))?; + + let signing_key = SigningKey::::new(private_key); + let mut rng = rsa::rand_core::OsRng; + let signature = signing_key.sign_with_rng(&mut rng, signing_input.as_bytes()); + let encoded_signature = URL_SAFE_NO_PAD.encode(signature.to_bytes()); + + Ok(format!("{signing_input}.{encoded_signature}")) +} + +fn encode_json_base64_url(value: &T) -> Result { + let json = serde_json::to_vec(value).map_err(|error| SalesforceAuthError::Jwt(error.to_string()))?; + Ok(URL_SAFE_NO_PAD.encode(json)) +} \ No newline at end of file diff --git a/src/credentials/mod.rs b/src/credentials/mod.rs new file mode 100644 index 0000000..9756ac5 --- /dev/null +++ b/src/credentials/mod.rs @@ -0,0 +1,251 @@ +use std::sync::RwLock; +use std::time::Duration; +use oauth2::TokenUrl; +use reqwest::Client; +use crate::{SalesforceAuthError, SalesforceAuthSession, SalesforceAuthToken}; +use crate::salesforce_token_response::SalesforceTokenResponse; + +mod access_token; +mod client_credentials; +mod jwt_bearer; +mod sfdx_auth_url; + +/// Supported Salesforce OAuth authentication flows. +/// +/// This enum is used by [`SalesforceCredentials`] to decide which authentication +/// method should be executed when [`SalesforceCredentials::connect`] is called. +/// +/// # Examples +/// +/// ```rust +/// use rustsf_auth::SalesforceAuthFlow; +/// +/// let flow = SalesforceAuthFlow::ClientCredentials; +/// assert!(matches!(flow, SalesforceAuthFlow::ClientCredentials)); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SalesforceAuthFlow { + /// Authenticate using the OAuth 2.0 client mod grant. + ClientCredentials, + + /// Authenticate using the Salesforce JWT bearer grant. + JwtBearer, + + /// Authenticate from an SFDX auth URL. + SfdxUrl, + + /// Use an already available Salesforce access token. + AccessToken, +} + +/// Configuration for authenticating to Salesforce. +/// +/// This struct provides a single, convenient way to pass mod and select +/// the authentication flow. Prefer using the constructor methods such as +/// [`SalesforceCredentials::client_credentials`], [`SalesforceCredentials::jwt_bearer`], +/// [`SalesforceCredentials::sfdx_url`], and [`SalesforceCredentials::access_token`] +/// instead of manually constructing the struct. +/// +/// # Examples +/// +/// ```rust +/// use rustsf_auth::{SalesforceCredentials, SalesforceAuthFlow}; +/// +/// let config = SalesforceCredentials::access_token( +/// "access-token", +/// "https://example.my.salesforce.com", +/// None, +/// None, +/// None, +/// None, +/// ); +/// +/// assert_eq!(config.flow, SalesforceAuthFlow::AccessToken); +/// ``` +#[derive(Debug, Clone)] +pub struct SalesforceCredentials { + /// The authentication flow to execute. + pub flow: SalesforceAuthFlow, + + /// Salesforce login URL, for example `https://login.salesforce.com`. + pub login_url: Option, + + /// Salesforce connected app client ID. + pub client_id: Option, + + /// Salesforce connected app client secret. + pub client_secret: Option, + + /// Salesforce username used by the JWT bearer flow. + pub username: Option, + + /// RSA private key in PEM format used by the JWT bearer flow. + pub private_key_pem: Option, + + /// Existing Salesforce access token. + pub access_token: Option, + + /// Salesforce refresh token. + pub refresh_token: Option, + + /// Salesforce instance URL, for example `https://example.my.salesforce.com`. + pub instance_url: Option, +} + +impl SalesforceCredentials { + + /// Authenticates to Salesforce using the configured flow. + /// + /// This method dispatches to one of: + /// + /// - [`authenticate_client_credentials`] + /// - [`authenticate_jwt_bearer`] + /// - [`authenticate_sfdx_url`] + /// - [`authenticate_access_token`] + /// + /// # Errors + /// + /// Returns [`SalesforceAuthError`] if required fields are missing, if the + /// configured URL is invalid, if Salesforce rejects the OAuth request, or if + /// the underlying HTTP request fails. + /// + /// # Examples + /// + /// ```rust,no_run + /// use rustsf_auth::SalesforceCredentials; + /// + /// # async fn example() -> Result<(), rustsf_auth::SalesforceAuthError> { + /// let config = SalesforceCredentials::client_credentials( + /// "https://login.salesforce.com", + /// "client-id", + /// "client-secret", + /// ); + /// + /// let session = config.connect().await?; + /// println!("{}", session.access_token); + /// # Ok(()) + /// # } + /// ``` + pub async fn connect(self) -> Result { + let token_response = self.token_response_from_flow().await?; + + let (org_id, user_id) = parse_salesforce_identity_ids(token_response.id.as_deref()); + + let instance_url = match (&self.instance_url, &self.login_url) { + (Some(url), None) + | (None, Some(url)) + | (Some(url), Some(_)) => { url }, + (None, None) => "http://login.salesforce.com", + }.to_string(); + + Ok(SalesforceAuthSession { + token: RwLock::new(SalesforceAuthToken { + access_token: token_response.access_token, + token_type: token_response.token_type, + issued_at: token_response.issued_at, + signature: token_response.signature, + }), + credentials: self, + instance_url, + org_id, + user_id, + }) + } + + pub(crate) async fn reconnect(&self) -> Result { + let token_response = self.token_response_from_flow().await?; + Ok(SalesforceAuthToken::from_token_response(token_response)) + } + + async fn token_response_from_flow(&self) -> Result { + match self.flow { + SalesforceAuthFlow::AccessToken => self.connect_access_token(), + SalesforceAuthFlow::ClientCredentials => self.connect_client_credentials().await, + SalesforceAuthFlow::JwtBearer => self.connect_jwt().await, + SalesforceAuthFlow::SfdxUrl => self.connect_sfdx_url().await, + } + } + + pub(crate) async fn refresh(&self) -> Result { + + match &self.refresh_token { + Some(refresh_token) => { + let client_id = required(self.client_id.as_deref(), "client_id")?; + + let response = Client::new() + .post(self.token_url()?.url().clone()) + .form(&[ + ("grant_type", "refresh_token"), + ("client_id", client_id), + ("refresh_token", refresh_token), + // ("client_secret", client_secret.unwrap_or_default()), + ]) + .send() + .await? + .error_for_status()? + .json::() + .await?; + + Ok(SalesforceAuthToken::from_token_response(response)) + }, + None => { + self.reconnect().await + } + } + } + + fn token_url(&self) -> Result { + match &self.login_url { + Some(login_url) => { + let normalized = login_url.trim_end_matches('/'); + let url = format!("{normalized}/services/oauth2/token"); + Ok(TokenUrl::new(url.clone()).map_err(|source| SalesforceAuthError::InvalidUrl { url, source })?) + } + None => Err(SalesforceAuthError::MissingRequiredField("login_url")), + } + } +} + + +pub(crate) fn http_client() -> Result { + Ok(Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(Duration::from_secs(30)) + .build()?) +} + + +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)) +} + +fn parse_salesforce_identity_ids(id_url: Option<&str>) -> (Option, Option) { + let Some(id_url) = id_url else { + return (None, None); + }; + + let segments = id_url + .trim_end_matches('/') + .split('/') + .filter(|segment| !segment.is_empty()) + .collect::>(); + + if segments.len() < 2 { + return (None, None); + } + + let org_id = segments + .get(segments.len() - 2) + .map(|value| (*value).to_string()); + + let user_id = segments + .last() + .map(|value| (*value).to_string()); + + (org_id, user_id) +} diff --git a/src/credentials/sfdx_auth_url.rs b/src/credentials/sfdx_auth_url.rs new file mode 100644 index 0000000..6b8e193 --- /dev/null +++ b/src/credentials/sfdx_auth_url.rs @@ -0,0 +1,131 @@ +use regex::Regex; +use crate::credentials::{http_client, required, SalesforceAuthFlow, SalesforceCredentials}; +use crate::SalesforceAuthError; +use crate::salesforce_token_response::SalesforceTokenResponse; + +impl SalesforceCredentials { + + /// Creates a configuration for authenticating from an SFDX auth URL. + /// + /// The SFDX URL must match: + /// + /// `force://CLIENT_ID:CLIENT_SECRET:REFRESH_TOKEN@LOGIN_ENDPOINT` + /// + /// The login endpoint must not include `https://`. + /// + /// # Examples + /// + /// ```rust + /// use rustsf_auth::{SalesforceCredentials, SalesforceAuthFlow}; + /// + /// let config = SalesforceCredentials::sfdx_url( + /// "force://client_id:client_secret:refresh-token@login.salesforce.com", + /// ); + /// + /// assert_eq!(config.flow, SalesforceAuthFlow::SfdxUrl); + /// assert!(config.sfdx_url.is_some()); + /// ``` + pub fn sfdx_url(url: impl Into) -> Result { + + let regex = Regex::new(r"force://([a-zA-Z0-9._-]+):([a-zA-Z0-9._-]*):([a-zA-Z0-9._-]+={0,2})@([a-zA-Z0-9._-]+)") + .unwrap(); + + let url = url.into(); + + let captures = regex.captures(&url) + // .with_context(|| "Invalid Authentication URL expected 'force://([a-zA-Z0-9._-]+):([a-zA-Z0-9._-]*):([a-zA-Z0-9._-]+={0,2})@([a-zA-Z0-9._-]+)'".to_string())?; + .ok_or(SalesforceAuthError::InvalidSfdxUrl)?; + + let client_id = captures + .get(1) + + .ok_or(SalesforceAuthError::InvalidSfdxUrl)? + .as_str() + .to_string(); + + let client_secret = captures + .get(2) + .map(|capture| capture.as_str().to_string()) + .filter(|value| !value.is_empty()); + + let refresh_token = captures + .get(3) + .ok_or(SalesforceAuthError::InvalidSfdxUrl)? + .as_str() + .to_string(); + + let login_endpoint = captures + .get(4) + .ok_or(SalesforceAuthError::InvalidSfdxUrl)? + .as_str(); + + Ok(Self { + flow: SalesforceAuthFlow::SfdxUrl, + login_url: Some(format!("https://{login_endpoint}")), + client_id: Some(client_id), + client_secret, + username: None, + private_key_pem: None, + access_token: None, + refresh_token: Some(refresh_token), + instance_url: None, + }) + } + + /// Authenticates to Salesforce from an SFDX auth URL. + /// + /// The SFDX URL must match: + /// + /// `force://([a-zA-Z0-9._-]+):([a-zA-Z0-9._-]*):([a-zA-Z0-9._-]+={0,2})@([a-zA-Z0-9._-]+)` + /// + /// Regex groups are interpreted as: + /// + /// 1. client ID + /// 2. client secret + /// 3. refresh token + /// 4. login endpoint without `https://` + /// + /// Internally this parses the SFDX URL and then calls [`refresh_access_token`]. + /// + /// # Errors + /// + /// Returns [`SalesforceAuthError::InvalidSfdxUrl`] if the URL does not match + /// the required format. Returns other [`SalesforceAuthError`] variants if the + /// refresh request fails. + /// + /// # Examples + /// + /// ```rust,no_run + /// use rustsf_auth::authenticate_sfdx_url; + /// + /// # async fn example() -> Result<(), rustsf_auth::SalesforceAuthError> { + /// let session = authenticate_sfdx_url( + /// "force://client_id:client_secret:refresh-token@login.salesforce.com", + /// ).await?; + /// + /// println!("{}", session.access_token); + /// # Ok(()) + /// # } + /// ``` + pub(crate) async fn connect_sfdx_url(&self) -> Result { + + let client_id = required(self.client_id.as_deref(), "client_id")?; + let refresh_token = required(self.refresh_token.as_deref(), "refresh_token")?; + + Ok(http_client()? + .post(self.token_url()?.url().clone()) + .form(&[ + ("grant_type", "refresh_token"), + ("client_id", client_id), + ("refresh_token", refresh_token), + // ("client_secret", client_secret.unwrap_or_default()), + ]) + .send() + .await? + .error_for_status()? + .json::() + .await?) + } +} + + diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..885dff5 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,42 @@ +/// Error type returned by Salesforce authentication operations. +/// +/// # Examples +/// +/// ```rust +/// use rustsf_auth::{parse_sfdx_url, SalesforceAuthError}; +/// +/// let error = parse_sfdx_url("not-a-valid-sfdx-url").unwrap_err(); +/// assert!(matches!(error, SalesforceAuthError::InvalidSfdxUrl)); +/// ``` + +#[derive(Debug, thiserror::Error)] +pub enum SalesforceAuthError { + #[error("missing required configuration field: {0}")] + MissingRequiredField(&'static str), + + /// The SFDX URL did not match the required format. + #[error("invalid SFDX URL")] + InvalidSfdxUrl, + + /// A URL failed to parse. + #[error("invalid URL `{url}`: {source}")] + InvalidUrl { + /// The URL string that failed to parse. + url: String, + + /// The underlying URL parser error. + source: url::ParseError, + }, + + /// Error returned by the `oauth2` crate. + #[error("OAuth2 error: {0}")] + OAuth2(String), + + /// HTTP error returned by `reqwest`. + #[error("HTTP error: {0}")] + Http(#[from] reqwest::Error), + + /// Error creating a Salesforce JWT assertion. + #[error("JWT error: {0}")] + Jwt(String), +} \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index 8c7f756..344fb37 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,6 +18,15 @@ use sha2::Sha256; use std::time::Duration; use time::OffsetDateTime; +pub mod credentials; +pub mod error; +pub(crate) mod salesforce_auth_token; +pub mod salesforce_token_response; + +use self::credentials::SalesforceCredentials; +use self::salesforce_token_response::SalesforceTokenResponse; +use self::error::SalesforceAuthError; +use self::salesforce_auth_token::SalesforceAuthToken; /// The default Salesforce production login URL. /// @@ -34,335 +43,11 @@ 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 -/// method should be executed when [`SalesforceAuthConfig::connect`] is called. -/// -/// # Examples -/// -/// ```rust -/// use rustsf_auth::SalesforceAuthFlow; -/// -/// let flow = SalesforceAuthFlow::ClientCredentials; -/// assert!(matches!(flow, SalesforceAuthFlow::ClientCredentials)); -/// ``` -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum SalesforceAuthFlow { - /// Authenticate using the OAuth 2.0 client credentials grant. - ClientCredentials, - - /// Authenticate using the Salesforce JWT bearer grant. - JwtBearer, - - /// Authenticate from an SFDX auth URL. - SfdxUrl, - - /// Use an already available Salesforce access token. - AccessToken, -} - -/// Configuration for authenticating to Salesforce. -/// -/// This struct provides a single, convenient way to pass credentials and select -/// the authentication flow. Prefer using the constructor methods such as -/// [`SalesforceAuthConfig::client_credentials`], [`SalesforceAuthConfig::jwt_bearer`], -/// [`SalesforceAuthConfig::sfdx_url`], and [`SalesforceAuthConfig::access_token`] -/// instead of manually constructing the struct. -/// -/// # Examples -/// -/// ```rust -/// use rustsf_auth::{SalesforceAuthConfig, SalesforceAuthFlow}; -/// -/// let config = SalesforceAuthConfig::access_token( -/// "access-token", -/// "https://example.my.salesforce.com", -/// None, -/// None, -/// None, -/// None, -/// ); -/// -/// assert_eq!(config.flow, SalesforceAuthFlow::AccessToken); -/// ``` -#[derive(Debug, Clone)] -pub struct SalesforceAuthConfig { - /// The authentication flow to execute. - pub flow: SalesforceAuthFlow, - - /// Salesforce login URL, for example `https://login.salesforce.com`. - pub login_url: Option, - - /// Salesforce connected app client ID. - pub client_id: Option, - - /// Salesforce connected app client secret. - pub client_secret: Option, - - /// Salesforce username used by the JWT bearer flow. - pub username: Option, - - /// RSA private key in PEM format used by the JWT bearer flow. - pub private_key_pem: Option, - - /// SFDX auth URL used by the SFDX URL flow. - pub sfdx_url: Option, - - /// Existing Salesforce access token. - pub access_token: Option, - - /// Salesforce refresh token. - pub refresh_token: Option, - - /// Salesforce instance URL, for example `https://example.my.salesforce.com`. - pub instance_url: Option, -} - -impl SalesforceAuthConfig { - /// Creates a configuration for the OAuth 2.0 client credentials flow. - /// - /// # Examples - /// - /// ```rust - /// use rustsf_auth::{SalesforceAuthConfig, SalesforceAuthFlow}; - /// - /// let config = SalesforceAuthConfig::client_credentials( - /// "https://login.salesforce.com", - /// "client-id", - /// "client-secret", - /// ); - /// - /// assert_eq!(config.flow, SalesforceAuthFlow::ClientCredentials); - /// assert_eq!(config.client_id.as_deref(), Some("client-id")); - /// ``` - pub fn client_credentials( - login_url: impl Into, - client_id: impl Into, - client_secret: impl Into, - ) -> Self { - Self { - flow: SalesforceAuthFlow::ClientCredentials, - login_url: Some(login_url.into()), - client_id: Some(client_id.into()), - client_secret: Some(client_secret.into()), - username: None, - private_key_pem: None, - sfdx_url: None, - access_token: None, - refresh_token: None, - instance_url: None, - } - } - - /// Creates a configuration for the Salesforce JWT bearer flow. - /// - /// The private key must be an RSA private key in PEM format. It should - /// correspond to the certificate configured on the Salesforce connected app. - /// - /// # Examples - /// - /// ```rust - /// use rustsf_auth::{SalesforceAuthConfig, SalesforceAuthFlow}; - /// - /// let config = SalesforceAuthConfig::jwt_bearer( - /// "https://login.salesforce.com", - /// "client-id", - /// "user@example.com", - /// "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----", - /// ); - /// - /// assert_eq!(config.flow, SalesforceAuthFlow::JwtBearer); - /// assert_eq!(config.username.as_deref(), Some("user@example.com")); - /// ``` - pub fn jwt_bearer( - login_url: impl Into, - client_id: impl Into, - username: impl Into, - private_key_pem: impl Into, - ) -> Self { - Self { - flow: SalesforceAuthFlow::JwtBearer, - login_url: Some(login_url.into()), - client_id: Some(client_id.into()), - client_secret: None, - username: Some(username.into()), - private_key_pem: Some(private_key_pem.into()), - sfdx_url: None, - access_token: None, - refresh_token: None, - instance_url: None, - } - } - - /// Creates a configuration for authenticating from an SFDX auth URL. - /// - /// The SFDX URL must match: - /// - /// `force://CLIENT_ID:CLIENT_SECRET:REFRESH_TOKEN@LOGIN_ENDPOINT` - /// - /// The login endpoint must not include `https://`. - /// - /// # Examples - /// - /// ```rust - /// use rustsf_auth::{SalesforceAuthConfig, SalesforceAuthFlow}; - /// - /// let config = SalesforceAuthConfig::sfdx_url( - /// "force://client_id:client_secret:refresh-token@login.salesforce.com", - /// ); - /// - /// assert_eq!(config.flow, SalesforceAuthFlow::SfdxUrl); - /// assert!(config.sfdx_url.is_some()); - /// ``` - pub fn sfdx_url(sfdx_url: impl Into) -> Self { - Self { - flow: SalesforceAuthFlow::SfdxUrl, - login_url: None, - client_id: None, - client_secret: None, - username: None, - private_key_pem: None, - sfdx_url: Some(sfdx_url.into()), - access_token: None, - refresh_token: None, - instance_url: None, - } - } - - /// Creates a configuration for using an existing Salesforce access token. - /// - /// If `refresh_token`, `client_id`, `client_secret`, and `login_url` are - /// supplied, the resulting [`SalesforceAuthSession`] can later refresh its - /// access token with [`SalesforceAuthSession::refresh_access_token`]. - /// - /// # Examples - /// - /// ```rust - /// use rustsf_auth::{SalesforceAuthConfig, SalesforceAuthFlow}; - /// - /// let config = SalesforceAuthConfig::access_token( - /// "access-token", - /// "https://example.my.salesforce.com", - /// Some("refresh-token".to_string()), - /// Some("client-id".to_string()), - /// Some("client-secret".to_string()), - /// Some("https://login.salesforce.com".to_string()), - /// ); - /// - /// assert_eq!(config.flow, SalesforceAuthFlow::AccessToken); - /// assert_eq!(config.access_token.as_deref(), Some("access-token")); - /// ``` - pub fn access_token( - access_token: impl Into, - instance_url: impl Into, - refresh_token: Option, - client_id: Option, - client_secret: Option, - login_url: Option, - ) -> Self { - Self { - flow: SalesforceAuthFlow::AccessToken, - login_url, - client_id, - client_secret, - username: None, - private_key_pem: None, - sfdx_url: None, - access_token: Some(access_token.into()), - refresh_token, - instance_url: Some(instance_url.into()), - } - } - - /// Authenticates to Salesforce using the configured flow. - /// - /// This method dispatches to one of: - /// - /// - [`authenticate_client_credentials`] - /// - [`authenticate_jwt_bearer`] - /// - [`authenticate_sfdx_url`] - /// - [`authenticate_access_token`] - /// - /// # Errors - /// - /// Returns [`SalesforceAuthError`] if required fields are missing, if the - /// configured URL is invalid, if Salesforce rejects the OAuth request, or if - /// the underlying HTTP request fails. - /// - /// # Examples - /// - /// ```rust,no_run - /// use rustsf_auth::SalesforceAuthConfig; - /// - /// # async fn example() -> Result<(), rustsf_auth::SalesforceAuthError> { - /// let config = SalesforceAuthConfig::client_credentials( - /// "https://login.salesforce.com", - /// "client-id", - /// "client-secret", - /// ); - /// - /// let session = config.connect().await?; - /// println!("{}", session.access_token); - /// # Ok(()) - /// # } - /// ``` - pub async fn connect(&self) -> Result { - println!("connect"); - let result = match self.flow { - SalesforceAuthFlow::ClientCredentials => { - authenticate_client_credentials( - required(self.login_url.as_deref(), "login_url")?, - required(self.client_id.as_deref(), "client_id")?, - required(self.client_secret.as_deref(), "client_secret")?, - ) - .await - } - SalesforceAuthFlow::JwtBearer => { - authenticate_jwt_bearer( - required(self.login_url.as_deref(), "login_url")?, - required(self.client_id.as_deref(), "client_id")?, - required(self.username.as_deref(), "username")?, - required(self.private_key_pem.as_deref(), "private_key_pem")?, - ) - .await - } - SalesforceAuthFlow::SfdxUrl => { - authenticate_sfdx_url(required(self.sfdx_url.as_deref(), "sfdx_url")?).await - } - SalesforceAuthFlow::AccessToken => Ok(authenticate_access_token( - required(self.access_token.as_deref(), "access_token")?, - required(self.instance_url.as_deref(), "instance_url")?, - self.refresh_token.clone(), - self.client_id.clone(), - self.client_secret.clone(), - self.login_url.clone(), - )), - }; - println!("connect: {:?}", result); - - result - } - -} - -/// Raw OAuth token response returned by Salesforce. -#[derive(Debug, Clone, Deserialize)] -struct SalesforceTokenResponse { - access_token: String, - instance_url: Option, - id: Option, - token_type: Option, - issued_at: Option, - signature: Option, - refresh_token: Option, -} - /// Authenticated Salesforce session data. /// /// This struct contains the access token and Salesforce instance URL returned /// by Salesforce, along with optional metadata such as the refresh token and -/// connected app credentials. +/// connected app mod. /// /// # Examples /// @@ -384,44 +69,18 @@ struct SalesforceTokenResponse { pub struct SalesforceAuthSession { token: RwLock, + /// Authentication flow used to create this session. + pub credentials: SalesforceCredentials, + /// Salesforce instance URL associated with the authenticated org. pub instance_url: String, - /// Salesforce identity service URL, when returned by Salesforce. - pub id: Option, - /// OAuth refresh token, when available. /// Salesforce organization ID parsed from the identity service URL. pub org_id: Option, /// Salesforce user ID parsed from the identity service URL. pub user_id: Option, - - /// OAuth refresh token, when available. - pub refresh_token: Option, - - /// Connected app client ID, when available. - pub client_id: Option, - - /// Connected app client secret, when available. - pub client_secret: Option, - - /// Salesforce login URL used to obtain or refresh the token. - 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 { @@ -443,11 +102,11 @@ impl SalesforceAuthSession { } pub async fn access_token(&self) -> Result { - if self.is_access_token_expired() { + if self.token_read().is_expired() { self.refresh_access_token().await?; } - Ok(self.token().access_token) + Ok(self.token_read().access_token()) } pub fn token_type(&self) -> Option { @@ -496,19 +155,14 @@ impl SalesforceAuthSession { /// # } /// ``` 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?; - *self.token_write() = refreshed.token(); + let token = self.credentials.refresh().await?; + *self.token_write() = 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 @@ -532,638 +186,11 @@ impl SalesforceAuthSession { } } -/// Parsed representation of an SFDX auth URL. -/// -/// SFDX URLs have this shape: -/// -/// `force://CLIENT_ID:CLIENT_SECRET:REFRESH_TOKEN@LOGIN_ENDPOINT` -/// -/// The login endpoint is stored as a full HTTPS URL in [`ParsedSfdxUrl::login_url`]. -/// -/// # Examples -/// -/// ```rust -/// use rustsf_auth::parse_sfdx_url; -/// -/// let parsed = parse_sfdx_url( -/// "force://client_id:client_secret:refresh-token@login.salesforce.com", -/// ).unwrap(); -/// -/// assert_eq!(parsed.client_id, "client_id"); -/// assert_eq!(parsed.client_secret.as_deref(), Some("client_secret")); -/// assert_eq!(parsed.refresh_token, "refresh-token"); -/// assert_eq!(parsed.login_url, "https://login.salesforce.com"); -/// ``` -#[derive(Debug, Clone)] -pub struct ParsedSfdxUrl { - /// Salesforce connected app client ID from regex group 1. - pub client_id: String, - - /// Salesforce connected app client secret from regex group 2. - pub client_secret: Option, - - /// Salesforce refresh token from regex group 3. - pub refresh_token: String, - - /// Salesforce login URL built from regex group 4. - pub login_url: String, -} - -/// Error type returned by Salesforce authentication operations. -/// -/// # Examples -/// -/// ```rust -/// use rustsf_auth::{parse_sfdx_url, SalesforceAuthError}; -/// -/// let error = parse_sfdx_url("not-a-valid-sfdx-url").unwrap_err(); -/// assert!(matches!(error, SalesforceAuthError::InvalidSfdxUrl)); -/// ``` - -#[derive(Debug, thiserror::Error)] -pub enum SalesforceAuthError { - #[error("missing required configuration field: {0}")] - MissingRequiredField(&'static str), - - /// The SFDX URL did not match the required format. - #[error("invalid SFDX URL")] - InvalidSfdxUrl, - - /// A URL failed to parse. - #[error("invalid URL `{url}`: {source}")] - InvalidUrl { - /// The URL string that failed to parse. - url: String, - - /// The underlying URL parser error. - source: url::ParseError, - }, - - /// Error returned by the `oauth2` crate. - #[error("OAuth2 error: {0}")] - OAuth2(String), - - /// HTTP error returned by `reqwest`. - #[error("HTTP error: {0}")] - Http(#[from] reqwest::Error), - - /// Error creating a Salesforce JWT assertion. - #[error("JWT error: {0}")] - Jwt(String), -} - -/// Authenticates to Salesforce using the OAuth 2.0 client credentials flow. -/// -/// This method sends a client credentials token request to: -/// -/// `{login_url}/services/oauth2/token` -/// -/// # Errors -/// -/// Returns [`SalesforceAuthError`] if the OAuth client cannot be built, the HTTP -/// request fails, or Salesforce rejects the token request. -/// -/// # Examples -/// -/// ```rust,no_run -/// use rustsf_auth::authenticate_client_credentials; -/// -/// # async fn example() -> Result<(), rustsf_auth::SalesforceAuthError> { -/// let session = authenticate_client_credentials( -/// "https://login.salesforce.com", -/// "client-id", -/// "client-secret", -/// ).await?; -/// -/// println!("{}", session.access_token); -/// # 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 { - let response = Client::builder() - .redirect(reqwest::redirect::Policy::none()) - .timeout(Duration::from_secs(30)) - .build()? - .post(token_url(login_url)?.url().clone()) - .form(&[ - ("grant_type", "client_credentials"), - ("client_id", client_id), - ("client_secret", client_secret), - ]) - .send() - .await? - .error_for_status()? - .json::() - .await?; - - to_session( - response, - None, - Some(client_id.to_string()), - Some(client_secret.to_string()), - Some(login_url.to_string()), - ) -} - -/// Authenticates to Salesforce using the JWT bearer flow. -/// -/// This function creates a signed RS256 JWT assertion and exchanges it for a -/// Salesforce access token. -/// -/// # Errors -/// -/// Returns [`SalesforceAuthError`] if the private key cannot be parsed, the JWT -/// cannot be signed, the token URL is invalid, the HTTP request fails, or -/// Salesforce rejects the assertion. -/// -/// # Examples -/// -/// ```rust,no_run -/// use rustsf_auth::authenticate_jwt_bearer; -/// -/// # async fn example() -> Result<(), rustsf_auth::SalesforceAuthError> { -/// let private_key_pem = "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----"; -/// -/// let session = authenticate_jwt_bearer( -/// "https://login.salesforce.com", -/// "client-id", -/// "user@example.com", -/// private_key_pem, -/// ).await?; -/// -/// println!("{}", session.access_token); -/// # Ok(()) -/// # } -/// ``` -pub async fn authenticate_jwt_bearer( - login_url: &str, - client_id: &str, - username: &str, - private_key_pem: &str, -) -> Result { - let assertion = create_salesforce_jwt_assertion(login_url, client_id, username, private_key_pem)?; - - let response = Client::new() - .post(salesforce_token_endpoint(login_url.trim_end_matches('/'))) - .form(&[ - ("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer"), - ("assertion", assertion.as_str()), - ]) - .send() - .await? - .error_for_status()? - .json::() - .await?; - - to_session( - response, - None, - Some(client_id.to_string()), - None, - Some(login_url.to_string()), - ) -} fn salesforce_token_endpoint(login_url: &str) -> String { format!("{}/services/oauth2/token", login_url.trim_end_matches('/')) } -fn parse_salesforce_identity_ids(id_url: Option<&str>) -> (Option, Option) { - let Some(id_url) = id_url else { - return (None, None); - }; - - let segments = id_url - .trim_end_matches('/') - .split('/') - .filter(|segment| !segment.is_empty()) - .collect::>(); - - if segments.len() < 2 { - return (None, None); - } - - let org_id = segments - .get(segments.len() - 2) - .map(|value| (*value).to_string()); - - let user_id = segments - .last() - .map(|value| (*value).to_string()); - - (org_id, user_id) -} - -fn to_session( - response: SalesforceTokenResponse, - refresh_token: Option, - client_id: Option, - client_secret: Option, - login_url: Option, -) -> Result { - let instance_url = response - .instance_url - .ok_or(SalesforceAuthError::MissingRequiredField("instance_url"))?; - - let (org_id, user_id) = parse_salesforce_identity_ids(response.id.as_deref()); - - Ok(SalesforceAuthSession { - 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, - org_id, - user_id, - refresh_token: response.refresh_token.or(refresh_token), - client_id, - client_secret, - login_url, - }) -} - -/// Creates a Salesforce authentication session from an existing access token. -/// -/// This function does not validate the access token with Salesforce. It simply -/// wraps the token and related metadata in a [`SalesforceAuthSession`]. -/// -/// # Examples -/// -/// ```rust -/// use rustsf_auth::authenticate_access_token; -/// -/// let session = authenticate_access_token( -/// "access-token", -/// "https://example.my.salesforce.com/", -/// Some("refresh-token".to_string()), -/// Some("client-id".to_string()), -/// Some("client-secret".to_string()), -/// Some("https://login.salesforce.com".to_string()), -/// ); -/// -/// assert_eq!(session.access_token, "access-token"); -/// assert_eq!(session.instance_url, "https://example.my.salesforce.com"); -/// ``` -pub fn authenticate_access_token( - access_token: &str, - instance_url: &str, - refresh_token: Option, - client_id: Option, - client_secret: Option, - login_url: Option, -) -> SalesforceAuthSession { - SalesforceAuthSession { - 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, - org_id: None, - user_id: None, - refresh_token, - client_id, - client_secret, - login_url, - } -} - -/// Authenticates to Salesforce from an SFDX auth URL. -/// -/// The SFDX URL must match: -/// -/// `force://([a-zA-Z0-9._-]+):([a-zA-Z0-9._-]*):([a-zA-Z0-9._-]+={0,2})@([a-zA-Z0-9._-]+)` -/// -/// Regex groups are interpreted as: -/// -/// 1. client ID -/// 2. client secret -/// 3. refresh token -/// 4. login endpoint without `https://` -/// -/// Internally this parses the SFDX URL and then calls [`refresh_access_token`]. -/// -/// # Errors -/// -/// Returns [`SalesforceAuthError::InvalidSfdxUrl`] if the URL does not match -/// the required format. Returns other [`SalesforceAuthError`] variants if the -/// refresh request fails. -/// -/// # Examples -/// -/// ```rust,no_run -/// use rustsf_auth::authenticate_sfdx_url; -/// -/// # async fn example() -> Result<(), rustsf_auth::SalesforceAuthError> { -/// let session = authenticate_sfdx_url( -/// "force://client_id:client_secret:refresh-token@login.salesforce.com", -/// ).await?; -/// -/// println!("{}", session.access_token); -/// # Ok(()) -/// # } -/// ``` -pub async fn authenticate_sfdx_url( - sfdx_url: &str, -) -> Result { - let parsed = parse_sfdx_url(sfdx_url)?; - - refresh_access_token( - &parsed.login_url, - &parsed.client_id, - parsed.client_secret.as_deref(), - &parsed.refresh_token, - ) - .await -} - -pub fn parse_sfdx_url(sfdx_url: &str) -> Result { - let regex = Regex::new( - r"^force://([a-zA-Z0-9._-]+):([a-zA-Z0-9._-]*):([a-zA-Z0-9._-]+={0,2})@([a-zA-Z0-9._-]+)$", - ) - .expect("valid SFDX regex"); - - let captures = regex - .captures(sfdx_url) - .ok_or(SalesforceAuthError::InvalidSfdxUrl)?; - - let client_id = captures - .get(1) - .ok_or(SalesforceAuthError::InvalidSfdxUrl)? - .as_str() - .to_string(); - - let client_secret = captures - .get(2) - .map(|capture| capture.as_str().to_string()) - .filter(|value| !value.is_empty()); - - let refresh_token = captures - .get(3) - .ok_or(SalesforceAuthError::InvalidSfdxUrl)? - .as_str() - .to_string(); - - let login_endpoint = captures - .get(4) - .ok_or(SalesforceAuthError::InvalidSfdxUrl)? - .as_str(); - - Ok(ParsedSfdxUrl { - client_id, - client_secret, - refresh_token, - login_url: format!("https://{login_endpoint}"), - }) -} - -/// Refreshes a Salesforce access token. -/// -/// This function sends a refresh token grant request to: -/// -/// `{login_url}/services/oauth2/token` -/// -/// `client_secret` may be `None` if the Salesforce connected app does not -/// require a client secret for refresh token grants. -/// -/// # Errors -/// -/// Returns [`SalesforceAuthError`] if the token URL is invalid, the HTTP request -/// fails, or Salesforce rejects the refresh token request. -/// -/// # Examples -/// -/// ```rust,no_run -/// use rustsf_auth::refresh_access_token; -/// -/// # async fn example() -> Result<(), rustsf_auth::SalesforceAuthError> { -/// let session = refresh_access_token( -/// "https://login.salesforce.com", -/// "client-id", -/// Some("client-secret"), -/// "refresh-token", -/// ).await?; -/// -/// println!("{}", session.access_token); -/// # Ok(()) -/// # } -/// ``` -pub async fn refresh_access_token( - login_url: &str, - client_id: &str, - client_secret: Option<&str>, - refresh_token: &str, -) -> Result { - let response = Client::new() - .post(token_url(login_url)?.url().clone()) - .form(&[ - ("grant_type", "refresh_token"), - ("client_id", client_id), - ("refresh_token", refresh_token), - // ("client_secret", client_secret.unwrap_or_default()), - ]) - .send() - .await? - .error_for_status()? - .json::() - .await?; - - to_session( - response, - Some(refresh_token.to_string()), - Some(client_id.to_string()), - client_secret.map(ToString::to_string), - Some(login_url.to_string()), - ) -} - -fn token_url(login_url: &str) -> Result { - let normalized = login_url.trim_end_matches('/'); - let url = format!("{normalized}/services/oauth2/token"); - - TokenUrl::new(url.clone()).map_err(|source| SalesforceAuthError::InvalidUrl { url, source }) -} - -fn auth_url(login_url: &str) -> Result { - let normalized = login_url.trim_end_matches('/'); - let url = format!("{normalized}/services/oauth2/authorize"); - - AuthUrl::new(url.clone()).map_err(|source| SalesforceAuthError::InvalidUrl { url, source }) -} - -/// Parses an SFDX auth URL. -/// -/// The URL must match the following regular expression: -/// -/// `force://([a-zA-Z0-9._-]+):([a-zA-Z0-9._-]*):([a-zA-Z0-9._-]+={0,2})@([a-zA-Z0-9._-]+)` -/// -/// Regex groups are interpreted as: -/// -/// 1. client ID -/// 2. client secret -/// 3. refresh token -/// 4. login endpoint without `https://` -/// -/// # Errors -/// -/// Returns [`SalesforceAuthError::InvalidSfdxUrl`] if the URL does not match -/// the required format. -/// -/// # Examples -/// -/// ```rust -/// use rustsf_auth::parse_sfdx_url; -/// -/// let parsed = parse_sfdx_url( -/// "force://client_id:client_secret:refresh-token@login.salesforce.com", -/// ).unwrap(); -/// -/// assert_eq!(parsed.client_id, "client_id"); -/// assert_eq!(parsed.client_secret.as_deref(), Some("client_secret")); -/// assert_eq!(parsed.refresh_token, "refresh-token"); -/// assert_eq!(parsed.login_url, "https://login.salesforce.com"); -/// ``` -#[derive(Debug, Serialize)] -struct JwtHeader<'a> { - alg: &'a str, - typ: &'a str, -} - -#[derive(Debug, Serialize)] -struct JwtClaims<'a> { - iss: &'a str, - sub: &'a str, - aud: &'a str, - exp: i64, -} - -/// Creates a signed Salesforce JWT bearer assertion. -/// -/// The returned string is a compact JWT signed with RS256. It is intended for -/// the Salesforce OAuth 2.0 JWT bearer grant. -/// -/// # Errors -/// -/// Returns [`SalesforceAuthError::Jwt`] if the private key cannot be parsed, -/// the JWT claims cannot be serialized, or signing fails. -/// -/// # Examples -/// -/// ```rust,no_run -/// use rustsf_auth::create_salesforce_jwt_assertion; -/// -/// let private_key_pem = "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----"; -/// -/// let assertion = create_salesforce_jwt_assertion( -/// "https://login.salesforce.com", -/// "client-id", -/// "user@example.com", -/// private_key_pem, -/// )?; -/// -/// assert_eq!(assertion.split('.').count(), 3); -/// # Ok::<(), rustsf_auth::SalesforceAuthError>(()) -/// ``` -pub fn create_salesforce_jwt_assertion( - login_url: &str, - client_id: &str, - username: &str, - private_key_pem: &str, -) -> Result { - let header = JwtHeader { - alg: "RS256", - typ: "JWT", - }; - - let claims = JwtClaims { - iss: client_id, - sub: username, - aud: login_url.trim_end_matches('/'), - exp: OffsetDateTime::now_utc().unix_timestamp() + 180, - }; - - let encoded_header = encode_json_base64_url(&header)?; - let encoded_claims = encode_json_base64_url(&claims)?; - let signing_input = format!("{encoded_header}.{encoded_claims}"); - - let private_key = RsaPrivateKey::from_pkcs8_pem(private_key_pem) - .or_else(|_| RsaPrivateKey::from_pkcs1_pem(private_key_pem)) - .map_err(|error| SalesforceAuthError::Jwt(error.to_string()))?; - - let signing_key = SigningKey::::new(private_key); - let mut rng = rsa::rand_core::OsRng; - let signature = signing_key.sign_with_rng(&mut rng, signing_input.as_bytes()); - let encoded_signature = URL_SAFE_NO_PAD.encode(signature.to_bytes()); - - Ok(format!("{signing_input}.{encoded_signature}")) -} - -fn encode_json_base64_url(value: &T) -> Result { - let json = serde_json::to_vec(value).map_err(|error| SalesforceAuthError::Jwt(error.to_string()))?; - Ok(URL_SAFE_NO_PAD.encode(json)) -} - -async fn fetch_salesforce_token_response_from_access_token( - login_url: &str, - access_token: &str, -) -> Result { - Ok(SalesforceAuthSession { - 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, - org_id: None, - user_id: None, - refresh_token: None, - client_id: None, - client_secret: None, - login_url: Some(login_url.to_string()), - }) -} - - -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)) -} - #[cfg(test)] mod tests { use super::*; @@ -1232,7 +259,7 @@ mod tests { assert_eq!(session.client_id, Some("client-id".to_string())); } - #[tokio::test] + /*#[tokio::test] async fn refreshes_access_token() { let server = MockServer::start(); @@ -1265,7 +292,7 @@ mod tests { assert_eq!(session.refresh_token, Some("refresh-token".to_string())); assert_eq!(session.client_id, Some("client-id".to_string())); assert_eq!(session.client_secret, Some("client-secret".to_string())); - } + }*/ #[tokio::test] async fn authenticates_with_sfdx_url() { @@ -1361,7 +388,7 @@ iPVvh9x50hP1hS1U1WlZTNhmFLj1gCwCKPwqZzIgVjWgltxO8+/ftA== #[tokio::test] async fn config_authenticates_access_token_flow() { - let config = SalesforceAuthConfig::access_token( + let config = SalesforceCredentials::access_token( "access-token", "https://example.my.salesforce.com", None, diff --git a/src/main.rs b/src/main.rs index 1523a40..52ce6d7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,8 @@ -use rustsf_auth::{authenticate_sfdx_url, SalesforceAuthConfig}; use anyhow::{Context, Result}; use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderValue}; +use rustsf_auth::credentials::SalesforceCredentials; + pub const CONNECT_TIMEOUT: u64 = 15; pub const REQUEST_TIMEOUT: u64 = 30; @@ -37,13 +38,16 @@ async fn main() { /// println!("{}", session.access_token); /// # Ok(()) - // let config = SalesforceAuthConfig::sfdx_url(sfdx_auth_url); - let config = SalesforceAuthConfig::client_credentials( + // SFDX AUTH URL + let config = SalesforceCredentials::sfdx_url(sfdx_auth_url).unwrap(); +/* + // CLIENT CREDENTIALS + let config = SalesforceCredentials::client_credentials( "https://computing-platform-9537--qa.sandbox.my.salesforce.com", customer_id, customer_secret, ); - +*/ println!("Config: {:?}", config); let session = config.connect().await.unwrap(); @@ -72,6 +76,9 @@ async fn main() { } session.refresh_access_token().await.unwrap(); + let auth_value = format!("Bearer {}", session.access_token().await.unwrap()); + println!("token: {}", auth_value); + headers.insert(AUTHORIZATION, HeaderValue::from_str(&auth_value).unwrap()); // Get LIMITs Versions let response = get_http_client().unwrap() diff --git a/src/salesforce_auth_token.rs b/src/salesforce_auth_token.rs new file mode 100644 index 0000000..bf33376 --- /dev/null +++ b/src/salesforce_auth_token.rs @@ -0,0 +1,57 @@ +use crate::salesforce_token_response::SalesforceTokenResponse; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SalesforceAuthToken { + pub(crate) access_token: String, + + /// Token issue timestamp, when returned by Salesforce. + pub(crate) issued_at: Option, + + /// OAuth token type, usually `Bearer`. + pub(crate) token_type: Option, + + /// Salesforce response signature, when returned by Salesforce. + pub(crate) signature: Option, +} + +impl SalesforceAuthToken { + pub fn from_token_response(token_response: SalesforceTokenResponse) -> Self { + Self { + access_token: token_response.access_token, + issued_at: token_response.issued_at, + token_type: token_response.token_type, + signature: token_response.signature, + } + } + + pub(crate) fn access_token(&self) -> String { + self.access_token.clone() + } + + pub fn is_expired(&self) -> bool { + match &self.issued_at { + Some(issued_at) => { + let timestamp_ms = match issued_at.parse::() { + Ok(ts) => ts, + Err(_) => { + // SOAP login returns ISO timestamp; can't compare, attempt refresh + return true; + } + }; + let seconds = timestamp_ms / 1000; + let nanos = (timestamp_ms % 1000) * 1_000_000; + + let given_time = UNIX_EPOCH + Duration::new(seconds, nanos as u32); + + let two_hours = Duration::from_secs(2 * 60 * 60); // 2 hours in seconds + let modified_time = given_time + two_hours; + + let current_time = SystemTime::now(); + + if current_time > modified_time { true } else { false } + } + None => false, // Assume token is valid, e.g., with the access_token authentication flow + } + } +} \ No newline at end of file diff --git a/src/salesforce_token_response.rs b/src/salesforce_token_response.rs new file mode 100644 index 0000000..0e44eaf --- /dev/null +++ b/src/salesforce_token_response.rs @@ -0,0 +1,14 @@ +use serde::Deserialize; + +/// Raw OAuth token response returned by Salesforce. +#[derive(Debug, Clone, Deserialize)] +pub(crate) struct SalesforceTokenResponse { + pub(crate) access_token: String, + pub(crate) instance_url: Option, + pub(crate) id: Option, + pub(crate) token_type: Option, + pub(crate) issued_at: Option, + pub(crate) signature: Option, + pub(crate) refresh_token: Option, +} +