Skip to content

Commit 3bf9116

Browse files
authored
Merge pull request #329 from EvgeniGordeev/feature-optional-scope-support
Extended scope support for grant_type=client_credentials and password
2 parents a7b07e2 + 1238b85 commit 3bf9116

File tree

8 files changed

+166
-33
lines changed

8 files changed

+166
-33
lines changed

docs/sections/contribute.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ We also use `travis <https://travis-ci.org/juanifioren/django-oidc-provider/>`_
3232
Improve Documentation
3333
=====================
3434

35-
We use `Sphinx <http://www.sphinx-doc.org/>`_ for generate this documentation. I you want to add or modify something just:
35+
We use `Sphinx <http://www.sphinx-doc.org/>`_ for generate this documentation. If you want to add or modify something just:
3636

3737
* Install Sphinx (``pip install sphinx``) and the auto-build tool (``pip install sphinx-autobuild``).
3838
* Move inside the docs folder. ``cd docs/``

docs/sections/settings.rst

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,3 +234,14 @@ Default is::
234234
See the :ref:`templates` section.
235235

236236
The templates that are not specified here will use the default ones.
237+
238+
OIDC_INTROSPECTION_RESPONSE_SCOPE_ENABLE
239+
==========================================
240+
241+
OPTIONAL ``bool``
242+
243+
A flag which toggles whether the scope is returned with successful response on introspection request.
244+
245+
Must be ``True`` to include ``scope`` into the successful response
246+
247+
Default is ``False``.

oidc_provider/lib/endpoints/introspection.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,8 @@ def create_response_dic(self):
8585
response_dic[k] = self.id_token[k]
8686
response_dic['active'] = True
8787
response_dic['client_id'] = self.token.client.client_id
88-
88+
if settings.get('OIDC_INTROSPECTION_RESPONSE_SCOPE_ENABLE'):
89+
response_dic['scope'] = ' '.join(self.token.scope)
8990
response_dic = run_processing_hook(response_dic,
9091
'OIDC_INTROSPECTION_PROCESSING_HOOK',
9192
client=self.client,

oidc_provider/lib/endpoints/token.py

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import inspect
2-
from base64 import urlsafe_b64encode
31
import hashlib
2+
import inspect
43
import logging
5-
from django.contrib.auth import authenticate
4+
from base64 import urlsafe_b64encode
65

6+
from django.contrib.auth import authenticate
77
from django.http import JsonResponse
88

9+
from oidc_provider import settings
910
from oidc_provider.lib.errors import (
1011
TokenError,
1112
UserAuthError,
@@ -21,7 +22,6 @@
2122
Code,
2223
Token,
2324
)
24-
from oidc_provider import settings
2525

2626
logger = logging.getLogger(__name__)
2727

@@ -76,16 +76,16 @@ def validate_params(self):
7676
raise TokenError('invalid_grant')
7777

7878
if not (self.code.client == self.client) \
79-
or self.code.has_expired():
79+
or self.code.has_expired():
8080
logger.debug('[Token] Invalid code: invalid client or code has expired')
8181
raise TokenError('invalid_grant')
8282

8383
# Validate PKCE parameters.
8484
if self.params['code_verifier']:
8585
if self.code.code_challenge_method == 'S256':
8686
new_code_challenge = urlsafe_b64encode(
87-
hashlib.sha256(self.params['code_verifier'].encode('ascii')).digest()
88-
).decode('utf-8').replace('=', '')
87+
hashlib.sha256(self.params['code_verifier'].encode('ascii')).digest()
88+
).decode('utf-8').replace('=', '')
8989
else:
9090
new_code_challenge = self.params['code_verifier']
9191

@@ -135,6 +135,27 @@ def validate_params(self):
135135
logger.debug('[Token] Invalid grant type: %s', self.params['grant_type'])
136136
raise TokenError('unsupported_grant_type')
137137

