From ef57ba0da98d052d86d8ce321efeddb105c7f748 Mon Sep 17 00:00:00 2001 From: Wim Velzeboer Date: Mon, 21 Sep 2026 11:24:45 +0100 Subject: [PATCH] stuff --- Cargo.toml | 22 + src/lib.rs | 1252 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 66 +++ 3 files changed, 1340 insertions(+) create mode 100644 Cargo.toml create mode 100644 src/lib.rs create mode 100644 src/main.rs diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..fc5501a --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "rustsf_auth" +version = "0.1.0" +edition = "2024" + +[dependencies] +anyhow = "1.0.104" +base64 = "0.22" +oauth2 = { version = "5", features = ["reqwest"] } +regex = "1" +reqwest = { version = "0.12", features = ["json", "rustls-tls"] } +rsa = { version = "0.9", features = ["pem"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = { version = "0.10", features = ["oid"] } +thiserror = "2" +time = "0.3" +url = "2" +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } + +[dev-dependencies] +httpmock = "0.7" diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..3fe46e1 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,1252 @@ +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use oauth2::{ + AuthUrl, ClientId, ClientSecret, EndpointNotSet, EndpointSet, RefreshToken, Scope, + StandardTokenResponse, TokenResponse, TokenUrl, + basic::{BasicClient, BasicTokenType}, +}; +use rsa::pkcs1::DecodeRsaPrivateKey; +use rsa::pkcs8::DecodePrivateKey; +use regex::Regex; +use reqwest::Client; +use rsa::pkcs1v15::SigningKey; +use rsa::signature::{RandomizedSigner, SignatureEncoding}; +use rsa::RsaPrivateKey; +use serde::{Deserialize, Serialize}; +use sha2::Sha256; +use std::time::Duration; +use thiserror::Error; +use time::OffsetDateTime; +use url::Url; + +/// The default Salesforce production login URL. +/// +/// Use this value when authenticating against a production Salesforce org. +/// +/// # Examples +/// +/// ```rust +/// use rustsf_auth::DEFAULT_SALESFORCE_LOGIN_URL; +/// +/// assert_eq!(DEFAULT_SALESFORCE_LOGIN_URL, "https://login.salesforce.com"); +/// ``` +pub const DEFAULT_SALESFORCE_LOGIN_URL: &str = "https://login.salesforce.com"; + +/// 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 { + 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(), + )), + } + } +} + +/// 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. +/// +/// # Examples +/// +/// ```rust +/// use rustsf_auth::authenticate_access_token; +/// +/// let session = authenticate_access_token( +/// "access-token", +/// "https://example.my.salesforce.com", +/// None, +/// None, +/// None, +/// None, +/// ); +/// +/// assert_eq!(session.access_token, "access-token"); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SalesforceAuthSession { + pub access_token: String, + + /// Salesforce instance URL associated with the authenticated org. + pub instance_url: String, + + /// Salesforce identity service URL, when returned by Salesforce. + pub id: Option, + + /// OAuth token type, usually `Bearer`. + pub token_type: Option, + + /// Token issue timestamp, when returned by Salesforce. + pub issued_at: Option, + + /// Salesforce response signature, when returned by Salesforce. + pub signature: Option, + + /// OAuth refresh token, when available. + pub refresh_token: Option, + + /// 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, +} + +impl SalesforceAuthSession { + /// Refreshes this session's access token. + /// + /// The session must contain `login_url`, `client_id`, and `refresh_token`. + /// `client_secret` is optional because Salesforce connected apps may be + /// configured without requiring a secret for refresh. + /// + /// # Errors + /// + /// Returns [`SalesforceAuthError::MissingField`] if required refresh fields + /// are absent. Returns other [`SalesforceAuthError`] variants if the refresh + /// request fails. + /// + /// # Examples + /// + /// ```rust,no_run + /// use rustsf_auth::authenticate_access_token; + /// + /// # async fn example() -> Result<(), rustsf_auth::SalesforceAuthError> { + /// let session = authenticate_access_token( + /// "old-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()), + /// ); + /// + /// let refreshed = session.refresh_access_token().await?; + /// println!("{}", refreshed.access_token); + /// # Ok(()) + /// # } + /// ``` + pub async fn refresh_access_token(&self) -> Result { + 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 + } +} + +/// 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(()) +/// # } +/// ``` +pub async fn authenticate_client_credentials( + login_url: &str, + client_id: &str, + client_secret: &str, +) -> Result { + let http_client = Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(Duration::from_secs(30)) + .build()?; + + let token_url = TokenUrl::new(format!( + "{}/services/oauth2/token", + login_url.trim_end_matches('/') + )) + .map_err(|error| SalesforceAuthError::OAuth2(error.to_string()))?; + + let oauth_client = BasicClient::new(ClientId::new(client_id.to_string())) + .set_client_secret(ClientSecret::new(client_secret.to_string())) + .set_token_uri(token_url); + + let token_response: StandardTokenResponse = + oauth_client + .exchange_client_credentials() + .add_scope(Scope::new("api".to_string())) + .request_async(&http_client) + .await + .map_err(|error| SalesforceAuthError::OAuth2(error.to_string()))?; + + let access_token = token_response.access_token().secret().to_string(); + + fetch_salesforce_token_response_from_access_token(login_url, &access_token) + .await + .or_else(|_| { + Ok(SalesforceAuthSession { + access_token, + instance_url: login_url.trim_end_matches('/').to_string(), + id: None, + token_type: Some("Bearer".to_string()), + issued_at: None, + signature: None, + refresh_token: None, + client_id: Some(client_id.to_string()), + client_secret: Some(client_secret.to_string()), + login_url: Some(login_url.to_string()), + }) + }) +} + +/// 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 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"))?; + + Ok(SalesforceAuthSession { + access_token: response.access_token, + instance_url, + id: response.id, + token_type: response.token_type, + issued_at: response.issued_at, + signature: response.signature, + refresh_token: response.refresh_token.or(refresh_token), + client_id, + client_secret, + 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 { + access_token: access_token.to_string(), + instance_url: instance_url.trim_end_matches('/').to_string(), + id: None, + token_type: Some("Bearer".to_string()), + issued_at: None, + signature: None, + refresh_token, + client_id, + client_secret, + 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 { + access_token: access_token.to_string(), + instance_url: login_url.trim_end_matches('/').to_string(), + id: None, + token_type: Some("Bearer".to_string()), + issued_at: None, + signature: None, + refresh_token: None, + client_id: None, + client_secret: None, + login_url: Some(login_url.to_string()), + }) +} + + +fn required<'a>( + value: Option<&'a str>, + field_name: &'static str, +) -> Result<&'a str, SalesforceAuthError> { + value.ok_or(SalesforceAuthError::MissingRequiredField(field_name)) +} + +#[cfg(test)] +mod tests { + use super::*; + use httpmock::Method::POST; + use httpmock::MockServer; + use serde_json::json; + + fn token_body() -> serde_json::Value { + json!({ + "access_token": "access-token-123", + "instance_url": "https://example.my.salesforce.com", + "id": "https://login.salesforce.com/id/00D/example", + "token_type": "Bearer", + "issued_at": "1700000000000", + "signature": "signature" + }) + } + + #[test] + fn parses_valid_sfdx_url_with_secret() { + 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, Some("client.secret".to_string())); + assert_eq!(parsed.refresh_token, "refresh_token=="); + assert_eq!(parsed.login_url, "https://login.salesforce.com"); + } + + #[test] + fn parses_valid_sfdx_url_without_secret() { + let parsed = parse_sfdx_url( + "force://client_id::refresh-token@sandbox.my.salesforce.com", + ) + .unwrap(); + + assert_eq!(parsed.client_id, "client_id"); + assert_eq!(parsed.client_secret, None); + assert_eq!(parsed.refresh_token, "refresh-token"); + assert_eq!(parsed.login_url, "https://sandbox.my.salesforce.com"); + } + + #[test] + fn rejects_invalid_sfdx_url() { + let result = parse_sfdx_url("https://not-a-force-url"); + + assert!(matches!(result, Err(SalesforceAuthError::InvalidSfdxUrl))); + } + + #[test] + fn authenticates_existing_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"); + assert_eq!(session.refresh_token, Some("refresh-token".to_string())); + assert_eq!(session.client_id, Some("client-id".to_string())); + } + + #[tokio::test] + async fn refreshes_access_token() { + let server = MockServer::start(); + + let mock = server.mock(|when, then| { + when.method(POST) + .path("/services/oauth2/token") + .body_contains("grant_type=refresh_token") + .body_contains("client_id=client-id") + .body_contains("refresh_token=refresh-token") + .body_contains("client_secret=client-secret"); + + then.status(200) + .header("content-type", "application/json") + .json_body(token_body()); + }); + + let session = refresh_access_token( + &server.base_url(), + "client-id", + Some("client-secret"), + "refresh-token", + ) + .await + .unwrap(); + + mock.assert(); + + assert_eq!(session.access_token, "access-token-123"); + assert_eq!(session.instance_url, "https://example.my.salesforce.com"); + 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() { + let server = MockServer::start(); + let login_endpoint = server.base_url().trim_start_matches("http://"); + + let mock = server.mock(|when, then| { + when.method(POST) + .path("/services/oauth2/token") + .body_contains("grant_type=refresh_token") + .body_contains("client_id=client_id") + .body_contains("refresh_token=refresh-token") + .body_contains("client_secret=client_secret"); + + then.status(200) + .header("content-type", "application/json") + .json_body(token_body()); + }); + + let sfdx_url = format!( + "force://client_id:client_secret:refresh-token@{}", + login_endpoint + ); + + let session = authenticate_sfdx_url(&sfdx_url).await.unwrap(); + + mock.assert(); + + assert_eq!(session.access_token, "access-token-123"); + assert_eq!(session.client_id, Some("client_id".to_string())); + assert_eq!(session.client_secret, Some("client_secret".to_string())); + assert_eq!(session.refresh_token, Some("refresh-token".to_string())); + } + + #[tokio::test] + async fn authenticates_jwt_bearer_with_mocked_server() { + let server = MockServer::start(); + + let private_key = r#"-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEAuG+lZLtnnpGIpi19j9vYTsMprthOKAlrdbL1xSdFypPe6KQm +Eu+/bywT79seAyVOg8cmZD98vrTGkYVOEZ18FaXpgFrH/dYRkUSr7UNpZvCE1z1l +Kw/I31QglVC5YACxoxAJ3jgy5bPG9YRVBzsvtvUfxTRMfl01nVNQf7M08KvAl9Ur +IcS67JVKqgJhjY6DShMk94U6ZFhReZR0aoXMN9nhAeJ7KxIuMuENyzdIArHnqijH +yYuSNxiVcCT5qV+6kPnwouyBUbZnqjoMzOls3hVjSR//TQlajfX5aaG/MQCMuFlr +wYoXF75A/cQHlcJzucYi9qSt8k1mKx8/rBY+iQIDAQABAoIBABWmZGQGM8H3mfIy +sWCVBCDnJJr34OjaUJFYihUac0uCRJo76iMcG2hFYyD9H8+B6uDpUy0Js25v0lpb +gVUeASidIcbIGCtsfdb2yyGYWcBN1ZQSrxtZV1iz3X3U5PpQ/dwLx+tmL7PTUR7Y +x+v9vyNdZwE8iqaGwO0csnl8leRYOvPP2ZEBx16aMl+lviuN+ephtU7avPEU3MDV +vtEX8f+P0B25NRasEnX7oH7xwhA0me6cfWAhw3tKBpEzHcKyXnP6EJJvvxTYdjr+ +JeunHPw4H3o6FlblMvvC9OF9xboq4lGxgcjdvCSji1TLlvNGjvpcpYPWlSKzMD1G +bWkB/QECgYEA5k5aosZbaLG0WBSX/Dby0EP3SWhSLuz6Qc5GZei5Pq12aXnbgnZR +k0OiZtxg5D+50ZyLzVC7T4y/NfaAVLO0wj0yM01Q9PrP/htIBaE+fjDUJpqE0um2 +bcxN0ZAKi82wKjUsnPRtyv0J3mDA3rQSjv8MW3QoM4l/XwwWfbJUjRkCgYEAzQ5s +efnKwDXJmzsNBMP4k9MUiOkFZoNg47UW97HvxUGd5jjJHdg91WovRuIi0GpeO2BO +lAaQwNvNHCLQVEV9hvYtPSFsKLYSR7qD6yzfvx3e+e0tY/KRnssSgIoOucONWRCM +MQkDWhe/WVc5yu/zmDpqQu5ZhKGecCWIrFsXstUCgYEAyptC1fmE8K7KI/pAJBX9 +TvG2GgsI1Fu5V+8FIxZo+Xdu/8SH38mBrlgnYiPbKmYNbVKPSgFw4eu+vt0G9+61 +vGZMhS+oBj4zFNN+wBvGslNCLdxUNLGhSvTMiN7ayRnS91ecFaMFO2tXLeXsbGpW +QT+BKwDQP47jm5xvWcOMmukCgYATvhwxfQUCHSwLURXZxK4z8K4yHBHujvcJJ8rC +4RrDiUv9gglAWtlMDAUt+YefZewA0ahxVBQgRpgndQrw0hBPn5MOO2X25O/NaWIr +xV1HOx+kgwv68s43WlVsnfpP/4eJeAUPOqpUWkaqWt+Rh/lQmkljPzCWO50NFesx +jJhWtQKBgQDGeJFyGAQ8RJCnzp9Lmhu8oayGjra/N/0mJ41eNxmF9muHfJvEK8hf +O+fFkSBwBf0RGpsaqlzEjOeuGk6nza0tKi9gXR/iVwpDAWx5p++xuKHmr0u6BzWN +iPVvh9x50hP1hS1U1WlZTNhmFLj1gCwCKPwqZzIgVjWgltxO8+/ftA== +-----END RSA PRIVATE KEY-----"#; + + let mock = server.mock(|when, then| { + when.method(POST) + .path("/services/oauth2/token") + .body_contains("grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer") + .body_contains("assertion="); + + then.status(200) + .header("content-type", "application/json") + .json_body(token_body()); + }); + + let session = authenticate_jwt_bearer( + &server.base_url(), + "client-id", + "user@example.com", + private_key, + ) + .await + .unwrap(); + + mock.assert(); + + assert_eq!(session.access_token, "access-token-123"); + assert_eq!(session.instance_url, "https://example.my.salesforce.com"); + assert_eq!(session.client_id, Some("client-id".to_string())); + } + + #[tokio::test] + async fn config_authenticates_access_token_flow() { + let config = SalesforceAuthConfig::access_token( + "access-token", + "https://example.my.salesforce.com", + None, + None, + None, + Some("https://computing-platform-9537--qa.sandbox.my.salesforce.com/".to_string()), + ); + + let session = config.connect().await.unwrap(); + + assert_eq!(session.access_token, "access-token"); + assert_eq!(session.instance_url, "https://example.my.salesforce.com"); + } +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..8a5300e --- /dev/null +++ b/src/main.rs @@ -0,0 +1,66 @@ +use rustsf_auth::{authenticate_sfdx_url, SalesforceAuthConfig}; +use anyhow::{Context, Result}; + +use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderValue}; +pub const CONNECT_TIMEOUT: u64 = 15; +pub const REQUEST_TIMEOUT: u64 = 30; + +pub fn get_http_client() -> Result { + Ok(reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .connect_timeout(std::time::Duration::from_secs(CONNECT_TIMEOUT)) + .timeout(std::time::Duration::from_secs(REQUEST_TIMEOUT)) + .build() + .context("Failed to build HTTP client")?) +} + +#[tokio::main] +async fn main() { + println!("Hello, world!"); + + let url = "https://computing-platform-9537--qa.sandbox.my.salesforce.com/"; + let customer_id = "3MVG9xj60O9CjKHpQMaI0gAqe_BibSxfGHYQBII24Bif2Nri7ewsMsz3kEbMr4vch36.q4I.B37EpwOOtg90e"; + let customer_secret = "68182180245EEBC7C54804536914BBEA32C0C347D83A67EB43A6225D342C95B9"; + + let sfdx_auth_url = "force://PlatformCLI::5Aep8618kS0Qqf3GEL4qVTX2VSz2qiUtn3Grl0qSlJjRoSGz03Uv86qbbx2MOhcIyj.OTqFKFH.olbk5LdlYEAv@computing-platform-9537--qa.sandbox.my.salesforce.com"; + + + /// let config = SalesforceAuthConfig::client_credentials( + /// "https://login.salesforce.com", + /// "client-id", + /// "client-secret", + /// ); + /// + /// let session = config.authenticate().await?; + /// println!("{}", session.access_token); + /// # Ok(()) + + let config = SalesforceAuthConfig::sfdx_url(sfdx_auth_url); + + let session = config.connect().await.unwrap(); + + println!("{}", session.access_token); + + + let mut headers = HeaderMap::new(); + let auth_value = format!("Bearer {}", session.access_token); + headers.insert(AUTHORIZATION, HeaderValue::from_str(&auth_value).unwrap()); + + //Default header + headers.insert("Accept", HeaderValue::from_static("application/json")); + + + let response = get_http_client().unwrap() + .get(format!("{}/services/data", session.instance_url)) + .headers(headers) + .send() + .await + .unwrap(); + + + if response.status().is_success() { + println!("SUCCESS Response: {:?}", response.text().await.unwrap()); + } else { + println!("ERROR Response: {:?}", response.text().await.unwrap()); + } +}