Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,7 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [2.0.0] unreleased

### Added
* #1106 Add "scopes_supported" to the [ConnectDiscoveryInfoView](https://django-oauth-toolkit.readthedocs.io/en/latest/oidc.html#connectdiscoveryinfoview).
* #1106 OIDC: Add "scopes_supported" to the [ConnectDiscoveryInfoView](https://django-oauth-toolkit.readthedocs.io/en/latest/oidc.html#connectdiscoveryinfoview).
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).

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

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

## [1.7.0] 2022-01-23

Expand Down
94 changes: 64 additions & 30 deletions docs/oidc.rst
Original file line numberDiff line numberDiff line change
Expand Up@@ -102,7 +102,7 @@ so there is no need to add a setting for the public key.


Rotating the RSA private key
~~~~~~~~~~~~~~~~~~~~~~~~
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Extra keys can be published in the jwks_uri with the ``OIDC_RSA_PRIVATE_KEYS_INACTIVE``
setting. For example:::

Expand DownExpand Up@@ -143,7 +143,7 @@ scopes in your ``settings.py``::
# ... any other settings you want
}

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

Expand DownExpand Up@@ -250,54 +250,88 @@ our custom validator. It takes one of two forms:
The first form gets passed a request object, and should return a dictionary
mapping a claim name to claim data::
class CustomOAuth2Validator(OAuth2Validator):
# Set `oidc_claim_scope = None` to ignore scopes that limit which claims to return,
# otherwise the OIDC standard scopes are used.

def get_additional_claims(self, request):
claims ={}
claims["email"] = request.user.get_user_email()
claims["username"] = request.user.get_full_name()
return{
"given_name": request.user.first_name,
"family_name": request.user.last_name,
"name": ' '.join([request.user.first_name, request.user.last_name]),
"preferred_username": request.user.username,
"email": request.user.email,
}

return claims

The second form gets no request object, and should return a dictionary
mapping a claim name to a callable, accepting a request and producing
the claim data::
class CustomOAuth2Validator(OAuth2Validator):
def get_additional_claims(self):
def get_user_email(request):
return request.user.get_user_email()
# Extend the standard scopes to add a new "permissions" scope
# which returns a "permissions" claim:
oidc_claim_scope = OAuth2Validator.oidc_claim_scope
oidc_claim_scope.update({"permissions": "permissions"})

def get_additional_claims(self):
return{
"given_name": lambda request: request.user.first_name,
"family_name": lambda request: request.user.last_name,
"name": lambda request: ' '.join([request.user.first_name, request.user.last_name]),
"preferred_username": lambda request: request.user.username,
"email": lambda request: request.user.email,
"permissions": lambda request: list(request.user.get_group_permissions()),
}

claims ={}
claims["email"] = get_user_email
claims["username"] = lambda r: r.user.get_full_name()

return claims

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

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

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

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

Using OIDC scopes to determine which claims are returned
--------------------------------------------------------

The ``oidc_claim_scope`` OAuth2Validator class attribute implements OIDC's
`5.4 Requesting Claims using Scope Values`_ feature.
For example, a ``given_name`` claim is only returned if the ``profile`` scope was granted.

To change the list of claims and which scopes result in their being returned,
override ``oidc_claim_scope`` with a dict keyed by claim with a value of scope.
The following example adds instructions to return the ``foo`` claim when the ``bar`` scope is granted::
class CustomOAuth2Validator(OAuth2Validator):
oidc_claim_scope = OAuth2Validator.oidc_claim_scope
oidc_claim_scope.update({"foo": "bar"})

Set ``oidc_claim_scope = None`` to return all claims irrespective of the granted scopes.

You have to make sure you've added addtional claims via ``get_additional_claims``
and defined the ``OAUTH2_PROVIDER["SCOPES"]`` in your settings in order for this functionality to work.

.. note::
This ``request`` object is not a ``django.http.Request`` object, but an
``oauthlib.common.Request`` object. This has a number of attributes that
you can use to decide what claims to put in to the ID token:

* ``request.scopes`` - a list of the scopes requested by the client when
making an authorization request.
* ``request.claims`` - a dictionary of the requested claims, using the
`OIDC claims requesting system`_. These must be requested by the client
when making an authorization request.
* ``request.user`` - the django user object.
* ``request.scopes`` - the list of granted scopes.
* ``request.claims`` - the requested claims per OIDC's `5.5 Requesting Claims using the "claims" Request Parameter`_.
These must be requested by the client when making an authorization request.
* ``request.user`` - the `Django User`_ object.

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

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

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

Again, to modify the content delivered, we need to add a function to our
custom validator. The default implementation adds the claims from the ID
Expand Down
37 changes: 34 additions & 3 deletions oauth2_provider/oauth2_validators.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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):
Expand DownExpand Up@@ -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{}
40 changes: 40 additions & 0 deletions tests/conftest.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
)
2 changes: 2 additions & 0 deletions tests/presets.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 ={
Expand Down
56 changes: 56 additions & 0 deletions tests/test_oidc_views.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand All@@ -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,
Expand All@@ -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