diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a18b1bd2..23035d0b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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 diff --git a/docs/oidc.rst b/docs/oidc.rst index 143bec5e5..8dfea2e16 100644 --- a/docs/oidc.rst +++ b/docs/oidc.rst @@ -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::: @@ -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. @@ -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. @@ -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 diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 00c5e7de0..b33c80f39 100644 --- 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 {} diff --git a/tests/conftest.py b/tests/conftest.py index 520b6cbac..14db54aa5 100644 --- 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 index 438da1e03..fa2d7a34c 100644 --- 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 index 5eb9c2d77..7b379d1b3 100644 --- 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