Skip to content

Commit f26759c

Browse files
authored
Merge pull request #222 from descarteslabs/export/2018-05-03
v0.8.1 Release
2 parents 56566a7 + a5e0839 commit f26759c

File tree

22 files changed

+1050
-292
lines changed

22 files changed

+1050
-292
lines changed

.travis.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ before_install:
99
- pip install --upgrade pip setuptools
1010
install:
1111
- pip install --upgrade -r requirements.txt
12-
- pip install flake8 coverage nose mock
12+
- pip install flake8 coverage nose mock responses shapely
1313
script:
1414
- if [[ $TRAVIS_PYTHON_VERSION == "3.5" ]]; then nosetests --with-coverage --cover-package=descarteslabs
1515
--with-doctest --doctest-options=+ELLIPSIS,+NORMALIZE_WHITESPACE && flake8; fi

README.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,15 @@ Changelog
3636
=========
3737

3838
## [Unreleased]
39+
## [0.8.1] - 2018-05-03
40+
### Changed
41+
- Switched to `start_datetime` argument pattern instead of `start_date`
42+
- Fixed minor regression with `descarteslabs.ext` clients
43+
- Deprecated token param for `Service` class
44+
45+
### Added
46+
- Raster stack method
47+
3948
## [0.8.0] - 2018-03-29
4049
### Changed
4150
- Removed deprecated searching by `const_id`
@@ -198,7 +207,8 @@ metadata.features for iterating over large search results
198207
### Added
199208
- Initial release of client library
200209

201-
[Unreleased]: https://github.com/descarteslabs/descarteslabs-python/compare/v0.8.0...HEAD
210+
[Unreleased]: https://github.com/descarteslabs/descarteslabs-python/compare/v0.8.1...HEAD
211+
[0.8.1]: https://github.com/descarteslabs/descarteslabs-python/compare/v0.8.0...v0.8.1
202212
[0.8.0]: https://github.com/descarteslabs/descarteslabs-python/compare/v0.7.0...v0.8.0
203213
[0.7.0]: https://github.com/descarteslabs/descarteslabs-python/compare/v0.6.2...v0.7.0
204214
[0.6.2]: https://github.com/descarteslabs/descarteslabs-python/compare/v0.6.1...v0.6.2

descarteslabs/client/addons.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,13 @@ def __call__(self, *args, **kwargs):
3838
import blosc
3939
except ImportError:
4040
blosc = ThirdParty("blosc")
41+
42+
try:
43+
import shapely.geometry
44+
except ImportError:
45+
shapely = ThirdParty("shapely")
46+
47+
try:
48+
import concurrent.futures
49+
except ImportError:
50+
concurrent = ThirdParty("futures")

descarteslabs/client/auth/auth.py

Lines changed: 78 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -12,20 +12,20 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15-
import requests
16-
from urllib3.util.retry import Retry
17-
from requests.adapters import HTTPAdapter
18-
1915
import base64
2016
import datetime
2117
import errno
2218
import json
2319
import os
2420
import random
25-
import six
2621
import stat
27-
from hashlib import sha1
2822
import warnings
23+
from hashlib import sha1
24+
25+
import requests
26+
import six
27+
from requests.adapters import HTTPAdapter
28+
from urllib3.util.retry import Retry
2929

3030
from descarteslabs.client.exceptions import AuthError, OauthError
3131

@@ -58,9 +58,9 @@ def makedirs_if_not_exists(path):
5858

5959

6060
class Auth:
61-
def __init__(self, domain="https://iam.descarteslabs.com",
61+
def __init__(self, domain="https://accounts.descarteslabs.com",
6262
scope=None, leeway=500, token_info_path=DEFAULT_TOKEN_INFO_PATH,
63-
client_id=None, client_secret=None, jwt_token=None):
63+
client_id=None, client_secret=None, jwt_token=None, refresh_token=None):
6464
"""
6565
Helps retrieve JWT from a client id and refresh token for cli usage.
6666
:param domain: endpoint for auth0
@@ -70,6 +70,7 @@ def __init__(self, domain="https://iam.descarteslabs.com",
7070
:param client_id: JWT client id
7171
:param client_secret: JWT client secret
7272
:param jwt_token: the JWT token, if we already have one
73+
:param refresh_token: the refresh token
7374
"""
7475
self.token_info_path = token_info_path
7576