138+
def validate_requested_scopes(self):
139+
"""
140+
Handling validation of requested scope for grant_type=[password|client_credentials]
141+
"""
142+
token_scopes = []
143+
if self.params['scope']:
144+
# See https://tools.ietf.org/html/rfc6749#section-3.3
145+
# The value of the scope parameter is expressed
146+
# as a list of space-delimited, case-sensitive strings
147+
for scope_requested in self.params['scope'].split(' '):
148+
if scope_requested in self.client.scope:
149+
token_scopes.append(scope_requested)
150+
else:
151+
logger.debug('[Token] The request scope %s is not supported by client %s',
152+
scope_requested, self.client.client_id)
153+
raise TokenError('invalid_scope')
154+
# if no scopes requested assign client's scopes
155+
else:
156+
token_scopes.extend(self.client.scope)
157+
return token_scopes
158+
138159
def create_response_dic(self):
139160
if self.params['grant_type'] == 'authorization_code':
140161
return self.create_code_response_dic()
@@ -230,11 +251,11 @@ def create_refresh_response_dic(self):
230251

231252
def create_access_token_response_dic(self):
232253
# See https://tools.ietf.org/html/rfc6749#section-4.3
233-
254+
token_scopes = self.validate_requested_scopes()
234255
token = create_token(
235256
self.user,
236257
self.client,
237-
self.params['scope'].split(' '))
258+
token_scopes)
238259

239260
id_token_dic = create_id_token(
240261
token=token,
@@ -255,23 +276,25 @@ def create_access_token_response_dic(self):
255276
'expires_in': settings.get('OIDC_TOKEN_EXPIRE'),
256277
'token_type': 'bearer',
257278
'id_token': encode_id_token(id_token_dic, token.client),
279+
'scope': ' '.join(token.scope)
258280
}
259281

260282
def create_client_credentials_response_dic(self):
261283
# See https://tools.ietf.org/html/rfc6749#section-4.4.3
284+
token_scopes = self.validate_requested_scopes()
262285

263286
token = create_token(
264287
user=None,
265288
client=self.client,
266-
scope=self.client.scope)
289+
scope=token_scopes)
267290

268291
token.save()
269292

270293
return {
271294
'access_token': token.access_token,
272295
'expires_in': settings.get('OIDC_TOKEN_EXPIRE'),
273296
'token_type': 'bearer',
274-
'scope': self.client._scope,
297+
'scope': ' '.join(token.scope),
275298
}
276299

277300
@classmethod

oidc_provider/settings.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,13 @@ def OIDC_TEMPLATES(self):
168168
'error': 'oidc_provider/error.html'
169169
}
170170

171+
@property
172+
def OIDC_INTROSPECTION_RESPONSE_SCOPE_ENABLE(self):
173+
"""
174+
OPTIONAL: A boolean to specify whether or not to include scope in introspection response.
175+
"""
176+
return False
177+
171178

172179
default_settings = DefaultSettings()
173180

oidc_provider/tests/app/utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ def create_fake_client(response_type, is_public=False, require_consent=True):
6161
client.client_secret = str(random.randint(1, 999999)).zfill(6)
6262
client.redirect_uris = ['http://example.com/']
6363
client.require_consent = require_consent
64-
64+
client.scope = ['openid', 'email']
6565
client.save()
6666

6767
# check if response_type is a string in a python 2 and 3 compatible way

oidc_provider/tests/cases/test_introspection_endpoint.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,3 +130,8 @@ def test_valid_client_grant_token_without_aud_validation(self):
130130
'active': True,
131131
'client_id': self.client.client_id,
132132
})
133+
134+
@override_settings(OIDC_INTROSPECTION_RESPONSE_SCOPE_ENABLE=True)
135+
def test_enable_scope(self):
136+
response = self._make_request()
137+
self._assert_active(response, scope='openid email')

oidc_provider/tests/cases/test_token_endpoint.py

