Repository: jazzband/django-oauth-toolkit

Issue:
OIDC: 5.4.  Requesting Claims using Scope Values
**Is your feature request related to a problem? Please describe.**
<!-- A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] -->
Per the [OIDC spec 5.4](https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims) one should be able to use OIDC standard scopes to limit which additional claims are returned in the ID Token and Userinfo response. The current implementation is unable to filter which additional claims to return in a reasonable way.

**Describe the solution you'd like**
<!-- A clear and concise description of what you want to happen. -->
The ability to extend `get_oidc_claims()` to use the granted access token scopes. This does not work because upstream oauthlib [does not pass the correct request.scopes](https://github.com/oauthlib/oauthlib/issues/799) instead hardcoding the scope list as `["openid"]`

**Describe alternatives you've considered**
<!-- A clear and concise description of any alternative solutions or features you've considered. -->
A small fix to `get_userinfo_claims()` will make the `access_token.scopes` available. PR coming soon;-)

I've prototyped this [here](https://github.com/n2ygk/dot-tutorial/blob/main/my_oidc/oauth_validator.py).

**Additional context**
<!-- Add any other context or screenshots about the feature request here. -->
I believe the "right" way to get access to the scopes might require a fix to oauthlib, but the workaround above seems to be a pretty good start.


Discussion:
Updated description above.

Resolving patch:
diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py
--- a/oauth2_provider/oauth2_validators.py
+++ b/oauth2_provider/oauth2_validators.py
@@ -65,6 +65,34 @@
 
 
 class OAuth2Validator(RequestValidator):
+    # Return the given claim only if the given scope is present.
+    # Extended as needed for non-standard OIDC claims/scopes.
+    # Override by setting to None to ignore scopes.
+    # see https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims
+    # For example, for the "nickname" claim, you need the "profile" scope.
+    oidc_claim_scope = {
+        "sub": "openid",
+        "name": "profile",
+        "family_name": "profile",
+        "given_name": "profile",
+        "middle_name": "profile",
+        "nickname": "profile",
+        "preferred_username": "profile",
+        "profile": "profile",
+        "picture": "profile",
+        "website": "profile",
+        "gender": "profile",
+        "birthdate": "profile",
+        "zoneinfo": "profile",
+        "locale": "profile",
+        "updated_at": "profile",
+        "email": "email",
+        "email_verified": "email",
+        "address": "address",
+        "phone_number": "phone",
+        "phone_number_verified": "phone",
+    }
+
     def _extract_basic_auth(self, request):
         """
         Return authentication string if request contains basic auth credentials,
@@ -397,7 +425,7 @@ def validate_bearer_token(self, token, scopes, request):
         if access_token and access_token.is_valid(scopes):
             request.client = access_token.application
             request.user = access_token.user
-            request.scopes = scopes
+            request.scopes = list(access_token.scopes)
 
             # this is needed by django rest framework
             request.access_token = access_token
@@ -759,8 +787,11 @@ def get_oidc_claims(self, token, token_handler, request):
         data = self.get_claim_dict(request)
         claims = {}
 
+        # TODO if request.claims then return only the claims requested, but limited by granted scopes.
+
         for k, v in data.items():
-            claims[k] = v(request) if callable(v) else v
+            if not self.oidc_claim_scope or self.oidc_claim_scope.get(k) in request.scopes:
+                claims[k] = v(request) if callable(v) else v
         return claims
 
     def get_id_token_dictionary(self, token, token_handler, request):
@@ -911,7 +942,7 @@ def get_userinfo_claims(self, request):
         current user's claims.
 
         """
-        return self.get_oidc_claims(None, None, request)
+        return self.get_oidc_claims(request.access_token, None, request)
 
     def get_additional_claims(self, request):
         return {}


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -158,3 +158,43 @@ def oidc_tokens(oauth2_settings, application, test_user, client):
         id_token=token_data["id_token"],
         oauth2_settings=oauth2_settings,
     )
