Skip to content
Merged

Jwt #79

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
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions cuenca/exc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""

Expand Down
52 changes: 13 additions & 39 deletions cuenca/http/client.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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'
Expand All @@ -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):
Expand All @@ -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,
)

Comment thread
matin marked this conversation as resolved.
@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,
):
"""
Expand All @@ -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,
Expand All @@ -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),
Expand Down
47 changes: 47 additions & 0 deletions cuenca/jwt.py
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 1 addition & 1 deletion cuenca/version.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
__version__ = '0.4.2'
__version__ = '0.5.0'
CLIENT_VERSION = __version__
API_VERSION = '2020-03-19'
1 change: 1 addition & 0 deletions requirements-test.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ black==20.8b1
isort==5.7.*
flake8==3.8.*
mypy==0.790
freezegun==1.0.*
Comment thread
matin marked this conversation as resolved.
1 change: 0 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -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
7 changes: 6 additions & 1 deletion setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -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:
5 changes: 4 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
44 changes: 44 additions & 0 deletions tests/http/cassettes/test_configures_jwt.yaml
Original file line number Diff line number Diff line change
@@ -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
128 changes: 128 additions & 0 deletions tests/http/cassettes/test_request_expired_token.yaml
Original file line number Diff line number Diff line change
@@ -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
Loading