Lines changed: 105 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
from django.core.management import call_command
1313
from django.http import JsonResponse
14+
1415
try:
1516
from django.urls import reverse
1617
except ImportError:
@@ -51,6 +52,8 @@ class TokenTestCase(TestCase):
5152
Token Request to the Token Endpoint to obtain a Token Response
5253
when using the Authorization Code Flow.
5354
"""
55+
SCOPE = 'openid email'
56+
SCOPE_LIST = SCOPE.split(' ')
5457

5558
def setUp(self):
5659
call_command('creatersakey')
@@ -64,7 +67,7 @@ def _password_grant_post_data(self, scope=None):
6467
'username': 'johndoe',
6568
'password': '1234',
6669
'grant_type': 'password',
67-
'scope': 'openid email',
70+
'scope': TokenTestCase.SCOPE,
6871
}
6972
if scope is not None:
7073
result['scope'] = ' '.join(scope)
@@ -102,6 +105,16 @@ def _refresh_token_post_data(self, refresh_token, scope=None):
102105

103106
return post_data
104107

108+
def _client_credentials_post_data(self, scope=None):
109+
post_data = {
110+
'client_id': self.client.client_id,
111+
'client_secret': self.client.client_secret,
112+
'grant_type': 'client_credentials',
113+
}
114+
if scope is not None:
115+
post_data['scope'] = ' '.join(scope)
116+
return post_data
117+
105118
def _post_request(self, post_data, extras={}):
106119
"""
107120
Makes a request to the token endpoint by sending the
@@ -127,7 +140,7 @@ def _create_code(self, scope=None):
127140
code = create_code(
128141
user=self.user,
129142
client=self.client,
130-
scope=(scope if scope else ['openid', 'email']),
143+
scope=(scope if scope else TokenTestCase.SCOPE_LIST),
131144
nonce=FAKE_NONCE,
132145
is_authentication=True)
133146
code.save()
@@ -227,7 +240,11 @@ def test_password_grant_full_response(self):
227240
self.check_password_grant(scope=['openid', 'email'])
228241

229242
def test_password_grant_scope(self):
230-
self.check_password_grant(scope=['openid', 'profile'])
243+
scopes_list = ['openid', 'profile']
244+
245+
self.client.scope = scopes_list
246+
self.client.save()
247+
self.check_password_grant(scope=scopes_list)
231248

232249
@override_settings(OIDC_TOKEN_EXPIRE=120,
233250
OIDC_GRANT_TYPE_PASSWORD_ENABLE=True)
@@ -361,7 +378,7 @@ def do_refresh_token_check(self, scope=None):
361378

362379
# Retrieve refresh token
363380
code = self._create_code()
364-
self.assertEqual(code.scope, ['openid', 'email'])
381+
self.assertEqual(code.scope, TokenTestCase.SCOPE_LIST)
365382
post_data = self._auth_code_post_data(code=code.code)
366383
start_time = time.time()
367384
with patch('oidc_provider.lib.utils.token.time.time') as time_func:
@@ -661,7 +678,7 @@ def test_additional_idtoken_processing_hook_one_element_in_tuple(self):
661678

662679
@override_settings(
663680
OIDC_IDTOKEN_PROCESSING_HOOK=[
664-
'oidc_provider.tests.app.utils.fake_idtoken_processing_hook',
681+
'oidc_provider.tests.app.utils.fake_idtoken_processing_hook',
665682
]
666683
)
667684
def test_additional_idtoken_processing_hook_one_element_in_list(self):
@@ -682,8 +699,8 @@ def test_additional_idtoken_processing_hook_one_element_in_list(self):
682699

683700
@override_settings(
684701
OIDC_IDTOKEN_PROCESSING_HOOK=[
685-
'oidc_provider.tests.app.utils.fake_idtoken_processing_hook',
686-
'oidc_provider.tests.app.utils.fake_idtoken_processing_hook2',
702+
'oidc_provider.tests.app.utils.fake_idtoken_processing_hook',
703+
'oidc_provider.tests.app.utils.fake_idtoken_processing_hook2',
687704
]
688705
)
689706
def test_additional_idtoken_processing_hook_two_elements_in_list(self):
@@ -754,7 +771,7 @@ def test_additional_idtoken_processing_hook_kwargs(self):
754771
kwargs_passed = id_token.get('kwargs_passed_to_processing_hook')
755772
assert kwargs_passed
756773
self.assertTrue(kwargs_passed.get('token').startswith(
757-
'<Token: Some Client -'))
774+
'<Token: Some Client -'))
758775
self.assertEqual(kwargs_passed.get('request'),
759776
"<WSGIRequest: POST '/openid/token'>")
760777
self.assertEqual(set(kwargs_passed.keys()), {'token', 'request'})
@@ -797,11 +814,7 @@ def test_client_credentials_grant_type(self):
797814
self.client.scope = fake_scopes_list
798815
self.client.save()
799816

