Skip to content

Commit bf07870

Browse files
SSladarovfdelavega
authored andcommitted
JWT in Keyrock and Keycloak (#29)
1 parent b570075 commit bf07870

File tree

9 files changed

+498
-74
lines changed

9 files changed

+498
-74
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,5 @@ nosetests.xml
3636
.mr.developer.cfg
3737
.project
3838
.pydevproject
39+
.idea
40+
venv

bin/travis-build.bash

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,6 @@ sudo -u postgres psql -c "CREATE USER datastore_default WITH PASSWORD 'pass';"
5757
sudo -u postgres psql -c "CREATE DATABASE ckan_test WITH OWNER ckan_default;"
5858
sudo -u postgres psql -c "CREATE DATABASE datastore_test WITH OWNER ckan_default;"
5959

60-
6160
echo "Initialising the database..."
6261
cd ckan
6362
paster db init -c test-core.ini

ckanext/oauth2/oauth2.py

Lines changed: 69 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@
3636
from requests_oauthlib import OAuth2Session
3737
import six
3838

39+
import jwt
40+
3941
import constants
4042

4143

@@ -61,6 +63,8 @@ def __init__(self):
6163
if self.verify_https and os.environ.get("REQUESTS_CA_BUNDLE", "").strip() != "":
6264
self.verify_https = os.environ["REQUESTS_CA_BUNDLE"].strip()
6365

66+
self.jwt_enable = six.text_type(os.environ.get('CKAN_OAUTH2_JWT_ENABLE', toolkit.config.get('ckan.oauth2.jwt.enable',''))).strip().lower() in ("true", "1", "on")
67+
6468
self.legacy_idm = six.text_type(os.environ.get('CKAN_OAUTH2_LEGACY_IDM', toolkit.config.get('ckan.oauth2.legacy_idm', ''))).strip().lower() in ("true", "1", "on")
6569
self.authorization_endpoint = six.text_type(os.environ.get('CKAN_OAUTH2_AUTHORIZATION_ENDPOINT', toolkit.config.get('ckan.oauth2.authorization_endpoint', ''))).strip()
6670
self.token_endpoint = six.text_type(os.environ.get('CKAN_OAUTH2_TOKEN_ENDPOINT', toolkit.config.get('ckan.oauth2.token_endpoint', ''))).strip()
@@ -126,61 +130,74 @@ def get_token(self):
126130
return token
127131

128132
def identify(self, token):
129-
try:
130-
if self.legacy_idm:
131-
profile_response = requests.get(self.profile_api_url + '?access_token=%s' % token['access_token'], verify=self.verify_https)
132-
else:
133-
oauth = OAuth2Session(self.client_id, token=token)
134-
profile_response = oauth.get(self.profile_api_url, verify=self.verify_https)
135133

136-
except requests.exceptions.SSLError as e:
137-
# TODO search a better way to detect invalid certificates
138-
if "verify failed" in six.text_type(e):
139-
raise InsecureTransportError()
140-
else:
141-
raise
134+
if self.jwt_enable:
142135

143-
# Token can be invalid
144-
if not profile_response.ok:
145-
error = profile_response.json()
146-
if error.get('error', '') == 'invalid_token':
147-
raise ValueError(error.get('error_description'))
148-
else:
149-
profile_response.raise_for_status()
136+
access_token = bytes(token['access_token'])
137+
user_data = jwt.decode(access_token, verify=False)
138+
user = self.user_json(user_data)
150139
else:
151-
user_data = profile_response.json()
152-
email = user_data[self.profile_api_mail_field]
153-
user_name = user_data[self.profile_api_user_field]
154140

155-
# In CKAN can exists more than one user associated with the same email
156-
# Some providers, like Google and FIWARE only allows one account per email
157-
user = None
158-
users = model.User.by_email(email)
159-
if len(users) == 1:
160-
user = users[0]
141+
try:
142+
if self.legacy_idm:
143+
profile_response = requests.get(self.profile_api_url + '?access_token=%s' % token['access_token'], verify=self.verify_https)
144+
else:
145+
oauth = OAuth2Session(self.client_id, token=token)
146+
profile_response = oauth.get(self.profile_api_url, verify=self.verify_https)
147+
148+
except requests.exceptions.SSLError as e:
149+
# TODO search a better way to detect invalid certificates
150+
if "verify failed" in six.text_type(e):
151+
raise InsecureTransportError()
152+
else:
153+
raise
154+
155+
# Token can be invalid
156+
if not profile_response.ok:
157+
error = profile_response.json()
158+
if error.get('error', '') == 'invalid_token':
159+
raise ValueError(error.get('error_description'))
160+
else:
161+
profile_response.raise_for_status()
162+
else:
163+
user_data = profile_response.json()
164+
user = self.user_json(user_data)
161165

162-
# If the user does not exist, we have to create it...
163-
if user is None:
164-
user = model.User(email=email)
166+
# Save the user in the database
167+
model.Session.add(user)
168+
model.Session.commit()
169+
model.Session.remove()
170+
171+
return user.name
172+
173+
def user_json(self, user_data):
174+
email = user_data[self.profile_api_mail_field]
175+
user_name = user_data[self.profile_api_user_field]
176+
177+
# In CKAN can exists more than one user associated with the same email
178+
# Some providers, like Google and FIWARE only allows one account per email
179+
user = None
180+
users = model.User.by_email(email)
181+
if len(users) == 1:
182+
user = users[0]
165183

166-
# Now we update his/her user_name with the one provided by the OAuth2 service
167-
# In the future, users will be obtained based on this field
168-
user.name = user_name
184+
# If the user does not exist, we have to create it...
185+
if user is None:
186+
user = model.User(email=email)
169187

170-
# Update fullname
171-
if self.profile_api_fullname_field != "" and self.profile_api_fullname_field in user_data:
172-
user.fullname = user_data[self.profile_api_fullname_field]
188+
# Now we update his/her user_name with the one provided by the OAuth2 service
189+
# In the future, users will be obtained based on this field
190+
user.name = user_name
173191

174-
# Update sysadmin status
175-
if self.profile_api_groupmembership_field != "" and self.profile_api_groupmembership_field in user_data:
176-
user.sysadmin = self.sysadmin_group_name in user_data[self.profile_api_groupmembership_field]
192+
# Update fullname
193+
if self.profile_api_fullname_field != "" and self.profile_api_fullname_field in user_data:
194+
user.fullname = user_data[self.profile_api_fullname_field]
177195

178-
# Save the user in the database
179-
model.Session.add(user)
180-
model.Session.commit()
181-
model.Session.remove()
196+
# Update sysadmin status
197+
if self.profile_api_groupmembership_field != "" and self.profile_api_groupmembership_field in user_data:
198+
user.sysadmin = self.sysadmin_group_name in user_data[self.profile_api_groupmembership_field]
182199

183-
return user.name
200+
return user
184201

185202
def _get_rememberer(self, environ):
186203
plugins = environ.get('repoze.who.plugins', {})
@@ -218,6 +235,7 @@ def get_stored_token(self, user_name):
218235
}
219236

