Skip to content

Commit 7c3b68f

Browse files
Ricardocloud-init created default user
andauthored
Jwt (#79)
* Logic for jwt token * Adding jwt support * Cassette * Testing * Fix date * Cassettes * Final fixes * This is not neede * Fix test * Upgrade version * Pre release * Otro * Correct version * Version * Default false * Create jwt class * README clarification * Update README.md * Update README.md * Fix comment * Add type hint * Fix auth * Add ignore in config * Rebase Co-authored-by: cloud-init created default user <ec2-user@ip-10-0-170-199.us-east-2.compute.internal>
1 parent 098522e commit 7c3b68f

15 files changed

Lines changed: 418 additions & 78 deletions

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,22 @@ import cuenca
2121
cuenca.configure(api_key='PKxxxx', api_secret='yyyyyy')
2222
```
2323

24+
### Jwt
25+
26+
JWT tokens can also be used if your credentials have enough permissions. To
27+
do so, you may include the parameter `use_jwt` as part of your `configure`
28+
29+
```python
30+
import cuenca
31+
32+
cuenca.configure(use_jwt=True)
33+
```
34+
35+
A new token will be created at this moment and automatically renewed before
36+
sending any request if there is less than 5 minutes to be expired according
37+
to its payload data.
38+
39+
2440
## Transfers
2541

2642
### Create transfer

cuenca/exc.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ class CuencaException(Exception):
77
...
88

99

10+
class MalformedJwtToken(CuencaException):
11+
"""An invalid JWT token was obtained during authentication"""
12+
13+
1014
class NoResultFound(CuencaException):
1115
"""No results were found"""
1216

cuenca/http/client.py

Lines changed: 13 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import os
2-
from typing import Optional, Tuple, Union
2+
from typing import Optional, Tuple
33
from urllib.parse import urljoin
44

55
import requests
6-
from aws_requests_auth.aws_auth import AWSRequestsAuth
76
from cuenca_validations.typing import (
87
ClientRequestParams,
98
DictStrAny,
@@ -12,6 +11,7 @@
1211
from requests import Response
1312

1413
from ..exc import CuencaResponseException
14+
from ..jwt import Jwt
1515
from ..version import API_VERSION, CLIENT_VERSION
1616

1717
API_HOST = 'api.cuenca.com'
@@ -24,7 +24,7 @@ class Session:
2424

2525
host: str = API_HOST
2626
basic_auth: Tuple[str, str]
27-
iam_auth: Optional[AWSRequestsAuth] = None
27+
jwt_token: Optional[Jwt] = None
2828
session: requests.Session
2929

3030
def __init__(self):
@@ -41,31 +41,15 @@ def __init__(self):
4141
api_secret = os.getenv('CUENCA_API_SECRET', '')
4242
self.basic_auth = (api_key, api_secret)
4343

44-
# IAM auth
45-
aws_access_key = os.getenv('AWS_ACCESS_KEY_ID', '')
46-
aws_secret_access_key = os.getenv('AWS_SECRET_ACCESS_KEY', '')
47-
aws_region = os.getenv('AWS_DEFAULT_REGION', AWS_DEFAULT_REGION)
48-
if aws_access_key and aws_secret_access_key:
49-
self.iam_auth = AWSRequestsAuth(
50-
aws_access_key=aws_access_key,
51-
aws_secret_access_key=aws_secret_access_key,
52-
aws_host=self.host,
53-
aws_region=aws_region,
54-
aws_service=AWS_SERVICE,
55-
)
56-
5744
@property
58-
def auth(self) -> Union[AWSRequestsAuth, Tuple[str, str]]:
59-
# preference to basic auth
60-
return self.basic_auth if all(self.basic_auth) else self.iam_auth
45+
def auth(self) -> Optional[Tuple[str, str]]:
46+
return self.basic_auth if all(self.basic_auth) else None
6147

6248
def configure(
6349
self,
6450
api_key: Optional[str] = None,
6551
api_secret: Optional[str] = None,
66-
aws_access_key: Optional[str] = None,
67-
aws_secret_access_key: Optional[str] = None,
68-
aws_region: str = AWS_DEFAULT_REGION,
52+
use_jwt: Optional[bool] = False,
6953
sandbox: Optional[bool] = None,
7054
):
7155
"""
@@ -84,23 +68,8 @@ def configure(
8468
api_secret or self.basic_auth[1],
8569
)
8670

87-
# IAM auth
88-
if self.iam_auth is not None:
89-
self.iam_auth.aws_access_key = (
90-
aws_access_key or self.iam_auth.aws_access_key
91-
)
92-
self.iam_auth.aws_secret_access_key = (
93-
aws_secret_access_key or self.iam_auth.aws_secret_access_key
94-
)
95-
self.iam_auth.aws_region = aws_region or self.iam_auth.aws_region
96-
elif aws_access_key and aws_secret_access_key:
97-
self.iam_auth = AWSRequestsAuth(
98-
aws_access_key=aws_access_key,
99-
aws_secret_access_key=aws_secret_access_key,
100-
aws_host=self.host,
101-
aws_region=aws_region,
102-
aws_service=AWS_SERVICE,
103-
)
71+
if use_jwt:
72+
self.jwt_token = Jwt.create(self)
10473

10574
def get(
10675
self,
@@ -126,6 +95,11 @@ def request(
12695
data: OptionalDict = None,
12796
**kwargs,
12897
) -> DictStrAny:
98+
if self.jwt_token:
99+
if self.jwt_token.is_expired:
100+
self.jwt_token = Jwt.create(self)
101+
self.session.headers['X-Cuenca-Token'] = self.jwt_token.token
102+
129103
resp = self.session.request(
130104
method=method,
131105
url='https://' + self.host + urljoin('/', endpoint),

cuenca/jwt.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import base64
2+
import binascii
3+
import datetime as dt
4+
import json
5+
from dataclasses import dataclass
6+
from typing import TYPE_CHECKING
7+
8+
from .exc import MalformedJwtToken
9+
10+
if TYPE_CHECKING:
11+
from .http import Session
12+
13+
14+
@dataclass
15+
class Jwt:
16+
expires_at: dt.datetime
17+
token: str
18+
19+
@property
20+
def is_expired(self) -> bool:
21+
return self.expires_at - dt.datetime.utcnow() <= dt.timedelta(
22+
minutes=5
23+
)
24+
25+
@staticmethod
26+
def get_expiration_date(token: str) -> dt.datetime:
27+
"""
28+
Jwt tokens contains the exp field in the payload data,
29+
this function extracts the date so we can validate the
30+
token before any request
31+
More info about JWT tokens at: https://jwt.io/
32+
"""
33+
try:
34+
payload_encoded = token.split('.')[1]
35+
payload = json.loads(base64.b64decode(f'{payload_encoded}=='))
36+
except (IndexError, json.JSONDecodeError, binascii.Error):
37+
raise MalformedJwtToken(f'Invalid JWT: {token}')
38+
# Expiration timestamp can be found in the `exp` key in the payload
39+
exp_timestamp = payload['exp']
40+
return dt.datetime.utcfromtimestamp(exp_timestamp)
41+
42+
@classmethod
43+
def create(cls, session: 'Session') -> 'Jwt':
44+
session.jwt_token = None
45+
token = session.post('/token', dict())['token']
46+
expires_at = Jwt.get_expiration_date(token)
47+
return cls(expires_at, token)

cuenca/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
__version__ = '0.4.2'
1+
__version__ = '0.5.0'
22
CLIENT_VERSION = __version__
33
API_VERSION = '2020-03-19'

requirements-test.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ black==20.8b1
55
isort==5.7.*
66
flake8==3.8.*
77
mypy==0.790
8+
freezegun==1.0.*

requirements.txt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
11
requests==2.25.0
22
cuenca-validations==0.6.9
33
dataclasses>=0.7;python_version<"3.7"
4-
aws-requests-auth==0.4.3

setup.cfg

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,10 @@ combine_as_imports=True
1717
[mypy-pytest]
1818
ignore_missing_imports = True
1919
20-
[mypy-aws_requests_auth.*]
20+
[mypy-freezegun.*]
2121
ignore_missing_imports = True
22+
23+
[coverage:report]
24+
exclude_lines =
25+
pragma: no cover
26+
if TYPE_CHECKING:

tests/conftest.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,8 @@
88
@pytest.fixture(scope='module')
99
def vcr_config():
1010
config = dict()
11-
config['filter_headers'] = [('Authorization', 'DUMMY')]
11+
config['filter_headers'] = [
12+
('Authorization', 'DUMMY'),
13+
('X-Cuenca-Token', 'DUMMY'),
14+
]
1215
return config
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
interactions:
2+
- request:
3+
body: null
4+
headers:
5+
Accept:
6+
- '*/*'
7+
Accept-Encoding:
8+
- gzip, deflate
9+
Authorization:
10+
- DUMMY
11+
Connection:
12+
- keep-alive
13+
Content-Length:
14+
- '0'
15+
User-Agent:
16+
- cuenca-python/0.3.6
17+
X-Cuenca-Api-Version:
18+
- '2020-03-19'
19+
method: POST
20+
uri: https://api.cuenca.com/token
21+
response:
22+
body:
23+
string: '{"id":1,"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE2MDM4NDM2NTUsImlhdCI6MTYwMzIzODg1NSwic3ViIjoiQUtib3hCeXNTd1NTbVR0VmtoT1I2X21RIn0.VAT9w746K0hfOKnvZCFxwBDKjV71j-ItaFdZrIc0xCM","created_at":"2020-10-21T00:07:35.290968+00:00","api_key_id":"AKboxBysSwSSmTtVkhOR6_mQ"}'
24+
headers:
25+
Connection:
26+
- keep-alive
27+
Content-Length:
28+
- '279'
29+
Content-Type:
30+
- application/json
31+
Date:
32+
- Wed, 21 Oct 2020 00:07:35 GMT
33+
Server:
34+
- nginx/1.18.0
35+
X-Amzn-Trace-Id:
36+
- Root=1-5f8f7bc6-16318c2505b404c030bbcaf4;Sampled=0
37+
x-amz-apigw-id:
38+
- UvBHAHOGCYcFZTw=
39+
x-amzn-RequestId:
40+
- 856c8bef-f148-48e4-a27b-391749b6b9f6
41+
status:
42+
code: 201
43+
message: Created
44+
version: 1

0 commit comments

Comments
 (0)