Repository: jazzband/django-oauth-toolkit

Issue:
Include required scopes in error response
To make it easier for a client to recover from a lack of scopes
it would be nice to get the required scopes back in the error response

Something like:

```http
HTTP/1.0 403 Forbidden
Content-Type: application/json

{
  "required_scopes": ["foo:write"],
  "detail": "You do not have permission to perform this action."
}
```


Discussion:

Resolving patch:
diff --git a/oauth2_provider/contrib/rest_framework/permissions.py b/oauth2_provider/contrib/rest_framework/permissions.py
--- a/oauth2_provider/contrib/rest_framework/permissions.py
+++ b/oauth2_provider/contrib/rest_framework/permissions.py
@@ -1,6 +1,7 @@
 import logging
 
 from django.core.exceptions import ImproperlyConfigured
+from rest_framework.exceptions import PermissionDenied
 from rest_framework.permissions import BasePermission, IsAuthenticated, SAFE_METHODS
 
 from ...settings import oauth2_settings
@@ -25,7 +26,24 @@ def has_permission(self, request, view):
             required_scopes = self.get_scopes(request, view)
             log.debug("Required scopes to access resource: {0}".format(required_scopes))
 
-            return token.is_valid(required_scopes)
+            if token.is_valid(required_scopes):
+                return True
+
+            # Provide information about required scope?
+            include_required_scope = (
+                oauth2_settings.ERROR_RESPONSE_WITH_SCOPES and
+                required_scopes and
+                not token.is_expired() and
+                not token.allow_scopes(required_scopes)
+            )
+
+            if include_required_scope:
+                self.message = {
+                    "detail": PermissionDenied.default_detail,
+                    "required_scopes": list(required_scopes),
+                }
+
+            return False
 
         assert False, ("TokenHasScope requires the"
                        "`oauth2_provider.rest_framework.OAuth2Authentication` authentication "
diff --git a/oauth2_provider/settings.py b/oauth2_provider/settings.py
--- a/oauth2_provider/settings.py
+++ b/oauth2_provider/settings.py
@@ -46,6 +46,7 @@
     "ACCESS_TOKEN_EXPIRE_SECONDS": 36000,
     "REFRESH_TOKEN_EXPIRE_SECONDS": None,
     "ROTATE_REFRESH_TOKEN": True,
+    "ERROR_RESPONSE_WITH_SCOPES": False,
     "APPLICATION_MODEL": APPLICATION_MODEL,
     "ACCESS_TOKEN_MODEL": ACCESS_TOKEN_MODEL,
     "GRANT_MODEL": GRANT_MODEL,


Test patch:
diff --git a/tests/test_rest_framework.py b/tests/test_rest_framework.py
--- a/tests/test_rest_framework.py
+++ b/tests/test_rest_framework.py
@@ -12,6 +12,12 @@
 from oauth2_provider.settings import oauth2_settings
 
 
+try:
+    from unittest import mock
+except ImportError:
+    import mock
+
+
 Application = get_application_model()
 AccessToken = get_access_token_model()
 UserModel = get_user_model()
@@ -243,3 +249,24 @@ def test_resource_scoped_permission_post_denied(self):
         auth = self._create_authorization_header(self.access_token.token)
         response = self.client.post("/oauth2-resource-scoped-test/", HTTP_AUTHORIZATION=auth)
         self.assertEqual(response.status_code, 403)
+
+    @unittest.skipUnless(rest_framework_installed, "djangorestframework not installed")
+    @mock.patch.object(oauth2_settings, "ERROR_RESPONSE_WITH_SCOPES", new=True)
+    def test_required_scope_in_response(self):
+        self.access_token.scope = "scope2"
+        self.access_token.save()
+
+        auth = self._create_authorization_header(self.access_token.token)
+        response = self.client.get("/oauth2-scoped-test/", HTTP_AUTHORIZATION=auth)
+        self.assertEqual(response.status_code, 403)
+        self.assertEqual(response.data["required_scopes"], ["scope1"])
+
+    @unittest.skipUnless(rest_framework_installed, "djangorestframework not installed")
+    def test_required_scope_not_in_response_by_default(self):
+        self.access_token.scope = "scope2"
+        self.access_token.save()
+
+        auth = self._create_authorization_header(self.access_token.token)
+        response = self.client.get("/oauth2-scoped-test/", HTTP_AUTHORIZATION=auth)
+        self.assertEqual(response.status_code, 403)
+        self.assertNotIn("required_scopes", response.data)