diff --git a/.gitignore b/.gitignore index 3643335d4..4d15af97f 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,6 @@ _build # Sqlite database files *.sqlite + +/venv/ +/coverage.xml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5c78568ea..386d28c9c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,11 +1,11 @@ repos: - repo: https://github.com/psf/black - rev: 21.12b0 + rev: 22.3.0 hooks: - id: black exclude: ^(oauth2_provider/migrations/|tests/migrations/) - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.1.0 + rev: v4.2.0 hooks: - id: check-ast - id: trailing-whitespace @@ -25,3 +25,7 @@ repos: hooks: - id: flake8 exclude: ^(oauth2_provider/migrations/|tests/migrations/) + - repo: https://github.com/sphinx-contrib/sphinx-lint + rev: v0.3 + hooks: + - id: sphinx-lint diff --git a/AUTHORS b/AUTHORS index a1591b6da..a5f652ea0 100644 --- a/AUTHORS +++ b/AUTHORS @@ -21,6 +21,9 @@ Ash Christopher Asif Saif Uddin Bart Merenda Bas van Oostveen +Brian Helba +Carl Schwan +Daniel 'Vector' Kerr Dave Burkholder David Fischer David Smith @@ -41,6 +44,7 @@ Jim Graham Jonas Nygaard Pedersen Jonathan Steffan Jozef Knaperek +Julien Palard Jun Zhou Kristian Rune Larsen Michael Howitz @@ -64,7 +68,10 @@ Jadiel Teófilo pySilver Łukasz Skarżyński Shaheed Haque +Peter Karman Vinay Karanam Eduardo Oliveira Andrea Greco Dominik George +David Hill +Darrel O'Pry diff --git a/CHANGELOG.md b/CHANGELOG.md index b66e0822d..7819fe616 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,51 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security --> +## [unreleased] + +## [2.0.0] 2022-04-24 + +This is a major release with **BREAKING** changes. Please make sure to review these changes before upgrading: + +### Added +* #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). +* #1128 Documentation: [Tutorial](https://django-oauth-toolkit.readthedocs.io/en/latest/tutorial/tutorial_05.html) + on using Celery to automate clearing expired tokens. + +### Changed +* #1129 (**Breaking**) Changed default value of PKCE_REQUIRED to True. This is a **breaking change**. Clients without + PKCE enabled will fail to authenticate. This breaks with [section 5 of RFC7636](https://datatracker.ietf.org/doc/html/rfc7636) + in favor of the [OAuth2 Security Best Practices for Authorization Code Grants](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics#section-2.1). + If you want to retain the pre-2.x behavior, set `PKCE_REQUIRED = False` in your settings.py +* #1093 (**Breaking**) Changed to implement [hashed](https://docs.djangoproject.com/en/stable/topics/auth/passwords/) + client_secret values. This is a **breaking change** that will migrate all your existing + 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`. +* #1132: Added `--algorithm` argument to `createapplication` management command + +### Fixed +* #1108 OIDC: Fix `validate_bearer_token()` to properly set `request.scopes` to the list of granted scopes. +* #1132: Fixed help text for `--skip-authorization` argument of the `createapplication` management command. + +### Removed +* #1124 (**Breaking**, **Security**) Removes support for insecure `urn:ietf:wg:oauth:2.0:oob` and `urn:ietf:wg:oauth:2.0:oob:auto` which are replaced + by [RFC 8252](https://datatracker.ietf.org/doc/html/rfc8252) "OAuth 2.0 for Native Apps" BCP. Google has + [deprecated use of oob](https://developers.googleblog.com/2022/02/making-oauth-flows-safer.html?m=1#disallowed-oob) with + a final end date of 2022-10-03. If you still rely on oob support in django-oauth-toolkit, do not upgrade to this release. + +## [1.7.1] 2022-03-19 + +### Removed +* #1126 Reverts #1070 which incorrectly added Celery auto-discovery tasks.py (as described in #1123) and because it conflicts + with Huey's auto-discovery which also uses tasks.py as described in #1114. If you are using Celery or Huey, you'll need + to separately implement these tasks. + ## [1.7.0] 2022-01-23 ### Added diff --git a/README.rst b/README.rst index 8a9f333db..3acf459d8 100644 --- a/README.rst +++ b/README.rst @@ -39,11 +39,6 @@ Note: If you have issues installing Django 4.0.0, it is because we only support Django 4.0.1+ due to a regression in Django 4.0.0. Besides 4.0.0, Django 2.2+ is supported. `Explanation `_. -Contributing ------------- - -We love contributions, so please feel free to fix bugs, improve things, provide documentation. Just `follow the -guidelines `_ and submit a PR. Reporting security issues ------------------------- @@ -99,3 +94,50 @@ License ------- django-oauth-toolkit is released under the terms of the **BSD license**. Full details in ``LICENSE`` file. + +Help Wanted +----------- + +We need help maintaining and enhancing django-oauth-toolkit (DOT). + +Join the team +~~~~~~~~~~~~~ + +Please consider joining `Jazzband `__ (If not +already a member) and the `DOT project +team `__. + +How you can help +~~~~~~~~~~~~~~~~ + +See our +`contributing `__ +info and the open +`issues `__ and +`PRs `__, +especially those labeled +`help-wanted `__. + +Submit PRs and Perform Reviews +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +PR submissions and reviews are always appreciated! Since we require an +independent review of any PR before it can be merged, having your second +set of eyes looking at PRs is extremely valuable. + +Please don’t merge PRs +~~~~~~~~~~~~~~~~~~~~~~ + +Please be aware that we don’t want *every* Jazzband member to merge PRs +but just a handful of project team members so that we can maintain a +modicum of control over what goes into a release of this security oriented code base. Only `project +leads `__ are able to +publish releases to Pypi and it becomes difficult when creating a new +release for the leads to deal with “unexpected” merged PRs. + +Become a Project Lead +~~~~~~~~~~~~~~~~~~~~~ + +If you are interested in stepping up to be a Project Lead, please join +the +`discussion `__. diff --git a/docs/glossary.rst b/docs/glossary.rst index c1536f801..7819129b1 100644 --- a/docs/glossary.rst +++ b/docs/glossary.rst @@ -39,4 +39,4 @@ Glossary Refresh Token A token the authorization server may issue to clients and can be swapped for a brand new access token, without - repeating the authorization process. It has no expire time. \ No newline at end of file + repeating the authorization process. It has no expire time. diff --git a/docs/management_commands.rst b/docs/management_commands.rst index 727ff9e98..085b130ec 100644 --- a/docs/management_commands.rst +++ b/docs/management_commands.rst @@ -1,9 +1,12 @@ Management commands =================== -Django OAuth Toolkit exposes some useful management commands that can be run via shell or by other means (eg: cron) +Django OAuth Toolkit exposes some useful management commands that can be run via shell or by other means such as cron +or :doc:`Celery `. .. _cleartokens: +.. _createapplication: + cleartokens ~~~~~~~~~~~ @@ -22,7 +25,37 @@ To prevent the CPU and RAM high peaks during deletion process use ``CLEAR_EXPIRE Note: Refresh tokens need to expire before AccessTokens can be removed from the database. Using ``cleartokens`` without ``REFRESH_TOKEN_EXPIRE_SECONDS`` has limited effect. -The ``cleartokens`` action can also be scheduled as a `Celery periodic task`_ -by using the ``clear_tokens`` task (automatically registered when using Celery). -.. _Celery periodic task: https://docs.celeryproject.org/en/stable/userguide/periodic-tasks.html + +createapplication +~~~~~~~~~~~~~~~~~ + +The ``createapplication`` management command provides a shortcut to create a new application in a programmatic way. + +.. code-block:: sh + + usage: manage.py createapplication [-h] [--client-id CLIENT_ID] [--user USER] [--redirect-uris REDIRECT_URIS] + [--client-secret CLIENT_SECRET] [--name NAME] [--skip-authorization] [--version] [-v {0,1,2,3}] + [--settings SETTINGS] [--pythonpath PYTHONPATH] [--traceback] [--no-color] [--force-color] + [--skip-checks] + client_type authorization_grant_type + + Shortcut to create a new application in a programmatic way + + positional arguments: + client_type The client type, can be confidential or public + authorization_grant_type + The type of authorization grant to be used + + optional arguments: + -h, --help show this help message and exit + --client-id CLIENT_ID + The ID of the new application + --user USER The user the application belongs to + --redirect-uris REDIRECT_URIS + The redirect URIs, this must be a space separated string e.g 'URI1 URI2' + --client-secret CLIENT_SECRET + The secret for this application + --name NAME The name this application + --skip-authorization The ID of the new application + ... diff --git a/docs/oidc.rst b/docs/oidc.rst index 143bec5e5..4b427ba86 100644 --- a/docs/oidc.rst +++ b/docs/oidc.rst @@ -80,7 +80,7 @@ Now we need to add this key to our settings and allow the ``openid`` scope to be used. Assuming we have set an environment variable called ``OIDC_RSA_PRIVATE_KEY``, we can make changes to our ``settings.py``:: - import os.environ + import os OAUTH2_PROVIDER = { "OIDC_ENABLED": True, @@ -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. @@ -249,55 +249,90 @@ 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"}) - claims = {} - claims["email"] = get_user_email - claims["username"] = lambda r: r.user.get_full_name() + 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()), + } - 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 +342,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/docs/resource_server.rst b/docs/resource_server.rst index e19e542a8..4e623b118 100644 --- a/docs/resource_server.rst +++ b/docs/resource_server.rst @@ -1,6 +1,6 @@ Separate Resource Server ======================== -Django OAuth Toolkit allows to separate the :term:`Authentication Server` and the :term:`Resource Server.` +Django OAuth Toolkit allows to separate the :term:`Authorization Server` and the :term:`Resource Server`. Based on the `RFC 7662 `_ Django OAuth Toolkit provides a rfc-compliant introspection endpoint. As well the Django OAuth Toolkit allows to verify access tokens by the use of an introspection endpoint. @@ -8,7 +8,7 @@ As well the Django OAuth Toolkit allows to verify access tokens by the use of an Setup the Authentication Server ------------------------------- -Setup the :term:`Authentication Server` as described in the :ref:`tutorial`. +Setup the :term:`Authorization Server` as described in the :doc:`tutorial/tutorial`. Create a OAuth2 access token for the :term:`Resource Server` and add the ``introspection``-Scope to the settings. @@ -21,7 +21,7 @@ Create a OAuth2 access token for the :term:`Resource Server` and add the ... }, -The :term:`Authentication Server` will listen for introspection requests. +The :term:`Authorization Server` will listen for introspection requests. The endpoint is located within the ``oauth2_provider.urls`` as ``/introspect/``. Example Request:: @@ -49,10 +49,10 @@ Example Response:: Setup the Resource Server ------------------------- -Setup the :term:`Resource Server` like the :term:`Authentication Server` as described in the :ref:`tutorial`. +Setup the :term:`Resource Server` like the :term:`Authorization Server` as described in the :doc:`tutorial/tutorial`. Add ``RESOURCE_SERVER_INTROSPECTION_URL`` and **either** ``RESOURCE_SERVER_AUTH_TOKEN`` **or** ``RESOURCE_SERVER_INTROSPECTION_CREDENTIALS`` as a ``(id,secret)`` tuple to your settings. -The :term:`Resource Server` will try to verify its requests on the :term:`Authentication Server`. +The :term:`Resource Server` will try to verify its requests on the :term:`Authorization Server`. .. code-block:: python @@ -66,7 +66,7 @@ The :term:`Resource Server` will try to verify its requests on the :term:`Authen ``RESOURCE_SERVER_INTROSPECTION_URL`` defines the introspection endpoint and ``RESOURCE_SERVER_AUTH_TOKEN`` an authentication token to authenticate against the -:term:`Authentication Server`. +:term:`Authorization Server`. As allowed by RFC 7662, some external OAuth 2.0 servers support HTTP Basic Authentication. For these, use: ``RESOURCE_SERVER_INTROSPECTION_CREDENTIALS=('client_id','client_secret')`` instead diff --git a/docs/rest-framework/permissions.rst b/docs/rest-framework/permissions.rst index 1058aed3f..ee398d9fc 100644 --- a/docs/rest-framework/permissions.rst +++ b/docs/rest-framework/permissions.rst @@ -114,4 +114,4 @@ The following is a minimal OAS declaration that shows the same required alternat to try it in the `swagger editor `_. .. literalinclude:: openapi.yaml - :language: YAML \ No newline at end of file + :language: YAML diff --git a/docs/settings.rst b/docs/settings.rst index 01baaaf4b..2ac31ccda 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -97,19 +97,19 @@ of those three can be a callable) must be passed here directly and classes must be instantiated (callables should accept request as their only argument). GRANT_MODEL -~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~ The import string of the class (model) representing your grants. Overwrite this value if you wrote your own implementation (subclass of ``oauth2_provider.models.Grant``). APPLICATION_ADMIN_CLASS -~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~ The import string of the class (model) representing your application admin class. Overwrite this value if you wrote your own implementation (subclass of ``oauth2_provider.admin.ApplicationAdmin``). ACCESS_TOKEN_ADMIN_CLASS -~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~~ The import string of the class (model) representing your access token admin class. Overwrite this value if you wrote your own implementation (subclass of ``oauth2_provider.admin.AccessTokenAdmin``). @@ -121,7 +121,7 @@ Overwrite this value if you wrote your own implementation (subclass of ``oauth2_provider.admin.GrantAdmin``). REFRESH_TOKEN_ADMIN_CLASS -~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~~~ The import string of the class (model) representing your refresh token admin class. Overwrite this value if you wrote your own implementation (subclass of ``oauth2_provider.admin.RefreshTokenAdmin``). @@ -154,7 +154,7 @@ If you don't change the validator code and don't run cleartokens all refresh tokens will last until revoked or the end of time. You should change this. REFRESH_TOKEN_GRACE_PERIOD_SECONDS -~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The number of seconds between when a refresh token is first used when it is expired. The most common case of this for this is native mobile applications that run into issues of network connectivity during the refresh cycle and are @@ -178,7 +178,7 @@ See also: validator's rotate_refresh_token method can be overridden to make this when close to expiration, theoretically). REFRESH_TOKEN_GENERATOR -~~~~~~~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~ See `ACCESS_TOKEN_GENERATOR`. This is the same but for refresh tokens. Defaults to access token generator if not provided. @@ -253,9 +253,21 @@ will be used. PKCE_REQUIRED ~~~~~~~~~~~~~ -Default: ``False`` +Default: ``True`` + +Can be either a bool or a callable that takes a client id and returns a bool. + +Whether or not `Proof Key for Code Exchange `_ is required. + +According to `OAuth 2.0 Security Best Current Practice `_ related to the +`Authorization Code Grant `_ + +- Public clients MUST use PKCE `RFC7636 `_ +- For confidential clients, the use of PKCE `RFC7636 `_ is RECOMMENDED. + + + -Whether or not PKCE is required. Can be either a bool or a callable that takes a client id and returns a bool. OIDC_RSA_PRIVATE_KEY @@ -265,7 +277,7 @@ Default: ``""`` The RSA private key used to sign OIDC ID tokens. If not set, OIDC is disabled. OIDC_RSA_PRIVATE_KEYS_INACTIVE -~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Default: ``[]`` An array of *inactive* RSA private keys. These keys are not used to sign tokens, @@ -276,7 +288,7 @@ This is useful for providing a smooth transition during key rotation. should be retained in this inactive list. OIDC_JWKS_MAX_AGE_SECONDS -~~~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~~~ Default: ``3600`` The max-age value for the Cache-Control header on jwks_uri. @@ -354,9 +366,9 @@ load when clearing large batches of expired tokens. Settings imported from Django project --------------------------- +------------------------------------- USE_TZ -~~~~~~~~~~~~~~~~~~~~~~~~~~~ +~~~~~~ Used to determine whether or not to make token expire dates timezone aware. diff --git a/docs/templates.rst b/docs/templates.rst index 4f6320bf7..8ebcd4127 100644 --- a/docs/templates.rst +++ b/docs/templates.rst @@ -242,4 +242,4 @@ This template gets passed the following template context variable: .. important:: To override successfully this template you should provide a form that posts to the same URL, example: - ``
`` \ No newline at end of file + ```` diff --git a/docs/tutorial/admin+celery.png b/docs/tutorial/admin+celery.png new file mode 100644 index 000000000..b9e25ea19 Binary files /dev/null and b/docs/tutorial/admin+celery.png differ diff --git a/docs/tutorial/celery+add.png b/docs/tutorial/celery+add.png new file mode 100644 index 000000000..eca8e02e2 Binary files /dev/null and b/docs/tutorial/celery+add.png differ diff --git a/docs/tutorial/tutorial.rst b/docs/tutorial/tutorial.rst index 0de799a6e..5a0662507 100644 --- a/docs/tutorial/tutorial.rst +++ b/docs/tutorial/tutorial.rst @@ -8,3 +8,5 @@ Tutorials tutorial_02 tutorial_03 tutorial_04 + tutorial_05 + diff --git a/docs/tutorial/tutorial_03.rst b/docs/tutorial/tutorial_03.rst index 52868c01f..30c8317e6 100644 --- a/docs/tutorial/tutorial_03.rst +++ b/docs/tutorial/tutorial_03.rst @@ -78,3 +78,35 @@ Now supposing your access token value is `123456` you can try to access your aut :: curl -H "Authorization: Bearer 123456" -X GET http://localhost:8000/secret + +Working with Rest_framework generic class based views +----------------------------------------------------- + +If you have completed the `Django REST framework tutorial +`_, +you will be familiar with the 'Snippet' example, in particular the SnippetList and SnippetDetail classes. + +It would be nice to reuse those views **and** support token handling. Instead of reworking +those classes to be ProtectedResourceView based, the solution is much simpler than that. + +Assume you have already modified the settings as was already shown. +The key is setting a class attribute to override the default *permissions_classes* with something that will use our :term:`Access Token` properly. + +.. code-block:: python + + from oauth2_provider.contrib.rest_framework import TokenHasReadWriteScope + + class SnippetList(generics.ListCreateAPIView): + ... + permission_classes = [TokenHasReadWriteScope] + + class SnippetDetail(generics.ListCreateAPIView): + ... + permission_classes = [TokenHasReadWriteScope] + +Note that this example overrides the Django default permission class setting. There are several other +ways this can be solved. Overriding the class function *get_permission_classes* is another way +to solve the problem. + +A detailed dive into the `Dango REST framework permissions is here. `_ + diff --git a/docs/tutorial/tutorial_05.rst b/docs/tutorial/tutorial_05.rst new file mode 100644 index 000000000..1be656b88 --- /dev/null +++ b/docs/tutorial/tutorial_05.rst @@ -0,0 +1,169 @@ +Part 5 - Using Celery to Automate Maintenance Chores +==================================================== + +Scenario +-------- +In :doc:`Part 1 ` you created your own :term:`Authorization Server` and it's running along just fine. +However, the database is getting cluttered with expired tokens. You can periodically run +the :doc:`cleartokens management command <../management_commands>`, but why not automate this with +`Celery `_? + +Set up RabbitMQ +--------------- +Celery components communicate via a message queue. We'll use `RabbitMQ `_. + +Install RabbitMQ on MacOS +~~~~~~~~~~~~~~~~~~~~~~~~~~ +If you are using MacOS it's likely you are already using `Homebrew `_. If not, now's +the time to install this fantastic package manager. + +:: + + brew install rabbitmq + brew service start rabbitmq + +Install RabbitMQ with Docker +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +This will start up a docker image that just works: +:: + + docker run -it --rm --name rabbitmq -p 5672:5672 -p 15672:15672 rabbitmq:3.9-management + + + +Install RabbitMQ on Windows +~~~~~~~~~~~~~~~~~~~~~~~~~~~ +See the `RabbitMQ Installing on Windows `_ instructions. + + +Add Celery +---------- +Make sure you virtualenv is active and install `celery` and +`django-celery-beat `_. + +:: + + pip install celery django-celery-beat + +Update your list of installed apps to include both your :term:`Authorization Server` app -- we'll call it ``tutorial``, +and ``django_celery_beat`` which extends your Django project to store your periodic task schedule +in the database and adds a Django Admin interface for configuring them. + +.. code-block:: python + + INSTALLED_APPS = { + # ... + "tutorial", + "django_celery_beat", + } + + +Now add a new file to your app to add Celery: ``tutorial/celery.py``: + +.. code-block:: python + + import os + + from celery import Celery + + # Set the default Django settings module for the 'celery' program. + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tutorial.settings') + app = Celery('tutorial', broker="pyamqp://guest@localhost//") + app.config_from_object('django.conf:settings', namespace='CELERY') + + # Load task modules from all registered Django apps. + app.autodiscover_tasks() + +This will autodiscover any ``tasks.py`` files in the list of installed apps. +We'll add ours now in ``tutorial/tasks.py``: + +.. code-block:: python + + from celery import shared_task + + @shared_task + def clear_tokens(): + from oauth2_provider.models import clear_expired + + clear_expired() + +Finally, update ``tutorial/__init__.py`` to make sure Celery gets loaded when the app starts up: + +.. code-block:: python + + from .celery import app as celery_app + + __all__ = ('celery_app',) + + +Run Celery Beat and the Worker +------------------------------ + +RabbitMQ should already be running; it's the "glue" between Beat and the Worker. + +It's best to run each of these in its own terminal window so you can see the log messages. + +Start Celery Beat +~~~~~~~~~~~~~~~~~ + +:: + + celery -A tutorial beat -l INFO --scheduler django_celery_beat.schedulers:DatabaseScheduler + +Start Celery Worker +~~~~~~~~~~~~~~~~~~~ + +:: + + celery -A tutorial worker -l INFO + +Configure the ``clear_tokens`` task +----------------------------------- + +Go into `Django Admin `_ and you'll see a new section for periodic tasks: + +.. image:: admin+celery.png + :width: 500 + :alt: Django Admin interface screenshot + +Now let's define a fairly short (10 second) interval. Go to: http://127.0.0.1:8000/admin/django_celery_beat/intervalschedule/ +and select Add Interval, set number of intervals to 10 and interval period to seconds and Save. + +Then go to http://127.0.0.1:8000/admin/django_celery_beat/periodictask/ to add a new periodic task by +selecting `Add Periodic Task `_ and +select ``tutorial.tasks.clear_tokens``, choose the ``every 10 seconds`` interval schedule, and "Save." + +.. image:: celery+add.png + :width: 500 + :alt: Django Admin interface screenshot + + +Now your Celery Beat and Celery Workers should start running the task every 10 seconds. + +The Beat console will look like this: + +:: + + [2022-03-19 22:06:35,605: INFO/MainProcess] Scheduler: Sending due task clear stale tokens (tutorial.tasks.clear_tokens) + +And the Workers console like this: + +:: + + [2022-03-19 22:06:35,614: INFO/MainProcess] Task tutorial.tasks.clear_tokens[5ec25fb8-5ce3-4d15-b9ad-750b80fc07e0] received + [2022-03-19 22:06:35,616: INFO/ForkPoolWorker-8] refresh_expire_at is None. No refresh tokens deleted. + [2022-03-19 22:06:35,629: INFO/ForkPoolWorker-8] 0 Expired access tokens deleted + [2022-03-19 22:06:35,631: INFO/ForkPoolWorker-8] 0 Expired grant tokens deleted + [2022-03-19 22:06:35,632: INFO/ForkPoolWorker-8] Task tutorial.tasks.clear_tokens[5ec25fb8-5ce3-4d15-b9ad-750b80fc07e0] succeeded in 0.016124433999999965s: None + + +References +---------- + +The preceding is based on these references: + +https://docs.celeryq.dev/en/stable/django/first-steps-with-django.html + +https://docs.celeryq.dev/en/stable/userguide/periodic-tasks.html#beat-custom-schedulers + +https://django-celery-beat.readthedocs.io/en/latest/index.html diff --git a/docs/views/mixins.rst b/docs/views/mixins.rst index be3541a88..a8da12414 100644 --- a/docs/views/mixins.rst +++ b/docs/views/mixins.rst @@ -2,4 +2,4 @@ Mixins for Class Based Views ============================ .. automodule:: oauth2_provider.views.mixins - :members: \ No newline at end of file + :members: diff --git a/oauth2_provider/__init__.py b/oauth2_provider/__init__.py index 805f886e8..49a4433da 100644 --- a/oauth2_provider/__init__.py +++ b/oauth2_provider/__init__.py @@ -1,7 +1,7 @@ import django -__version__ = "1.7.0" +__version__ = "2.0.0" if django.VERSION < (3, 2): default_app_config = "oauth2_provider.apps.DOTConfig" diff --git a/oauth2_provider/management/commands/createapplication.py b/oauth2_provider/management/commands/createapplication.py index 92c4ae46b..f8575a8b0 100644 --- a/oauth2_provider/management/commands/createapplication.py +++ b/oauth2_provider/management/commands/createapplication.py @@ -49,7 +49,12 @@ def add_arguments(self, parser): parser.add_argument( "--skip-authorization", action="store_true", - help="The ID of the new application", + help="If set, completely bypass the authorization form, even on the first use of the application", + ) + parser.add_argument( + "--algorithm", + type=str, + help="The OIDC token signing algorithm for this application (e.g., 'RS256' or 'HS256')", ) def handle(self, *args, **options): diff --git a/oauth2_provider/migrations/0006_alter_application_client_secret.py b/oauth2_provider/migrations/0006_alter_application_client_secret.py new file mode 100644 index 000000000..88e148274 --- /dev/null +++ b/oauth2_provider/migrations/0006_alter_application_client_secret.py @@ -0,0 +1,31 @@ +from django.db import migrations +from django.contrib.auth.hashers import identify_hasher, make_password +import logging +import oauth2_provider.generators +import oauth2_provider.models + + +def forwards_func(apps, schema_editor): + """ + Forward migration touches every application.client_secret which will cause it to be hashed if not already the case. + """ + Application = apps.get_model('oauth2_provider', 'application') + applications = Application.objects.all() + for application in applications: + application.save(update_fields=['client_secret']) + + +class Migration(migrations.Migration): + + dependencies = [ + ('oauth2_provider', '0005_auto_20211222_2352'), + ] + + operations = [ + migrations.AlterField( + model_name='application', + name='client_secret', + field=oauth2_provider.models.ClientSecretField(blank=True, db_index=True, default=oauth2_provider.generators.generate_client_secret, help_text='Hashed on Save. Copy it now if this is a new secret.', max_length=255), + ), + migrations.RunPython(forwards_func), + ] diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index 2c9747ce8..1ded7a4e2 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -6,6 +6,7 @@ from django.apps import apps from django.conf import settings +from django.contrib.auth.hashers import identify_hasher, make_password from django.core.exceptions import ImproperlyConfigured from django.db import models, transaction from django.urls import reverse @@ -24,6 +25,20 @@ logger = logging.getLogger(__name__) +class ClientSecretField(models.CharField): + def pre_save(self, model_instance, add): + secret = getattr(model_instance, self.attname) + try: + hasher = identify_hasher(secret) + logger.debug(f"{model_instance}: {self.attname} is already hashed with {hasher}.") + except ValueError: + logger.debug(f"{model_instance}: {self.attname} is not hashed; hashing it now.") + hashed_secret = make_password(secret) + setattr(model_instance, self.attname, hashed_secret) + return hashed_secret + return super().pre_save(model_instance, add) + + class AbstractApplication(models.Model): """ An Application instance represents a Client on the Authorization server. @@ -90,8 +105,12 @@ class AbstractApplication(models.Model): ) client_type = models.CharField(max_length=32, choices=CLIENT_TYPES) authorization_grant_type = models.CharField(max_length=32, choices=GRANT_TYPES) - client_secret = models.CharField( - max_length=255, blank=True, default=generate_client_secret, db_index=True + client_secret = ClientSecretField( + max_length=255, + blank=True, + default=generate_client_secret, + db_index=True, + help_text=_("Hashed on Save. Copy it now if this is a new secret."), ) name = models.CharField(max_length=255, blank=True) skip_authorization = models.BooleanField(default=False) diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 4d9480be1..b33c80f39 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -12,6 +12,7 @@ import requests from django.conf import settings from django.contrib.auth import authenticate, get_user_model +from django.contrib.auth.hashers import check_password from django.core.exceptions import ObjectDoesNotExist from django.db import transaction from django.db.models import Q @@ -64,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, @@ -123,7 +152,7 @@ def _authenticate_basic_auth(self, request): elif request.client.client_id != client_id: log.debug("Failed basic auth: wrong client id %s" % client_id) return False - elif request.client.client_secret != client_secret: + elif not check_password(client_secret, request.client.client_secret): log.debug("Failed basic auth: wrong client secret %s" % client_secret) return False else: @@ -148,7 +177,7 @@ def _authenticate_request_body(self, request): if self._load_application(client_id, request) is None: log.debug("Failed body auth: Application %s does not exists" % client_id) return False - elif request.client.client_secret != client_secret: + elif not check_password(client_secret, request.client.client_secret): log.debug("Failed body auth: wrong client secret %s" % client_secret) return False else: @@ -396,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 @@ -758,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): @@ -910,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/oauth2_provider/settings.py b/oauth2_provider/settings.py index 3b7dea3f8..00a4e631c 100644 --- a/oauth2_provider/settings.py +++ b/oauth2_provider/settings.py @@ -97,7 +97,7 @@ "RESOURCE_SERVER_INTROSPECTION_CREDENTIALS": None, "RESOURCE_SERVER_TOKEN_CACHING_SECONDS": 36000, # Whether or not PKCE is required - "PKCE_REQUIRED": False, + "PKCE_REQUIRED": True, # Whether to re-create OAuthlibCore on every request. # Should only be required in testing. "ALWAYS_RELOAD_OAUTHLIB_CORE": False, diff --git a/oauth2_provider/tasks.py b/oauth2_provider/tasks.py deleted file mode 100644 index d86c33720..000000000 --- a/oauth2_provider/tasks.py +++ /dev/null @@ -1,8 +0,0 @@ -from celery import shared_task - - -@shared_task -def clear_tokens(): - from ...models import clear_expired # noqa - - clear_expired() diff --git a/oauth2_provider/templates/oauth2_provider/authorized-oob.html b/oauth2_provider/templates/oauth2_provider/authorized-oob.html deleted file mode 100644 index 78399da7c..000000000 --- a/oauth2_provider/templates/oauth2_provider/authorized-oob.html +++ /dev/null @@ -1,23 +0,0 @@ -{% extends "oauth2_provider/base.html" %} - -{% load i18n %} - -{% block title %} -Success code={{code}} -{% endblock %} - -{% block content %} -
- {% if not error %} -

