Repository: juanifioren/django-oidc-provider

Issue:
Handle optional scope on token and introspection endpoints for password and client_credentials grant_type
Parameter scope is defined as optional on authorization https://tools.ietf.org/html/rfc6749#section-4.1.1 and introspection https://tools.ietf.org/html/rfc7662 requests. 

An enhancement is proposed to handle `scope` parameter on authorization requests with grant_type=password or grant_type= client_credentials (related to https://github.com/juanifioren/django-oidc-provider/pull/193):
1) if `scope` is not provided client's scope(s) are assigned to access token - 

- grant_type=client_credentials: current behavior of django-oidc-provider
- grant_type=password: currently django-oidc-provider assigns empty scope

2) if valid (supported by given client) scopes are requested requested scopes are assigned to access token
3) if requested scope(s) are not supported `raise TokenError('invalid_scope')`

An enhancement is proposed to display optional `scope` in introspection response controlled by new settings variable `OIDC_INTROSPECTION_RESPONSE_SCOPE_ENABLE`. 

Discussion:

Patch:
diff --git a/docs/sections/contribute.rst b/docs/sections/contribute.rst
index e67769c..9a19042 100644
--- a/docs/sections/contribute.rst
+++ b/docs/sections/contribute.rst
@@ -32,7 +32,7 @@ We also use `travis <https://travis-ci.org/juanifioren/django-oidc-provider/>`_
 Improve Documentation
 =====================
 
-We use `Sphinx <http://www.sphinx-doc.org/>`_ for generate this documentation. I you want to add or modify something just:
+We use `Sphinx <http://www.sphinx-doc.org/>`_ for generate this documentation. If you want to add or modify something just:
 
 * Install Sphinx (``pip install sphinx``) and the auto-build tool (``pip install sphinx-autobuild``).
 * Move inside the docs folder. ``cd docs/``
diff --git a/docs/sections/settings.rst b/docs/sections/settings.rst
index eae2e8b..984d2df 100644
--- a/docs/sections/settings.rst
+++ b/docs/sections/settings.rst
@@ -234,3 +234,14 @@ Default is::
 See the :ref:`templates` section.
 
 The templates that are not specified here will use the default ones.
+
+OIDC_INTROSPECTION_RESPONSE_SCOPE_ENABLE
+==========================================
+
+OPTIONAL ``bool``
+
+A flag which toggles whether the scope is returned with successful response on introspection request.
+
+Must be ``True`` to include ``scope`` into the successful response
+
+Default is ``False``.
\ No newline at end of file
diff --git a/oidc_provider/lib/endpoints/introspection.py b/oidc_provider/lib/endpoints/introspection.py
index 8f41de9..c1e8a8e 100644
--- a/oidc_provider/lib/endpoints/introspection.py
+++ b/oidc_provider/lib/endpoints/introspection.py
@@ -85,7 +85,8 @@ class TokenIntrospectionEndpoint(object):
                 response_dic[k] = self.id_token[k]
         response_dic['active'] = True
         response_dic['client_id'] = self.token.client.client_id