220237
def update_token(self, user_name, token):
238+
221239
user_token = db.UserToken.by_user_name(user_name=user_name)
222240
# Create the user if it does not exist
223241
if not user_token:
@@ -227,7 +245,12 @@ def update_token(self, user_name, token):
227245
user_token.access_token = token['access_token']
228246
user_token.token_type = token['token_type']
229247
user_token.refresh_token = token.get('refresh_token')
230-
user_token.expires_in = token['expires_in']
248+
if 'expires_in' in token:
249+
user_token.expires_in = token['expires_in']
250+
else:
251+
access_token = jwt.decode(user_token.access_token, verify=False)
252+
user_token.expires_in = access_token['exp'] - access_token['iat']
253+
231254
model.Session.add(user_token)
232255
model.Session.commit()
233256

ckanext/oauth2/tests/test_oauth2.py

Lines changed: 74 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@
3434
from oauthlib.oauth2 import InsecureTransportError, MissingCodeError, MissingTokenError
3535
from requests.exceptions import SSLError
3636

37-
3837
OAUTH2TOKEN = {
3938
'access_token': 'token',
4039
'token_type': 'Bearer',
@@ -87,8 +86,9 @@ def tearDown(self):
8786
oauth2.db = self._db
8887
oauth2.OAuth2Session = self._OAuth2Session
8988

90-
def _helper(self, fullname_field=True, mail_field=True, conf=None, missing_conf=None):
89+
def _helper(self, fullname_field=True, mail_field=True, conf=None, missing_conf=None, jwt_enable=False):
9190
oauth2.db = MagicMock()
91+
oauth2.jwt = MagicMock()
9292

9393
oauth2.toolkit.config = {
9494
'ckan.oauth2.legacy_idm': 'false',
@@ -110,6 +110,9 @@ def _helper(self, fullname_field=True, mail_field=True, conf=None, missing_conf=
110110
if fullname_field:
111111
helper.profile_api_fullname_field = self._fullname_field
112112

113+
if jwt_enable:
114+
helper.jwt_enable = True
115+
113116
return helper
114117

115118
@parameterized.expand([
@@ -341,7 +344,6 @@ def test_identify(self, username, fullname=None, email=None, user_exists=True,
341344
print(username, fullname, email, user_exists, fullname_field, sysadmin)
342345

343346
# Create the mocks
344-
request = MagicMock()
345347
request = make_request(False, 'localhost', '/oauth2/callback', {})
346348
oauth2.toolkit.request = request
347349
oauth2.model.Session = MagicMock()
@@ -383,6 +385,29 @@ def test_identify(self, username, fullname=None, email=None, user_exists=True,
383385
oauth2.model.Session.commit.assert_called_once()
384386
oauth2.model.Session.remove.assert_called_once()
385387

388+
def test_identify_jwt(self):
389+
390+
helper = self._helper(jwt_enable=True)
391+
token = OAUTH2TOKEN
392+
user_data ={self._user_field: 'test_user', self._email_field: '[email protected]'}
393+
394+
oauth2.jwt.decode.return_value = user_data
395+
396+
oauth2.model.Session = MagicMock()
397+
user = MagicMock()
398+
user.name = None
399+
user.email = None
400+
oauth2.model.User = MagicMock(return_value=user)
401+
oauth2.model.User.by_email = MagicMock(return_value=[user])
402+
403+
returned_username = helper.identify(token)
404+
405+
self.assertEquals(user_data[self._user_field], returned_username)
406+
407+
oauth2.model.Session.add.assert_called_once_with(user)
408+
oauth2.model.Session.commit.assert_called_once()
409+
oauth2.model.Session.remove.assert_called_once()
410+
386411
@parameterized.expand([
387412
({'error': 'invalid_token', 'error_description': 'Error Description'},),
388413
({'error': 'another_error'},)
@@ -472,10 +497,12 @@ def test_redirect_from_callback(self, identity):
472497
self.assertEquals(came_from, oauth2.toolkit.response.location)
473498

474499
@parameterized.expand([
475-
(True,),
476-
(False,)
500+
(True, True),
501+
(True, False),
502+
(False, False),
503+
(False, True),
477504
])
478-
def test_update_token(self, user_exists):
505+
def test_update_token(self, user_exists, jwt_expires_in):
479506
helper = self._helper()
480507
user = 'user'
481508

@@ -494,26 +521,48 @@ def test_update_token(self, user_exists):
494521
oauth2.db.UserToken.by_user_name = MagicMock(return_value=usertoken)
495522

496523
# The token to be updated
497-
newtoken = {
498-
'access_token': 'new_access_token',
499-
'token_type': 'new_token_type',
500-
'expires_in': 'new_expires_in',
501-
'refresh_token': 'new_refresh_token'
502-
}
503-
504-
helper.update_token('user', newtoken)
505-
506-
# Check that the object has been stored
507-
oauth2.model.Session.add.assert_called_once()
508-
oauth2.model.Session.commit.assert_called_once()
524+
if jwt_expires_in:
525+
newtoken = {
526+
'access_token': 'new_access_token',
527+
'token_type': 'new_token_type',
528+
'expires_in': 'new_expires_in',
529+
'refresh_token': 'new_refresh_token'
530+
}
531+
helper.update_token('user', newtoken)
532+
533+
# Check that the object has been stored
534+
oauth2.model.Session.add.assert_called_once()
535+
oauth2.model.Session.commit.assert_called_once()
536+
537+
# Check that the object contains the correct information
538+
tk = oauth2.model.Session.add.call_args_list[0][0][0]
539+
self.assertEquals(user, tk.user_name)
540+
self.assertEquals(newtoken['access_token'], tk.access_token)
541+
self.assertEquals(newtoken['token_type'], tk.token_type)
542+
self.assertEquals(newtoken['expires_in'], tk.expires_in)
543+
self.assertEquals(newtoken['refresh_token'], tk.refresh_token)
544+
else:
545+
newtoken = {
546+
'access_token': 'new_access_token',
547+
'token_type': 'new_token_type',
548+
'refresh_token': 'new_refresh_token'
549+
}
550+
expires_in_data = {'exp': 3600, 'iat': 0}
551+
oauth2.jwt.decode.return_value = expires_in_data
552+
helper.update_token('user', newtoken)
553+
554+
# Check that the object has been stored
555+
oauth2.model.Session.add.assert_called_once()
556+
oauth2.model.Session.commit.assert_called_once()
557+
558+
# Check that the object contains the correct information
559+
tk = oauth2.model.Session.add.call_args_list[0][0][0]
560+
self.assertEquals(user, tk.user_name)
561+
self.assertEquals(newtoken['access_token'], tk.access_token)
562+
self.assertEquals(newtoken['token_type'], tk.token_type)
563+
self.assertEquals(3600, tk.expires_in)
564+
self.assertEquals(newtoken['refresh_token'], tk.refresh_token)
509565

510-
# Check that the object contains the correct information
511-
tk = oauth2.model.Session.add.call_args_list[0][0][0]
512-
self.assertEquals(user, tk.user_name)
513-
self.assertEquals(newtoken['access_token'], tk.access_token)
514-
self.assertEquals(newtoken['token_type'], tk.token_type)
515-
self.assertEquals(newtoken['expires_in'], tk.expires_in)
516-
self.assertEquals(newtoken['refresh_token'], tk.refresh_token)
517566

518567
@parameterized.expand([
519568
(True,),

0 commit comments

Comments
 (0)