Implement OAuth2 client credentials flow with backward compatibility - #220
Conversation
There was a problem hiding this comment.
Pull request overview
This PR migrates the tap’s Salesforce authentication from refresh-token (auth code) OAuth to OAuth2 client_credentials (“BYOC OAuth”), updating configuration, docs, and tests to align with the new flow.
Changes:
- Replace refresh-token login (and sandbox redirect support) with client_credentials login using a customer-provided
instance_url. - Update tap-tester/integration and unit tests to remove
refresh_token/is_sandboxand validate the new login behavior. - Bump version to 3.0.0 and update README + CHANGELOG for the breaking auth change.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/unittests/test_refresh_token_rotation.py | Removes refresh-token rotation tests (no longer applicable to client_credentials). |
| tests/unittests/test_multiline_critical_error_message.py | Updates test config to use instance_url and remove refresh-token/sandbox fields. |
| tests/unittests/test_client_credentials_login.py | Adds new unit tests for client_credentials login (success, payload, URL handling, retry behavior). |
| tests/test_salesforce_sync_canary.py | Updates test properties to use instance_url only (drops is_sandbox). |
| tests/test_salesforce_lookback_window.py | Updates test properties to use instance_url only (drops is_sandbox). |
| tests/sfbase.py | Updates tap-tester base expectations/type and credentials env vars for BYOC. |
| tests/base.py | Updates tap-tester base expectations/type and credentials env vars for BYOC. |
| tap_salesforce/salesforce/init.py | Implements client_credentials token request; removes refresh-token rotation logic and stops logging POST bodies. |
| tap_salesforce/init.py | Removes refresh_token config usage and passes instance_url into Salesforce. |
| setup.py | Bumps package version to 3.0.0. |
| README.md | Documents new config format (instance_url) and new auth setup guidance. |
| CHANGELOG.md | Adds 3.0.0 entry documenting the auth migration. |
Comments suppressed due to low confidence (1)
tap_salesforce/init.py:31
- The default
CONFIGdict doesn't includeinstance_url, even though later code expects it to be present (CONFIG['instance_url']). Adding the key here improves clarity and avoids surprising KeyErrors if code paths accessCONFIGbeforeargs.configis merged.
CONFIG = {
'client_id': None,
'client_secret': None,
'start_date': None
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Make the tap backward compatible by detecting the auth flow at runtime
based on config properties:
- If 'refresh_token' is present → OAuth authorization code flow
(existing connections created via Salesforce Connected App/ECA)
- If 'instance_url' is present → OAuth client credentials flow
(new BYOC connections using ECA client_credentials grant)
Changes:
- Remove 'instance_url' from REQUIRED_CONFIG_KEYS; validate that at
least one of 'refresh_token' or 'instance_url' is provided
- Add refresh_token, is_sandbox params to Salesforce.__init__
- Branch login() on self.refresh_token: refresh_token flow uses
login.salesforce.com/test.salesforce.com and derives instance_url
from the token response; client_credentials flow posts directly to
{instance_url}/services/oauth2/token
- Restore refresh token rotation handling in the refresh_token path
All 16 existing unit tests pass.
Add _normalize_instance_url() to enforce security constraints on the customer-supplied instance_url before any network request is made: - Reject http:// (Salesforce requires HTTPS) - Auto-prepend https:// if scheme is missing (with a warning) - Reject non-Salesforce domains to prevent SSRF — a malicious value like http://169.254.169.254 would otherwise cause the tap to POST client credentials to an arbitrary internal endpoint - Strip trailing slash once at construction rather than at each usage Also normalize instance_url derived from the refresh_token response for consistency. Addresses the open SSRF concern raised by RushiT0122 in PR #220. Reverts tests/base.py connection type to "platform.salesforce".
- setup.py: keep version 3.0.0 (breaking auth change) - CHANGELOG.md: prepend 3.0.0 entry, retain 2.10.0 and 2.9.1 from master
- Fix SSRF bypass: parse hostname with urlparse instead of substring match to prevent tricks like https://salesforce.com@evil.example - README: document required ECA OAuth scope (api), client credentials flow setup, Run As user requirement, and legacy refresh_token config - CHANGELOG: clarify entry — both flows are supported, not replaced
Add _use_client_credentials flag set at __init__ time based on whether instance_url was supplied in config. This locks the flow for the lifetime of the Salesforce object, so timer-triggered re-auths always use the same flow regardless of instance_url being populated from the first response.
Prefer the client credentials flow for connections with instance_url. When Salesforce rejects client credentials and an existing refresh token is available, retry authentication through the legacy OAuth refresh-token flow and log the fallback. This preserves existing Stitch connections, which may already store instance_url in production, while QTC begins providing instance_url for new client-credentials connections.
There was a problem hiding this comment.
🟡 Changes recommended
Mixed configurations use the wrong authentication precedence, and the SSRF validation lacks regression coverage.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
tap_salesforce/salesforce/init.py:275
- The new hostname allowlist is the security boundary preventing credentials from being posted to an attacker-controlled host, but none of the client-credentials tests exercise it. Add tests covering plain HTTP, a non-Salesforce hostname, the
salesforce.com@evil.exampleuser-info trick, a missing scheme, and the accepted Salesforce domains so future changes cannot silently reopen the SSRF path.
# Parse the hostname to prevent SSRF via URL tricks (e.g. https://salesforce.com@evil.example)
hostname = urlparse(instance_url).hostname or ''
if hostname != 'salesforce.com' and not hostname.endswith('.salesforce.com') and \
hostname != 'salesforce.mil' and not hostname.endswith('.salesforce.mil'):
- Files reviewed: 8/8 changed files
- Comments generated: 1
- Review effort level: Balanced
There was a problem hiding this comment.
🟡 Changes recommended
Transient HTTP failures can permanently switch authentication modes, and the documented precedence remains inconsistent with the implementation.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 8/8 changed files
- Comments generated: 3
- Review effort level: Balanced
| if self._use_client_credentials and self.refresh_token and isinstance(e, requests.exceptions.HTTPError): | ||
| LOGGER.warning("Client credentials login failed; retrying with the legacy refresh_token flow.") | ||
| self._use_client_credentials = False |
| # Parse the hostname to prevent SSRF via URL tricks (e.g. https://salesforce.com@evil.example) | ||
| hostname = urlparse(instance_url).hostname or '' | ||
| if hostname != 'salesforce.com' and not hostname.endswith('.salesforce.com') and \ | ||
| hostname != 'salesforce.mil' and not hostname.endswith('.salesforce.mil'): |
| self.assertIn('retrying with the legacy refresh_token flow', logs.output[0]) | ||
| self.assertEqual(mock_request.call_args_list[0].kwargs['body']['grant_type'], 'client_credentials') | ||
| self.assertEqual(mock_request.call_args_list[1].kwargs['body']['grant_type'], 'refresh_token') |
Description of change
Replaces the Salesforce authentication mechanism to support OAuth2 client credentials flow (BYOC — Bring Your Own Credentials), while maintaining full backward compatibility with existing connections created via the
authorization code (refresh token) flow.
What changed
Auth flow — dual support
instance_urlis no longer a hard required key — it is optional for therefresh_token path (derived from the token response) and required only for
client_credentials
Security — SSRF protection on
instance_urlinstance_urlis customer-supplied in the client_credentials flow, so it isvalidated before any network request:
http://(Salesforce requires HTTPS)https://if the scheme is missing (with a warning log)salesforce.) to prevent a malicious value likehttp://169.254.169.254from causing the tap to POSTclient_id/client_secretto an internal endpointToken refresh
threading.Timeris preserved for both flows — access tokens expire; long-running syncs must re-authenticateConfig shapes
New (client credentials):
{ "client_id": "...", "client_secret": "...", "instance_url": "https://<my-domain>.my.salesforce.com", "start_date": "2020-01-01T00:00:00Z", "api_type": "REST" }Legacy (refresh token — unchanged):
{ "client_id": "...", "client_secret": "...", "refresh_token": "...", "is_sandbox": "false", "start_date": "2020-01-01T00:00:00Z", "api_type": "REST" }QA steps
python -m pytest tests/unittests/ -v)Risks
salesforce.domain check could reject legitimate non-standard Salesforce endpoints (e.g. custom vanity domains) — can be relaxed if neededRollback steps
Related tickets
SAC-32028, SAC-31202, SAC-31200
AI generated code
This PR has been written with the help of GitHub Copilot.