Skip to content

Commit 492a867

Browse files
authored
OIDC: Standard scopes to determine which claims are returned (#1108)
* Add configurable attribute to restrict returned claims based on scopes.
1 parent f46439e commit 492a867

File tree

6 files changed

+203
-34
lines changed

6 files changed

+203
-34
lines changed

CHANGELOG.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1919
## [2.0.0] unreleased
2020

2121
### Added
22-
* #1106 Add "scopes_supported" to the [ConnectDiscoveryInfoView](https://django-oauth-toolkit.readthedocs.io/en/latest/oidc.html#connectdiscoveryinfoview).
22+
* #1106 OIDC: Add "scopes_supported" to the [ConnectDiscoveryInfoView](https://django-oauth-toolkit.readthedocs.io/en/latest/oidc.html#connectdiscoveryinfoview).
2323
This completes the view to provide all the REQUIRED and RECOMMENDED [OpenID Provider Metadata](https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata).
2424

2525
### Changed
@@ -28,7 +28,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2828
cleartext `application.client_secret` values to be hashed with Django's default password hashing algorithm
2929
and can not be reversed. When adding or modifying an Application in the Admin console, you must copy the
3030
auto-generated or manually-entered `client_secret` before hitting Save.
31+
* #1108 OIDC: (**Breaking**) Add default configurable OIDC standard scopes that determine which claims are returned.
32+
If you've [customized OIDC responses](https://django-oauth-toolkit.readthedocs.io/en/latest/oidc.html#customizing-the-oidc-responses)
33+
and want to retain the pre-2.x behavior, set `oidc_claim_scope = None` in your subclass of `OAuth2Validator`.
34+
* #1108 OIDC: Make the `access_token` available to `get_oidc_claims` when called from `get_userinfo_claims`.
3135

36+
### Fixed
37+
* #1108 OIDC: Fix `validate_bearer_token()` to properly set `request.scopes` to the list of granted scopes.
3238

3339
## [1.7.0] 2022-01-23
3440

docs/oidc.rst

Lines changed: 64 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ so there is no need to add a setting for the public key.
102102

103103

104104
Rotating the RSA private key
105-
~~~~~~~~~~~~~~~~~~~~~~~~
105+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
106106
Extra keys can be published in the jwks_uri with the ``OIDC_RSA_PRIVATE_KEYS_INACTIVE``
107107
setting. For example:::
108108

@@ -143,7 +143,7 @@ scopes in your ``settings.py``::
143143
# ... any other settings you want
144144
}
145145

146-
.. info::
146+
.. note::
147147
If you want to enable ``RS256`` at a later date, you can do so - just add
148148
the private key as described above.
149149

@@ -250,54 +250,88 @@ our custom validator. It takes one of two forms:
250250
The first form gets passed a request object, and should return a dictionary
251251
mapping a claim name to claim data::
252252
class CustomOAuth2Validator(OAuth2Validator):
253+
# Set `oidc_claim_scope = None` to ignore scopes that limit which claims to return,
254+
# otherwise the OIDC standard scopes are used.
255+
253256
def get_additional_claims(self, request):
254-
claims = {}
255-
claims["email"] = request.user.get_user_email()
256-
claims["username"] = request.user.get_full_name()
257+
return {
258+
"given_name": request.user.first_name,
259+
"family_name": request.user.last_name,
260+
"name": ' '.join([request.user.first_name, request.user.last_name]),
261+
"preferred_username": request.user.username,
262+
"email": request.user.email,
263+
}
257264

258-
return claims
259265

260266
The second form gets no request object, and should return a dictionary
261267
mapping a claim name to a callable, accepting a request and producing
262268
the claim data::
263269
class CustomOAuth2Validator(OAuth2Validator):
264-
def get_additional_claims(self):
265-
def get_user_email(request):
266-
return request.user.get_user_email()
270+
# Extend the standard scopes to add a new "permissions" scope
271+
# which returns a "permissions" claim:
272+
oidc_claim_scope = OAuth2Validator.oidc_claim_scope
273+
oidc_claim_scope.update({"permissions": "permissions"})
274+
275+
def get_additional_claims(self):
276+
return {
277+
"given_name": lambda request: request.user.first_name,
278+
"family_name": lambda request: request.user.last_name,
279+
"name": lambda request: ' '.join([request.user.first_name, request.user.last_name]),
280+
"preferred_username": lambda request: request.user.username,
281+
"email": lambda request: request.user.email,
282+
"permissions": lambda request: list(request.user.get_group_permissions()),
283+
}
267284