{% trans "Success" %}

- -

{% trans "Please return to your application and enter this code:" %}

- -

{{ code }}

- - {% else %} -

Error: {{ error.error }}

-

{{ error.description }}

- {% endif %} -
-{% endblock %} diff --git a/oauth2_provider/views/base.py b/oauth2_provider/views/base.py index e46a49d10..211da45ed 100644 --- a/oauth2_provider/views/base.py +++ b/oauth2_provider/views/base.py @@ -1,11 +1,8 @@ import json import logging -import urllib.parse from django.contrib.auth.mixins import LoginRequiredMixin -from django.http import HttpResponse, JsonResponse -from django.shortcuts import render -from django.urls import reverse +from django.http import HttpResponse from django.utils import timezone from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt @@ -207,42 +204,13 @@ def get(self, request, *args, **kwargs): credentials=credentials, allow=True, ) - return self.redirect(uri, application, token) + return self.redirect(uri, application) except OAuthToolkitError as error: return self.error_response(error, application) return self.render_to_response(self.get_context_data(**kwargs)) - def redirect(self, redirect_to, application, token=None): - - if not redirect_to.startswith("urn:ietf:wg:oauth:2.0:oob"): - return super().redirect(redirect_to, application) - - parsed_redirect = urllib.parse.urlparse(redirect_to) - code = urllib.parse.parse_qs(parsed_redirect.query)["code"][0] - - if redirect_to.startswith("urn:ietf:wg:oauth:2.0:oob:auto"): - - response = { - "access_token": code, - "token_uri": redirect_to, - "client_id": application.client_id, - "client_secret": application.client_secret, - "revoke_uri": reverse("oauth2_provider:revoke-token"), - } - - return JsonResponse(response) - - else: - return render( - request=self.request, - template_name="oauth2_provider/authorized-oob.html", - context={ - "code": code, - }, - ) - @method_decorator(csrf_exempt, name="dispatch") class TokenView(OAuthLibMixin, View): diff --git a/oauth2_provider/views/oidc.py b/oauth2_provider/views/oidc.py index e66b30a86..bb47d4f43 100644 --- a/oauth2_provider/views/oidc.py +++ b/oauth2_provider/views/oidc.py @@ -18,7 +18,8 @@ class ConnectDiscoveryInfoView(OIDCOnlyMixin, View): """ - View used to show oidc provider configuration information + View used to show oidc provider configuration information per + `OpenID Provider Metadata `_ """ def get(self, request, *args, **kwargs): @@ -49,6 +50,9 @@ def get(self, request, *args, **kwargs): validator_class = oauth2_settings.OAUTH2_VALIDATOR_CLASS validator = validator_class() oidc_claims = list(set(validator.get_discovery_claims(request))) + scopes_class = oauth2_settings.SCOPES_BACKEND_CLASS + scopes = scopes_class() + scopes_supported = [scope for scope in scopes.get_available_scopes()] data = { "issuer": issuer_url, @@ -56,6 +60,7 @@ def get(self, request, *args, **kwargs): "token_endpoint": token_endpoint, "userinfo_endpoint": userinfo_endpoint, "jwks_uri": jwks_uri, + "scopes_supported": scopes_supported, "response_types_supported": oauth2_settings.OIDC_RESPONSE_TYPES_SUPPORTED, "subject_types_supported": oauth2_settings.OIDC_SUBJECT_TYPES_SUPPORTED, "id_token_signing_alg_values_supported": signing_algorithms, diff --git a/setup.cfg b/setup.cfg index 4a8de4569..7fc5a9243 100644 --- a/setup.cfg +++ b/setup.cfg @@ -38,4 +38,6 @@ install_requires = jwcrypto >= 0.8.0 [options.packages.find] -exclude = tests +exclude = + tests + tests.* diff --git a/tests/conftest.py b/tests/conftest.py index a3274aa33..14db54aa5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,6 +16,8 @@ Application = get_application_model() UserModel = get_user_model() +CLEARTEXT_SECRET = "1234567890abcdefghijklmnopqrstuvwxyz" + class OAuthSettingsWrapper: """ @@ -101,12 +103,14 @@ def application(): client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, algorithm=Application.RS256_ALGORITHM, + client_secret=CLEARTEXT_SECRET, ) @pytest.fixture def hybrid_application(application): application.authorization_grant_type = application.GRANT_OPENID_HYBRID + application.client_secret = CLEARTEXT_SECRET application.save() return application @@ -141,7 +145,7 @@ def oidc_tokens(oauth2_settings, application, test_user, client): "code": code, "redirect_uri": "http://example.org", "client_id": application.client_id, - "client_secret": application.client_secret, + "client_secret": CLEARTEXT_SECRET, "scope": "openid", }, ) @@ -154,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..6411687a4 100644 --- a/tests/presets.py +++ b/tests/presets.py @@ -19,9 +19,12 @@ "openid": "OpenID connect", }, "DEFAULT_SCOPES": ["read", "write"], + "PKCE_REQUIRED": False, } 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/settings.py b/tests/settings.py index d2fbe6a56..27dcfe9a3 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -159,3 +159,4 @@ CLEAR_EXPIRED_TOKENS_BATCH_SIZE = 1 CLEAR_EXPIRED_TOKENS_BATCH_INTERVAL = 0 +PKCE_REQUIRED = False diff --git a/tests/test_authorization_code.py b/tests/test_authorization_code.py index c9bef0f5c..8bface719 100644 --- a/tests/test_authorization_code.py +++ b/tests/test_authorization_code.py @@ -2,7 +2,6 @@ import datetime import hashlib import json -import re from urllib.parse import parse_qs, urlparse import pytest @@ -32,8 +31,7 @@ RefreshToken = get_refresh_token_model() UserModel = get_user_model() -URI_OOB = "urn:ietf:wg:oauth:2.0:oob" -URI_OOB_AUTO = "urn:ietf:wg:oauth:2.0:oob:auto" +CLEARTEXT_SECRET = "1234567890abcdefghijklmnopqrstuvwxyz" # mocking a protected resource view @@ -50,16 +48,17 @@ def setUp(self): self.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") self.oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES = ["http", "custom-scheme"] + self.oauth2_settings.PKCE_REQUIRED = False self.application = Application.objects.create( name="Test Application", redirect_uris=( "http://localhost http://example.com http://example.org custom-scheme://example.com" - " " + URI_OOB + " " + URI_OOB_AUTO ), user=self.dev_user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, + client_secret=CLEARTEXT_SECRET, ) def tearDown(self): @@ -75,6 +74,7 @@ class TestRegressionIssue315(BaseTest): """ def test_request_is_not_overwritten(self): + self.oauth2_settings.PKCE_REQUIRED = False self.client.login(username="test_user", password="123456") response = self.client.get( reverse("oauth2_provider:authorize"), @@ -96,6 +96,7 @@ def test_skip_authorization_completely(self): """ If application.skip_authorization = True, should skip the authorization page. """ + self.oauth2_settings.PKCE_REQUIRED = False self.client.login(username="test_user", password="123456") self.application.skip_authorization = True self.application.save() @@ -134,6 +135,7 @@ def test_pre_auth_valid_client(self): """ Test response for a valid client_id with response_type: code """ + self.oauth2_settings.PKCE_REQUIRED = False self.client.login(username="test_user", password="123456") query_data = { @@ -646,7 +648,6 @@ def get_pkce_auth(self, code_challenge, code_challenge_method): """ Helper method to retrieve a valid authorization code using pkce """ - self.oauth2_settings.PKCE_REQUIRED = True authcode_data = { "client_id": self.application.client_id, "state": "random_state_string", @@ -677,7 +678,7 @@ def test_basic_auth(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) @@ -699,7 +700,7 @@ def test_refresh(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) @@ -744,7 +745,7 @@ def test_refresh_with_grace_period(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) @@ -795,7 +796,7 @@ def test_refresh_invalidates_old_tokens(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) @@ -827,7 +828,7 @@ def test_refresh_no_scopes(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) @@ -855,7 +856,7 @@ def test_refresh_bad_scopes(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) @@ -881,7 +882,7 @@ def test_refresh_fail_repeating_requests(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) @@ -911,7 +912,7 @@ def test_refresh_repeating_requests(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) @@ -948,7 +949,7 @@ def test_refresh_repeating_requests_non_rotating_tokens(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) @@ -977,7 +978,7 @@ def test_basic_auth_bad_authcode(self): "code": "BLAH", "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 400) @@ -989,7 +990,7 @@ def test_basic_auth_bad_granttype(self): self.client.login(username="test_user", password="123456") token_request_data = {"grant_type": "UNKNOWN", "code": "BLAH", "redirect_uri": "http://example.org"} - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 400) @@ -1014,7 +1015,7 @@ def test_basic_auth_grant_expired(self): "code": "BLAH", "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 400) @@ -1049,7 +1050,7 @@ def test_basic_auth_wrong_auth_type(self): "redirect_uri": "http://example.org", } - user_pass = "{0}:{1}".format(self.application.client_id, self.application.client_secret) + user_pass = "{0}:{1}".format(self.application.client_id, CLEARTEXT_SECRET) auth_string = base64.b64encode(user_pass.encode("utf-8")) auth_headers = { "HTTP_AUTHORIZATION": "Wrong " + auth_string.decode("utf-8"), @@ -1070,7 +1071,7 @@ def test_request_body_params(self): "code": authorization_code, "redirect_uri": "http://example.org", "client_id": self.application.client_id, - "client_secret": self.application.client_secret, + "client_secret": CLEARTEXT_SECRET, } response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) @@ -1117,7 +1118,6 @@ def test_public_pkce_S256_authorize_get(self): self.application.client_type = Application.CLIENT_PUBLIC self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("S256") - self.oauth2_settings.PKCE_REQUIRED = True query_data = { "client_id": self.application.client_id, @@ -1145,7 +1145,6 @@ def test_public_pkce_plain_authorize_get(self): self.application.client_type = Application.CLIENT_PUBLIC self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("plain") - self.oauth2_settings.PKCE_REQUIRED = True query_data = { "client_id": self.application.client_id, @@ -1173,7 +1172,6 @@ def test_public_pkce_S256(self): self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("S256") authorization_code = self.get_pkce_auth(code_challenge, "S256") - self.oauth2_settings.PKCE_REQUIRED = True token_request_data = { "grant_type": "authorization_code", @@ -1202,7 +1200,6 @@ def test_public_pkce_plain(self): self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("plain") authorization_code = self.get_pkce_auth(code_challenge, "plain") - self.oauth2_settings.PKCE_REQUIRED = True token_request_data = { "grant_type": "authorization_code", @@ -1230,7 +1227,6 @@ def test_public_pkce_invalid_algorithm(self): self.application.client_type = Application.CLIENT_PUBLIC self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("invalid") - self.oauth2_settings.PKCE_REQUIRED = True query_data = { "client_id": self.application.client_id, @@ -1252,13 +1248,13 @@ def test_public_pkce_missing_code_challenge(self): Request an access token using client_type: public and PKCE enabled but with the code_challenge missing """ + self.oauth2_settings.PKCE_REQUIRED = True self.client.login(username="test_user", password="123456") self.application.client_type = Application.CLIENT_PUBLIC self.application.skip_authorization = True self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("S256") - self.oauth2_settings.PKCE_REQUIRED = True query_data = { "client_id": self.application.client_id, @@ -1284,7 +1280,6 @@ def test_public_pkce_missing_code_challenge_method(self): self.application.client_type = Application.CLIENT_PUBLIC self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("S256") - self.oauth2_settings.PKCE_REQUIRED = True query_data = { "client_id": self.application.client_id, @@ -1310,7 +1305,6 @@ def test_public_pkce_S256_invalid_code_verifier(self): self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("S256") authorization_code = self.get_pkce_auth(code_challenge, "S256") - self.oauth2_settings.PKCE_REQUIRED = True token_request_data = { "grant_type": "authorization_code", @@ -1334,7 +1328,6 @@ def test_public_pkce_plain_invalid_code_verifier(self): self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("plain") authorization_code = self.get_pkce_auth(code_challenge, "plain") - self.oauth2_settings.PKCE_REQUIRED = True token_request_data = { "grant_type": "authorization_code", @@ -1358,7 +1351,6 @@ def test_public_pkce_S256_missing_code_verifier(self): self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("S256") authorization_code = self.get_pkce_auth(code_challenge, "S256") - self.oauth2_settings.PKCE_REQUIRED = True token_request_data = { "grant_type": "authorization_code", @@ -1381,7 +1373,6 @@ def test_public_pkce_plain_missing_code_verifier(self): self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("plain") authorization_code = self.get_pkce_auth(code_challenge, "plain") - self.oauth2_settings.PKCE_REQUIRED = True token_request_data = { "grant_type": "authorization_code", @@ -1445,7 +1436,7 @@ def test_code_exchange_succeed_when_redirect_uri_match(self): "code": authorization_code, "redirect_uri": "http://example.org?foo=bar", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) @@ -1480,7 +1471,7 @@ def test_code_exchange_fails_when_redirect_uri_does_not_match(self): "code": authorization_code, "redirect_uri": "http://example.org?foo=baraa", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 400) @@ -1520,7 +1511,7 @@ def test_code_exchange_succeed_when_redirect_uri_match_with_multiple_query_param "code": authorization_code, "redirect_uri": "http://example.com?bar=baz&foo=bar", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) @@ -1530,92 +1521,6 @@ def test_code_exchange_succeed_when_redirect_uri_match_with_multiple_query_param self.assertEqual(content["scope"], "read write") self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) - def test_oob_as_html(self): - """ - Test out-of-band authentication. - """ - self.client.login(username="test_user", password="123456") - - authcode_data = { - "client_id": self.application.client_id, - "state": "random_state_string", - "scope": "read write", - "redirect_uri": URI_OOB, - "response_type": "code", - "allow": True, - } - - response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) - self.assertEqual(response.status_code, 200) - self.assertRegex(response["Content-Type"], r"^text/html") - - content = response.content.decode("utf-8") - - # "A lot of applications, for legacy reasons, use this and regex - # to extract the token, risking summoning zalgo in the process." - # -- https://github.com/jazzband/django-oauth-toolkit/issues/235 - - matches = re.search(r".*([^<>]*)", content) - self.assertIsNotNone(matches, msg="OOB response contains code inside tag") - self.assertEqual(len(matches.groups()), 1, msg="OOB response contains multiple tags") - authorization_code = matches.groups()[0] - - token_request_data = { - "grant_type": "authorization_code", - "code": authorization_code, - "redirect_uri": URI_OOB, - "client_id": self.application.client_id, - "client_secret": self.application.client_secret, - } - - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) - self.assertEqual(response.status_code, 200) - - content = json.loads(response.content.decode("utf-8")) - self.assertEqual(content["token_type"], "Bearer") - self.assertEqual(content["scope"], "read write") - self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) - - def test_oob_as_json(self): - """ - Test out-of-band authentication, with a JSON response. - """ - self.client.login(username="test_user", password="123456") - - authcode_data = { - "client_id": self.application.client_id, - "state": "random_state_string", - "scope": "read write", - "redirect_uri": URI_OOB_AUTO, - "response_type": "code", - "allow": True, - } - - response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) - self.assertEqual(response.status_code, 200) - self.assertRegex(response["Content-Type"], "^application/json") - - parsed_response = json.loads(response.content.decode("utf-8")) - - self.assertIn("access_token", parsed_response) - authorization_code = parsed_response["access_token"] - - token_request_data = { - "grant_type": "authorization_code", - "code": authorization_code, - "redirect_uri": URI_OOB_AUTO, - "client_id": self.application.client_id, - "client_secret": self.application.client_secret, - } - - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) - self.assertEqual(response.status_code, 200) - - content = json.loads(response.content.decode("utf-8")) - self.assertEqual(content["token_type"], "Bearer") - self.assertEqual(content["scope"], "read write") - self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) - @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) class TestOIDCAuthorizationCodeTokenView(BaseAuthorizationCodeTokenView): @@ -1681,7 +1586,7 @@ def test_id_token_code_exchange_succeed_when_redirect_uri_match_with_multiple_qu "code": authorization_code, "redirect_uri": "http://example.com?bar=baz&foo=bar", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) @@ -1715,7 +1620,7 @@ def test_id_token(self): "code": authorization_code, "redirect_uri": "http://example.org", "client_id": self.application.client_id, - "client_secret": self.application.client_secret, + "client_secret": CLEARTEXT_SECRET, "scope": "openid", } @@ -1761,7 +1666,7 @@ def test_resource_access_allowed(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) @@ -1819,7 +1724,7 @@ def test_id_token_resource_access_allowed(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) diff --git a/tests/test_client_credential.py b/tests/test_client_credential.py index 8159d55db..38265c3d9 100644 --- a/tests/test_client_credential.py +++ b/tests/test_client_credential.py @@ -1,6 +1,5 @@ import json from unittest.mock import patch -from urllib.parse import quote_plus import pytest from django.contrib.auth import get_user_model @@ -24,6 +23,8 @@ AccessToken = get_access_token_model() UserModel = get_user_model() +CLEARTEXT_SECRET = "abcdefghijklmnopqrstuvwxyz1234567890" + # mocking a protected resource view class ResourceView(ProtectedResourceView): @@ -44,6 +45,7 @@ def setUp(self): user=self.dev_user, client_type=Application.CLIENT_PUBLIC, authorization_grant_type=Application.GRANT_CLIENT_CREDENTIALS, + client_secret=CLEARTEXT_SECRET, ) def tearDown(self): @@ -55,35 +57,28 @@ def tearDown(self): class TestClientCredential(BaseTest): def test_client_credential_access_allowed(self): """ - Request an access token using Client Credential Flow + Request an access token using Client Credential Flow with hashed secrets """ + self.assertNotEqual(self.application.client_secret, CLEARTEXT_SECRET) + token_request_data = { "grant_type": "client_credentials", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) - content = json.loads(response.content.decode("utf-8")) - access_token = content["access_token"] - - # use token to access the resource - auth_headers = { - "HTTP_AUTHORIZATION": "Bearer " + access_token, - } - request = self.factory.get("/fake-resource", **auth_headers) - request.user = self.test_user - - view = ResourceView.as_view() - response = view(request) - self.assertEqual(response, "This is a protected resource") + # secret mismatch should return a 401 + auth_headers = get_basic_auth_header(self.application.client_id, "not-the-secret") + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + self.assertEqual(response.status_code, 401) def test_client_credential_does_not_issue_refresh_token(self): token_request_data = { "grant_type": "client_credentials", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) @@ -93,7 +88,7 @@ def test_client_credential_does_not_issue_refresh_token(self): def test_client_credential_user_is_none_on_access_token(self): token_request_data = {"grant_type": "client_credentials"} - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) @@ -122,7 +117,7 @@ def test_extended_request(self): token_request_data = { "grant_type": "client_credentials", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) @@ -174,12 +169,11 @@ def test_client_resource_password_based(self): user=self.dev_user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_PASSWORD, + client_secret=CLEARTEXT_SECRET, ) token_request_data = {"grant_type": "password", "username": "test_user", "password": "123456"} - auth_headers = get_basic_auth_header( - quote_plus(self.application.client_id), quote_plus(self.application.client_secret) - ) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) diff --git a/tests/test_commands.py b/tests/test_commands.py index ff5deba4e..f9a9f5ade 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1,12 +1,16 @@ from io import StringIO +import pytest from django.contrib.auth import get_user_model +from django.contrib.auth.hashers import check_password from django.core.management import call_command from django.core.management.base import CommandError from django.test import TestCase from oauth2_provider.models import get_application_model +from . import presets + Application = get_application_model() @@ -83,7 +87,7 @@ def test_application_created_with_client_secret(self): ) app = Application.objects.get() - self.assertEqual(app.client_secret, "SECRET") + self.assertTrue(check_password("SECRET", app.client_secret)) def test_application_created_with_client_id(self): call_command( @@ -111,6 +115,20 @@ def test_application_created_with_user(self): self.assertEqual(app.user, user) + @pytest.mark.usefixtures("oauth2_settings") + @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) + def test_application_created_with_algorithm(self): + call_command( + "createapplication", + "confidential", + "authorization-code", + "--redirect-uris=http://example.com http://example2.com", + "--algorithm=RS256", + ) + app = Application.objects.get() + + self.assertEqual(app.algorithm, "RS256") + def test_validation_failed_message(self): output = StringIO() call_command( diff --git a/tests/test_hybrid.py b/tests/test_hybrid.py index 4f9753979..2e85b05b1 100644 --- a/tests/test_hybrid.py +++ b/tests/test_hybrid.py @@ -30,6 +30,8 @@ RefreshToken = get_refresh_token_model() UserModel = get_user_model() +CLEARTEXT_SECRET = "1234567890abcdefghijklmnopqrstuvwxyz" + # mocking a protected resource view class ResourceView(ProtectedResourceView): @@ -50,7 +52,7 @@ def setUp(self): self.factory = RequestFactory() self.hy_test_user = UserModel.objects.create_user("hy_test_user", "test_hy@example.com", "123456") self.hy_dev_user = UserModel.objects.create_user("hy_dev_user", "dev_hy@example.com", "123456") - + self.oauth2_settings.PKCE_REQUIRED = False self.oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES = ["http", "custom-scheme"] self.application = Application( @@ -62,6 +64,7 @@ def setUp(self): client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_OPENID_HYBRID, algorithm=Application.RS256_ALGORITHM, + client_secret=CLEARTEXT_SECRET, ) self.application.save() @@ -829,7 +832,7 @@ def test_basic_auth(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) @@ -850,7 +853,7 @@ def test_basic_auth_bad_authcode(self): "code": "BLAH", "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 400) @@ -862,7 +865,7 @@ def test_basic_auth_bad_granttype(self): self.client.login(username="hy_test_user", password="123456") token_request_data = {"grant_type": "UNKNOWN", "code": "BLAH", "redirect_uri": "http://example.org"} - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 400) @@ -887,7 +890,7 @@ def test_basic_auth_grant_expired(self): "code": "BLAH", "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 400) @@ -922,7 +925,7 @@ def test_basic_auth_wrong_auth_type(self): "redirect_uri": "http://example.org", } - user_pass = "{0}:{1}".format(self.application.client_id, self.application.client_secret) + user_pass = "{0}:{1}".format(self.application.client_id, CLEARTEXT_SECRET) auth_string = base64.b64encode(user_pass.encode("utf-8")) auth_headers = { "HTTP_AUTHORIZATION": "Wrong " + auth_string.decode("utf-8"), @@ -943,7 +946,7 @@ def test_request_body_params(self): "code": authorization_code, "redirect_uri": "http://example.org", "client_id": self.application.client_id, - "client_secret": self.application.client_secret, + "client_secret": CLEARTEXT_SECRET, } response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) @@ -1056,7 +1059,7 @@ def test_code_exchange_succeed_when_redirect_uri_match(self): "code": authorization_code, "redirect_uri": "http://example.org?foo=bar", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) @@ -1091,7 +1094,7 @@ def test_code_exchange_fails_when_redirect_uri_does_not_match(self): "code": authorization_code, "redirect_uri": "http://example.org?foo=baraa", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 400) @@ -1126,7 +1129,7 @@ def test_code_exchange_succeed_when_redirect_uri_match_with_multiple_query_param "code": authorization_code, "redirect_uri": "http://example.com?bar=baz&foo=bar", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) @@ -1163,7 +1166,7 @@ def test_id_token_code_exchange_succeed_when_redirect_uri_match_with_multiple_qu "code": authorization_code, "redirect_uri": "http://example.com?bar=baz&foo=bar", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) @@ -1200,7 +1203,7 @@ def test_resource_access_allowed(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) @@ -1239,7 +1242,7 @@ def test_id_token_resource_access_allowed(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) @@ -1351,7 +1354,7 @@ def test_id_token_nonce_in_token_response(oauth2_settings, test_user, hybrid_app "code": code, "redirect_uri": "http://example.org", "client_id": hybrid_application.client_id, - "client_secret": hybrid_application.client_secret, + "client_secret": CLEARTEXT_SECRET, "scope": "openid", }, ) @@ -1422,7 +1425,7 @@ def test_claims_passed_to_code_generation( "code": code, "redirect_uri": "http://example.org", "client_id": hybrid_application.client_id, - "client_secret": hybrid_application.client_secret, + "client_secret": CLEARTEXT_SECRET, "scope": "openid", }, ) diff --git a/tests/test_introspection_view.py b/tests/test_introspection_view.py index 95374cda5..b19c521d5 100644 --- a/tests/test_introspection_view.py +++ b/tests/test_introspection_view.py @@ -17,6 +17,8 @@ AccessToken = get_access_token_model() UserModel = get_user_model() +CLEARTEXT_SECRET = "1234567890abcdefghijklmnopqrstuvwxyz" + @pytest.mark.usefixtures("oauth2_settings") @pytest.mark.oauth2_settings(presets.INTROSPECTION_SETTINGS) @@ -35,6 +37,7 @@ def setUp(self): user=self.test_user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, + client_secret=CLEARTEXT_SECRET, ) self.resource_server_token = AccessToken.objects.create( @@ -281,7 +284,7 @@ def test_view_post_notexisting_token(self): def test_view_post_valid_client_creds_basic_auth(self): """Test HTTP basic auth working""" - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post( reverse("oauth2_provider:introspect"), {"token": self.valid_token.token}, **auth_headers ) @@ -301,9 +304,7 @@ def test_view_post_valid_client_creds_basic_auth(self): def test_view_post_invalid_client_creds_basic_auth(self): """Must fail for invalid client credentials""" - auth_headers = get_basic_auth_header( - self.application.client_id, self.application.client_secret + "_so_wrong" - ) + auth_headers = get_basic_auth_header(self.application.client_id, f"{CLEARTEXT_SECRET}_so_wrong") response = self.client.post( reverse("oauth2_provider:introspect"), {"token": self.valid_token.token}, **auth_headers ) @@ -316,7 +317,7 @@ def test_view_post_valid_client_creds_plaintext(self): { "token": self.valid_token.token, "client_id": self.application.client_id, - "client_secret": self.application.client_secret, + "client_secret": CLEARTEXT_SECRET, }, ) self.assertEqual(response.status_code, 200) @@ -340,7 +341,7 @@ def test_view_post_invalid_client_creds_plaintext(self): { "token": self.valid_token.token, "client_id": self.application.client_id, - "client_secret": self.application.client_secret + "_so_wrong", + "client_secret": f"{CLEARTEXT_SECRET}_so_wrong", }, ) self.assertEqual(response.status_code, 403) diff --git a/tests/test_oauth2_validators.py b/tests/test_oauth2_validators.py index 7997d3bca..fd06a1eda 100644 --- a/tests/test_oauth2_validators.py +++ b/tests/test_oauth2_validators.py @@ -15,6 +15,7 @@ from oauth2_provider.oauth2_validators import OAuth2Validator from . import presets +from .utils import get_basic_auth_header try: @@ -28,6 +29,8 @@ AccessToken = get_access_token_model() RefreshToken = get_refresh_token_model() +CLEARTEXT_SECRET = "1234567890abcdefghijklmnopqrstuvwxyz" + @contextlib.contextmanager def always_invalid_token(): @@ -51,7 +54,7 @@ def setUp(self): self.validator = OAuth2Validator() self.application = Application.objects.create( client_id="client_id", - client_secret="client_secret", + client_secret=CLEARTEXT_SECRET, user=self.user, client_type=Application.CLIENT_PUBLIC, authorization_grant_type=Application.GRANT_PASSWORD, @@ -69,7 +72,7 @@ def test_authenticate_request_body(self): self.request.client_secret = "wrong_client_secret" self.assertFalse(self.validator._authenticate_request_body(self.request)) - self.request.client_secret = "client_secret" + self.request.client_secret = CLEARTEXT_SECRET self.assertTrue(self.validator._authenticate_request_body(self.request)) def test_extract_basic_auth(self): @@ -86,26 +89,22 @@ def test_extract_basic_auth(self): def test_authenticate_basic_auth(self): self.request.encoding = "utf-8" - # client_id:client_secret - self.request.headers = {"HTTP_AUTHORIZATION": "Basic Y2xpZW50X2lkOmNsaWVudF9zZWNyZXQ=\n"} + self.request.headers = get_basic_auth_header("client_id", CLEARTEXT_SECRET) self.assertTrue(self.validator._authenticate_basic_auth(self.request)) def test_authenticate_basic_auth_default_encoding(self): self.request.encoding = None - # client_id:client_secret - self.request.headers = {"HTTP_AUTHORIZATION": "Basic Y2xpZW50X2lkOmNsaWVudF9zZWNyZXQ=\n"} + self.request.headers = get_basic_auth_header("client_id", CLEARTEXT_SECRET) self.assertTrue(self.validator._authenticate_basic_auth(self.request)) def test_authenticate_basic_auth_wrong_client_id(self): self.request.encoding = "utf-8" - # wrong_id:client_secret - self.request.headers = {"HTTP_AUTHORIZATION": "Basic d3JvbmdfaWQ6Y2xpZW50X3NlY3JldA==\n"} + self.request.headers = get_basic_auth_header("wrong_id", CLEARTEXT_SECRET) self.assertFalse(self.validator._authenticate_basic_auth(self.request)) def test_authenticate_basic_auth_wrong_client_secret(self): self.request.encoding = "utf-8" - # client_id:wrong_secret - self.request.headers = {"HTTP_AUTHORIZATION": "Basic Y2xpZW50X2lkOndyb25nX3NlY3JldA==\n"} + self.request.headers = get_basic_auth_header("client_id", "wrong_secret") self.assertFalse(self.validator._authenticate_basic_auth(self.request)) def test_authenticate_basic_auth_not_b64_auth_string(self): @@ -116,7 +115,6 @@ def test_authenticate_basic_auth_not_b64_auth_string(self): def test_authenticate_basic_auth_invalid_b64_string(self): self.request.encoding = "utf-8" - # client_id:wrong_secret self.request.headers = {"HTTP_AUTHORIZATION": "Basic ZHVtbXk=:ZHVtbXk=\n"} self.assertFalse(self.validator._authenticate_basic_auth(self.request)) @@ -140,7 +138,7 @@ def test_client_authentication_required(self): self.assertTrue(self.validator.client_authentication_required(self.request)) self.request.headers = {} self.request.client_id = "client_id" - self.request.client_secret = "client_secret" + self.request.client_secret = CLEARTEXT_SECRET self.assertTrue(self.validator.client_authentication_required(self.request)) self.request.client_secret = "" self.assertFalse(self.validator.client_authentication_required(self.request)) @@ -327,7 +325,7 @@ def setUp(self): self.validator = OAuth2Validator() self.application = Application.objects.create( client_id="client_id", - client_secret="client_secret", + client_secret=CLEARTEXT_SECRET, user=self.user, client_type=Application.CLIENT_PUBLIC, authorization_grant_type=Application.GRANT_PASSWORD, diff --git a/tests/test_oidc_views.py b/tests/test_oidc_views.py index fa514ac92..7b379d1b3 100644 --- a/tests/test_oidc_views.py +++ b/tests/test_oidc_views.py @@ -17,6 +17,7 @@ def test_get_connect_discovery_info(self): "token_endpoint": "http://localhost/o/token/", "userinfo_endpoint": "http://localhost/o/userinfo/", "jwks_uri": "http://localhost/o/.well-known/jwks.json", + "scopes_supported": ["read", "write", "openid"], "response_types_supported": [ "code", "token", @@ -44,6 +45,7 @@ def test_get_connect_discovery_info_without_issuer_url(self): "token_endpoint": "http://testserver/o/token/", "userinfo_endpoint": "http://testserver/o/userinfo/", "jwks_uri": "http://testserver/o/.well-known/jwks.json", + "scopes_supported": ["read", "write", "openid"], "response_types_supported": [ "code", "token", @@ -158,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, @@ -181,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, @@ -205,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 diff --git a/tests/test_password.py b/tests/test_password.py index 953b076e2..ab0f49228 100644 --- a/tests/test_password.py +++ b/tests/test_password.py @@ -14,6 +14,8 @@ Application = get_application_model() UserModel = get_user_model() +CLEARTEXT_SECRET = "1234567890abcdefghijklmnopqrstuvwxyz" + # mocking a protected resource view class ResourceView(ProtectedResourceView): @@ -33,6 +35,7 @@ def setUp(self): user=self.dev_user, client_type=Application.CLIENT_PUBLIC, authorization_grant_type=Application.GRANT_PASSWORD, + client_secret=CLEARTEXT_SECRET, ) def tearDown(self): @@ -51,7 +54,7 @@ def test_get_token(self): "username": "test_user", "password": "123456", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) @@ -70,7 +73,7 @@ def test_bad_credentials(self): "username": "test_user", "password": "NOT_MY_PASS", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 400) @@ -83,7 +86,7 @@ def test_password_resource_access_allowed(self): "username": "test_user", "password": "123456", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) diff --git a/tests/test_scopes.py b/tests/test_scopes.py index a310e223a..548cc060c 100644 --- a/tests/test_scopes.py +++ b/tests/test_scopes.py @@ -18,6 +18,8 @@ Grant = get_grant_model() UserModel = get_user_model() +CLEARTEXT_SECRET = "1234567890abcdefghijklmnopqrstuvwxyz" + # mocking a protected resource view class ScopeResourceView(ScopedProtectedResourceView): @@ -67,6 +69,7 @@ def setUp(self): user=self.dev_user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, + client_secret=CLEARTEXT_SECRET, ) def tearDown(self): @@ -80,6 +83,7 @@ def test_scopes_saved_in_grant(self): """ Test scopes are properly saved in grant """ + self.oauth2_settings.PKCE_REQUIRED = False self.client.login(username="test_user", password="123456") # retrieve a valid authorization code @@ -102,6 +106,7 @@ def test_scopes_save_in_access_token(self): """ Test scopes are properly saved in access token """ + self.oauth2_settings.PKCE_REQUIRED = False self.client.login(username="test_user", password="123456") # retrieve a valid authorization code @@ -123,7 +128,7 @@ def test_scopes_save_in_access_token(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) @@ -138,6 +143,7 @@ def test_scopes_protection_valid(self): """ Test access to a scope protected resource with correct scopes provided """ + self.oauth2_settings.PKCE_REQUIRED = False self.client.login(username="test_user", password="123456") # retrieve a valid authorization code @@ -159,7 +165,7 @@ def test_scopes_protection_valid(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) @@ -180,6 +186,7 @@ def test_scopes_protection_fail(self): """ Test access to a scope protected resource with wrong scopes provided """ + self.oauth2_settings.PKCE_REQUIRED = False self.client.login(username="test_user", password="123456") # retrieve a valid authorization code @@ -201,7 +208,7 @@ def test_scopes_protection_fail(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) @@ -222,6 +229,7 @@ def test_multi_scope_fail(self): """ Test access to a multi-scope protected resource with wrong scopes provided """ + self.oauth2_settings.PKCE_REQUIRED = False self.client.login(username="test_user", password="123456") # retrieve a valid authorization code @@ -243,7 +251,7 @@ def test_multi_scope_fail(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) @@ -264,6 +272,7 @@ def test_multi_scope_valid(self): """ Test access to a multi-scope protected resource with correct scopes provided """ + self.oauth2_settings.PKCE_REQUIRED = False self.client.login(username="test_user", password="123456") # retrieve a valid authorization code @@ -285,7 +294,7 @@ def test_multi_scope_valid(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) @@ -305,6 +314,7 @@ def test_multi_scope_valid(self): class TestReadWriteScope(BaseTest): def get_access_token(self, scopes): + self.oauth2_settings.PKCE_REQUIRED = False self.client.login(username="test_user", password="123456") # retrieve a valid authorization code @@ -326,7 +336,7 @@ def get_access_token(self, scopes): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) diff --git a/tests/test_settings.py b/tests/test_settings.py index 52bdafe03..f9f540339 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -167,3 +167,8 @@ def test_generating_iss_endpoint_type_error(oauth2_settings): with pytest.raises(TypeError) as exc: oauth2_settings.oidc_issuer(None) assert str(exc.value) == "request must be a django or oauthlib request: got None" + + +def test_pkce_required_is_default(): + settings = OAuth2ProviderSettings() + assert settings.PKCE_REQUIRED is True diff --git a/tests/test_token_revocation.py b/tests/test_token_revocation.py index 1ed1c9119..b4f5af7dd 100644 --- a/tests/test_token_revocation.py +++ b/tests/test_token_revocation.py @@ -13,6 +13,8 @@ RefreshToken = get_refresh_token_model() UserModel = get_user_model() +CLEARTEXT_SECRET = "1234567890abcdefghijklmnopqrstuvwxyz" + class BaseTest(TestCase): def setUp(self): @@ -26,6 +28,7 @@ def setUp(self): user=self.dev_user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, + client_secret=CLEARTEXT_SECRET, ) def tearDown(self): @@ -46,7 +49,7 @@ def test_revoke_access_token(self): data = { "client_id": self.application.client_id, - "client_secret": self.application.client_secret, + "client_secret": CLEARTEXT_SECRET, "token": tok.token, } url = reverse("oauth2_provider:revoke-token") @@ -93,7 +96,7 @@ def test_revoke_access_token_with_hint(self): data = { "client_id": self.application.client_id, - "client_secret": self.application.client_secret, + "client_secret": CLEARTEXT_SECRET, "token": tok.token, "token_type_hint": "access_token", } @@ -115,7 +118,7 @@ def test_revoke_access_token_with_invalid_hint(self): data = { "client_id": self.application.client_id, - "client_secret": self.application.client_secret, + "client_secret": CLEARTEXT_SECRET, "token": tok.token, "token_type_hint": "bad_hint", } @@ -139,7 +142,7 @@ def test_revoke_refresh_token(self): data = { "client_id": self.application.client_id, - "client_secret": self.application.client_secret, + "client_secret": CLEARTEXT_SECRET, "token": rtok.token, } @@ -164,7 +167,7 @@ def test_revoke_refresh_token_with_revoked_access_token(self): for token in (tok.token, rtok.token): data = { "client_id": self.application.client_id, - "client_secret": self.application.client_secret, + "client_secret": CLEARTEXT_SECRET, "token": token, } @@ -194,7 +197,7 @@ def test_revoke_token_with_wrong_hint(self): data = { "client_id": self.application.client_id, - "client_secret": self.application.client_secret, + "client_secret": CLEARTEXT_SECRET, "token": tok.token, "token_type_hint": "refresh_token", } diff --git a/tox.ini b/tox.ini index 03241132f..117a5e901 100644 --- a/tox.ini +++ b/tox.ini @@ -3,6 +3,7 @@ envlist = flake8, migrations, docs, + sphinxlint, py{37,38,39}-dj22, py{37,38,39,310}-dj32, py{38,39,310}-dj40, @@ -11,7 +12,7 @@ envlist = [gh-actions] python = 3.7: py37 - 3.8: py38, docs, flake8, migrations + 3.8: py38, docs, flake8, migrations, sphinxlint 3.9: py39 3.10: py310 @@ -56,6 +57,12 @@ passenv = ignore_errors = true ignore_outcome = true +[testenv:sphinxlint] +deps = sphinx-lint +skip_install = True +commands = + sphinx-lint docs/ + [testenv:{docs,livedocs}] basepython = python3.8 changedir = docs @@ -64,6 +71,7 @@ commands = docs: make html livedocs: make livehtml deps = + Jinja2<3.1 sphinx<3 oauthlib>=3.1.0 m2r>=0.2.1