Repository navigation
Support PKCE and refresh token rotation for connected app logins #4013
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
26469f1
961ac98
64f603e
4a314b6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
|
|
@@ -14,9 +14,11 @@ | |
| from cumulusci.core.exceptions import ( | ||
| CumulusCIException, | ||
| DependencyResolutionError, | ||
| 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 | ||
|
|
@@ -60,6 +62,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 | ||
|
|
@@ -90,7 +93,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. | ||
| """ | ||
|
|
@@ -107,13 +113,67 @@ 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: | ||
| # save() would take the lock again, which is already held | ||
| self.keychain.set_org(self, self.global_org) | ||
| 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, in case | ||
| another process rotated them after this org config was loaded.""" | ||
| if not self.keychain or not self.name: | ||
| return | ||
| try: | ||
| stored_config = self.keychain.reload_org(self.name).config | ||
| except OrgNotFound: | ||
| return | ||
| stored_token = stored_config.get("refresh_token") | ||
| if stored_token and stored_token != self.refresh_token: | ||
| for key in ("refresh_token", "access_token", "refresh_token_rotation"): | ||
| if key in stored_config: | ||
| self.config[key] = stored_config[key] | ||
|
|
||
| def _access_token_works(self) -> bool: | ||
| if not self.access_token: | ||
| return False | ||
| response = requests.get( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Suggestions:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed. The userinfo GET times out after 20s ( |
||
| self.instance_url + "/services/oauth2/userinfo", | ||
| headers={"Authorization": "Bearer " + self.access_token}, | ||
| ) | ||
| 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() | ||
|
|
@@ -150,7 +210,16 @@ 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) | ||
| except OAuth2Error as e: | ||
| if "invalid_grant" not in str(e): | ||
| raise | ||
| raise OAuth2Error( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Should fix] The new message hides Salesforce's
Suggestion: parse the response and include Small nit: matching on
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed. |
||
| f"The refresh token for org {self.name} is no longer valid. " | ||
| "It may have expired from inactivity or been revoked. " | ||
| f"Run `cci org connect {self.name}` to connect the org again." | ||
| ) from e | ||
|
|
||
| @property | ||
| def lightning_base_url(self): | ||
|
|
@@ -370,7 +439,14 @@ 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_rotation: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Fix before merge] Only processes that already have the rotation flag are protected here The lock and merge in Reproduced:
The dead token is back in the file and the flag is gone, so the next command reuses Suggestion: also look at the flag in the stored file. Or always take the lock and merge tokens for orgs that use refresh tokens (it's cheap). Be careful not to break Related, but code can't fix it: an older cci version sharing
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed. |
||
| # 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) | ||
| else: | ||
| self.keychain.set_org(self, self.global_org) | ||
|
|
||
| def get_domain(self): | ||
| instance_url = self.config.get("instance_url", "") | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Should fix] Taking the lock twice in one process blocks for 60s
The comment above explains that
set_orgis used becausesave()would take the lock again. Eachorg_lock()call creates a newFileLock, and on Unixflocklocks belong to the open file descriptor. So if one process takes the lock twice, the second attempt blocks forORG_LOCK_TIMEOUTand then raisesTimeout. Any later change that callssave()(or something that calls it) inside this block will hit that.Suggestions:
FileLock(..., is_singleton=True)(or cache one lock per path) so taking it twice in the same process is safefilelockis added topyproject.tomlwith no version floor. Please add a minimum (e.g. the version whereis_singletonwas added) so older installs don't behave differentlyThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed.
FileLock(..., is_singleton=True), so the refresh path now callssave()directly. The pin isfilelock>=3.15.2: is_singleton came in 3.13.0, but singleton fixes kept landing until 3.15.2.