-
+        if settings.get('OIDC_INTROSPECTION_RESPONSE_SCOPE_ENABLE'):
+            response_dic['scope'] = ' '.join(self.token.scope)
         response_dic = run_processing_hook(response_dic,
                                            'OIDC_INTROSPECTION_PROCESSING_HOOK',
                                            client=self.client,
diff --git a/oidc_provider/lib/endpoints/token.py b/oidc_provider/lib/endpoints/token.py
index 8c32046..7ecde44 100644
--- a/oidc_provider/lib/endpoints/token.py
+++ b/oidc_provider/lib/endpoints/token.py
@@ -1,11 +1,12 @@
-import inspect
-from base64 import urlsafe_b64encode
 import hashlib
+import inspect
 import logging
-from django.contrib.auth import authenticate
+from base64 import urlsafe_b64encode
 
+from django.contrib.auth import authenticate
 from django.http import JsonResponse
 
+from oidc_provider import settings
 from oidc_provider.lib.errors import (
     TokenError,
     UserAuthError,
@@ -21,7 +22,6 @@ from oidc_provider.models import (
     Code,
     Token,
 )
-from oidc_provider import settings
 
 logger = logging.getLogger(__name__)
 
@@ -76,7 +76,7 @@ class TokenEndpoint(object):
                 raise TokenError('invalid_grant')
 
             if not (self.code.client == self.client) \
-               or self.code.has_expired():
+                    or self.code.has_expired():
                 logger.debug('[Token] Invalid code: invalid client or code has expired')
                 raise TokenError('invalid_grant')
 
@@ -84,8 +84,8 @@ class TokenEndpoint(object):
             if self.params['code_verifier']:
                 if self.code.code_challenge_method == 'S256':
                     new_code_challenge = urlsafe_b64encode(
-                            hashlib.sha256(self.params['code_verifier'].encode('ascii')).digest()
-                        ).decode('utf-8').replace('=', '')
+                        hashlib.sha256(self.params['code_verifier'].encode('ascii')).digest()
+                    ).decode('utf-8').replace('=', '')
                 else:
                     new_code_challenge = self.params['code_verifier']
 
@@ -135,6 +135,27 @@ class TokenEndpoint(object):
             logger.debug('[Token] Invalid grant type: %s', self.params['grant_type'])
             raise TokenError('unsupported_grant_type')
 
+    def validate_requested_scopes(self):
+        """
+        Handling validation of requested scope for grant_type=[password|client_credentials]
+        """
+        token_scopes = []
+        if self.params['scope']:
+            # See https://tools.ietf.org/html/rfc6749#section-3.3
+            # The value of the scope parameter is expressed
+            # as a list of space-delimited, case-sensitive strings
+            for scope_requested in self.params['scope'].split(' '):
+                if scope_requested in self.client.scope:
+                    token_scopes.append(scope_requested)
+                else:
+                    logger.debug('[Token] The request scope %s is not supported by client %s',
+                                 scope_requested, self.client.client_id)
+                    raise TokenError('invalid_scope')
+        # if no scopes requested assign client's scopes
+        else:
+            token_scopes.extend(self.client.scope)
+        return token_scopes
+
     def create_response_dic(self):
         if self.params['grant_type'] == 'authorization_code':
             return self.create_code_response_dic()
@@ -230,11 +251,11 @@ class TokenEndpoint(object):
 
     def create_access_token_response_dic(self):
         # See https://tools.ietf.org/html/rfc6749#section-4.3
-
+        token_scopes = self.validate_requested_scopes()
         token = create_token(
             self.user,
             self.client,
-            self.params['scope'].split(' '))
+            token_scopes)
 
         id_token_dic = create_id_token(
             token=token,
@@ -255,15 +276,17 @@ class TokenEndpoint(object):
             'expires_in': settings.get('OIDC_TOKEN_EXPIRE'),
             'token_type': 'bearer',
             'id_token': encode_id_token(id_token_dic, token.client),
+            'scope': ' '.join(token.scope)
         }
 
     def create_client_credentials_response_dic(self):
         # See https://tools.ietf.org/html/rfc6749#section-4.4.3
+        token_scopes = self.validate_requested_scopes()
 
         token = create_token(
             user=None,
             client=self.client,
-            scope=self.client.scope)
+            scope=token_scopes)
 
         token.save()
 
@@ -271,7 +294,7 @@ class TokenEndpoint(object):
             'access_token': token.access_token,
             'expires_in': settings.get('OIDC_TOKEN_EXPIRE'),
             'token_type': 'bearer',
-            'scope': self.client._scope,
+            'scope': ' '.join(token.scope),
         }
 
     @classmethod
diff --git a/oidc_provider/settings.py b/oidc_provider/settings.py
index 6d0607e..90750fd 100644
--- a/oidc_provider/settings.py
+++ b/oidc_provider/settings.py
@@ -168,6 +168,13 @@ class DefaultSettings(object):
             'error': 'oidc_provider/error.html'
         }
 
+    @property
+    def OIDC_INTROSPECTION_RESPONSE_SCOPE_ENABLE(self):
+        """
+        OPTIONAL: A boolean to specify whether or not to include scope in introspection response.
+        """
+        return False
+
 
 default_settings = DefaultSettings()
 


Test patch:
diff --git a/oidc_provider/tests/app/utils.py b/oidc_provider/tests/app/utils.py
index 6a65b64..51f51d4 100644
--- a/oidc_provider/tests/app/utils.py
+++ b/oidc_provider/tests/app/utils.py
@@ -61,7 +61,7 @@ def create_fake_client(response_type, is_public=False, require_consent=True):
         client.client_secret = str(random.randint(1, 999999)).zfill(6)
     client.redirect_uris = ['http://example.com/']
     client.require_consent = require_consent
-
+    client.scope = ['openid', 'email']
     client.save()
 
     # check if response_type is a string in a python 2 and 3 compatible way