@@ -84,26 +85,26 @@ def __init__(self, domain="https://iam.descarteslabs.com",
8485
self.client_id = client_id if client_id else os.environ.get('CLIENT_ID', token_info.get('client_id', None))
8586
self.client_secret = client_secret if client_secret else os.environ.get('CLIENT_SECRET', token_info.get(
8687
'client_secret', None))
88+
self.refresh_token = refresh_token if refresh_token \
89+
else os.environ.get('DESCARTESLABS_REFRESH_TOKEN', token_info.get('refresh_token', None))
90+
self.scope = scope if scope else token_info.get('scope')
8791
self._token = jwt_token if jwt_token else os.environ.get('JWT_TOKEN', token_info.get('jwt_token', None))
8892

8993
if token_info:
9094
# If the token was read from a path but environment variables were set, we may need
9195
# to reset the token.
9296
client_id_changed = token_info.get('client_id', None) != self.client_id
9397
client_secret_changed = token_info.get('client_secret', None) != self.client_secret
98+
refresh_token_changed = token_info.get('refresh_token', None) != self.refresh_token
9499

95-
if client_id_changed or client_secret_changed:
100+
if client_id_changed or client_secret_changed or refresh_token_changed:
96101
self._token = None
97102

98103
self._namespace = None
99-
104+
self._session = None
100105
self.domain = domain
101-
self.scope = scope
102106
self.leeway = leeway
103107

104-
if self.scope is None:
105-
self.scope = ['openid', 'name', 'groups']
106-
107108
@classmethod
108109
def from_environment_or_token_json(cls, **kwargs):
109110
"""
@@ -120,18 +121,18 @@ def from_environment_or_token_json(cls, **kwargs):
120121
def token(self):
121122
if self._token is None:
122123
self._get_token()
123-
124-
exp = self.payload.get('exp')
125-
126-
if exp is not None:
127-
now = (datetime.datetime.utcnow() - datetime.datetime(1970, 1, 1)).total_seconds()
128-
if now + self.leeway > exp:
129-
try:
130-
self._get_token()
131-
except AuthError as e:
132-
# Unable to refresh, raise if now > exp
133-
if now > exp:
134-
raise e
124+
else: # might have token but could be close to expiration
125+
exp = self.payload.get('exp')
126+
127+
if exp is not None:
128+
now = (datetime.datetime.utcnow() - datetime.datetime(1970, 1, 1)).total_seconds()
129+
if now + self.leeway > exp:
130+
try:
131+
self._get_token()
132+
except AuthError as e:
133+
# Unable to refresh, raise if now > exp
134+
if now > exp:
135+
raise e
135136

136137
return self._token
137138

@@ -148,38 +149,65 @@ def payload(self):
148149
claims = token.split(b'.')[1]
149150
return json.loads(base64url_decode(claims).decode('utf-8'))
150151

152+
@property
153+
def session(self):
154+
if self._session is None:
155+
self._session = requests.Session()
156+
retries = Retry(total=5,
157+
backoff_factor=random.uniform(1, 10),
158+
method_whitelist=frozenset(['GET', 'POST']),
159+
status_forcelist=[429, 500, 502, 503, 504])
160+
161+
self._session.mount('https://', HTTPAdapter(max_retries=retries))
162+
163+
return self._session
164+
151165
def _get_token(self, timeout=100):
152166
if self.client_id is None:
153-
raise AuthError("Could not find CLIENT_ID")
154-
155-
if self.client_secret is None:
156-
raise AuthError("Could not find CLIENT_SECRET")
157-
158-
s = requests.Session()
159-
retries = Retry(total=5,
160-
backoff_factor=random.uniform(1, 10),
161-
method_whitelist=frozenset(['GET', 'POST']),
162-
status_forcelist=[429, 500, 502, 503, 504])
163-
164-
s.mount('https://', HTTPAdapter(max_retries=retries))
165-
166-
headers = {"content-type": "application/json"}
167-
params = {
168-
"scope": " ".join(self.scope),
169-
"client_id": self.client_id,
170-
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
171-
"target": self.client_id,
172-
"api_type": "app",
173-
"refresh_token": self.client_secret
174-
}
175-
r = s.post(self.domain + "/auth/delegation", headers=headers, data=json.dumps(params), timeout=timeout)
167+
raise AuthError("Could not find client_id")
168+
169+
if self.client_secret is None and self.refresh_token is None:
170+
raise AuthError("Could not find client_secret or refresh token")
171+
172+
if self.client_id in ["ZOBAi4UROl5gKZIpxxlwOEfx8KpqXf2c"]: # TODO(justin) remove legacy handling
173+
# TODO (justin) insert deprecation warning
174+
if self.scope is None:
175+
scope = ['openid', 'name', 'groups']
176+
else:
177+
scope = self.scope
178+
params = {
179+
"scope": " ".join(scope),
180+
"client_id": self.client_id,
181+
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
182+
"target": self.client_id,
183+
"api_type": "app",
184+
"refresh_token": self.refresh_token if self.refresh_token is not None else self.client_secret
185+
}
186+
else:
187+
params = {
188+
"client_id": self.client_id,
189+
"grant_type": "refresh_token",
190+
"refresh_token": self.refresh_token if self.refresh_token is not None else self.client_secret
191+
}
192+
193+
if self.scope is not None:
194+
params["scope"] = " ".join(self.scope)
195+
196+
r = self.session.post(self.domain + "/token", json=params, timeout=timeout)
176197

177198
if r.status_code != 200:
178199
raise OauthError("%s: %s" % (r.status_code, r.text))
179200

180201
data = r.json()
181-
self._token = data['id_token']
202+
access_token = data.get('access_token')
203+
id_token = data.get('id_token') # TODO(justin) remove legacy id_token usage
182204

205+
if access_token is not None:
206+
self._token = access_token
207+
elif id_token is not None:
208+
self._token = id_token
209+
else:
210+
raise OauthError("could not retrieve token")
183211
token_info = {}
184212

185213
if self.token_info_path:

descarteslabs/client/auth/cli.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15-
from __future__ import print_function
1615

1716
import os
1817
import stat
@@ -39,6 +38,8 @@ def auth_handler(args):
3938
if s:
4039

4140
token_info = json.loads(base64url_decode(s).decode('utf-8'))
41+
if "refresh_token" not in token_info: # TODO(justin) legacy for previous IDP
42+
token_info['refresh_token'] = token_info.get("client_secret")
4243

4344
token_info_directory = os.path.dirname(DEFAULT_TOKEN_INFO_PATH)
4445
makedirs_if_not_exists(token_info_directory)
@@ -76,6 +77,7 @@ def auth_handler(args):
7677
auth.token
7778
print('%s=%s' % ('CLIENT_ID', auth.client_id))
7879
print('%s=%s' % ('CLIENT_SECRET', auth.client_secret))
80+
print('%s=%s' % ('REFRESH_TOKEN', auth.refresh_token))
7981

8082
if args.command == 'version':
8183
print(__version__)

descarteslabs/client/auth/tests/test_auth.py

Lines changed: 71 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,36 +12,90 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
import json
16+
import six
1517
import unittest
18+
19+
import responses
20+
from mock import patch
21+
1622
from descarteslabs.client.auth import Auth
17-
import requests
18-
import json
1923

2024

21-
# flake8: noqa
22-
anon_token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJncm91cHMiOlsicHVibGljIl0sImlzcyI6Imh0dHBzOi8vZGVzY2FydGVzbGFicy5hdXRoMC5jb20vIiwic3ViIjoiZGVzY2FydGVzfGFub24tdG9rZW4iLCJhdWQiOiJaT0JBaTRVUk9sNWdLWklweHhsd09FZng4S3BxWGYyYyIsImV4cCI6OTk5OTk5OTk5OSwiaWF0IjoxNDc4MjAxNDE5fQ.QL9zq5SkpO7skIy0niIxI0B92uOzZT5t1abuiJaspRI"
25+
def token_response_callback(request):
26+
body = request.body
27+
if not isinstance(body, six.text_type):
28+
body = body.decode('utf-8')
29+
30+
data = json.loads(body)
31+
32+
required_fields = ['client_id', 'grant_type', 'refresh_token']
33+
legacy_required_fields = ["api_type", "target"]
34+
35+
if not all(field in data for field in required_fields):
36+
return 400, {"Content-Type": "application/json"}, json.dumps("missing fields")
37+
38+
if data['grant_type'] == "urn:ietf:params:oauth:grant-type:jwt-bearer" \
39+
and all(field in data for field in legacy_required_fields):
40+
return 200, {"Content-Type": "application/json"}, json.dumps(dict(id_token="id_token"))
41+
42+
if data['grant_type'] == "refresh_token" \
43+
and all(field not in data for field in legacy_required_fields):
44+
return 200, {"Content-Type": "application/json"}, json.dumps(dict(access_token="access_token",
45+
id_token="id_token"))
46+
return 400, {"Content-Type": "application/json"}, json.dumps(data)
2347

2448

2549
class TestAuth(unittest.TestCase):
50+
@responses.activate
2651
def test_get_token(self):
27-
# get a jwt
28-
auth = Auth.from_environment_or_token_json()
29-
self.assertIsNotNone(auth.token)
52+
responses.add(responses.POST, 'https://accounts.descarteslabs.com/token',
53+
json=dict(access_token="access_token"),
54+
status=200)
55+
auth = Auth(token_info_path=None, client_secret="client_secret", client_id="client_id")
56+
auth._get_token()
57+
58+
self.assertEqual("access_token", auth._token)
3059

31-
# validate the jwt
32-
url = "https://descarteslabs.auth0.com" + "/tokeninfo"
33-
params = {"id_token": auth.token}
34-
headers = {"content-type": "application/json"}
35-
r = requests.post(url, data=json.dumps(params), headers=headers)
36-
self.assertEqual(200, r.status_code)
60+
@responses.activate
61+
def test_get_token_legacy(self):
62+
responses.add(responses.POST, 'https://accounts.descarteslabs.com/token',
63+
json=dict(id_token="id_token"), status=200)
64+
auth = Auth(token_info_path=None, client_secret="client_secret", client_id="client_id")
65+
auth._get_token()
3766

67+
self.assertEqual("id_token", auth._token)
68+
69+
@patch("descarteslabs.client.auth.Auth.payload", new=dict(sub="asdf"))
3870
def test_get_namespace(self):
39-
auth = Auth.from_environment_or_token_json()
40-
self.assertIsNotNone(auth.namespace)
71+
auth = Auth(token_info_path=None, client_secret="client_secret", client_id="client_id")
72+
self.assertEqual(auth.namespace, "3da541559918a808c2402bba5012f6c60b27661c")
4173

4274
def test_init_token_no_path(self):
43-
auth = Auth(jwt_token=anon_token, token_info_path=None, client_id="foo")
44-
self.assertEquals(anon_token, auth._token)
75+
auth = Auth(jwt_token="token", token_info_path=None, client_id="foo")
76+
self.assertEqual("token", auth._token)
77+
78+
@responses.activate
79+
def test_get_token_schema_internal_only(self):
80+
responses.add_callback(responses.POST, 'https://accounts.descarteslabs.com/token',
81+
callback=token_response_callback)
82+
auth = Auth(token_info_path=None, refresh_token="refresh_token", client_id="client_id")
83+
auth._get_token()
84+
85+
self.assertEqual("access_token", auth._token)
86+
87+
auth = Auth(token_info_path=None, client_secret="refresh_token", client_id="client_id")
88+
auth._get_token()
89+
90+
self.assertEqual("access_token", auth._token)
91+
92+
@responses.activate
93+
def test_get_token_schema_legacy_internal_only(self):
94+
responses.add_callback(responses.POST, 'https://accounts.descarteslabs.com/token',
95+
callback=token_response_callback)
96+
auth = Auth(token_info_path=None, client_secret="client_secret", client_id="ZOBAi4UROl5gKZIpxxlwOEfx8KpqXf2c")
97+
auth._get_token()
98+
self.assertEqual("id_token", auth._token)
4599

46100

47101
if __name__ == '__main__':

descarteslabs/client/scripts/cli.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
# limitations under the License.
1515

1616
# flake8: noqa
17-
from __future__ import print_function
17+
1818

1919
import argparse
2020
from descarteslabs.client.auth.cli import auth_handler

0 commit comments

Comments
 (0)