Skip to content

Commit 8951f2f

Browse files
authored
Merge branch 'master' into pre-commit-ci-update-config
2 parents 0c3d5c7 + c48b751 commit 8951f2f

File tree

9 files changed

+240
-35
lines changed

9 files changed

+240
-35
lines changed

AUTHORS

+2
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ Ash Christopher
2121
Asif Saif Uddin
2222
Bart Merenda
2323
Bas van Oostveen
24+
Brian Helba
2425
Dave Burkholder
2526
David Fischer
2627
David Smith
@@ -69,3 +70,4 @@ Vinay Karanam
6970
Eduardo Oliveira
7071
Andrea Greco
7172
Dominik George
73+
David Hill

CHANGELOG.md

+7-1
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

+64-30
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

docs/tutorial/tutorial_03.rst

+32
Original file line numberDiff line numberDiff line change
@@ -78,3 +78,35 @@ Now supposing your access token value is `123456` you can try to access your aut
7878
::
7979

8080
curl -H "Authorization: Bearer 123456" -X GET http://localhost:8000/secret
81+
82+
Working with Rest_framework generic class based views
83+
-----------------------------------------------------
84+
85+
If you have completed the `Django REST framework tutorial
86+
<https://www.django-rest-framework.org/tutorial/3-class-based-views/#using-generic-class-based-views>`_,
87+
you will be familiar with the 'Snippet' example, in particular the SnippetList and SnippetDetail classes.
88+
89+
It would be nice to reuse those views **and** support token handling. Instead of reworking
90+
those classes to be ProtectedResourceView based, the solution is much simpler than that.
91+
92+
Assume you have already modified the settings as was already shown.
93+
The key is setting a class attribute to override the default *permissions_classes* with something that will use our :term:`Access Token` properly.
94+
95+
.. code-block:: python
96+
97+
from oauth2_provider.contrib.rest_framework import TokenHasReadWriteScope
98+
99+
class SnippetList(generics.ListCreateAPIView):
100+
...
101+
permission_classes = [TokenHasReadWriteScope]
102+
103+
class SnippetDetail(generics.ListCreateAPIView):
104+
...
105+
permission_classes = [TokenHasReadWriteScope]
106+
107+
Note that this example overrides the Django default permission class setting. There are several other
108+
ways this can be solved. Overriding the class function *get_permission_classes* is another way
109+
to solve the problem.
110+
111+
A detailed dive into the `Dango REST framework permissions is here. <https://www.django-rest-framework.org/api-guide/permissions/>`_
112+

oauth2_provider/oauth2_validators.py

+34-3
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 {}

setup.cfg

+3-1
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,6 @@ install_requires =
3838
jwcrypto >= 0.8.0
3939

4040
[options.packages.find]
41-
exclude = tests
41+
exclude =
42+
tests
43+
tests.*

tests/conftest.py

+40
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

+2
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 = {

0 commit comments

Comments
 (0)