diff --git a/oidc_provider/tests/cases/test_introspection_endpoint.py b/oidc_provider/tests/cases/test_introspection_endpoint.py
index 38b3c3c..13fb2ab 100644
--- a/oidc_provider/tests/cases/test_introspection_endpoint.py
+++ b/oidc_provider/tests/cases/test_introspection_endpoint.py
@@ -130,3 +130,8 @@ class IntrospectionTestCase(TestCase):
             'active': True,
             'client_id': self.client.client_id,
         })
+
+    @override_settings(OIDC_INTROSPECTION_RESPONSE_SCOPE_ENABLE=True)
+    def test_enable_scope(self):
+        response = self._make_request()
+        self._assert_active(response, scope='openid email')
diff --git a/oidc_provider/tests/cases/test_token_endpoint.py b/oidc_provider/tests/cases/test_token_endpoint.py
index dab90e7..91f950d 100644
--- a/oidc_provider/tests/cases/test_token_endpoint.py
+++ b/oidc_provider/tests/cases/test_token_endpoint.py
@@ -11,6 +11,7 @@ except ImportError:
 
 from django.core.management import call_command
 from django.http import JsonResponse
+
 try:
     from django.urls import reverse
 except ImportError:
@@ -51,6 +52,8 @@ class TokenTestCase(TestCase):
     Token Request to the Token Endpoint to obtain a Token Response
     when using the Authorization Code Flow.
     """
+    SCOPE = 'openid email'
+    SCOPE_LIST = SCOPE.split(' ')
 
     def setUp(self):
         call_command('creatersakey')
@@ -64,7 +67,7 @@ class TokenTestCase(TestCase):
             'username': 'johndoe',
             'password': '1234',
             'grant_type': 'password',
-            'scope': 'openid email',
+            'scope': TokenTestCase.SCOPE,
         }
         if scope is not None:
             result['scope'] = ' '.join(scope)
@@ -102,6 +105,16 @@ class TokenTestCase(TestCase):
 
         return post_data
 
+    def _client_credentials_post_data(self, scope=None):
+        post_data = {
+            'client_id': self.client.client_id,
+            'client_secret': self.client.client_secret,
+            'grant_type': 'client_credentials',
+        }
+        if scope is not None:
+            post_data['scope'] = ' '.join(scope)
+        return post_data
+
     def _post_request(self, post_data, extras={}):
         """
         Makes a request to the token endpoint by sending the
@@ -127,7 +140,7 @@ class TokenTestCase(TestCase):
         code = create_code(
             user=self.user,
             client=self.client,
-            scope=(scope if scope else ['openid', 'email']),
+            scope=(scope if scope else TokenTestCase.SCOPE_LIST),
             nonce=FAKE_NONCE,
             is_authentication=True)
         code.save()
@@ -227,7 +240,11 @@ class TokenTestCase(TestCase):
         self.check_password_grant(scope=['openid', 'email'])
 
     def test_password_grant_scope(self):
-        self.check_password_grant(scope=['openid', 'profile'])
+        scopes_list = ['openid', 'profile']
+
+        self.client.scope = scopes_list
+        self.client.save()
+        self.check_password_grant(scope=scopes_list)
 
     @override_settings(OIDC_TOKEN_EXPIRE=120,
                        OIDC_GRANT_TYPE_PASSWORD_ENABLE=True)
@@ -361,7 +378,7 @@ class TokenTestCase(TestCase):
 
         # Retrieve refresh token
         code = self._create_code()
-        self.assertEqual(code.scope, ['openid', 'email'])
+        self.assertEqual(code.scope, TokenTestCase.SCOPE_LIST)
         post_data = self._auth_code_post_data(code=code.code)
         start_time = time.time()
         with patch('oidc_provider.lib.utils.token.time.time') as time_func:
@@ -661,7 +678,7 @@ class TokenTestCase(TestCase):
 
     @override_settings(
         OIDC_IDTOKEN_PROCESSING_HOOK=[
-                'oidc_provider.tests.app.utils.fake_idtoken_processing_hook',
+            'oidc_provider.tests.app.utils.fake_idtoken_processing_hook',
         ]
     )
     def test_additional_idtoken_processing_hook_one_element_in_list(self):
@@ -682,8 +699,8 @@ class TokenTestCase(TestCase):
 
     @override_settings(
         OIDC_IDTOKEN_PROCESSING_HOOK=[
-                'oidc_provider.tests.app.utils.fake_idtoken_processing_hook',
-                'oidc_provider.tests.app.utils.fake_idtoken_processing_hook2',
+            'oidc_provider.tests.app.utils.fake_idtoken_processing_hook',
+            'oidc_provider.tests.app.utils.fake_idtoken_processing_hook2',
         ]
     )
     def test_additional_idtoken_processing_hook_two_elements_in_list(self):
