From e23e6b7df218c6701e8c633cc44ffd7d25b1206b Mon Sep 17 00:00:00 2001 From: Granit Mullahasani Dula <156331405+gdulafactset@users.noreply.github.com> Date: Wed, 1 Jul 2026 14:45:28 +0100 Subject: [PATCH] feat(scopes): supporting scopes in confidential client --- README.md | 15 ++++++++ .../sdk/utils/authentication/confidential.py | 6 +++ .../utils/authentication/test_confidential.py | 38 +++++++++++++++++++ 3 files changed, 59 insertions(+) diff --git a/README.md b/README.md index 7cb53ef..8e76da7 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,21 @@ client = ConfidentialClient( ) ``` +### OAuth 2.0 Scopes + +By default no scope is requested and the authorization server determines the granted scopes based on the client's +identity. If you need to request one or more specific +[OAuth 2.0 scopes](https://github.com/factset/oauth2-guidelines#client-credentials-flow-1), pass them as a list to the +`scope` parameter. + +```python +from fds.sdk.utils.authentication import ConfidentialClient + +client = ConfidentialClient( + config_path="/path/to/config.json", scope=["factset.api.read"] +) +``` + ## Modules Information about the various utility modules contained in this library can be found below. diff --git a/src/fds/sdk/utils/authentication/confidential.py b/src/fds/sdk/utils/authentication/confidential.py index c46ea12..99b2e77 100644 --- a/src/fds/sdk/utils/authentication/confidential.py +++ b/src/fds/sdk/utils/authentication/confidential.py @@ -45,6 +45,7 @@ def __init__( verify_ssl: bool = True, ssl_ca_cert: str | None = None, retry: Retry | None = None, + scope: list | None = None, ) -> None: """ Creates a new ConfidentialClient. @@ -101,6 +102,9 @@ def __init__( `retry` (Retry): Set this to customize the retry policy for the requests. If not set, the default is used. + `scope` (list): Set this to request one or more OAuth 2.0 scopes when fetching an access token, for + example ``["factset.api.read"]``. If not set, no scope is sent and the authorization server determines + the granted scopes based on the client's identity. Raises: AuthServerMetadataError: Raised if there's an issue retrieving the authorization server metadata @@ -138,6 +142,7 @@ def __init__( self._verify_ssl = verify_ssl self._proxy_headers = proxy_headers self._ssl_ca_cert = ssl_ca_cert + self._scope = scope if retry is not None: self._retry = retry @@ -336,6 +341,7 @@ def get_access_token(self) -> str: verify=verify, # pyright: ignore[reportArgumentType] proxies=self._proxy, headers=headers, + scope=self._scope, ) self._cached_token = token log.info("Caching token that expires at %s", token[CONSTS.TOKEN_EXPIRES_AT]) diff --git a/tests/fds/sdk/utils/authentication/test_confidential.py b/tests/fds/sdk/utils/authentication/test_confidential.py index 26d7383..fe17fce 100644 --- a/tests/fds/sdk/utils/authentication/test_confidential.py +++ b/tests/fds/sdk/utils/authentication/test_confidential.py @@ -415,6 +415,44 @@ def test_get_access_token_fetch(client, mocker): "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", "User-Agent": constants.CONSTS.USER_AGENT, }, + scope=None, + ) + + +def test_get_access_token_fetch_with_scope(mocker, example_config): + mocker.patch("fds.sdk.utils.authentication.confidential.BackendApplicationClient") + mock_fetch_token = mocker.patch( + "fds.sdk.utils.authentication.confidential.OAuth2Session.fetch_token", + return_value={"access_token": "test", "expires_at": 10}, + ) + + mock_get = mocker.patch("requests.Session.get") + mock_get.return_value.json.return_value = { + "issuer": "test-issuer", + "token_endpoint": "https://test.token.endpoint", + } + + mocker.patch("joserfc.jwt.encode", return_value="jws") + mocker.patch("joserfc.jwk.RSAKey.import_key", return_value="jwk") + + scope = ["factset.api.read", "factset.api.write"] + client = ConfidentialClient(config=example_config, scope=scope) + + client.get_access_token() + + mock_fetch_token.assert_called_once_with( + token_url="https://test.token.endpoint", + client_id="test-clientid", + client_assertion_type="urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + client_assertion="jws", + proxies=None, + verify=True, + headers={ + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", + "User-Agent": constants.CONSTS.USER_AGENT, + }, + scope=scope, )