diff --git a/README.md b/README.md index 8bbd410..de969c9 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,9 @@ from fds.sdk.utils.authentication import ConfidentialClient import requests # The ConfidentialClient instance should be reused in production environments. -client = ConfidentialClient('/path/to/config.json') +client = ConfidentialClient( + config_path='/path/to/config.json' +) res = requests.get( 'https://api.factset.com/analytics/lookups/v3/currencies', headers={ @@ -47,6 +49,47 @@ res = requests.get( print(res.text) ``` +### Configure a Proxy + +You can pass proxy settings to the ConfidentialClient if necessary. +The `proxy` parameter takes a URL to tell the request library which proxy should be used. + +If necessary it is possible to set custom `proxy_headers` as dictionary. + +```python +from fds.sdk.utils.authentication import ConfidentialClient + +client = ConfidentialClient( + config_path='/path/to/config.json', + proxy= "http://secret:password@localhost:5050", + proxy_headers={ + "Custom-Proxy-Header": "Custom-Proxy-Header-Value" + } +) +``` + +### Custom SSL Certificate + +If you have proxies or firewalls which are using custom TLS certificates, +you are able to pass a custom pem file (`ssl_ca_cert` parameter) so that the +request library is able to verify the validity of that certificate. If a +ca cert is passed it is validated regardless if `verify_ssl` is set to false. + +With `verify_ssl` it is possible to disable the verifications of certificates. +Disabling the verification is not recommended, but it might be useful during +local development or testing + + +```python +from fds.sdk.utils.authentication import ConfidentialClient + +client = ConfidentialClient( + config_path='/path/to/config.json', + verify_ssl=True, + ssl_ca_cert='/path/to/ca.pem' +) +``` + ## 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 5c4b078..a38b7c5 100644 --- a/src/fds/sdk/utils/authentication/confidential.py +++ b/src/fds/sdk/utils/authentication/confidential.py @@ -33,7 +33,15 @@ class ConfidentialClient(OAuth2Client): the access token, caching it and refreshing it as needed. """ - def __init__(self, config_path: str = "", config: dict = None) -> None: + def __init__( + self, + config_path: str = None, + config: dict = None, + proxy: str = None, + proxy_headers: dict = None, + verify_ssl: bool = True, + ssl_ca_cert: str = None, + ) -> None: """ Creates a new ConfidentialClient. @@ -70,7 +78,24 @@ def __init__(self, config_path: str = "", config: dict = None) -> None: } } - `NB`: Within the JWK parameters kty, alg, use, kid, n, e, d, p, q, dp, dq, qi are required for authorization. + `NB`: Within the JWK parameters kty, alg, use, kid, n, e, d, p, q, dp, dq, qi are + required for authorization. + + `proxy` (str) : Proxy URL + + `proxy_headers` (dict) : Sometimes it is necessary to add custom headers to http requests to be able to + use a proxy or firewall + + `verify_ssl` (bool): Set this to ``False`` to skip verifying SSL certificate when calling API from + https server. When set to ``False``, requests will accept any TLS certificate presented by the server, + and will ignore hostname mismatches and/or expired certificates, which will make your application + vulnerable to man-in-the-middle (MitM) attacks. Setting verify to ``False`` may be useful during + local development or testing. + + `ssl_ca_cert` (str): Set this to customize the certificate file to verify the peer. If ``ssl_ca_cert`` is + set, the ca_cert will be verified whether ``verify_ssl`` is enabled + + Raises: AuthServerMetadataError: Raised if there's an issue retrieving the authorization server metadata AuthServerMetadataContentError: Raised if the authorization server metadata is incomplete @@ -84,7 +109,7 @@ def __init__(self, config_path: str = "", config: dict = None) -> None: raise ValueError("Either 'config_path' or 'config' must be set.") if config_path and config: - raise ValueError("Either 'config_path' or 'config' must be set. Not Both.") + raise ValueError("Either 'config_path' or 'config' must be set. Not Both.") if config_path: try: @@ -97,6 +122,15 @@ def __init__(self, config_path: str = "", config: dict = None) -> None: if config: self._config = config + if proxy: + self._proxy = {"http": proxy, "https": proxy} + else: + self._proxy = None + + self._verify_ssl = verify_ssl + self._proxy_headers = proxy_headers + self._ssl_ca_cert = ssl_ca_cert + try: self._oauth_session = OAuth2Session( client=BackendApplicationClient(client_id=self._config[CONSTS.CONFIG_CLIENT_ID]) @@ -127,7 +161,18 @@ def _init_auth_server_metadata(self) -> None: log.debug( "Attempting metadata retrieval from well_known_uri: %s", self._config[CONSTS.CONFIG_WELL_KNOWN_URI] ) - res = requests.get(self._config[CONSTS.CONFIG_WELL_KNOWN_URI]) + + verify = self._verify_ssl + + if self._ssl_ca_cert: + verify = self._ssl_ca_cert + + res = requests.get( + url=self._config[CONSTS.CONFIG_WELL_KNOWN_URI], + proxies=self._proxy, + verify=verify, + headers=self._proxy_headers, + ) log.debug("Request from well_known_uri completed with status: %s", res.status_code) log.debug("Response headers from well_known_uri were %s", res.headers) self._well_known_uri_metadata = res.json() @@ -208,11 +253,27 @@ def get_access_token(self) -> str: try: log.debug("Fetching new access token") + + headers = { + "Accept": "application/json", + "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", + } + + if self._proxy_headers: + headers |= self._proxy_headers + + verify = self._verify_ssl + if self._ssl_ca_cert: + verify = self._ssl_ca_cert + token = self._oauth_session.fetch_token( token_url=self._well_known_uri_metadata[CONSTS.META_TOKEN_ENDPOINT], client_id=self._config[CONSTS.CONFIG_CLIENT_ID], client_assertion_type="urn:ietf:params:oauth:client-assertion-type:jwt-bearer", client_assertion=self._get_client_assertion_jws(), + verify=verify, + proxies=self._proxy, + headers=headers, ) 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 81fb622..821cdea 100644 --- a/tests/fds/sdk/utils/authentication/test_confidential.py +++ b/tests/fds/sdk/utils/authentication/test_confidential.py @@ -166,6 +166,43 @@ def test_constructor_session_instantiation(mocker, example_config): mock_oauth2_session.assert_called_with(client=backend_result) +def test_constructor_session_instantiation_with_additional_parameters(mocker, example_config): + test_client_id = "good_test" + backend_result = "good_mock_backend" + example_config["clientId"] = test_client_id + mock_oauth_backend = mocker.patch( + "fds.sdk.utils.authentication.confidential.BackendApplicationClient", return_value=backend_result + ) + + mock_oauth2_session = mocker.patch("fds.sdk.utils.authentication.confidential.OAuth2Session") + + additional_parameters = { + "proxy": "http://my:pass@test.test.test", + "verify_ssl": False, + "proxy_headers": {}, + } + + class AuthServerMetadataRes: + status_code = 200 + headers = {"header": "value"} + + def json(self): + return {"issuer": "test", "token_endpoint": "http://test.test"} + + get_mock = mocker.patch("requests.get", return_value=AuthServerMetadataRes()) + + ConfidentialClient(config=example_config, **additional_parameters) + + mock_oauth_backend.assert_called_with(client_id=test_client_id) + mock_oauth2_session.assert_called_with(client=backend_result) + get_mock.assert_called_with( + url="https://auth.factset.com/.well-known/openid-configuration", + proxies={"http": "http://my:pass@test.test.test", "https": "http://my:pass@test.test.test"}, + verify=False, + headers={}, + ) + + def test_constructor_custom_well_known_uri(mocker, example_config, caplog): caplog.set_level(logging.DEBUG) mocker.patch("fds.sdk.utils.authentication.confidential.BackendApplicationClient") @@ -188,7 +225,7 @@ def json(self): client = ConfidentialClient(config=example_config) - get_mock.assert_called_with(auth_test) + get_mock.assert_called_with(url=auth_test, proxies=None, verify=True, headers=None) assert client assert "Attempting metadata retrieval from well_known_uri: https://auth.test" in caplog.text @@ -325,6 +362,12 @@ def test_get_access_token_fetch(client, mocker): 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", + }, )