Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cumulusci/cli/org.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ def setup_client(connected_app, login_url=None, sandbox=None) -> OAuth2Client:
auth_uri=f"{base_uri}/services/oauth2/authorize",
token_uri=f"{base_uri}/services/oauth2/token",
scope="web full refresh_token",
pkce=True,
)
return OAuth2Client(sf_client_config)

Expand Down
125 changes: 118 additions & 7 deletions cumulusci/core/config/org_config.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import os
import re
from collections import defaultdict, namedtuple
from contextlib import contextmanager
from contextlib import contextmanager, nullcontext
from datetime import date, datetime
from typing import Optional
from urllib.parse import urlparse
Expand All @@ -14,9 +14,12 @@
from cumulusci.core.exceptions import (
CumulusCIException,
DependencyResolutionError,
OrgCannotBeLoaded,
OrgNotFound,
ServiceNotConfigured,
)
from cumulusci.oauth.client import OAuth2Client, OAuth2ClientConfig
from cumulusci.oauth.exceptions import OAuth2Error
from cumulusci.oauth.salesforce import SANDBOX_LOGIN_URL, jwt_session
from cumulusci.utils import parse_api_datetime
from cumulusci.utils.fileutils import open_fs_resource
Expand All @@ -26,6 +29,13 @@
SKIP_REFRESH = os.environ.get("CUMULUSCI_DISABLE_REFRESH")
SANDBOX_MYDOMAIN_RE = re.compile(r"\.cs\d+\.my\.(.*)salesforce\.com")
MYDOMAIN_RE = re.compile(r"\.my\.(.*)salesforce\.com")
# Checking whether an access token still works is safe to retry, so it gives up
# instead of keeping other cci processes waiting for the org's lock.
USERINFO_TIMEOUT = 20 # seconds
# Refreshing isn't safe to retry with refresh token rotation: if Salesforce rotated
# the token but its response never arrived, sending the old token again would
# revoke the whole token family. So only connecting has a timeout.
TOKEN_REFRESH_TIMEOUT = (10, None) # (connect, read) seconds


VersionInfo = namedtuple("VersionInfo", ["id", "number"])
Expand Down Expand Up @@ -60,6 +70,7 @@ class OrgConfig(BaseConfig):
default: bool
client_id: str
refresh_token: str
refresh_token_rotation: bool
client_secret: str
connected_app: str
serialization_format: str
Expand All @@ -80,6 +91,9 @@ def __init__(self, config: dict, name: str, keychain=None, global_org=False):
self._installed_packages = None
self._is_person_accounts_enabled = None
self._multiple_currencies_is_enabled = False
# The refresh token as of when this org was loaded from the keychain or last
# saved, or None if it didn't come from the keychain
self._loaded_refresh_token = None

super().__init__(config)

Expand All @@ -90,7 +104,10 @@ def refresh_oauth_token(self, keychain, connected_app=None, is_sandbox=False):
this is done using the Oauth2 JWT flow.

Otherwise it is done using the Oauth2 Refresh Token flow using the connected app
configured in the keychain's connected_app service.
configured in the keychain's connected_app service. If the connected app uses
refresh token rotation, the stored access token is reused while it still works,
and a new refresh token is saved to the keychain right away, because the old one
no longer works.

Also refreshes user and org info that is cached in the org config.
"""
Expand All @@ -107,13 +124,79 @@ def refresh_oauth_token(self, keychain, connected_app=None, is_sandbox=False):
auth_url=auth_url,
is_sandbox=is_sandbox,
)
if info != self.config:
self.config.update(info)
else:
info = self._refresh_token(keychain, connected_app)
if info != self.config:
self.config.update(info)
# With refresh token rotation, each refresh invalidates the refresh token
# it used and the access token issued before it, and reusing an old refresh
# token revokes the whole token family. So under a lock, start from the
# latest stored tokens, refresh only if the stored access token no longer
# works (which would end other cci processes' sessions), and save the new
# tokens at once.
with self._refresh_lock():
self._load_latest_tokens()
if not (self.refresh_token_rotation and self._access_token_works()):
info = self._refresh_token(keychain, connected_app)
new_refresh_token = info.get("refresh_token")
rotated = (
new_refresh_token
and new_refresh_token != self.refresh_token
)
self.config.update(info)
if rotated:
self.config["refresh_token_rotation"] = True
if self.keychain:
self.save()
self._load_userinfo()
self._load_orginfo()

def _refresh_lock(self):
if self.keychain and self.name:
return self.keychain.org_lock(self.name, self.global_org)
return nullcontext()

def _load_latest_tokens(self):
"""Use the tokens as currently stored in the keychain if another process
changed them after this org config was loaded from the keychain or saved.

