Added new authentication method for Client Credentials flow: https://… - #40
Added new authentication method for Client Credentials flow: https://…#40dehman wants to merge 1 commit into
Conversation
WalkthroughThis PR introduces OAuth2 client credentials authentication support to the Salesforce ExecuteQuery module. It adds a new Changes
Sequence DiagramsequenceDiagram
actor Client
participant ExecuteQuery as ExecuteQuery Task
participant SalesforceAuth as Salesforce Auth Server
participant SalesforceAPI as Salesforce API
Client->>ExecuteQuery: Execute with OAuth2ClientCredentials
ExecuteQuery->>ExecuteQuery: Check AuthenticationMethod
ExecuteQuery->>SalesforceAuth: GetAccessToken(url, clientId, clientSecret)
SalesforceAuth->>SalesforceAuth: Validate credentials
SalesforceAuth-->>ExecuteQuery: Return access_token
ExecuteQuery->>ExecuteQuery: Attach Bearer token to request
ExecuteQuery->>SalesforceAPI: Execute SOQL query with Bearer token
SalesforceAPI-->>ExecuteQuery: Query result
ExecuteQuery-->>Client: Return result (and token if requested)
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
Frends.Salesforce.ExecuteQuery/Frends.Salesforce.ExecuteQuery/Definitions/Input.cs (1)
20-26:⚠️ Potential issue | 🟡 MinorUpdate XML documentation to match new default value.
The XML documentation on line 22 still references
v61.0but the actual default is nowv65.0.📝 Proposed fix
/// <summary> /// The API version to use when making requests to Salesforce. - /// If left empty, the default value is v61.0. + /// If left empty, the default value is v65.0. /// </summary> [DefaultValue("v65.0")]
🤖 Fix all issues with AI agents
In
`@Frends.Salesforce.ExecuteQuery/Frends.Salesforce.ExecuteQuery.Tests/UnitTests.cs`:
- Around line 76-99: The test
ExecuteQuery_QueryWithClientCredentials_ReturnToken is verifying a
client-credentials flow but calls the password-based GetAccessToken overload;
replace the 5-parameter call to Salesforce.GetAccessToken with the
client-credentials overload that accepts (authUrl, clientId, clientSecret,
cancellationToken) so the expected token is retrieved using
AuthenticationMethod.OAuth2WithClientCredentials and then compare that token to
result.Token.
In
`@Frends.Salesforce.ExecuteQuery/Frends.Salesforce.ExecuteQuery/ExecuteQuery.cs`:
- Around line 94-107: The GetAccessToken method currently deserializes
authResponse.Content without checking for HTTP or authentication errors; update
GetAccessToken to validate authResponse (e.g., check authResponse.IsSuccessful /
StatusCode and non-empty Content) and if not successful parse the error body (or
use authResponse.ErrorMessage) and throw a descriptive exception instead of
proceeding, then only deserialize the successful response (or use JObject) to
extract access_token into accessToken and return it; ensure you preserve the
CancellationToken usage and surface useful details (status code and error
fields) in the thrown exception for callers to debug.
🧹 Nitpick comments (2)
Frends.Salesforce.ExecuteQuery/CHANGELOG.md (1)
3-5: Use "### Added" section for new features per Keep a Changelog.The new authentication method is a new feature, not a change to existing functionality. Per Keep a Changelog format, new features should be listed under "### Added".
📝 Proposed fix
## [2.3.0] - 2026-01-31 -### Changed -- Added new authentication method for authentication with client credentials +### Added +- New authentication method for OAuth2 Client Credentials flowAs per coding guidelines: "Validate format against Keep a Changelog (https://keepachangelog.com/en/1.0.0/)"
Frends.Salesforce.ExecuteQuery/Frends.Salesforce.ExecuteQuery/Definitions/AuthenticationMethod.cs (1)
16-19: Minor grammar issue: "informations" → "information"."Information" is an uncountable noun in English. This also applies to the existing documentation on line 13.
📝 Proposed fix
/// <summary> - /// Authenticate by providing required informations to fetch OAuth2 access token client_credentials. + /// Authenticate by providing required information to fetch OAuth2 access token using client_credentials grant. /// </summary> OAuth2WithClientCredentials = 2
| [TestMethod] | ||
| public async Task ExecuteQuery_QueryWithClientCredentials_ReturnToken() | ||
| { | ||
| var input = new Input | ||
| { | ||
| Domain = _domain, | ||
| Query = "SELECT Name from Customer", | ||
| ApiVersion = "v65.0" | ||
| }; | ||
|
|
||
| var options = new Options | ||
| { | ||
| AuthenticationMethod = AuthenticationMethod.OAuth2WithClientCredentials, | ||
| AuthUrl = _authurl, | ||
| ClientID = _clientID, | ||
| ClientSecret = _clientSecret, | ||
| ReturnAccessToken = true | ||
| }; | ||
|
|
||
| var result = await Salesforce.ExecuteQuery(input, options, _cancellationToken); | ||
| var accessToken = await Salesforce.GetAccessToken(_authurl, _clientID, _clientSecret, _username, _password + _securityToken, _cancellationToken); | ||
| Assert.IsTrue(result.RequestIsSuccessful); | ||
| Assert.AreEqual(result.Token, accessToken); | ||
| } |
There was a problem hiding this comment.
Token verification uses wrong GetAccessToken overload.
Line 96 calls the password-based GetAccessToken overload (5 parameters) to obtain the expected token, but the test is verifying the client credentials flow. Client credentials and password grants return different tokens from Salesforce. The comparison on line 98 will likely fail or incorrectly pass depending on token caching behavior.
Use the client credentials overload (4 parameters) for verification:
🐛 Proposed fix
var result = await Salesforce.ExecuteQuery(input, options, _cancellationToken);
- var accessToken = await Salesforce.GetAccessToken(_authurl, _clientID, _clientSecret, _username, _password + _securityToken, _cancellationToken);
+ var accessToken = await Salesforce.GetAccessToken(_authurl, _clientID, _clientSecret, _cancellationToken);
Assert.IsTrue(result.RequestIsSuccessful);
Assert.AreEqual(result.Token, accessToken);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| [TestMethod] | |
| public async Task ExecuteQuery_QueryWithClientCredentials_ReturnToken() | |
| { | |
| var input = new Input | |
| { | |
| Domain = _domain, | |
| Query = "SELECT Name from Customer", | |
| ApiVersion = "v65.0" | |
| }; | |
| var options = new Options | |
| { | |
| AuthenticationMethod = AuthenticationMethod.OAuth2WithClientCredentials, | |
| AuthUrl = _authurl, | |
| ClientID = _clientID, | |
| ClientSecret = _clientSecret, | |
| ReturnAccessToken = true | |
| }; | |
| var result = await Salesforce.ExecuteQuery(input, options, _cancellationToken); | |
| var accessToken = await Salesforce.GetAccessToken(_authurl, _clientID, _clientSecret, _username, _password + _securityToken, _cancellationToken); | |
| Assert.IsTrue(result.RequestIsSuccessful); | |
| Assert.AreEqual(result.Token, accessToken); | |
| } | |
| [TestMethod] | |
| public async Task ExecuteQuery_QueryWithClientCredentials_ReturnToken() | |
| { | |
| var input = new Input | |
| { | |
| Domain = _domain, | |
| Query = "SELECT Name from Customer", | |
| ApiVersion = "v65.0" | |
| }; | |
| var options = new Options | |
| { | |
| AuthenticationMethod = AuthenticationMethod.OAuth2WithClientCredentials, | |
| AuthUrl = _authurl, | |
| ClientID = _clientID, | |
| ClientSecret = _clientSecret, | |
| ReturnAccessToken = true | |
| }; | |
| var result = await Salesforce.ExecuteQuery(input, options, _cancellationToken); | |
| var accessToken = await Salesforce.GetAccessToken(_authurl, _clientID, _clientSecret, _cancellationToken); | |
| Assert.IsTrue(result.RequestIsSuccessful); | |
| Assert.AreEqual(result.Token, accessToken); | |
| } |
🤖 Prompt for AI Agents
In
`@Frends.Salesforce.ExecuteQuery/Frends.Salesforce.ExecuteQuery.Tests/UnitTests.cs`
around lines 76 - 99, The test
ExecuteQuery_QueryWithClientCredentials_ReturnToken is verifying a
client-credentials flow but calls the password-based GetAccessToken overload;
replace the 5-parameter call to Salesforce.GetAccessToken with the
client-credentials overload that accepts (authUrl, clientId, clientSecret,
cancellationToken) so the expected token is retrieved using
AuthenticationMethod.OAuth2WithClientCredentials and then compare that token to
result.Token.
There was a problem hiding this comment.
Please check whether this is true, and if so, update the access token handling here to match the one used in the tested method.
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Read the ExecuteQuery.cs file to examine both GetAccessToken overloads
cat Frends.Salesforce.ExecuteQuery/Frends.Salesforce.ExecuteQuery/ExecuteQuery.cs
Length of output: 5833
@MatteoDelOmbra Yes, the review comment is correct. Here's the issue:
The test ExecuteQuery_QueryWithClientCredentials_ReturnToken is verifying the client credentials flow but Line 96 calls the password-based GetAccessToken overload (5 parameters including username and password).
Looking at ExecuteQuery.cs, there are two distinct overloads:
- Client credentials (4 parameters, lines 91-103): Uses
grant_type="client_credentials" - Password (5 parameters, lines 108-122): Uses
grant_type="password"
These are different OAuth2 flows that return different tokens from Salesforce. The test is comparing tokens from two different grant types, which won't match.
Line 96 should use the 4-parameter overload to match what ExecuteQuery does internally at line 58-60:
- var accessToken = await Salesforce.GetAccessToken(_authurl, _clientID, _clientSecret, _username, _password + _securityToken, _cancellationToken);
+ var accessToken = await Salesforce.GetAccessToken(_authurl, _clientID, _clientSecret, _cancellationToken);This ensures the test verifies the same token type that the method under test actually generates.
🧬 Code Graph Analysis Results
filename:Frends.Salesforce.ExecuteQuery/Frends.Salesforce.ExecuteQuery/ExecuteQuery.cs|LineRange:18-132
public class Salesforce
{
///
/// Execute a query to Salesforce.
/// Documentation
///
/// Information to update the sobject.
/// Information about the salesforce destination.
///
/// Object { dynamic Body, bool RequestIsSuccessful, Exception ErrorException, string ErrorMessage, string Token }
public static async Task ExecuteQuery(
[PropertyTab] Input input,
[PropertyTab] Options options,
CancellationToken cancellationToken
)
{
var accessToken = string.Empty;
try
{
if (string.IsNullOrWhiteSpace(input.Domain)) throw new ArgumentNullException("Domain cannot be empty.");
if (string.IsNullOrWhiteSpace(input.Query)) throw new ArgumentNullException("Query cannot be empty.");
var query = WebUtility.UrlEncode(input.Query);
var client = new RestClient($"{input.Domain}/services/data/{input.ApiVersion}/query/?q={query}");
var request = new RestRequest("/", Method.Get);
switch (options.AuthenticationMethod)
{
case AuthenticationMethod.AccessToken:
if (string.IsNullOrWhiteSpace(options.AccessToken))
throw new ArgumentException(
"Access token cannot be null when using Access Token authentication method");
request.AddHeader("Authorization", "Bearer " + options.AccessToken);
break;
case AuthenticationMethod.OAuth2WithClientCredentials:
accessToken = await GetAccessToken(options.AuthUrl, options.ClientID, options.ClientSecret,
cancellationToken);
request.AddHeader("Authorization", "Bearer " + accessToken);
break;
case AuthenticationMethod.OAuth2WithPassword:
accessToken = await GetAccessToken(options.AuthUrl, options.ClientID, options.ClientSecret,
options.Username, options.Password + options.SecurityToken, cancellationToken);
request.AddHeader("Authorization", "Bearer " + accessToken);
break;
}
if (!((options.AuthenticationMethod is AuthenticationMethod.OAuth2WithPassword ||
options.AuthenticationMethod is AuthenticationMethod.OAuth2WithClientCredentials)
&& options.ReturnAccessToken))
accessToken = string.Empty;
var response = await client.ExecuteAsync(request, cancellationToken);
dynamic content = JsonConvert.DeserializeObject(response.Content);
return new Result(content, response.IsSuccessful, response.ErrorException,
response.IsSuccessful ? string.Empty : content[0].Value<string>("message"), accessToken);
}
catch (Exception e)
{
return Helpers.ErrorHandler.Handle(e, accessToken);
}
}
`#region` HelperMethods
/// <summary>
/// Get OAuth2 access token with Client Credentials.
/// This method is public since it is used also in Unit tests.
/// </summary>
internal static async Task<string> GetAccessToken(string url, string clientId, string clientSecret,
CancellationToken cancellationToken)
{
var authClient = new RestClient(url);
var authRequest = new RestRequest("", Method.Post);
authRequest.AddHeader("Content-Type", "application/x-www-form-urlencoded");
authRequest.AddParameter("grant_type", "client_credentials");
authRequest.AddParameter("client_id", clientId);
authRequest.AddParameter("client_secret", clientSecret);
var authResponse = await authClient.ExecuteAsync(authRequest, cancellationToken);
string accessToken = JsonConvert.DeserializeObject<dynamic>(authResponse.Content).access_token;
return accessToken;
}
/// <summary>
/// Get OAuth2 access token with username-password.
/// This method is public since it is used also in Unit tests.
/// </summary>
internal static async Task<string> GetAccessToken(string url, string clientId, string clientSecret, string username,
string passwordWithSecurityToken, CancellationToken cancellationToken)
{
var authClient = new RestClient(url);
var authRequest = new RestRequest("", Method.Post);
authRequest.AddHeader("Content-Type", "application/x-www-form-urlencoded");
authRequest.AddParameter("grant_type", "password");
authRequest.AddParameter("client_id", clientId);
authRequest.AddParameter("client_secret", clientSecret);
authRequest.AddParameter("username", username);
authRequest.AddParameter("password", passwordWithSecurityToken);
var authResponse = await authClient.ExecuteAsync(authRequest, cancellationToken);
string accessToken = JsonConvert.DeserializeObject<dynamic>(authResponse.Content).access_token;
return accessToken;
}
`#endregion`
}
filename:Frends.Salesforce.ExecuteQuery/Frends.Salesforce.ExecuteQuery.Tests/UnitTests.cs|LineRange:1-60
[TestClass]
public class UnitTests
{
private readonly string _clientSecret = Environment.GetEnvironmentVariable("Salesforce_Client_Secret");
private readonly string _password = Environment.GetEnvironmentVariable("Salesforce_Password");
private readonly string _securityToken = Environment.GetEnvironmentVariable("Salesforce_Security_Token");
private readonly string _clientID = Environment.GetEnvironmentVariable("Salesforce_ClientID");
private readonly string _username = Environment.GetEnvironmentVariable("Salesforce_Username");
private readonly string _domain = Environment.GetEnvironmentVariable("Salesforce_Domain_Url");
private readonly string _authurl = Environment.GetEnvironmentVariable("Salesforce_Auth_Url");
private readonly CancellationToken _cancellationToken = new();
[TestMethod]
public async Task ExecuteQuery_QueryWithToken()
{
var input = new Input
{
Domain = _domain,
Query = "SELECT Name from Customer",
ApiVersion = "v65.0"
};
var options = new Options
{
AuthenticationMethod = AuthenticationMethod.AccessToken,
AccessToken = await Salesforce.GetAccessToken(_authurl, _clientID, _clientSecret, _username, _password + _securityToken, _cancellationToken)
};
var result = await Salesforce.ExecuteQuery(input, options, _cancellationToken);
Assert.IsTrue(result.RequestIsSuccessful);
}
[TestMethod]
public async Task ExecuteQuery_QueryWithClientCredentials_ReturnToken()
{
var input = new Input
{
Domain = _domain,
Query = "SELECT Name from Customer",
ApiVersion = "v65.0"
};
var options = new Options
{
AuthenticationMethod = AuthenticationMethod.OAuth2WithClientCredentials,
AuthUrl = _authurl,
ClientID = _clientID,
ClientSecret = _clientSecret,
ReturnAccessToken = true
};
var result = await Salesforce.ExecuteQuery(input, options, _cancellationToken);
var accessToken = await Salesforce.GetAccessToken(_authurl, _clientID, _clientSecret, _username, _password + _securityToken, _cancellationToken);
Assert.IsTrue(result.RequestIsSuccessful);
Assert.AreEqual(result.Token, accessToken);
}
}
filename:Frends.Salesforce.ExecuteQuery/Frends.Salesforce.ExecuteQuery/ExecuteQuery.cs|LineRange:1-40 (additional snippet)
public class Salesforce
{
// ... (already shown above)
}
| internal static async Task<string> GetAccessToken(string url, string clientId, string clientSecret, | ||
| CancellationToken cancellationToken) | ||
| { | ||
| var authClient = new RestClient(url); | ||
| var authRequest = new RestRequest("", Method.Post); | ||
| authRequest.AddHeader("Content-Type", "application/x-www-form-urlencoded"); | ||
| authRequest.AddParameter("grant_type", "client_credentials"); | ||
| authRequest.AddParameter("client_id", clientId); | ||
| authRequest.AddParameter("client_secret", clientSecret); | ||
| var authResponse = await authClient.ExecuteAsync(authRequest, cancellationToken); | ||
| string accessToken = JsonConvert.DeserializeObject<dynamic>(authResponse.Content).access_token; | ||
|
|
||
| return accessToken; | ||
| } |
There was a problem hiding this comment.
Add error handling for authentication failures.
The method deserializes the response without checking if the authentication request succeeded. If authResponse has an error status or returns an error JSON (e.g., {"error": "invalid_client"}), accessing .access_token will throw a RuntimeBinderException or return null.
🛡️ Proposed fix with error handling
internal static async Task<string> GetAccessToken(string url, string clientId, string clientSecret,
CancellationToken cancellationToken)
{
var authClient = new RestClient(url);
var authRequest = new RestRequest("", Method.Post);
authRequest.AddHeader("Content-Type", "application/x-www-form-urlencoded");
authRequest.AddParameter("grant_type", "client_credentials");
authRequest.AddParameter("client_id", clientId);
authRequest.AddParameter("client_secret", clientSecret);
var authResponse = await authClient.ExecuteAsync(authRequest, cancellationToken);
+
+ if (!authResponse.IsSuccessful)
+ throw new Exception($"Failed to obtain access token: {authResponse.Content}");
+
- string accessToken = JsonConvert.DeserializeObject<dynamic>(authResponse.Content).access_token;
+ dynamic responseContent = JsonConvert.DeserializeObject<dynamic>(authResponse.Content);
+ string accessToken = responseContent?.access_token
+ ?? throw new Exception($"Access token not found in response: {authResponse.Content}");
return accessToken;
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| internal static async Task<string> GetAccessToken(string url, string clientId, string clientSecret, | |
| CancellationToken cancellationToken) | |
| { | |
| var authClient = new RestClient(url); | |
| var authRequest = new RestRequest("", Method.Post); | |
| authRequest.AddHeader("Content-Type", "application/x-www-form-urlencoded"); | |
| authRequest.AddParameter("grant_type", "client_credentials"); | |
| authRequest.AddParameter("client_id", clientId); | |
| authRequest.AddParameter("client_secret", clientSecret); | |
| var authResponse = await authClient.ExecuteAsync(authRequest, cancellationToken); | |
| string accessToken = JsonConvert.DeserializeObject<dynamic>(authResponse.Content).access_token; | |
| return accessToken; | |
| } | |
| internal static async Task<string> GetAccessToken(string url, string clientId, string clientSecret, | |
| CancellationToken cancellationToken) | |
| { | |
| var authClient = new RestClient(url); | |
| var authRequest = new RestRequest("", Method.Post); | |
| authRequest.AddHeader("Content-Type", "application/x-www-form-urlencoded"); | |
| authRequest.AddParameter("grant_type", "client_credentials"); | |
| authRequest.AddParameter("client_id", clientId); | |
| authRequest.AddParameter("client_secret", clientSecret); | |
| var authResponse = await authClient.ExecuteAsync(authRequest, cancellationToken); | |
| if (!authResponse.IsSuccessful) | |
| throw new Exception($"Failed to obtain access token: {authResponse.Content}"); | |
| dynamic responseContent = JsonConvert.DeserializeObject<dynamic>(authResponse.Content); | |
| string accessToken = responseContent?.access_token | |
| ?? throw new Exception($"Access token not found in response: {authResponse.Content}"); | |
| return accessToken; | |
| } |
🤖 Prompt for AI Agents
In
`@Frends.Salesforce.ExecuteQuery/Frends.Salesforce.ExecuteQuery/ExecuteQuery.cs`
around lines 94 - 107, The GetAccessToken method currently deserializes
authResponse.Content without checking for HTTP or authentication errors; update
GetAccessToken to validate authResponse (e.g., check authResponse.IsSuccessful /
StatusCode and non-empty Content) and if not successful parse the error body (or
use authResponse.ErrorMessage) and throw a descriptive exception instead of
proceeding, then only deserialize the successful response (or use JObject) to
extract access_token into accessToken and return it; ensure you preserve the
CancellationToken usage and surface useful details (status code and error
fields) in the thrown exception for callers to debug.
There was a problem hiding this comment.
agree, we should at least assume smth different can be returned and enclose it it with try catch and some sensible error information
There was a problem hiding this comment.
@MatteoDelOmbra, thank you for agreeing! Adding proper error handling is important for this authentication flow.
Would you like me to help implement the error handling for both GetAccessToken methods? I can generate the code changes that wrap the authentication response handling in try-catch blocks and provide clear error messages including the HTTP status code and error details from Salesforce.
Just let me know if you'd like me to proceed with planning those changes, or if you prefer to implement it yourself!
| /// </summary> | ||
| [DefaultValue("v61.0")] | ||
| public string ApiVersion { get; set; } = "v61.0"; | ||
| [DefaultValue("v65.0")] |
There was a problem hiding this comment.
Leave the default value as v61.0 (this applies to all changes in this PR) to maintain consistency with other tasks.
Update to v65.0 in tests only if it is necessary for them to work (e.g., if the feature is not yet implemented in v61.0).
| break; | ||
|
|
||
| case AuthenticationMethod.OAuth2WithClientCredentials: | ||
| accessToken = await GetAccessToken(options.AuthUrl, options.ClientID, options.ClientSecret, |
There was a problem hiding this comment.
according to documentation :
For this flow, requests to https://login.salesforce.com and https://test.salesforce.com aren't supported. Use your My Domain URL instead. To find your My Domain URL, from Setup, in the Quick Find box, enter My Domain, and then select My Domain.
My Domain URL is in our case input.Domain parameter
|
Environment prepared with instructions from our documentation is not working. Probably there is some more settings to setup on Salesforce to make it work. We need to update this documentation to specify what needs to be done to make OAuth2WithClientCredentials flow work. |
…help.salesforce.com/s/articleView?id=xcloud.remoteaccess_oauth_client_credentials_flow.htm&type=5
I added a new authentication method to the ExecuteQuery Task. It now supports the more common Client Credentials OAuth2 flow https://help.salesforce.com/s/articleView?id=xcloud.remoteaccess_oauth_client_credentials_flow.htm&type=5
I also updated the Default version and added correct DisplayFormat in a few places
Task Update PR template
Review Checklist
Summary by CodeRabbit
New Features
Tests