268-
claims = {}
269-
claims["email"] = get_user_email
270-
claims["username"] = lambda r: r.user.get_full_name()
271-
272-
return claims
273285

274286
Standard claim ``sub`` is included by default, to remove it override ``get_claim_dict``.
275287

276-
In some cases, it might be desirable to not list all claims in discovery info. To customize
277-
which claims are advertised, you can override the ``get_discovery_claims`` method to return
278-
a list of claim names to advertise. If your ``get_additional_claims`` uses the first form
279-
and you still want to advertise claims, you can also override ``get_discovery_claims``.
288+
Supported claims discovery
289+
--------------------------
280290

281-
In order to help lcients discover claims early, they can be advertised in the discovery
291+
In order to help clients discover claims early, they can be advertised in the discovery
282292
info, under the ``claims_supported`` key. In order for the discovery info view to automatically
283293
add all claims your validator returns, you need to use the second form (producing callables),
284294
because the discovery info views are requested with an unauthenticated request, so directly
285295
producing claim data would fail. If you use the first form, producing claim data directly,
286296
your claims will not be added to discovery info.
287297

298+
In some cases, it might be desirable to not list all claims in discovery info. To customize
299+
which claims are advertised, you can override the ``get_discovery_claims`` method to return
300+
a list of claim names to advertise. If your ``get_additional_claims`` uses the first form
301+
and you still want to advertise claims, you can also override ``get_discovery_claims``.
302+
303+
Using OIDC scopes to determine which claims are returned
304+
--------------------------------------------------------
305+
306+
The ``oidc_claim_scope`` OAuth2Validator class attribute implements OIDC's
307+
`5.4 Requesting Claims using Scope Values`_ feature.
308+
For example, a ``given_name`` claim is only returned if the ``profile`` scope was granted.
309+
310+
To change the list of claims and which scopes result in their being returned,
311+
override ``oidc_claim_scope`` with a dict keyed by claim with a value of scope.
312+
The following example adds instructions to return the ``foo`` claim when the ``bar`` scope is granted::
313+
class CustomOAuth2Validator(OAuth2Validator):
314+
oidc_claim_scope = OAuth2Validator.oidc_claim_scope
315+
oidc_claim_scope.update({"foo": "bar"})
316+
317+
Set ``oidc_claim_scope = None`` to return all claims irrespective of the granted scopes.
318+
319+
You have to make sure you've added addtional claims via ``get_additional_claims``
320+
and defined the ``OAUTH2_PROVIDER["SCOPES"]`` in your settings in order for this functionality to work.
321+
288322
.. note::
289323
This ``request`` object is not a ``django.http.Request`` object, but an
290324
``oauthlib.common.Request`` object. This has a number of attributes that
291325
you can use to decide what claims to put in to the ID token:
292326

293-
* ``request.scopes`` - a list of the scopes requested by the client when
294-
making an authorization request.
295-
* ``request.claims`` - a dictionary of the requested claims, using the
296-
`OIDC claims requesting system`_. These must be requested by the client
297-
when making an authorization request.
298-
* ``request.user`` - the django user object.
327+
* ``request.scopes`` - the list of granted scopes.
328+
* ``request.claims`` - the requested claims per OIDC's `5.5 Requesting Claims using the "claims" Request Parameter`_.
329+
These must be requested by the client when making an authorization request.
330+
* ``request.user`` - the `Django User`_ object.
299331

300-
.. _OIDC claims requesting system: https://openid.net/specs/openid-connect-core-1_0.html#ClaimsParameter
332+
.. _5.4 Requesting Claims using Scope Values: https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims
333+
.. _5.5 Requesting Claims using the "claims" Request Parameter: https://openid.net/specs/openid-connect-core-1_0.html#ClaimsParameter
334+
.. _Django User: https://docs.djangoproject.com/en/stable/ref/contrib/auth/#user-model
301335

302336
What claims you decide to put in to the token is up to you to determine based
303337
upon what the scopes and / or claims means to your provider.
@@ -307,11 +341,11 @@ Adding information to the ``UserInfo`` service
307341
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
308342