Tokens in an org config that didn't come from the keychain, like one that
`cci org connect` just made, replace the stored ones."""
if not self.keychain or not self.name or self._loaded_refresh_token is None:
return
try:
stored_config = self.keychain.reload_org(self.name).config
except (OrgNotFound, OrgCannotBeLoaded):
# Nothing usable is stored, so this config's tokens replace it.
return
# If the org was connected again as another org or user, the stored tokens
# don't go with the rest of this config. Keep this config's own, as before.
# Only the org and user IDs at the end of the identity URL are compared,
# because its host can differ between logging in and refreshing.
stored_id, own_id = stored_config.get("id"), self.config.get("id")
if stored_id and own_id and stored_id.split("/")[-2:] != own_id.split("/")[-2:]:
return
stored_token = stored_config.get("refresh_token")
if stored_token and stored_token != self._loaded_refresh_token:
for key in ("refresh_token", "access_token", "refresh_token_rotation"):
if key in stored_config:
self.config[key] = stored_config[key]
self._loaded_refresh_token = stored_token

def _access_token_works(self) -> bool:
if not self.access_token:
return False
response = requests.get(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Fix before merge] Network calls run while the lock is held, with no timeout

This requests.get (and the token POST in _refresh_token) runs while the per-org file lock is held, and neither call has a timeout. If either one hangs (VPN drop, proxy, Salesforce maintenance), the lock stays held indefinitely. Every other cci process on that org then waits ORG_LOCK_TIMEOUT (60s) and fails with a raw filelock.Timeout.

filelock.Timeout isn't a CumulusCIException, so users see something like:

Error: The file lock '/Users/.../.cumulusci/<project>/dev.org.lock' could not be acquired.

Suggestions:

  • Pass an explicit timeout= to both HTTP calls (ideally well under ORG_LOCK_TIMEOUT)
  • Catch filelock.Timeout in _refresh_lock/org_lock and raise a CumulusCIException that says another cci process is refreshing org X and to retry

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. The userinfo GET times out after 20s (USERINFO_TIMEOUT). The refresh POST only gets a 10s connect timeout (TOKEN_REFRESH_TIMEOUT = (10, None)), because it isn't safe to retry under rotation: if Salesforce rotated the token and the response timed out, the next run would send the old token and revoke the whole family. In that rare case, waiting processes get the lock timeout instead, which is safe. filelock.Timeout becomes a CumulusCIException: "Timed out after 60 seconds waiting for another cci process to finish updating org X. Try again."

self.instance_url + "/services/oauth2/userinfo",
headers={"Authorization": "Bearer " + self.access_token},
timeout=USERINFO_TIMEOUT,
)
if response.status_code in (401, 403):
return False
# Other errors, like a 503 during maintenance, don't mean the token is dead,
# and refreshing anyway would end other cci processes' sessions.
response.raise_for_status()
return True

@contextmanager
def save_if_changed(self):
orig_config = self.config.copy()
Expand Down Expand Up @@ -150,7 +233,27 @@ def _refresh_token(self, keychain, connected_app):
scope="web full refresh_token",
)
sf_oauth = self.OAuth2Client(sf_oauth_config)
return sf_oauth.refresh_token(self.refresh_token)
try:
return sf_oauth.refresh_token(
self.refresh_token, timeout=TOKEN_REFRESH_TIMEOUT
)
except OAuth2Error as e:
error = None
if e.response is not None:
try:
error = e.response.json()
except ValueError:
pass
if not isinstance(error, dict) or error.get("error") != "invalid_grant":
raise
# invalid_grant also covers problems that reconnecting won't fix,
# like an inactive user, so show what Salesforce said.
description = error.get("error_description") or "invalid_grant"
raise OAuth2Error(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Should fix] The new message hides Salesforce's error_description

handle_exception in cli/cci.py prints only str(error); the chained from e goes only to the log file. invalid_grant isn't always an expired or revoked token. Salesforce also returns it for inactive user, ip restricted, user hasn't approved this consumer, authentication failure, and a sandbox/prod login URL mix-up. In those cases, telling the user to run cci org connect sends them the wrong way.

Suggestion: parse the response and include error_description, e.g.

The refresh token for org dev is no longer valid (Salesforce said: "expired access/refresh token"). ...

Small nit: matching on "invalid_grant" in str(e) is fragile. Exposing the status/JSON on OAuth2Error would make this sturdier.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. OAuth2Error now carries response. The message is "Salesforce rejected the refresh token for org X: <error_description>. If the token expired from inactivity or was revoked, run cci org connect X...". It matches on the JSON error field, not on a substring.

f"Salesforce rejected the refresh token for org {self.name}: "
f"{description}. If the token expired from inactivity or was revoked, "
f"run `cci org connect {self.name}` to connect the org again."
) from e

@property
def lightning_base_url(self):
Expand Down Expand Up @@ -370,7 +473,15 @@ def reset_installed_packages(self):

def save(self):
assert self.keychain, "Keychain was not set on OrgConfig"
self.keychain.set_org(self, self.global_org)
if self.refresh_token:
# Another cci process may have rotated the tokens since this org was
# loaded. Saving the old ones would bring back a dead refresh token.
with self._refresh_lock():
self._load_latest_tokens()
self.keychain.set_org(self, self.global_org)
self._loaded_refresh_token = self.refresh_token
else:
self.keychain.set_org(self, self.global_org)

def get_domain(self):
instance_url = self.config.get("instance_url", "")
Expand Down
Loading
Loading