Working SFDX Auth URL and Client Credentials
This commit is contained in:
@@ -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<String>,
|
||||
instance_url: impl Into<String>,
|
||||
refresh_token: Option<String>,
|
||||
client_id: Option<String>,
|
||||
client_secret: Option<String>,
|
||||
login_url: Option<String>,
|
||||
) -> 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<SalesforceTokenResponse, SalesforceAuthError> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
client_id: impl Into<String>,
|
||||
client_secret: impl Into<String>,
|
||||
) -> 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<SalesforceTokenResponse, SalesforceAuthError> {
|
||||
|
||||
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::<SalesforceTokenResponse>()
|
||||
.await?)
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
client_id: impl Into<String>,
|
||||
username: impl Into<String>,
|
||||
private_key_pem: impl Into<String>,
|
||||
) -> 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<SalesforceTokenResponse, SalesforceAuthError> {
|
||||
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::<SalesforceTokenResponse>()
|
||||
.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<String, SalesforceAuthError> {
|
||||
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::<Sha256>::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<T: Serialize>(value: &T) -> Result<String, SalesforceAuthError> {
|
||||
let json = serde_json::to_vec(value).map_err(|error| SalesforceAuthError::Jwt(error.to_string()))?;
|
||||
Ok(URL_SAFE_NO_PAD.encode(json))
|
||||
}
|
||||
@@ -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<String>,
|
||||
|
||||
/// Salesforce connected app client ID.
|
||||
pub client_id: Option<String>,
|
||||
|
||||
/// Salesforce connected app client secret.
|
||||
pub client_secret: Option<String>,
|
||||
|
||||
/// Salesforce username used by the JWT bearer flow.
|
||||
pub username: Option<String>,
|
||||
|
||||
/// RSA private key in PEM format used by the JWT bearer flow.
|
||||
pub private_key_pem: Option<String>,
|
||||
|
||||
/// Existing Salesforce access token.
|
||||
pub access_token: Option<String>,
|
||||
|
||||
/// Salesforce refresh token.
|
||||
pub refresh_token: Option<String>,
|
||||
|
||||
/// Salesforce instance URL, for example `https://example.my.salesforce.com`.
|
||||
pub instance_url: Option<String>,
|
||||
}
|
||||
|
||||
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<SalesforceAuthSession, SalesforceAuthError> {
|
||||
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<SalesforceAuthToken, SalesforceAuthError> {
|
||||
let token_response = self.token_response_from_flow().await?;
|
||||
Ok(SalesforceAuthToken::from_token_response(token_response))
|
||||
}
|
||||
|
||||
async fn token_response_from_flow(&self) -> Result<SalesforceTokenResponse, SalesforceAuthError> {
|
||||
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<SalesforceAuthToken, SalesforceAuthError> {
|
||||
|
||||
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::<SalesforceTokenResponse>()
|
||||
.await?;
|
||||
|
||||
Ok(SalesforceAuthToken::from_token_response(response))
|
||||
},
|
||||
None => {
|
||||
self.reconnect().await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn token_url(&self) -> Result<TokenUrl, SalesforceAuthError> {
|
||||
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<reqwest::Client, SalesforceAuthError> {
|
||||
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<String>, Option<String>) {
|
||||
let Some(id_url) = id_url else {
|
||||
return (None, None);
|
||||
};
|
||||
|
||||
let segments = id_url
|
||||
.trim_end_matches('/')
|
||||
.split('/')
|
||||
.filter(|segment| !segment.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -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<String>) -> Result<Self, SalesforceAuthError> {
|
||||
|
||||
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<SalesforceTokenResponse, SalesforceAuthError> {
|
||||
|
||||
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::<SalesforceTokenResponse>()
|
||||
.await?)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
+21
-994
File diff suppressed because it is too large
Load Diff
+11
-4
@@ -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()
|
||||
|
||||
@@ -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<String>,
|
||||
|
||||
/// OAuth token type, usually `Bearer`.
|
||||
pub(crate) token_type: Option<String>,
|
||||
|
||||
/// Salesforce response signature, when returned by Salesforce.
|
||||
pub(crate) signature: Option<String>,
|
||||
}
|
||||
|
||||
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::<u64>() {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
pub(crate) id: Option<String>,
|
||||
pub(crate) token_type: Option<String>,
|
||||
pub(crate) issued_at: Option<String>,
|
||||
pub(crate) signature: Option<String>,
|
||||
pub(crate) refresh_token: Option<String>,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user