309343
The ``UserInfo`` service is supplied as part of the OIDC service, and is used
310-
to retrieve more information about the user than was supplied in the ID token
311-
when the user logged in to the OIDC client application. It is optional to use
312-
the service. The service is accessed by making a request to the
344+
to retrieve information about the user given their Access Token.
345+
It is optional to use the service. The service is accessed by making a request to the
313346
``UserInfo`` endpoint, eg ``/o/userinfo/`` and supplying the access token
314-
retrieved at login as a ``Bearer`` token.
347+
retrieved at login as a ``Bearer`` token or as a form-encoded ``access_token`` body parameter
348+
for a POST request.
315349

316350
Again, to modify the content delivered, we need to add a function to our
317351
custom validator. The default implementation adds the claims from the ID

oauth2_provider/oauth2_validators.py

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,34 @@
6565

6666

6767
class OAuth2Validator(RequestValidator):
68+
# Return the given claim only if the given scope is present.
69+
# Extended as needed for non-standard OIDC claims/scopes.
70+
# Override by setting to None to ignore scopes.
71+
# see https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims
72+
# For example, for the "nickname" claim, you need the "profile" scope.
73+
oidc_claim_scope = {
74+
"sub": "openid",
75+
"name": "profile",
76+
"family_name": "profile",
77+
"given_name": "profile",
78+
"middle_name": "profile",
79+
"nickname": "profile",
80+
"preferred_username": "profile",
81+
"profile": "profile",
82+
"picture": "profile",
83+
"website": "profile",
84+
"gender": "profile",
85+
"birthdate": "profile",
86+
"zoneinfo": "profile",
87+
"locale": "profile",
88+
"updated_at": "profile",
89+
"email": "email",
90+
"email_verified": "email",
91+
"address": "address",
92+
"phone_number": "phone",
93+
"phone_number_verified": "phone",
94+
}
95+
6896
def _extract_basic_auth(self, request):
6997
"""
7098
Return authentication string if request contains basic auth credentials,
@@ -397,7 +425,7 @@ def validate_bearer_token(self, token, scopes, request):
397425
if access_token and access_token.is_valid(scopes):
398426
request.client = access_token.application
399427
request.user = access_token.user
400-
request.scopes = scopes
428+
request.scopes = list(access_token.scopes)
401429

402430
# this is needed by django rest framework
403431
request.access_token = access_token
@@ -759,8 +787,11 @@ def get_oidc_claims(self, token, token_handler, request):
759787
data = self.get_claim_dict(request)
760788
claims = {}
761789

790+
# TODO if request.claims then return only the claims requested, but limited by granted scopes.
791+
762792
for k, v in data.items():
763-
claims[k] = v(request) if callable(v) else v
793+
if not self.oidc_claim_scope or self.oidc_claim_scope.get(k) in request.scopes:
794+
claims[k] = v(request) if callable(v) else v
764795
return claims
765796

766797
def get_id_token_dictionary(self, token, token_handler, request):
@@ -911,7 +942,7 @@ def get_userinfo_claims(self, request):
911942
current user's claims.
912943
913944
"""
914-
return self.get_oidc_claims(None, None, request)
945+
return self.get_oidc_claims(request.access_token, None, request)
915946

916947
def get_additional_claims(self, request):
917948
return {}

tests/conftest.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,3 +158,43 @@ def oidc_tokens(oauth2_settings, application, test_user, client):
158158
id_token=token_data["id_token"],
159159
oauth2_settings=oauth2_settings,
160160
)
161+
162+
163+
@pytest.fixture
164+
def oidc_email_scope_tokens(oauth2_settings, application, test_user, client):
165+
oauth2_settings.update(presets.OIDC_SETTINGS_EMAIL_SCOPE)
166+
client.force_login(test_user)
167+
auth_rsp = client.post(
168+
reverse("oauth2_provider:authorize"),
169+
data={
170+
"client_id": application.client_id,
171+
"state": "random_state_string",
172+
"scope": "openid email",
173+
"redirect_uri": "http://example.org",
174+
"response_type": "code",
175+
"allow": True,
176+
},
177+
)
178+
assert auth_rsp.status_code == 302
179+
code = parse_qs(urlparse(auth_rsp["Location"]).query)["code"]
180+
client.logout()
181+
token_rsp = client.post(
182+
reverse("oauth2_provider:token"),
183+
data={
184+
"grant_type": "authorization_code",
185+
"code": code,
186+
"redirect_uri": "http://example.org",
187+
"client_id": application.client_id,
188+
"client_secret": CLEARTEXT_SECRET,
189+
"scope": "openid email",
190+
},
191+
)
192+
assert token_rsp.status_code == 200
193+
token_data = token_rsp.json()
194+
return SimpleNamespace(
195+
user=test_user,
196+
application=application,
197+
access_token=token_data["access_token"],
198+
id_token=token_data["id_token"],
199+
oauth2_settings=oauth2_settings,
200+
)