+
+
+@pytest.fixture
+def oidc_email_scope_tokens(oauth2_settings, application, test_user, client):
+    oauth2_settings.update(presets.OIDC_SETTINGS_EMAIL_SCOPE)
+    client.force_login(test_user)
+    auth_rsp = client.post(
+        reverse("oauth2_provider:authorize"),
+        data={
+            "client_id": application.client_id,
+            "state": "random_state_string",
+            "scope": "openid email",
+            "redirect_uri": "http://example.org",
+            "response_type": "code",
+            "allow": True,
+        },
+    )
+    assert auth_rsp.status_code == 302
+    code = parse_qs(urlparse(auth_rsp["Location"]).query)["code"]
+    client.logout()
+    token_rsp = client.post(
+        reverse("oauth2_provider:token"),
+        data={
+            "grant_type": "authorization_code",
+            "code": code,
+            "redirect_uri": "http://example.org",
+            "client_id": application.client_id,
+            "client_secret": CLEARTEXT_SECRET,
+            "scope": "openid email",
+        },
+    )
+    assert token_rsp.status_code == 200
+    token_data = token_rsp.json()
+    return SimpleNamespace(
+        user=test_user,
+        application=application,
+        access_token=token_data["access_token"],
+        id_token=token_data["id_token"],
+        oauth2_settings=oauth2_settings,
+    )
diff --git a/tests/presets.py b/tests/presets.py
--- a/tests/presets.py
+++ b/tests/presets.py
@@ -22,6 +22,8 @@
 }
 OIDC_SETTINGS_RO = deepcopy(OIDC_SETTINGS_RW)
 OIDC_SETTINGS_RO["DEFAULT_SCOPES"] = ["read"]
+OIDC_SETTINGS_EMAIL_SCOPE = deepcopy(OIDC_SETTINGS_RW)
+OIDC_SETTINGS_EMAIL_SCOPE["SCOPES"].update({"email": "return email address"})
 OIDC_SETTINGS_HS256_ONLY = deepcopy(OIDC_SETTINGS_RW)
 del OIDC_SETTINGS_HS256_ONLY["OIDC_RSA_PRIVATE_KEY"]
 REST_FRAMEWORK_SCOPES = {
diff --git a/tests/test_oidc_views.py b/tests/test_oidc_views.py
--- a/tests/test_oidc_views.py
+++ b/tests/test_oidc_views.py
@@ -160,6 +160,8 @@ def claim_user_email(request):
 @pytest.mark.django_db
 def test_userinfo_endpoint_custom_claims_callable(oidc_tokens, client, oauth2_settings):
     class CustomValidator(OAuth2Validator):
+        oidc_claim_scope = None
+
         def get_additional_claims(self):
             return {
                 "username": claim_user_email,
@@ -183,9 +185,38 @@ def get_additional_claims(self):
     assert data["email"] == EXAMPLE_EMAIL
 
 
+@pytest.mark.django_db
+def test_userinfo_endpoint_custom_claims_email_scope_callable(
+    oidc_email_scope_tokens, client, oauth2_settings
+):
+    class CustomValidator(OAuth2Validator):
+        def get_additional_claims(self):
+            return {
+                "username": claim_user_email,
+                "email": claim_user_email,
+            }
+
+    oidc_email_scope_tokens.oauth2_settings.OAUTH2_VALIDATOR_CLASS = CustomValidator
+    auth_header = "Bearer %s" % oidc_email_scope_tokens.access_token
+    rsp = client.get(
+        reverse("oauth2_provider:user-info"),
+        HTTP_AUTHORIZATION=auth_header,
+    )
+    data = rsp.json()
+    assert "sub" in data
+    assert data["sub"] == str(oidc_email_scope_tokens.user.pk)
+
+    assert "username" not in data
+
+    assert "email" in data
+    assert data["email"] == EXAMPLE_EMAIL
+
+
 @pytest.mark.django_db
 def test_userinfo_endpoint_custom_claims_plain(oidc_tokens, client, oauth2_settings):
     class CustomValidator(OAuth2Validator):
+        oidc_claim_scope = None
+
         def get_additional_claims(self, request):
             return {
                 "username": EXAMPLE_EMAIL,
@@ -207,3 +238,28 @@ def get_additional_claims(self, request):
 
     assert "email" in data
     assert data["email"] == EXAMPLE_EMAIL
+
+
+@pytest.mark.django_db
+def test_userinfo_endpoint_custom_claims_email_scopeplain(oidc_email_scope_tokens, client, oauth2_settings):
+    class CustomValidator(OAuth2Validator):
+        def get_additional_claims(self, request):
+            return {
+                "username": EXAMPLE_EMAIL,
+                "email": EXAMPLE_EMAIL,
+            }
+
+    oidc_email_scope_tokens.oauth2_settings.OAUTH2_VALIDATOR_CLASS = CustomValidator
+    auth_header = "Bearer %s" % oidc_email_scope_tokens.access_token
+    rsp = client.get(
+        reverse("oauth2_provider:user-info"),
+        HTTP_AUTHORIZATION=auth_header,
+    )
+    data = rsp.json()
+    assert "sub" in data
+    assert data["sub"] == str(oidc_email_scope_tokens.user.pk)
+
+    assert "username" not in data
+
+    assert "email" in data
+    assert data["email"] == EXAMPLE_EMAIL