diff --git a/README.md b/README.md index d2945c75..56958291 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,22 @@ import cuenca cuenca.configure(api_key='PKxxxx', api_secret='yyyyyy') ``` +### Jwt + +JWT tokens can also be used if your credentials have enough permissions. To +do so, you may include the parameter `use_jwt` as part of your `configure` + +```python +import cuenca + +cuenca.configure(use_jwt=True) +``` + +A new token will be created at this moment and automatically renewed before +sending any request if there is less than 5 minutes to be expired according +to its payload data. + + ## Transfers ### Create transfer diff --git a/cuenca/exc.py b/cuenca/exc.py index abc49568..f0e9ad30 100644 --- a/cuenca/exc.py +++ b/cuenca/exc.py @@ -7,6 +7,10 @@ class CuencaException(Exception): ... +class MalformedJwtToken(CuencaException): + """An invalid JWT token was obtained during authentication""" + + class NoResultFound(CuencaException): """No results were found""" diff --git a/cuenca/http/client.py b/cuenca/http/client.py index b3af7baa..ce807b38 100644 --- a/cuenca/http/client.py +++ b/cuenca/http/client.py @@ -1,9 +1,8 @@ import os -from typing import Optional, Tuple, Union +from typing import Optional, Tuple from urllib.parse import urljoin import requests -from aws_requests_auth.aws_auth import AWSRequestsAuth from cuenca_validations.typing import ( ClientRequestParams, DictStrAny, @@ -12,6 +11,7 @@ from requests import Response from ..exc import CuencaResponseException +from ..jwt import Jwt from ..version import API_VERSION, CLIENT_VERSION API_HOST = 'api.cuenca.com' @@ -24,7 +24,7 @@ class Session: host: str = API_HOST basic_auth: Tuple[str, str] - iam_auth: Optional[AWSRequestsAuth] = None + jwt_token: Optional[Jwt] = None session: requests.Session def __init__(self): @@ -41,31 +41,15 @@ def __init__(self): api_secret = os.getenv('CUENCA_API_SECRET', '') self.basic_auth = (api_key, api_secret) - # IAM auth - aws_access_key = os.getenv('AWS_ACCESS_KEY_ID', '') - aws_secret_access_key = os.getenv('AWS_SECRET_ACCESS_KEY', '') - aws_region = os.getenv('AWS_DEFAULT_REGION', AWS_DEFAULT_REGION) - if aws_access_key and aws_secret_access_key: - self.iam_auth = AWSRequestsAuth( - aws_access_key=aws_access_key, - aws_secret_access_key=aws_secret_access_key, - aws_host=self.host, - aws_region=aws_region, - aws_service=AWS_SERVICE, - ) - @property - def auth(self) -> Union[AWSRequestsAuth, Tuple[str, str]]: - # preference to basic auth - return self.basic_auth if all(self.basic_auth) else self.iam_auth + def auth(self) -> Optional[Tuple[str, str]]: + return self.basic_auth if all(self.basic_auth) else None def configure( self, api_key: Optional[str] = None, api_secret: Optional[str] = None, - aws_access_key: Optional[str] = None, - aws_secret_access_key: Optional[str] = None, - aws_region: str = AWS_DEFAULT_REGION, + use_jwt: Optional[bool] = False, sandbox: Optional[bool] = None, ): """ @@ -84,23 +68,8 @@ def configure( api_secret or self.basic_auth[1], ) - # IAM auth - if self.iam_auth is not None: - self.iam_auth.aws_access_key = ( - aws_access_key or self.iam_auth.aws_access_key - ) - self.iam_auth.aws_secret_access_key = ( - aws_secret_access_key or self.iam_auth.aws_secret_access_key - ) - self.iam_auth.aws_region = aws_region or self.iam_auth.aws_region - elif aws_access_key and aws_secret_access_key: - self.iam_auth = AWSRequestsAuth( - aws_access_key=aws_access_key, - aws_secret_access_key=aws_secret_access_key, - aws_host=self.host, - aws_region=aws_region, - aws_service=AWS_SERVICE, - ) + if use_jwt: + self.jwt_token = Jwt.create(self) def get( self, @@ -126,6 +95,11 @@ def request( data: OptionalDict = None, **kwargs, ) -> DictStrAny: + if self.jwt_token: + if self.jwt_token.is_expired: + self.jwt_token = Jwt.create(self) + self.session.headers['X-Cuenca-Token'] = self.jwt_token.token + resp = self.session.request( method=method, url='https://' + self.host + urljoin('/', endpoint), diff --git a/cuenca/jwt.py b/cuenca/jwt.py new file mode 100644 index 00000000..3bf40a8d --- /dev/null +++ b/cuenca/jwt.py @@ -0,0 +1,47 @@ +import base64 +import binascii +import datetime as dt +import json +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from .exc import MalformedJwtToken + +if TYPE_CHECKING: + from .http import Session + + +@dataclass +class Jwt: + expires_at: dt.datetime + token: str + + @property + def is_expired(self) -> bool: + return self.expires_at - dt.datetime.utcnow() <= dt.timedelta( + minutes=5 + ) + + @staticmethod + def get_expiration_date(token: str) -> dt.datetime: + """ + Jwt tokens contains the exp field in the payload data, + this function extracts the date so we can validate the + token before any request + More info about JWT tokens at: https://jwt.io/ + """ + try: + payload_encoded = token.split('.')[1] + payload = json.loads(base64.b64decode(f'{payload_encoded}==')) + except (IndexError, json.JSONDecodeError, binascii.Error): + raise MalformedJwtToken(f'Invalid JWT: {token}') + # Expiration timestamp can be found in the `exp` key in the payload + exp_timestamp = payload['exp'] + return dt.datetime.utcfromtimestamp(exp_timestamp) + + @classmethod + def create(cls, session: 'Session') -> 'Jwt': + session.jwt_token = None + token = session.post('/token', dict())['token'] + expires_at = Jwt.get_expiration_date(token) + return cls(expires_at, token) diff --git a/cuenca/version.py b/cuenca/version.py index 6603bec5..8b60b9c3 100644 --- a/cuenca/version.py +++ b/cuenca/version.py @@ -1,3 +1,3 @@ -__version__ = '0.4.2' +__version__ = '0.5.0' CLIENT_VERSION = __version__ API_VERSION = '2020-03-19' diff --git a/requirements-test.txt b/requirements-test.txt index adcb757b..9d9625fa 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -5,3 +5,4 @@ black==20.8b1 isort==5.7.* flake8==3.8.* mypy==0.790 +freezegun==1.0.* diff --git a/requirements.txt b/requirements.txt index 7be43d2b..b761df7e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,3 @@ requests==2.25.0 cuenca-validations==0.6.9 dataclasses>=0.7;python_version<"3.7" -aws-requests-auth==0.4.3 diff --git a/setup.cfg b/setup.cfg index 84f0fbe8..48119983 100644 --- a/setup.cfg +++ b/setup.cfg @@ -17,5 +17,10 @@ combine_as_imports=True [mypy-pytest] ignore_missing_imports = True -[mypy-aws_requests_auth.*] +[mypy-freezegun.*] ignore_missing_imports = True + +[coverage:report] +exclude_lines = + pragma: no cover + if TYPE_CHECKING: diff --git a/tests/conftest.py b/tests/conftest.py index c189b790..a28b192e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,5 +8,8 @@ @pytest.fixture(scope='module') def vcr_config(): config = dict() - config['filter_headers'] = [('Authorization', 'DUMMY')] + config['filter_headers'] = [ + ('Authorization', 'DUMMY'), + ('X-Cuenca-Token', 'DUMMY'), + ] return config diff --git a/tests/http/cassettes/test_configures_jwt.yaml b/tests/http/cassettes/test_configures_jwt.yaml new file mode 100644 index 00000000..aa638e36 --- /dev/null +++ b/tests/http/cassettes/test_configures_jwt.yaml @@ -0,0 +1,44 @@ +interactions: +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Authorization: + - DUMMY + Connection: + - keep-alive + Content-Length: + - '0' + User-Agent: + - cuenca-python/0.3.6 + X-Cuenca-Api-Version: + - '2020-03-19' + method: POST + uri: https://api.cuenca.com/token + response: + body: + string: '{"id":1,"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE2MDM4NDM2NTUsImlhdCI6MTYwMzIzODg1NSwic3ViIjoiQUtib3hCeXNTd1NTbVR0VmtoT1I2X21RIn0.VAT9w746K0hfOKnvZCFxwBDKjV71j-ItaFdZrIc0xCM","created_at":"2020-10-21T00:07:35.290968+00:00","api_key_id":"AKboxBysSwSSmTtVkhOR6_mQ"}' + headers: + Connection: + - keep-alive + Content-Length: + - '279' + Content-Type: + - application/json + Date: + - Wed, 21 Oct 2020 00:07:35 GMT + Server: + - nginx/1.18.0 + X-Amzn-Trace-Id: + - Root=1-5f8f7bc6-16318c2505b404c030bbcaf4;Sampled=0 + x-amz-apigw-id: + - UvBHAHOGCYcFZTw= + x-amzn-RequestId: + - 856c8bef-f148-48e4-a27b-391749b6b9f6 + status: + code: 201 + message: Created +version: 1 diff --git a/tests/http/cassettes/test_request_expired_token.yaml b/tests/http/cassettes/test_request_expired_token.yaml new file mode 100644 index 00000000..1b4e041d --- /dev/null +++ b/tests/http/cassettes/test_request_expired_token.yaml @@ -0,0 +1,128 @@ +interactions: +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Authorization: + - DUMMY + Connection: + - keep-alive + Content-Length: + - '0' + User-Agent: + - cuenca-python/0.3.6 + X-Cuenca-Api-Version: + - '2020-03-19' + method: POST + uri: https://api.cuenca.com/token + response: + body: + string: '{"id":12,"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE2MDM5MTA5NDksImlhdCI6MTYwMzMwNjE0OSwic3ViIjoiQUtib3hCeXNTd1NTbVR0VmtoT1I2X21RIiwidWlkIjoiMWFkNWUwZTMtMTNjZS0xMWViLTg0MjItNDE3MmZhYTQwZjQyIn0.5OX-MCcgi4cyiGexSsDIwk8HyhJxuIT8VF7X41GkO6w","created_at":"2020-10-21T18:49:09.935845","api_key":{"id":"AKboxBysSwSSmTtVkhOR6_mQ","created_at":"2020-10-20T23:56:28.845757","secret":"********","deactivated_at":null,"user_id":"rsc"}}' + headers: + Connection: + - keep-alive + Content-Length: + - '438' + Content-Type: + - application/json + Date: + - Wed, 21 Oct 2020 18:49:09 GMT + Server: + - nginx/1.18.0 + X-Amzn-Trace-Id: + - Root=1-5f9082a5-37ce7b1275ae2055215eb14d;Sampled=0 + x-amz-apigw-id: + - UxlZ8FEyCYcFUuQ= + x-amzn-RequestId: + - ee534c24-2878-4efe-b91e-3c99757adff6 + status: + code: 201 + message: Created +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Authorization: + - DUMMY + Connection: + - keep-alive + Content-Length: + - '0' + User-Agent: + - cuenca-python/0.3.6 + X-Cuenca-Api-Version: + - '2020-03-19' + X-Cuenca-Token: + - DUMMY + method: POST + uri: https://api.cuenca.com/token + response: + body: + string: '{"id":13,"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE2MDM5MTA5NTAsImlhdCI6MTYwMzMwNjE1MCwic3ViIjoiQUtib3hCeXNTd1NTbVR0VmtoT1I2X21RIiwidWlkIjoiMWFlMjQ5MjUtMTNjZS0xMWViLTk0ZDQtNDE3MmZhYTQwZjQyIn0.GmDxVfvt2Es6mf1upY5Y7RdkGgeos7mTpA_7wFb8ZaI","created_at":"2020-10-21T18:49:10.017165","api_key":{"id":"AKboxBysSwSSmTtVkhOR6_mQ","created_at":"2020-10-20T23:56:28.845757","secret":"********","deactivated_at":null,"user_id":"rsc"}}' + headers: + Connection: + - keep-alive + Content-Length: + - '438' + Content-Type: + - application/json + Date: + - Wed, 21 Oct 2020 18:49:10 GMT + Server: + - nginx/1.18.0 + X-Amzn-Trace-Id: + - Root=1-5f9082a5-25f2f46b7c8b558769ecf708;Sampled=0 + x-amz-apigw-id: + - UxlZ8Gs5CYcFtFg= + x-amzn-RequestId: + - 6553d156-d57e-48a4-b16a-42588c221827 + status: + code: 201 + message: Created +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + User-Agent: + - cuenca-python/0.3.6 + X-Cuenca-Api-Version: + - '2020-03-19' + X-Cuenca-Token: + - DUMMY + method: GET + uri: https://api.cuenca.com/api_keys + response: + body: + string: '{"items":[{"id":"AKboxBysSwSSmTtVkhOR6_mQ","created_at":"2020-10-20T23:56:28.845757","secret":"********","deactivated_at":null,"user_id":"rsc"}],"next_page_uri":null}' + headers: + Connection: + - keep-alive + Content-Length: + - '166' + Content-Type: + - application/json + Date: + - Wed, 21 Oct 2020 18:49:10 GMT + Server: + - nginx/1.18.0 + X-Amzn-Trace-Id: + - Root=1-5f9082a6-6a70a6d03f0698b549541398;Sampled=0 + x-amz-apigw-id: + - UxlZ9G4tiYcFmjQ= + x-amzn-RequestId: + - e3f54e5b-13ec-4cf8-83da-4f93f7bd9c54 + status: + code: 200 + message: OK +version: 1 diff --git a/tests/http/cassettes/test_request_valid_token.yaml b/tests/http/cassettes/test_request_valid_token.yaml new file mode 100644 index 00000000..325b5f4e --- /dev/null +++ b/tests/http/cassettes/test_request_valid_token.yaml @@ -0,0 +1,84 @@ +interactions: +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Authorization: + - DUMMY + Connection: + - keep-alive + Content-Length: + - '0' + User-Agent: + - cuenca-python/0.3.6 + X-Cuenca-Api-Version: + - '2020-03-19' + method: POST + uri: https://api.cuenca.com/token + response: + body: + string: '{"id":11,"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE2MDM5MTA4MDksImlhdCI6MTYwMzMwNjAwOSwic3ViIjoiQUtib3hCeXNTd1NTbVR0VmtoT1I2X21RIiwidWlkIjoiYzcxZDhiZWYtMTNjZC0xMWViLTlmYzItNDE3MmZhYTQwZjQyIn0.fXy3EYJTaKQv61wQuKRUwR9BHvnovsUO74X3XpEhvBk","created_at":"2020-10-21T18:46:49.514738","api_key":{"id":"AKboxBysSwSSmTtVkhOR6_mQ","created_at":"2020-10-20T23:56:28.845757","secret":"********","deactivated_at":null,"user_id":"rsc"}}' + headers: + Connection: + - keep-alive + Content-Length: + - '438' + Content-Type: + - application/json + Date: + - Wed, 21 Oct 2020 18:46:49 GMT + Server: + - nginx/1.18.0 + X-Amzn-Trace-Id: + - Root=1-5f908218-39f33c5c4167d480550635de;Sampled=0 + x-amz-apigw-id: + - UxlD0EUliYcFsKQ= + x-amzn-RequestId: + - c3dc4715-5946-421f-889c-763837d156fc + status: + code: 201 + message: Created +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + User-Agent: + - cuenca-python/0.3.6 + X-Cuenca-Api-Version: + - '2020-03-19' + X-Cuenca-Token: + - DUMMY + method: GET + uri: https://api.cuenca.com/api_keys + response: + body: + string: '{"items":[{"id":"AKboxBysSwSSmTtVkhOR6_mQ","created_at":"2020-10-20T23:56:28.845757","secret":"********","deactivated_at":null,"user_id":"rsc"}],"next_page_uri":null}' + headers: + Connection: + - keep-alive + Content-Length: + - '166' + Content-Type: + - application/json + Date: + - Wed, 21 Oct 2020 18:46:49 GMT + Server: + - nginx/1.18.0 + X-Amzn-Trace-Id: + - Root=1-5f908219-170a4d1628d8c2e3614879c7;Sampled=0 + x-amz-apigw-id: + - UxlEAEmgiYcFbUQ= + x-amzn-RequestId: + - 58dd24dd-11aa-4ab0-a174-63de03131988 + status: + code: 200 + message: OK +version: 1 diff --git a/tests/http/conftest.py b/tests/http/conftest.py index 308deecb..74fdbd7b 100644 --- a/tests/http/conftest.py +++ b/tests/http/conftest.py @@ -5,10 +5,3 @@ def cuenca_creds(monkeypatch) -> None: monkeypatch.setenv('CUENCA_API_KEY', 'api_key') monkeypatch.setenv('CUENCA_API_SECRET', 'secret') - - -@pytest.fixture -def aws_creds(monkeypatch) -> None: - monkeypatch.setenv('AWS_ACCESS_KEY_ID', 'aws_key') - monkeypatch.setenv('AWS_SECRET_ACCESS_KEY', 'aws_secret') - monkeypatch.setenv('AWS_DEFAULT_REGION', 'us-east-1') diff --git a/tests/http/test_client.py b/tests/http/test_client.py index 867e41a9..e2580e0f 100644 --- a/tests/http/test_client.py +++ b/tests/http/test_client.py @@ -1,6 +1,8 @@ +import datetime as dt from unittest.mock import MagicMock, patch import pytest +from freezegun import freeze_time from cuenca.exc import CuencaResponseException from cuenca.http.client import Session @@ -22,43 +24,40 @@ def test_basic_auth_configuration(): session = Session() assert session.auth == session.basic_auth assert session.auth == ('api_key', 'secret') - assert not session.iam_auth + assert not session.jwt_token -@pytest.mark.usefixtures('cuenca_creds', 'aws_creds') -def test_gives_preference_to_basic_auth_configuration(): - session = Session() - assert session.auth == session.basic_auth - assert session.iam_auth - - -@pytest.mark.usefixtures('aws_creds') -def test_aws_iam_auth_configuration(): +@pytest.mark.vcr +@pytest.mark.usefixtures('cuenca_creds') +def test_configures_jwt(): session = Session() - assert session.auth == session.iam_auth + session.configure(use_jwt=True) + assert session.auth + assert session.jwt_token -def test_configures_new_aws_creds(): +@pytest.mark.vcr +@pytest.mark.usefixtures('cuenca_creds') +def test_request_valid_token(): session = Session() - session.configure( - aws_access_key='new_aws_key', aws_secret_access_key='new_aws_secret' - ) - assert session.auth.aws_secret_access_key == 'new_aws_secret' - assert session.auth.aws_access_key == 'new_aws_key' - assert session.auth.aws_region == 'us-east-1' + # Set date when the cassette was created otherwise will retrieve + # an expired token + with freeze_time(dt.date(2020, 10, 22)): + session.configure(use_jwt=True) + response = session.get('/api_keys') + assert response['items'] -@pytest.mark.usefixtures('aws_creds') -def test_overrides_aws_creds(): +@pytest.mark.vcr +@pytest.mark.usefixtures('cuenca_creds') +def test_request_expired_token(): session = Session() - session.configure( - aws_access_key='new_aws_key', - aws_secret_access_key='new_aws_secret', - aws_region='us-east-2', - ) - assert session.auth.aws_secret_access_key == 'new_aws_secret' - assert session.auth.aws_access_key == 'new_aws_key' - assert session.auth.aws_region == 'us-east-2' + session.configure(use_jwt=True) + previous_jwt = session.jwt_token.token + with freeze_time(dt.datetime.utcnow() + dt.timedelta(days=40)): + response = session.get('/api_keys') + assert response['items'] + assert session.jwt_token != previous_jwt @patch('cuenca.http.client.requests.Session.request') diff --git a/tests/test_jwt.py b/tests/test_jwt.py new file mode 100644 index 00000000..2eab8cf2 --- /dev/null +++ b/tests/test_jwt.py @@ -0,0 +1,43 @@ +import datetime as dt +from unittest.mock import MagicMock + +import pytest + +from cuenca.exc import MalformedJwtToken +from cuenca.jwt import Jwt + +TEST_TOKEN = ( + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6' + 'IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjE2MDYyNjI0MDB9.gbrIYP6Q0yf2' + 'V3GEgxnzO5fNHKgHPqaIzZ-cjvFlnik' +) + + +@pytest.mark.parametrize( + 'expires_at, should_be_expired', + [ + (dt.datetime.utcnow() + dt.timedelta(days=-1), True), + (dt.datetime.utcnow() + dt.timedelta(days=1), False), + ], +) +def test_expired(expires_at: dt.datetime, should_be_expired: bool): + jwt = Jwt(expires_at, '_') + assert jwt.is_expired == should_be_expired + + +def test_get_expiration_date(): + assert Jwt.get_expiration_date(TEST_TOKEN) == dt.datetime(2020, 11, 25) + + +def test_invalid_token(): + test_token = 'INVALID' + with pytest.raises(MalformedJwtToken): + Jwt.get_expiration_date(test_token) + + +def test_create_token(): + session = MagicMock() + session.post.return_value = dict(token=TEST_TOKEN) + jwt = Jwt.create(session) + assert jwt.expires_at == dt.datetime(2020, 11, 25) + assert jwt.token == TEST_TOKEN