tests/presets.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
}
2323
OIDC_SETTINGS_RO = deepcopy(OIDC_SETTINGS_RW)
2424
OIDC_SETTINGS_RO["DEFAULT_SCOPES"] = ["read"]
25+
OIDC_SETTINGS_EMAIL_SCOPE = deepcopy(OIDC_SETTINGS_RW)
26+
OIDC_SETTINGS_EMAIL_SCOPE["SCOPES"].update({"email": "return email address"})
2527
OIDC_SETTINGS_HS256_ONLY = deepcopy(OIDC_SETTINGS_RW)
2628
del OIDC_SETTINGS_HS256_ONLY["OIDC_RSA_PRIVATE_KEY"]
2729
REST_FRAMEWORK_SCOPES = {

tests/test_oidc_views.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,8 @@ def claim_user_email(request):
160160
@pytest.mark.django_db
161161
def test_userinfo_endpoint_custom_claims_callable(oidc_tokens, client, oauth2_settings):
162162
class CustomValidator(OAuth2Validator):
163+
oidc_claim_scope = None
164+
163165
def get_additional_claims(self):
164166
return {
165167
"username": claim_user_email,
@@ -183,9 +185,38 @@ def get_additional_claims(self):
183185
assert data["email"] == EXAMPLE_EMAIL
184186

185187

188+
@pytest.mark.django_db
189+
def test_userinfo_endpoint_custom_claims_email_scope_callable(
190+
oidc_email_scope_tokens, client, oauth2_settings
191+
):
192+
class CustomValidator(OAuth2Validator):
193+
def get_additional_claims(self):
194+
return {
195+
"username": claim_user_email,
196+
"email": claim_user_email,
197+
}
198+
199+
oidc_email_scope_tokens.oauth2_settings.OAUTH2_VALIDATOR_CLASS = CustomValidator
200+
auth_header = "Bearer %s" % oidc_email_scope_tokens.access_token
201+
rsp = client.get(
202+
reverse("oauth2_provider:user-info"),
203+
HTTP_AUTHORIZATION=auth_header,
204+
)
205+
data = rsp.json()
206+
assert "sub" in data
207+
assert data["sub"] == str(oidc_email_scope_tokens.user.pk)
208+
209+
assert "username" not in data
210+
211+
assert "email" in data
212+
assert data["email"] == EXAMPLE_EMAIL
213+
214+
186215
@pytest.mark.django_db
187216
def test_userinfo_endpoint_custom_claims_plain(oidc_tokens, client, oauth2_settings):
188217
class CustomValidator(OAuth2Validator):
218+
oidc_claim_scope = None
219+
189220
def get_additional_claims(self, request):
190221
return {
191222
"username": EXAMPLE_EMAIL,
@@ -207,3 +238,28 @@ def get_additional_claims(self, request):
207238

208239
assert "email" in data
209240
assert data["email"] == EXAMPLE_EMAIL
241+
242+
243+
@pytest.mark.django_db
244+
def test_userinfo_endpoint_custom_claims_email_scopeplain(oidc_email_scope_tokens, client, oauth2_settings):
245+
class CustomValidator(OAuth2Validator):
246+
def get_additional_claims(self, request):
247+
return {
248+
"username": EXAMPLE_EMAIL,
249+
"email": EXAMPLE_EMAIL,
250+
}
251+
252+
oidc_email_scope_tokens.oauth2_settings.OAUTH2_VALIDATOR_CLASS = CustomValidator
253+
auth_header = "Bearer %s" % oidc_email_scope_tokens.access_token
254+
rsp = client.get(
255+
reverse("oauth2_provider:user-info"),
256+
HTTP_AUTHORIZATION=auth_header,
257+
)
258+
data = rsp.json()
259+
assert "sub" in data
260+
assert data["sub"] == str(oidc_email_scope_tokens.user.pk)
261+
262+
assert "username" not in data
263+
264+
assert "email" in data
265+
assert data["email"] == EXAMPLE_EMAIL

0 commit comments

Comments
 (0)