@@ -754,7 +771,7 @@ class TokenTestCase(TestCase):
         kwargs_passed = id_token.get('kwargs_passed_to_processing_hook')
         assert kwargs_passed
         self.assertTrue(kwargs_passed.get('token').startswith(
-                        '<Token: Some Client -'))
+            '<Token: Some Client -'))
         self.assertEqual(kwargs_passed.get('request'),
                          "<WSGIRequest: POST '/openid/token'>")
         self.assertEqual(set(kwargs_passed.keys()), {'token', 'request'})
@@ -797,11 +814,7 @@ class TokenTestCase(TestCase):
         self.client.scope = fake_scopes_list
         self.client.save()
 
-        post_data = {
-            'client_id': self.client.client_id,
-            'client_secret': self.client.client_secret,
-            'grant_type': 'client_credentials',
-        }
+        post_data = self._client_credentials_post_data()
         response = self._post_request(post_data)
         response_dict = json.loads(response.content.decode('utf-8'))
 
@@ -857,12 +870,85 @@ class TokenTestCase(TestCase):
         self.client.scope = ['something']
         self.client.save()
 
-        post_data = {
-            'client_id': self.client.client_id,
-            'client_secret': self.client.client_secret,
-            'grant_type': 'client_credentials',
-        }
-        response = self._post_request(post_data)
+        response = self._post_request(self._client_credentials_post_data())
         response_dict = json.loads(response.content.decode('utf-8'))
         token = Token.objects.get(access_token=response_dict['access_token'])
         self.assertTrue(str(token))
+
+    @override_settings(OIDC_GRANT_TYPE_PASSWORD_ENABLE=True)
+    def test_requested_scope(self):
+        # GRANT_TYPE=PASSWORD
+        response = self._post_request(
+            post_data=self._password_grant_post_data(['openid', 'invalid_scope']),
+            extras=self._password_grant_auth_header()
+        )
+
+        response_dict = json.loads(response.content.decode('utf-8'))
+
+        # It should fail when client requested an invalid scope.
+        self.assertEqual(400, response.status_code)
+        self.assertEqual('invalid_scope', response_dict['error'])
+
+        # happy path: no scope
+        response = self._post_request(
+            post_data=self._password_grant_post_data([]),
+            extras=self._password_grant_auth_header()
+        )
+
+        response_dict = json.loads(response.content.decode('utf-8'))
+        self.assertEqual(200, response.status_code)
+        self.assertEqual(TokenTestCase.SCOPE, response_dict['scope'])
+
+        # happy path: single scope
+        response = self._post_request(
+            post_data=self._password_grant_post_data(['email']),
+            extras=self._password_grant_auth_header()
+        )
+
+        response_dict = json.loads(response.content.decode('utf-8'))
+        self.assertEqual(200, response.status_code)
+        self.assertEqual('email', response_dict['scope'])
+
+        # happy path: multiple scopes
+        response = self._post_request(
+            post_data=self._password_grant_post_data(['email', 'openid']),
+            extras=self._password_grant_auth_header()
+        )
+
+        # GRANT_TYPE=CLIENT_CREDENTIALS
+        response_dict = json.loads(response.content.decode('utf-8'))
+        self.assertEqual(200, response.status_code)
+        self.assertEqual('email openid', response_dict['scope'])
+
+        response = self._post_request(
+            post_data=self._client_credentials_post_data(['openid', 'invalid_scope'])
+        )
+
+        response_dict = json.loads(response.content.decode('utf-8'))
+
+        # It should fail when client requested an invalid scope.
+        self.assertEqual(400, response.status_code)
+        self.assertEqual('invalid_scope', response_dict['error'])
+
+        # happy path: no scope
+        response = self._post_request(post_data=self._client_credentials_post_data())
+
+        response_dict = json.loads(response.content.decode('utf-8'))
+        self.assertEqual(200, response.status_code)
+        self.assertEqual(TokenTestCase.SCOPE, response_dict['scope'])
+
+        # happy path: single scope
+        response = self._post_request(post_data=self._client_credentials_post_data(['email']))
+
+        response_dict = json.loads(response.content.decode('utf-8'))
+        self.assertEqual(200, response.status_code)
+        self.assertEqual('email', response_dict['scope'])
+
+        # happy path: multiple scopes
+        response = self._post_request(
+            post_data=self._client_credentials_post_data(['email', 'openid'])
+        )
+
+        response_dict = json.loads(response.content.decode('utf-8'))
+        self.assertEqual(200, response.status_code)
+        self.assertEqual('email openid', response_dict['scope'])