800-
post_data = {
801-
'client_id': self.client.client_id,
802-
'client_secret': self.client.client_secret,
803-
'grant_type': 'client_credentials',
804-
}
817+
post_data = self._client_credentials_post_data()
805818
response = self._post_request(post_data)
806819
response_dict = json.loads(response.content.decode('utf-8'))
807820

@@ -857,12 +870,85 @@ def test_printing_token_used_by_client_credentials_grant_type(self):
857870
self.client.scope = ['something']
858871
self.client.save()
859872

860-
post_data = {
861-
'client_id': self.client.client_id,
862-
'client_secret': self.client.client_secret,
863-
'grant_type': 'client_credentials',
864-
}
865-
response = self._post_request(post_data)
873+
response = self._post_request(self._client_credentials_post_data())
866874
response_dict = json.loads(response.content.decode('utf-8'))
867875
token = Token.objects.get(access_token=response_dict['access_token'])
868876
self.assertTrue(str(token))
877+
878+
@override_settings(OIDC_GRANT_TYPE_PASSWORD_ENABLE=True)
879+
def test_requested_scope(self):
880+
# GRANT_TYPE=PASSWORD
881+
response = self._post_request(
882+
post_data=self._password_grant_post_data(['openid', 'invalid_scope']),
883+
extras=self._password_grant_auth_header()
884+
)
885+
886+
response_dict = json.loads(response.content.decode('utf-8'))
887+
888+
# It should fail when client requested an invalid scope.
889+
self.assertEqual(400, response.status_code)
890+
self.assertEqual('invalid_scope', response_dict['error'])
891+
892+
# happy path: no scope
893+
response = self._post_request(
894+
post_data=self._password_grant_post_data([]),
895+
extras=self._password_grant_auth_header()
896+
)
897+
898+
response_dict = json.loads(response.content.decode('utf-8'))
899+
self.assertEqual(200, response.status_code)
900+
self.assertEqual(TokenTestCase.SCOPE, response_dict['scope'])
901+
902+
# happy path: single scope
903+
response = self._post_request(
904+
post_data=self._password_grant_post_data(['email']),
905+
extras=self._password_grant_auth_header()
906+
)
907+
908+
response_dict = json.loads(response.content.decode('utf-8'))
909+
self.assertEqual(200, response.status_code)
910+
self.assertEqual('email', response_dict['scope'])
911+
912+
# happy path: multiple scopes
913+
response = self._post_request(
914+
post_data=self._password_grant_post_data(['email', 'openid']),
915+
extras=self._password_grant_auth_header()
916+
)
917+
918+
# GRANT_TYPE=CLIENT_CREDENTIALS
919+
response_dict = json.loads(response.content.decode('utf-8'))
920+
self.assertEqual(200, response.status_code)
921+
self.assertEqual('email openid', response_dict['scope'])
922+
923+
response = self._post_request(
924+
post_data=self._client_credentials_post_data(['openid', 'invalid_scope'])
925+
)
926+
927+
response_dict = json.loads(response.content.decode('utf-8'))
928+
929+
# It should fail when client requested an invalid scope.
930+
self.assertEqual(400, response.status_code)
931+
self.assertEqual('invalid_scope', response_dict['error'])
932+
933+
# happy path: no scope
934+
response = self._post_request(post_data=self._client_credentials_post_data())
935+
936+
response_dict = json.loads(response.content.decode('utf-8'))
937+
self.assertEqual(200, response.status_code)
938+
self.assertEqual(TokenTestCase.SCOPE, response_dict['scope'])
939+
940+
# happy path: single scope
941+
response = self._post_request(post_data=self._client_credentials_post_data(['email']))
942+
943+
response_dict = json.loads(response.content.decode('utf-8'))
944+
self.assertEqual(200, response.status_code)
945+
self.assertEqual('email', response_dict['scope'])
946+
947+
# happy path: multiple scopes
948+
response = self._post_request(
949+
post_data=self._client_credentials_post_data(['email', 'openid'])
950+
)
951+
952+
response_dict = json.loads(response.content.decode('utf-8'))
953+
self.assertEqual(200, response.status_code)
954+
self.assertEqual('email openid', response_dict['scope'])

0 commit comments

Comments
 (0)