diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cf4b545e2..99993c029 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,17 +9,16 @@ jobs: fail-fast: false max-parallel: 5 matrix: - python-version: ['3.9'] - + python-version: ['3.9', '3.10'] + django-version: ['3.2'] steps: - uses: actions/checkout@v2 - - name: Set up Python ${{ matrix.python-version }} + - name: Set up Python ${{ matrix.python-version }} Django ${{ matrix.django-version }}) uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} - - name: Get pip cache dir id: pip-cache run: | @@ -42,6 +41,8 @@ jobs: - name: Tox tests run: | tox -v + env: + DJANGO: ${{ matrix.django-version }} - name: Upload coverage uses: codecov/codecov-action@v1 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 323a7fcff..5c78568ea 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,11 +1,11 @@ repos: - - repo: https://github.com/ambv/black - rev: 20.8b1 + - repo: https://github.com/psf/black + rev: 21.12b0 hooks: - id: black exclude: ^(oauth2_provider/migrations/|tests/migrations/) - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v3.2.0 + rev: v4.1.0 hooks: - id: check-ast - id: trailing-whitespace @@ -16,12 +16,12 @@ repos: - id: mixed-line-ending args: ['--fix=lf'] - repo: https://github.com/PyCQA/isort - rev: 5.6.3 + rev: 5.10.1 hooks: - id: isort exclude: ^(oauth2_provider/migrations/|tests/migrations/) - - repo: https://gitlab.com/pycqa/flake8 - rev: 3.8.4 + - repo: https://github.com/PyCQA/flake8 + rev: 4.0.1 hooks: - id: flake8 exclude: ^(oauth2_provider/migrations/|tests/migrations/) diff --git a/.readthedocs.yml b/.readthedocs.yml index eef926c3b..e6f30f627 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -1,15 +1,29 @@ -# .readthedocs.yml +# .readthedocs.yaml # Read the Docs configuration file # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details # Required version: 2 +# Set the version of Python and other tools you might need +build: + os: ubuntu-20.04 + tools: + python: "3.9" + # You can also specify other tool versions: + # nodejs: "16" + # rust: "1.55" + # golang: "1.17" + # Build documentation in the docs/ directory with Sphinx sphinx: - configuration: docs/conf.py + configuration: docs/conf.py + +# If using Sphinx, optionally build your docs in additional formats such as PDF +# formats: +# - pdf +# Optionally declare the Python requirements required to build your docs python: - version: 3.7 - install: - - requirements: docs/requirements.txt + install: + - requirements: docs/requirements.txt diff --git a/AUTHORS b/AUTHORS index da2570ef7..a1591b6da 100644 --- a/AUTHORS +++ b/AUTHORS @@ -1,17 +1,19 @@ Authors -======= +------- Massimiliano Pippi Federico Frenguelli Contributors -============ +------------ Abhishek Patel Alan Crosswell Aleksander Vaskevich Alessandro De Angelis +Alex Szabó Allisson Azevedo +Andrew Chen Wang Anvesh Agarwal Aristóbulo Meneses Aryan Iyappan @@ -22,21 +24,32 @@ Bas van Oostveen Dave Burkholder David Fischer David Smith +Dawid Wolski Diego Garcia Dulmandakh Sukhbaatar Dylan Giesler +Dylan Tack Emanuele Palazzetti Federico Dolce Frederico Vieira +Hasan Ramezani +Hossein Shakiba Hiroki Kiyohara Jens Timmerman Jerome Leclanche Jim Graham +Jonas Nygaard Pedersen Jonathan Steffan +Jozef Knaperek Jun Zhou Kristian Rune Larsen +Michael Howitz +Paul Dekkers Paul Oswald Pavel Tvrdík +Patrick Palacin +Peter Carnesciali +Petr Dlouhý Rodney Richardson Rustem Saiargaliev Sandro Rodrigues @@ -46,5 +59,12 @@ Spencer Carroll Stéphane Raimbault Tom Evans Will Beaufoy +Rustem Saiargaliev +Jadiel Teófilo pySilver Łukasz Skarżyński +Shaheed Haque +Vinay Karanam +Eduardo Oliveira +Andrea Greco +Dominik George diff --git a/CHANGELOG.md b/CHANGELOG.md index 534ba1c86..b66e0822d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,75 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security --> +## [1.7.0] 2022-01-23 + +### Added +* #969 Add batching of expired token deletions in `cleartokens` management command and `models.clear_expired()` + to improve performance for removal of large numers of expired tokens. Configure with + [`CLEAR_EXPIRED_TOKENS_BATCH_SIZE`](https://django-oauth-toolkit.readthedocs.io/en/latest/settings.html#clear-expired-tokens-batch-size) and + [`CLEAR_EXPIRED_TOKENS_BATCH_INTERVAL`](https://django-oauth-toolkit.readthedocs.io/en/latest/settings.html#clear-expired-tokens-batch-interval). +* #1070 Add a Celery task for clearing expired tokens, e.g. to be scheduled as a [periodic task](https://docs.celeryproject.org/en/stable/userguide/periodic-tasks.html). +* #1062 Add Brazilian Portuguese (pt-BR) translations. +* #1069 OIDC: Add an alternate form of + [get_additional_claims()](https://django-oauth-toolkit.readthedocs.io/en/latest/oidc.html#adding-claims-to-the-id-token) + which makes the list of additional `claims_supported` available at the OIDC auto-discovery endpoint (`.well-known/openid-configuration`). + +### Fixed +* #1012 Return 200 status code with `{"active": false}` when introspecting a nonexistent token + per [RFC 7662](https://datatracker.ietf.org/doc/html/rfc7662#section-2.2). It had been incorrectly returning 401. + +## [1.6.3] 2022-01-11 + +### Fixed +* #1085 Fix for #1083 admin UI search for idtoken results in `django.core.exceptions.FieldError: Cannot resolve keyword 'token' into field.` + +### Added +* #1085 Add admin UI search fields for additional models. + +## [1.6.2] 2022-01-06 + +**NOTE: This release reverts an inadvertently-added breaking change.** + +### Fixed + +* #1056 Add missing migration triggered by [Django 4.0 changes to the migrations autodetector](https://docs.djangoproject.com/en/4.0/releases/4.0/#migrations-autodetector-changes). +* #1068 Revert #967 which incorrectly changed an API. See #1066. + +## [1.6.1] 2021-12-23 + +### Changed +* Note: Only Django 4.0.1+ is supported due to a regression in Django 4.0.0. [Explanation](https://github.com/jazzband/django-oauth-toolkit/pull/1046#issuecomment-998015272) + +### Fixed +* Miscellaneous 1.6.0 packaging issues. + +## [1.6.0] 2021-12-19 +### Added +* #949 Provide django.contrib.auth.authenticate() with a `request` for compatibiity with more backends (like django-axes). +* #968, #1039 Add support for Django 3.2 and 4.0. +* #953 Allow loopback redirect URIs using random ports as described in [RFC8252 section 7.3](https://datatracker.ietf.org/doc/html/rfc8252#section-7.3). +* #972 Add Farsi/fa language support. +* #978 OIDC: Add support for [rotating multiple RSA private keys](https://django-oauth-toolkit.readthedocs.io/en/latest/oidc.html#rotating-the-rsa-private-key). +* #978 OIDC: Add new [OIDC_JWKS_MAX_AGE_SECONDS](https://django-oauth-toolkit.readthedocs.io/en/latest/settings.html#oidc-jwks-max-age-seconds) to improve `jwks_uri` caching. +* #967 OIDC: Add [additional claims](https://django-oauth-toolkit.readthedocs.io/en/latest/oidc.html#adding-claims-to-the-id-token) beyond `sub` to the id_token. +* #1041 Add a search field to the Admin UI (e.g. for search for tokens by email address). + +### Changed +* #981 Require redirect_uri if multiple URIs are registered per [RFC6749 section 3.1.2.3](https://datatracker.ietf.org/doc/html/rfc6749#section-3.1.2.3) +* #991 Update documentation of [REFRESH_TOKEN_EXPIRE_SECONDS](https://django-oauth-toolkit.readthedocs.io/en/latest/settings.html#refresh-token-expire-seconds) to indicate it may be `int` or `datetime.timedelta`. +* #977 Update [Tutorial](https://django-oauth-toolkit.readthedocs.io/en/stable/tutorial/tutorial_01.html#) to show required `include`. + +### Removed +* #968 Remove support for Django 3.0 & 3.1 and Python 3.6 +* #1035 Removes default_app_config for Django Deprecation Warning +* #1023 six should be dropped + +### Fixed +* #963 Fix handling invalid hex values in client query strings with a 400 error rather than 500. +* #973 [Tutorial](https://django-oauth-toolkit.readthedocs.io/en/latest/tutorial/tutorial_01.html#start-your-app) updated to use `django-cors-headers`. +* #956 OIDC: Update documentation of [get_userinfo_claims](https://django-oauth-toolkit.readthedocs.io/en/latest/oidc.html#adding-information-to-the-userinfo-service) to add the missing argument. + + ## [1.5.0] 2021-03-18 ### Added diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..e0d5efab5 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,46 @@ +# Code of Conduct + +As contributors and maintainers of the Jazzband projects, and in the interest of +fostering an open and welcoming community, we pledge to respect all people who +contribute through reporting issues, posting feature requests, updating documentation, +submitting pull requests or patches, and other activities. + +We are committed to making participation in the Jazzband a harassment-free experience +for everyone, regardless of the level of experience, gender, gender identity and +expression, sexual orientation, disability, personal appearance, body size, race, +ethnicity, age, religion, or nationality. + +Examples of unacceptable behavior by participants include: + +- The use of sexualized language or imagery +- Personal attacks +- Trolling or insulting/derogatory comments +- Public or private harassment +- Publishing other's private information, such as physical or electronic addresses, + without explicit permission +- Other unethical or unprofessional conduct + +The Jazzband roadies have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are not +aligned to this Code of Conduct, or to ban temporarily or permanently any contributor +for other behaviors that they deem inappropriate, threatening, offensive, or harmful. + +By adopting this Code of Conduct, the roadies commit themselves to fairly and +consistently applying these principles to every aspect of managing the jazzband +projects. Roadies who do not follow or enforce the Code of Conduct may be permanently +removed from the Jazzband roadies. + +This code of conduct applies both within project spaces and in public spaces when an +individual is representing the project or its community. + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by +contacting the roadies at `roadies@jazzband.co`. All complaints will be reviewed and +investigated and will result in a response that is deemed necessary and appropriate to +the circumstances. Roadies are obligated to maintain confidentiality with regard to the +reporter of an incident. + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version +1.3.0, available at [https://contributor-covenant.org/version/1/3/0/][version] + +[homepage]: https://contributor-covenant.org +[version]: https://contributor-covenant.org/version/1/3/0/ diff --git a/LICENSE b/LICENSE index 13606c9fb..423ba01fb 100644 --- a/LICENSE +++ b/LICENSE @@ -2,13 +2,13 @@ Copyright (c) 2013, Massimiliano Pippi, Federico Frenguelli and contributors All rights reserved. Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: +modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. + list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. + and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED @@ -22,5 +22,5 @@ ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. The views and conclusions contained in the software and documentation are those -of the authors and should not be interpreted as representing official policies, +of the authors and should not be interpreted as representing official policies, either expressed or implied, of the FreeBSD Project. diff --git a/README.rst b/README.rst index 1fc8a275b..6d42962e4 100644 --- a/README.rst +++ b/README.rst @@ -18,6 +18,14 @@ Django OAuth Toolkit :target: https://codecov.io/gh/jazzband/django-oauth-toolkit :alt: Coverage +.. image:: https://img.shields.io/pypi/pyversions/django-oauth-toolkit.svg + :target: https://pypi.org/project/django-oauth-toolkit/ + :alt: Supported Python versions + +.. image:: https://img.shields.io/pypi/djversions/django-oauth-toolkit.svg + :target: https://pypi.org/project/django-oauth-toolkit/ + :alt: Supported Django versions + If you are facing one or more of the following: * Your Django app exposes a web API you want to protect with OAuth2 authentication, * You need to implement an OAuth2 authorization server to provide tokens management for your infrastructure, @@ -27,6 +35,10 @@ capabilities to your Django projects. Django OAuth Toolkit makes extensive use o `OAuthLib `_, so that everything is `rfc-compliant `_. +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 ------------ @@ -41,8 +53,8 @@ Please report any security issues to the JazzBand security team at =4.0.1 * oauthlib 3.1+ Installation diff --git a/docs/contributing.rst b/docs/contributing.rst index c336d0422..00b4dbedc 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -96,6 +96,24 @@ When deploying your app, don't forget to compile the messages with:: django-admin compilemessages +Migrations +========== + +If you alter any models, a new migration will need to be generated. This step is frequently missed +by new contributors. You can check if a new migration is needed with:: + + tox -e migrations + +And, if a new migration is needed, use:: + + django-admin makemigrations --settings tests.mig_settings + +Auto migrations frequently have ugly names like `0004_auto_20200902_2022`. You can make your migration +name "better" by adding the `-n name` option:: + + django-admin makemigrations --settings tests.mig_settings -n widget + + Pull requests ============= @@ -154,7 +172,8 @@ When you begin your PR, you'll be asked to provide the following: If your PR is not yet ready to be merged mark it as a Work-in-Progress By prepending `WIP:` to the PR title so that it doesn't get inadvertently approved and merged. -The repo managers will be notified of your pull request and it will be reviewed, in the meantime you can continue to add +Make sure to request a review by assigning Reviewer `jazzband/django-oauth-toolkit`. +This will assign the review to the project team and a member will review it. In the meantime you can continue to add commits to your topic branch (and push them up to GitHub) either if you see something that needs changing, or in response to a reviewer's comments. If a reviewer asks for changes, you do not need to close the pull and reissue it after making changes. Just make the changes locally, push them to GitHub, then add a comment to the discussion section @@ -255,7 +274,7 @@ The following notes are to remind the project maintainers and leads of the steps review and merge PRs and to publish a new release. Reviewing and Merging PRs ------------------------- +------------------------- - Make sure the PR description includes the `pull request template `_ @@ -271,18 +290,25 @@ PRs that are incorrectly merged may (reluctantly) be reverted by the Project Lea Publishing a Release -------------------- -Only Project Leads can publish a release to pypi.org and rtfd.io. This checklist is a reminder -of steps. +Only Project Leads can `publish a release `_ to pypi.org +and rtfd.io. This checklist is a reminder of the required steps. - When planning a new release, create a `milestone `_ and assign issues, PRs, etc. to that milestone. - Review all commits since the last release and confirm that they are properly - documented in the CHANGELOG. (Unfortunately, this has not always been the case - so you may be stuck documenting things that should have been documented as part of their PRs.) + documented in the CHANGELOG. Reword entries as appropriate with links to docs + to make them meaningful to users. - Make a final PR for the release that updates: - CHANGELOG to show the release date. - - setup.cfg to set `version = ...` - -- Once the final PR is committed push the new release to pypi and rtfd.io. + - `oauth2_provider/__init__.py` to set `__version__ = "..."` + +- Once the final PR is merged, create and push a tag for the release. You'll shortly + get a notification from Jazzband of the availability of two pypi packages (source tgz + and wheel). Download these locally before releasing them. +- Do a `tox -e build` and extract the downloaded and bullt wheel zip and tgz files into + temp directories and do a `diff -r` to make sure they have the same content. + (Unfortunately the checksums do not match due to timestamps in the metadata + so you need to compare all the files.) +- Once happy that the above comparison checks out, approve the releases to Pypi.org. diff --git a/docs/getting_started.rst b/docs/getting_started.rst index 427195ae9..3ea4f7e58 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -87,7 +87,7 @@ Edit :file:`users/models.py` adding the code below: .. code-block:: python from django.contrib.auth.models import AbstractUser - + class User(AbstractUser): pass @@ -213,8 +213,8 @@ Create a user:: Username: wiliam Email address: me@wiliam.dev - Password: - Password (again): + Password: + Password (again): Superuser created successfully. OAuth2 Authorization Grants @@ -358,7 +358,7 @@ Export the credential as an environment variable export CREDENTIAL=YXhYU1NCVnV2T3lHVnpoNFB1cnZLYXE1TUhYTW03RnRySGdETWk0dToxZnV2NVdWZlI3QTVCbEYwbzE1NUg3czViTGdYbHdXTGhpM1k3cGRKOWFKdUNkbDBYVjVDeGdkMHRyaTduU3pDODBxeXJvdmg4cUZYRkhnRkFBYzBsZFBObjVaWUxhbnhTbTFTSTFyeGxScldVUDU5MXdwSERHYTNwU3BCNmRDWg== -To start the Client Credential flow you call ``/token/`` endpoint direct:: +To start the Client Credential flow you call ``/token/`` endpoint directly:: curl -X POST -H "Authorization: Basic ${CREDENTIAL}" -H "Cache-Control: no-cache" -H "Content-Type: application/x-www-form-urlencoded" "http://127.0.0.1:8000/o/token/" -d "grant_type=client_credentials" diff --git a/docs/index.rst b/docs/index.rst index d2d4e8c3c..fdd8131b7 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -21,8 +21,8 @@ If you need help please submit a `question =3.0,<3.1 +Django oauthlib>=3.1.0 m2r>=0.2.1 -. +mistune<2 +-e . diff --git a/docs/settings.rst b/docs/settings.rst index afca76e01..01baaaf4b 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -52,6 +52,11 @@ Default: ``["http", "https"]`` A list of schemes that the ``redirect_uri`` field will be validated against. Setting this to ``["https"]`` only in production is strongly recommended. +For Native Apps the ``http`` scheme can be safely used with loopback addresses in the +Application (``[::1]`` or ``127.0.0.1``). In this case the ``redirect_uri`` can be +configured without explicit port specification, so that the Application accepts randomly +assigned ports. + Note that you may override ``Application.get_allowed_schemes()`` to set this on a per-application basis. @@ -142,6 +147,8 @@ REFRESH_TOKEN_EXPIRE_SECONDS ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The number of seconds before a refresh token gets removed from the database by the ``cleartokens`` management command. Check :ref:`cleartokens` management command for further info. +Can be an ``Int`` or ``datetime.timedelta``. + NOTE: This value is completely ignored when validating refresh tokens. 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. @@ -257,6 +264,25 @@ 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, +but are published in the jwks_uri location. + +This is useful for providing a smooth transition during key rotation. +``OIDC_RSA_PRIVATE_KEY`` can be replaced, and recently decommissioned keys +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. + +This enables the verifier to safely cache the JWK Set and not have to re-download +the document for every token. OIDC_USERINFO_ENDPOINT ~~~~~~~~~~~~~~~~~~~~~~ @@ -310,3 +336,27 @@ OIDC_TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED Default: ``["client_secret_post", "client_secret_basic"]`` The authentication methods that are advertised to be supported by this server. + +CLEAR_EXPIRED_TOKENS_BATCH_SIZE +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Default: ``10000`` + +The size of delete batches used by ``cleartokens`` management command. + +CLEAR_EXPIRED_TOKENS_BATCH_INTERVAL +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Default: ``0`` + +Time of sleep in seconds used by ``cleartokens`` management command between batch deletions. + +Set this to a non-zero value (e.g. `0.1`) to add a pause between batch sizes to reduce system +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/tutorial/tutorial_01.rst b/docs/tutorial/tutorial_01.rst index 6b605c19f..f0b8cb3ed 100644 --- a/docs/tutorial/tutorial_01.rst +++ b/docs/tutorial/tutorial_01.rst @@ -9,14 +9,14 @@ Start Your App -------------- During this tutorial you will make an XHR POST from a Heroku deployed app to your localhost instance. Since the domain that will originate the request (the app on Heroku) is different from the destination domain (your local instance), -you will need to install the `django-cors-middleware `_ app. +you will need to install the `django-cors-headers `_ app. These "cross-domain" requests are by default forbidden by web browsers unless you use `CORS `_. -Create a virtualenv and install `django-oauth-toolkit` and `django-cors-middleware`: +Create a virtualenv and install `django-oauth-toolkit` and `django-cors-headers`: :: - pip install django-oauth-toolkit django-cors-middleware + pip install django-oauth-toolkit django-cors-headers Start a Django project, add `oauth2_provider` and `corsheaders` to the installed apps, and enable admin: @@ -33,6 +33,8 @@ Include the Django OAuth Toolkit urls in your `urls.py`, choosing the urlspace y .. code-block:: python + from django.urls import path, include + urlpatterns = [ path("admin", admin.site.urls), path("o/", include('oauth2_provider.urls', namespace='oauth2_provider')), @@ -78,7 +80,7 @@ the API, subject to approval by its users. Let's register your application. -You need to be logged in before registration. So, go to http://localhost:8000/admin and log in. After that +You need to be logged in before registration. So, go to http://localhost:8000/admin and log in. After that point your browser to http://localhost:8000/o/applications/ and add an Application instance. `Client id` and `Client Secret` are automatically generated; you have to provide the rest of the informations: diff --git a/docs/tutorial/tutorial_03.rst b/docs/tutorial/tutorial_03.rst index ad56e310a..52868c01f 100644 --- a/docs/tutorial/tutorial_03.rst +++ b/docs/tutorial/tutorial_03.rst @@ -15,21 +15,21 @@ which takes care of token verification. In your settings.py: .. code-block:: python - AUTHENTICATION_BACKENDS = ( + AUTHENTICATION_BACKENDS = [ 'oauth2_provider.backends.OAuth2Backend', # Uncomment following if you want to access the admin - #'django.contrib.auth.backends.ModelBackend' + #'django.contrib.auth.backends.ModelBackend', '...', - ) + ] - MIDDLEWARE = ( + MIDDLEWARE = [ '...', # If you use SessionAuthenticationMiddleware, be sure it appears before OAuth2TokenMiddleware. # SessionAuthenticationMiddleware is NOT required for using django-oauth-toolkit. 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', 'oauth2_provider.middleware.OAuth2TokenMiddleware', '...', - ) + ] You will likely use the `django.contrib.auth.backends.ModelBackend` along with the OAuth2 backend (or you might not be able to log in into the admin), only pay attention to the order in which diff --git a/docs/tutorial/tutorial_04.rst b/docs/tutorial/tutorial_04.rst index 3908579da..c13974e18 100644 --- a/docs/tutorial/tutorial_04.rst +++ b/docs/tutorial/tutorial_04.rst @@ -1,4 +1,4 @@ -Part 4 - Revoking an OAuth2 Token +Part 4 - Revoking an OAuth2 Token ================================= Scenario @@ -11,10 +11,10 @@ Be sure that you've granted a valid token. If you've hooked in `oauth-toolkit` i `Oauthlib `_ is compliant with https://tools.ietf.org/html/rfc7009, so as specified, the revocation request requires: -- token: REQUIRED, this is the :term:`Access Token` you want to revoke -- token_type_hint: OPTIONAL, designating either 'access_token' or 'refresh_token'. +- token: REQUIRED, this is the :term:`Access Token` you want to revoke +- token_type_hint: OPTIONAL, designating either 'access_token' or 'refresh_token'. -Note that these revocation-specific parameters are in addition to the authentication parameters already specified by your particular client type. +Note that these revocation-specific parameters are in addition to the authentication parameters already specified by your particular client type. Setup a Request --------------- @@ -26,8 +26,8 @@ Depending on the client type you're using, the token revocation request you may Content-Type: application/x-www-form-urlencoded token=XXXX&client_id=XXXX -Where token is :term:`Access Token` specified above, and client_id is the `Client id` obtained in -obtained in :doc:`part 1 `. If your application type is `Confidential` , it requires a `Client secret`, you will have to add it as one of the parameters: +Where token is :term:`Access Token` specified above, and client_id is the `Client id` obtained in +obtained in :doc:`part 1 `. If your application type is `Confidential` , it requires a `Client secret`, you will have to add it as one of the parameters: :: @@ -36,7 +36,7 @@ obtained in :doc:`part 1 `. If your application type is `Confidenti token=XXXX&client_id=XXXX&client_secret=XXXX -The server will respond wih a `200` status code on successful revocation. You can use `curl` to make a revoke request on your server. If you have access to a local installation of your authorization server, you can test revoking a token with a request like that shown below, for a `Confidential` client. +The server will respond wih a `200` status code on successful revocation. You can use `curl` to make a revoke request on your server. If you have access to a local installation of your authorization server, you can test revoking a token with a request like that shown below, for a `Confidential` client. :: diff --git a/docs/views/token.rst b/docs/views/token.rst index 02f6bf53e..ead0d023d 100644 --- a/docs/views/token.rst +++ b/docs/views/token.rst @@ -8,7 +8,7 @@ Every view provides access only to the tokens that have been granted to the user Granted Token views are listed at the url `authorized_tokens/`. -For each granted token there is a delete view that allows you to delete such token. You can override default templates `authorized-tokens.html` for the list view and `authorized-token-delete.html` for the delete view; they are located inside `templates/oauth2_provider` folder. +For each granted token there is a delete view that allows you to delete such token. You can override default templates `authorized-tokens.html` for the list view and `authorized-token-delete.html` for the delete view; they are located inside `templates/oauth2_provider` folder. .. automodule:: oauth2_provider.views.token diff --git a/oauth2_provider/__init__.py b/oauth2_provider/__init__.py index c4e8c4eb4..805f886e8 100644 --- a/oauth2_provider/__init__.py +++ b/oauth2_provider/__init__.py @@ -1,6 +1,7 @@ -import pkg_resources +import django -__version__ = pkg_resources.require("django-oauth-toolkit")[0].version +__version__ = "1.7.0" -default_app_config = "oauth2_provider.apps.DOTConfig" +if django.VERSION < (3, 2): + default_app_config = "oauth2_provider.apps.DOTConfig" diff --git a/oauth2_provider/admin.py b/oauth2_provider/admin.py index 79bcf7702..cf41ec5b2 100644 --- a/oauth2_provider/admin.py +++ b/oauth2_provider/admin.py @@ -1,4 +1,5 @@ from django.contrib import admin +from django.contrib.auth import get_user_model from oauth2_provider.models import ( get_access_token_admin_class, @@ -14,6 +15,9 @@ ) +has_email = hasattr(get_user_model(), "email") + + class ApplicationAdmin(admin.ModelAdmin): list_display = ("id", "name", "user", "client_type", "authorization_grant_type") list_filter = ("client_type", "authorization_grant_type", "skip_authorization") @@ -21,27 +25,36 @@ class ApplicationAdmin(admin.ModelAdmin): "client_type": admin.HORIZONTAL, "authorization_grant_type": admin.VERTICAL, } + search_fields = ("name",) + (("user__email",) if has_email else ()) raw_id_fields = ("user",) class AccessTokenAdmin(admin.ModelAdmin): list_display = ("token", "user", "application", "expires") + list_select_related = ("application", "user") raw_id_fields = ("user", "source_refresh_token") + search_fields = ("token",) + (("user__email",) if has_email else ()) + list_filter = ("application",) class GrantAdmin(admin.ModelAdmin): list_display = ("code", "application", "user", "expires") raw_id_fields = ("user",) + search_fields = ("code",) + (("user__email",) if has_email else ()) class IDTokenAdmin(admin.ModelAdmin): list_display = ("jti", "user", "application", "expires") raw_id_fields = ("user",) + search_fields = ("user__email",) if has_email else () + list_filter = ("application",) class RefreshTokenAdmin(admin.ModelAdmin): list_display = ("token", "user", "application") raw_id_fields = ("user", "access_token") + search_fields = ("token",) + (("user__email",) if has_email else ()) + list_filter = ("application",) application_model = get_application_model() diff --git a/oauth2_provider/backends.py b/oauth2_provider/backends.py index 3f6fab9af..2570cd62b 100644 --- a/oauth2_provider/backends.py +++ b/oauth2_provider/backends.py @@ -1,4 +1,5 @@ from django.contrib.auth import get_user_model +from django.core.exceptions import SuspiciousOperation from .oauth2_backends import get_oauthlib_core @@ -14,9 +15,17 @@ class OAuth2Backend: def authenticate(self, request=None, **credentials): if request is not None: - valid, r = OAuthLibCore.verify_request(request, scopes=[]) - if valid: - return r.user + try: + valid, request = OAuthLibCore.verify_request(request, scopes=[]) + except ValueError as error: + if str(error) == "Invalid hex encoding in query string.": + raise SuspiciousOperation(error) + else: + raise + else: + if valid: + return request.user + return None def get_user(self, user_id): diff --git a/oauth2_provider/locale/fa/LC_MESSAGES/django.po b/oauth2_provider/locale/fa/LC_MESSAGES/django.po new file mode 100644 index 000000000..017e50ddf --- /dev/null +++ b/oauth2_provider/locale/fa/LC_MESSAGES/django.po @@ -0,0 +1,202 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2021-05-01 15:33+0430\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: HOSSEIN SHAKIBA \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" + +#: models.py:49 +msgid "Confidential" +msgstr "محرمانه" + +#: models.py:50 +msgid "Public" +msgstr "عمومی" + +#: models.py:59 +msgid "Authorization code" +msgstr "کد مجوز" + +#: models.py:60 +msgid "Implicit" +msgstr "ضمنی" + +#: models.py:61 +msgid "Resource owner password-based" +msgstr "صاحب منبع مبتنی بر رمز عبور" + +#: models.py:62 +msgid "Client credentials" +msgstr "اعتبار مخاطب" + +#: models.py:63 +msgid "OpenID connect hybrid" +msgstr "اتصال ترکیبی OpenID" + +#: models.py:70 +msgid "No OIDC support" +msgstr "OIDC پشتیبانی وجود ندارد از" + +#: models.py:71 +msgid "RSA with SHA-2 256" +msgstr "SHA-2 256 با RSA" + +#: models.py:72 +msgid "HMAC with SHA-2 256" +msgstr "SHA-2 256 با HMAC" + +#: models.py:87 +msgid "Allowed URIs list, space separated" +msgstr "مجاز، با فاصله از هم جدا شده‌اند URIs فهرست" + +#: models.py:152 +#, python-brace-format +msgid "Unauthorized redirect scheme: {scheme}" +msgstr "{scheme} :طرح تغییر مسیر غیرمجاز" + +#: models.py:156 +#, python-brace-format +msgid "redirect_uris cannot be empty with grant_type {grant_type}" +msgstr "{grant_type} خالی باشد grant_type نمی تواند با redirect_uris " + +#: models.py:162 +msgid "You must set OIDC_RSA_PRIVATE_KEY to use RSA algorithm" +msgstr "را تنظیم کنید OIDC_RSA_PRIVATE_KEY باید RSA برای استفاده از الگوریتم" + +#: models.py:171 +msgid "You cannot use HS256 with public grants or clients" +msgstr "" + +#: oauth2_validators.py:181 +msgid "The access token is invalid." +msgstr "توکن دسترسی نامعتبر است" + +#: oauth2_validators.py:188 +msgid "The access token has expired." +msgstr "توکن دسترسی منقضی شده است" + +#: oauth2_validators.py:195 +msgid "The access token is valid but does not have enough scope." +msgstr "توکن دسترسی معتبر است اما دامنه کافی ندارد" + +#: templates/oauth2_provider/application_confirm_delete.html:6 +msgid "Are you sure to delete the application" +msgstr "آیا مطمئن هستید که برنامه را حذف می کنید" + +#: templates/oauth2_provider/application_confirm_delete.html:12 +#: templates/oauth2_provider/authorize.html:29 +msgid "Cancel" +msgstr "لغو" + +#: templates/oauth2_provider/application_confirm_delete.html:13 +#: templates/oauth2_provider/application_detail.html:38 +#: templates/oauth2_provider/authorized-token-delete.html:7 +msgid "Delete" +msgstr "حذف" + +#: templates/oauth2_provider/application_detail.html:10 +msgid "Client id" +msgstr "شناسه(آیدی) کاربر" + +#: templates/oauth2_provider/application_detail.html:15 +msgid "Client secret" +msgstr "راز کاربر" + +#: templates/oauth2_provider/application_detail.html:20 +msgid "Client type" +msgstr "نوع کاربر" + +#: templates/oauth2_provider/application_detail.html:25 +msgid "Authorization Grant Type" +msgstr "نوع اعطای مجوز" + +#: templates/oauth2_provider/application_detail.html:30 +msgid "Redirect Uris" +msgstr "تغییر مسیر URIs" + +#: templates/oauth2_provider/application_detail.html:36 +#: templates/oauth2_provider/application_form.html:35 +msgid "Go Back" +msgstr "بازگشت" + +#: templates/oauth2_provider/application_detail.html:37 +msgid "Edit" +msgstr "ویرایش" + +#: templates/oauth2_provider/application_form.html:9 +msgid "Edit application" +msgstr "ویرایش برنامه" + +#: templates/oauth2_provider/application_form.html:37 +msgid "Save" +msgstr "ذخیره" + +#: templates/oauth2_provider/application_list.html:6 +msgid "Your applications" +msgstr "برنامه شما" + +#: templates/oauth2_provider/application_list.html:14 +msgid "New Application" +msgstr "برنامه جدید" + +#: templates/oauth2_provider/application_list.html:17 +msgid "No applications defined" +msgstr "هیچ برنامه ای تعریف نشده است" + +#: templates/oauth2_provider/application_list.html:17 +msgid "Click here" +msgstr "اینجا کلیک کنید" + +#: templates/oauth2_provider/application_list.html:17 +msgid "if you want to register a new one" +msgstr "اگر می خواهید مورد جدیدی ثبت کنید" + +#: templates/oauth2_provider/application_registration_form.html:5 +msgid "Register a new application" +msgstr "ثبت یک برنامه جدید" + +#: templates/oauth2_provider/authorize.html:8 +#: templates/oauth2_provider/authorize.html:30 +msgid "Authorize" +msgstr "اجازه دادن" + +#: templates/oauth2_provider/authorize.html:17 +msgid "Application requires the following permissions" +msgstr "برنامه به مجوزهای زیر نیاز دارد" + +#: templates/oauth2_provider/authorized-oob.html:12 +msgid "Success" +msgstr "موفقیت" + +#: templates/oauth2_provider/authorized-oob.html:14 +msgid "Please return to your application and enter this code:" +msgstr "لطفاً به برنامه خود برگردید و این کد را وارد کنید:" + +#: templates/oauth2_provider/authorized-token-delete.html:6 +msgid "Are you sure you want to delete this token?" +msgstr "آیا مطمئن هستید که می خواهید این توکن را حذف کنید؟" + +#: templates/oauth2_provider/authorized-tokens.html:6 +msgid "Tokens" +msgstr "توکن‌ها" + +#: templates/oauth2_provider/authorized-tokens.html:11 +msgid "revoke" +msgstr "باطل کردن" + +#: templates/oauth2_provider/authorized-tokens.html:19 +msgid "There are no authorized tokens yet." +msgstr "هنوز هیچ توکن مجازی وجود ندارد." diff --git a/oauth2_provider/locale/pt_BR/LC_MESSAGES/django.po b/oauth2_provider/locale/pt_BR/LC_MESSAGES/django.po new file mode 100644 index 000000000..48d673e33 --- /dev/null +++ b/oauth2_provider/locale/pt_BR/LC_MESSAGES/django.po @@ -0,0 +1,202 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# Eduardo Oliveira , 2021. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2021-12-30 09:50-0300\n" +"PO-Revision-Date: 2021-12-30 09:50-0300\n" +"Last-Translator: Eduardo Oliveira \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" + +#: models.py:50 +msgid "Confidential" +msgstr "Confidencial" + +#: models.py:51 +msgid "Public" +msgstr "Público" + +#: models.py:60 +msgid "Authorization code" +msgstr "Código de Autorização" + +#: models.py:61 +msgid "Implicit" +msgstr "Implícito" + +#: models.py:62 +msgid "Resource owner password-based" +msgstr "Baseado na senha do proprietário do recurso" + +#: models.py:63 +msgid "Client credentials" +msgstr "Credenciais do cliente" + +#: models.py:64 +msgid "OpenID connect hybrid" +msgstr "Híbrido de conexão OpenID" + +#: models.py:71 +msgid "No OIDC support" +msgstr "Sem suporte a OIDC" + +#: models.py:72 +msgid "RSA with SHA-2 256" +msgstr "RSA com SHA-2 256" + +#: models.py:73 +msgid "HMAC with SHA-2 256" +msgstr "HMAC com SHA-2 256" + +#: models.py:88 +msgid "Allowed URIs list, space separated" +msgstr "Lista de URLs permitidos, separados por espaço" + +#: models.py:155 +#, python-brace-format +msgid "Unauthorized redirect scheme: {scheme}" +msgstr "Esquema de redirecionamento não autorizado: {scheme}" + +#: models.py:159 +#, python-brace-format +msgid "redirect_uris cannot be empty with grant_type {grant_type}" +msgstr "redirect_uris não pode ser vázio com o grant_type {grant_type}" + +#: models.py:165 +msgid "You must set OIDC_RSA_PRIVATE_KEY to use RSA algorithm" +msgstr "Você precisa definir OIDC_RSA_PRIVATE_KEY para usar o algoritmo RSA" + +#: models.py:174 +msgid "You cannot use HS256 with public grants or clients" +msgstr "Você não pode usar HS256 com concessões publicas ou clientes" + +#: oauth2_validators.py:181 +msgid "The access token is invalid." +msgstr "O token de acesso é inválido." + +#: oauth2_validators.py:188 +msgid "The access token has expired." +msgstr "O token de acesso expirou." + +#: oauth2_validators.py:195 +msgid "The access token is valid but does not have enough scope." +msgstr "O token de acesso é valido porém não tem o escopo necessário." + +#: templates/oauth2_provider/application_confirm_delete.html:6 +msgid "Are you sure to delete the application" +msgstr "Tem certeza que deseja remover a aplicação?" + +#: templates/oauth2_provider/application_confirm_delete.html:12 +#: templates/oauth2_provider/authorize.html:29 +msgid "Cancel" +msgstr "Cancelar" + +#: templates/oauth2_provider/application_confirm_delete.html:13 +#: templates/oauth2_provider/application_detail.html:38 +#: templates/oauth2_provider/authorized-token-delete.html:7 +msgid "Delete" +msgstr "Remover" + +#: templates/oauth2_provider/application_detail.html:10 +msgid "Client id" +msgstr "ID do Cliente" + +#: templates/oauth2_provider/application_detail.html:15 +msgid "Client secret" +msgstr "Palavra-Chave Secreta do Cliente" + +#: templates/oauth2_provider/application_detail.html:20 +msgid "Client type" +msgstr "Tipo de Cliente" + +#: templates/oauth2_provider/application_detail.html:25 +msgid "Authorization Grant Type" +msgstr "Tipo de concessão de autorização" + +#: templates/oauth2_provider/application_detail.html:30 +msgid "Redirect Uris" +msgstr "URLs de redirecionamento" + +#: templates/oauth2_provider/application_detail.html:36 +#: templates/oauth2_provider/application_form.html:35 +msgid "Go Back" +msgstr "Voltar" + +#: templates/oauth2_provider/application_detail.html:37 +msgid "Edit" +msgstr "Editar" + +#: templates/oauth2_provider/application_form.html:9 +msgid "Edit application" +msgstr "Editar Aplicação" + +#: templates/oauth2_provider/application_form.html:37 +msgid "Save" +msgstr "Salvar" + +#: templates/oauth2_provider/application_list.html:6 +msgid "Your applications" +msgstr "Suas Aplicações" + +#: templates/oauth2_provider/application_list.html:14 +msgid "New Application" +msgstr "Nova Aplicação" + +#: templates/oauth2_provider/application_list.html:17 +msgid "No applications defined" +msgstr "Não existem aplicações definidas" + +#: templates/oauth2_provider/application_list.html:17 +msgid "Click here" +msgstr "Clicar aqui" + +#: templates/oauth2_provider/application_list.html:17 +msgid "if you want to register a new one" +msgstr "se você deseja registrar uma nova" + +#: templates/oauth2_provider/application_registration_form.html:5 +msgid "Register a new application" +msgstr "Registrar uma nova aplicação" + +#: templates/oauth2_provider/authorize.html:8 +#: templates/oauth2_provider/authorize.html:30 +msgid "Authorize" +msgstr "Autorizar" + +#: templates/oauth2_provider/authorize.html:17 +msgid "Application requires the following permissions" +msgstr "A Aplicação precisa das seguintes permissões" + +#: templates/oauth2_provider/authorized-oob.html:12 +msgid "Success" +msgstr "Sucesso" + +#: templates/oauth2_provider/authorized-oob.html:14 +msgid "Please return to your application and enter this code:" +msgstr "Por favor, retorne para a sua aplicação e insira o seguinte código:" + +#: templates/oauth2_provider/authorized-token-delete.html:6 +msgid "Are you sure you want to delete this token?" +msgstr "Você tem certeza que deseja remover esse token?" + +#: templates/oauth2_provider/authorized-tokens.html:6 +msgid "Tokens" +msgstr "Tokens" + +#: templates/oauth2_provider/authorized-tokens.html:11 +msgid "revoke" +msgstr "Revogar" + +#: templates/oauth2_provider/authorized-tokens.html:19 +msgid "There are no authorized tokens yet." +msgstr "Não existem tokens autorizados ainda." diff --git a/oauth2_provider/management/commands/cleartokens.py b/oauth2_provider/management/commands/cleartokens.py index 3fb1827f6..9d58361bc 100644 --- a/oauth2_provider/management/commands/cleartokens.py +++ b/oauth2_provider/management/commands/cleartokens.py @@ -3,7 +3,7 @@ from ...models import clear_expired -class Command(BaseCommand): +class Command(BaseCommand): # pragma: no cover help = "Can be run as a cronjob or directly to clean out expired tokens" def handle(self, *args, **options): diff --git a/oauth2_provider/migrations/0005_auto_20211222_2352.py b/oauth2_provider/migrations/0005_auto_20211222_2352.py new file mode 100644 index 000000000..ebff59f80 --- /dev/null +++ b/oauth2_provider/migrations/0005_auto_20211222_2352.py @@ -0,0 +1,39 @@ +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('oauth2_provider', '0004_auto_20200902_2022'), + ] + + operations = [ + migrations.AlterField( + model_name='accesstoken', + name='user', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='application', + name='user', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='grant', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='idtoken', + name='user', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='refreshtoken', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/oauth2_provider/migrations/0009_remove_idtoken_token.py b/oauth2_provider/migrations/0009_remove_idtoken_token.py new file mode 100644 index 000000000..22d5529cf --- /dev/null +++ b/oauth2_provider/migrations/0009_remove_idtoken_token.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.15 on 2023-04-11 21:54 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('oauth2_provider', '0008_alter_idtoken_token'), + ] + + operations = [ + migrations.RemoveField( + model_name='idtoken', + name='token', + ), + ] diff --git a/oauth2_provider/migrations/0010_merge_20230412_1346.py b/oauth2_provider/migrations/0010_merge_20230412_1346.py new file mode 100644 index 000000000..ce03556c7 --- /dev/null +++ b/oauth2_provider/migrations/0010_merge_20230412_1346.py @@ -0,0 +1,14 @@ +# Generated by Django 3.2.15 on 2023-04-12 17:46 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('oauth2_provider', '0005_auto_20211222_2352'), + ('oauth2_provider', '0009_remove_idtoken_token'), + ] + + operations = [ + ] diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index dd0a1eb75..2c9747ce8 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -1,4 +1,5 @@ import logging +import time import uuid from datetime import timedelta from urllib.parse import parse_qsl, urlparse @@ -12,6 +13,7 @@ from django.utils.translation import gettext_lazy as _ from jwcrypto import jwk from jwcrypto.common import base64url_encode +from oauthlib.oauth2.rfc6749 import errors from .generators import generate_client_id, generate_client_secret from .scopes import get_scopes_backend @@ -107,14 +109,16 @@ def __str__(self): @property def default_redirect_uri(self): """ - Returns the default redirect_uri extracting the first item from - the :attr:`redirect_uris` string + Returns the default redirect_uri, *if* only one is registered. """ if self.redirect_uris: - return self.redirect_uris.split().pop(0) + uris = self.redirect_uris.split() + if len(uris) == 1: + return self.redirect_uris.split().pop(0) + raise errors.MissingRedirectURIError() assert False, ( - "If you are using implicit, authorization_code" + "If you are using implicit, authorization_code " "or all-in-one grant_type, you must define " "redirect_uris field in your Application model" ) @@ -125,23 +129,7 @@ def redirect_uri_allowed(self, uri): :param uri: Url to check """ - parsed_uri = urlparse(uri) - uqs_set = set(parse_qsl(parsed_uri.query)) - for allowed_uri in self.redirect_uris.split(): - parsed_allowed_uri = urlparse(allowed_uri) - - if ( - parsed_allowed_uri.scheme == parsed_uri.scheme - and parsed_allowed_uri.netloc == parsed_uri.netloc - and parsed_allowed_uri.path == parsed_uri.path - ): - - aqs_set = set(parse_qsl(parsed_allowed_uri.query)) - - if aqs_set.issubset(uqs_set): - return True - - return False + return redirect_to_uri_allowed(uri, self.redirect_uris.split()) def clean(self): from django.core.exceptions import ValidationError @@ -506,7 +494,6 @@ class AbstractIDToken(models.Model): null=True, related_name="%(app_label)s_%(class)s", ) - token = models.TextField(null=True, unique=False, blank=True) jti = models.UUIDField(unique=True, default=uuid.uuid4, editable=False, verbose_name="JWT Token ID") application = models.ForeignKey( oauth2_settings.APPLICATION_MODEL, @@ -580,67 +567,86 @@ class Meta(AbstractIDToken.Meta): def get_application_model(): - """ Return the Application model that is active in this project. """ + """Return the Application model that is active in this project.""" return apps.get_model(oauth2_settings.APPLICATION_MODEL) def get_grant_model(): - """ Return the Grant model that is active in this project. """ + """Return the Grant model that is active in this project.""" return apps.get_model(oauth2_settings.GRANT_MODEL) def get_access_token_model(): - """ Return the AccessToken model that is active in this project. """ + """Return the AccessToken model that is active in this project.""" return apps.get_model(oauth2_settings.ACCESS_TOKEN_MODEL) def get_id_token_model(): - """ Return the AccessToken model that is active in this project. """ + """Return the AccessToken model that is active in this project.""" return apps.get_model(oauth2_settings.ID_TOKEN_MODEL) def get_refresh_token_model(): - """ Return the RefreshToken model that is active in this project. """ + """Return the RefreshToken model that is active in this project.""" return apps.get_model(oauth2_settings.REFRESH_TOKEN_MODEL) def get_application_admin_class(): - """ Return the Application admin class that is active in this project. """ + """Return the Application admin class that is active in this project.""" application_admin_class = oauth2_settings.APPLICATION_ADMIN_CLASS return application_admin_class def get_access_token_admin_class(): - """ Return the AccessToken admin class that is active in this project. """ + """Return the AccessToken admin class that is active in this project.""" access_token_admin_class = oauth2_settings.ACCESS_TOKEN_ADMIN_CLASS return access_token_admin_class def get_grant_admin_class(): - """ Return the Grant admin class that is active in this project. """ + """Return the Grant admin class that is active in this project.""" grant_admin_class = oauth2_settings.GRANT_ADMIN_CLASS return grant_admin_class def get_id_token_admin_class(): - """ Return the IDToken admin class that is active in this project. """ + """Return the IDToken admin class that is active in this project.""" id_token_admin_class = oauth2_settings.ID_TOKEN_ADMIN_CLASS return id_token_admin_class def get_refresh_token_admin_class(): - """ Return the RefreshToken admin class that is active in this project. """ + """Return the RefreshToken admin class that is active in this project.""" refresh_token_admin_class = oauth2_settings.REFRESH_TOKEN_ADMIN_CLASS return refresh_token_admin_class def clear_expired(): + def batch_delete(queryset, query): + CLEAR_EXPIRED_TOKENS_BATCH_SIZE = oauth2_settings.CLEAR_EXPIRED_TOKENS_BATCH_SIZE + CLEAR_EXPIRED_TOKENS_BATCH_INTERVAL = oauth2_settings.CLEAR_EXPIRED_TOKENS_BATCH_INTERVAL + current_no = start_no = queryset.count() + + while current_no: + flat_queryset = queryset.values_list("id", flat=True)[:CLEAR_EXPIRED_TOKENS_BATCH_SIZE] + batch_length = flat_queryset.count() + queryset.model.objects.filter(id__in=list(flat_queryset)).delete() + logger.debug(f"{batch_length} tokens deleted, {current_no-batch_length} left") + queryset = queryset.model.objects.filter(query) + time.sleep(CLEAR_EXPIRED_TOKENS_BATCH_INTERVAL) + current_no = queryset.count() + + stop_no = queryset.model.objects.filter(query).count() + deleted = start_no - stop_no + return deleted + now = timezone.now() refresh_expire_at = None access_token_model = get_access_token_model() refresh_token_model = get_refresh_token_model() grant_model = get_grant_model() REFRESH_TOKEN_EXPIRE_SECONDS = oauth2_settings.REFRESH_TOKEN_EXPIRE_SECONDS + if REFRESH_TOKEN_EXPIRE_SECONDS: if not isinstance(REFRESH_TOKEN_EXPIRE_SECONDS, timedelta): try: @@ -650,28 +656,76 @@ def clear_expired(): raise ImproperlyConfigured(e) refresh_expire_at = now - REFRESH_TOKEN_EXPIRE_SECONDS - with transaction.atomic(): - if refresh_expire_at: - revoked = refresh_token_model.objects.filter( - revoked__lt=refresh_expire_at, - ) - expired = refresh_token_model.objects.filter( - access_token__expires__lt=refresh_expire_at, - ) + if refresh_expire_at: + revoked_query = models.Q(revoked__lt=refresh_expire_at) + revoked = refresh_token_model.objects.filter(revoked_query) + + revoked_deleted_no = batch_delete(revoked, revoked_query) + logger.info("%s Revoked refresh tokens deleted", revoked_deleted_no) - logger.info("%s Revoked refresh tokens to be deleted", revoked.count()) - logger.info("%s Expired refresh tokens to be deleted", expired.count()) + expired_query = models.Q(access_token__expires__lt=refresh_expire_at) + expired = refresh_token_model.objects.filter(expired_query) - revoked.delete() - expired.delete() - else: - logger.info("refresh_expire_at is %s. No refresh tokens deleted.", refresh_expire_at) + expired_deleted_no = batch_delete(expired, expired_query) + logger.info("%s Expired refresh tokens deleted", expired_deleted_no) + else: + logger.info("refresh_expire_at is %s. No refresh tokens deleted.", refresh_expire_at) - access_tokens = access_token_model.objects.filter(refresh_token__isnull=True, expires__lt=now) - grants = grant_model.objects.filter(expires__lt=now) + access_token_query = models.Q(refresh_token__isnull=True, expires__lt=now) + access_tokens = access_token_model.objects.filter(access_token_query) - logger.info("%s Expired access tokens to be deleted", access_tokens.count()) - logger.info("%s Expired grant tokens to be deleted", grants.count()) + access_tokens_delete_no = batch_delete(access_tokens, access_token_query) + logger.info("%s Expired access tokens deleted", access_tokens_delete_no) - access_tokens.delete() - grants.delete() + grants_query = models.Q(expires__lt=now) + grants = grant_model.objects.filter(grants_query) + + grants_deleted_no = batch_delete(grants, grants_query) + logger.info("%s Expired grant tokens deleted", grants_deleted_no) + + +def redirect_to_uri_allowed(uri, allowed_uris): + """ + Checks if a given uri can be redirected to based on the provided allowed_uris configuration. + + On top of exact matches, this function also handles loopback IPs based on RFC 8252. + + :param uri: URI to check + :param allowed_uris: A list of URIs that are allowed + """ + + parsed_uri = urlparse(uri) + uqs_set = set(parse_qsl(parsed_uri.query)) + for allowed_uri in allowed_uris: + parsed_allowed_uri = urlparse(allowed_uri) + + # From RFC 8252 (Section 7.3) + # + # Loopback redirect URIs use the "http" scheme + # [...] + # The authorization server MUST allow any port to be specified at the + # time of the request for loopback IP redirect URIs, to accommodate + # clients that obtain an available ephemeral port from the operating + # system at the time of the request. + + allowed_uri_is_loopback = ( + parsed_allowed_uri.scheme == "http" + and parsed_allowed_uri.hostname in ["127.0.0.1", "::1"] + and parsed_allowed_uri.port is None + ) + if ( + allowed_uri_is_loopback + and parsed_allowed_uri.scheme == parsed_uri.scheme + and parsed_allowed_uri.hostname == parsed_uri.hostname + and parsed_allowed_uri.path == parsed_uri.path + ) or ( + parsed_allowed_uri.scheme == parsed_uri.scheme + and parsed_allowed_uri.netloc == parsed_uri.netloc + and parsed_allowed_uri.path == parsed_uri.path + ): + + aqs_set = set(parse_qsl(parsed_allowed_uri.query)) + if aqs_set.issubset(uqs_set): + return True + + return False diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index cce9f9616..29f9e1c38 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -1,6 +1,7 @@ import base64 import binascii import http.client +import inspect import json import logging import uuid @@ -14,6 +15,7 @@ from django.core.exceptions import ObjectDoesNotExist from django.db import transaction from django.db.models import Q +from django.http import HttpRequest from django.utils import dateformat, timezone from django.utils.timezone import make_aware from django.utils.translation import gettext_lazy as _ @@ -357,7 +359,7 @@ def _get_token_from_authentication_server( expires = max_caching_time scope = content.get("scope", "") - expires = make_aware(expires) + expires = make_aware(expires) if settings.USE_TZ else expires access_token, _created = AccessToken.objects.update_or_create( token=token, @@ -664,7 +666,15 @@ def validate_user(self, username, password, client, request, *args, **kwargs): """ Check username and password correspond to a valid and active User """ - u = authenticate(username=username, password=password) + # Passing the optional HttpRequest adds compatibility for backends + # which depend on its presence. Create one with attributes likely + # to be used. + http_request = HttpRequest() + http_request.path = request.uri + http_request.method = request.http_method + getattr(http_request, request.http_method).update(dict(request.decoded_body)) + http_request.META = request.headers + u = authenticate(http_request, username=username, password=password) if u is not None and u.is_active: request.user = u return True @@ -716,18 +726,40 @@ def _save_id_token(self, id_token, request, expires, *args, **kwargs): ) return id_token + @classmethod + def _get_additional_claims_is_request_agnostic(cls): + return len(inspect.signature(cls.get_additional_claims).parameters) == 1 + def get_jwt_bearer_token(self, token, token_handler, request): return self.get_id_token(token, token_handler, request) - def get_oidc_claims(self, token, token_handler, request): - # Required OIDC claims - claims = { - "sub": str(request.user.id), - } + def get_claim_dict(self, request): + if self._get_additional_claims_is_request_agnostic(): + claims = {"sub": lambda r: str(r.user.id)} + else: + claims = {"sub": str(request.user.id)} # https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims - claims.update(**self.get_additional_claims(request)) + if self._get_additional_claims_is_request_agnostic(): + add = self.get_additional_claims() + else: + add = self.get_additional_claims(request) + claims.update(add) + + return claims + + def get_discovery_claims(self, request): + claims = ["sub"] + if self._get_additional_claims_is_request_agnostic(): + claims += list(self.get_claim_dict(request).keys()) + return claims + + def get_oidc_claims(self, token, token_handler, request): + data = self.get_claim_dict(request) + claims = {} + for k, v in data.items(): + claims[k] = v(request) if callable(v) else v return claims def get_id_token_dictionary(self, token, token_handler, request): diff --git a/oauth2_provider/settings.py b/oauth2_provider/settings.py index b862fca7a..3b7dea3f8 100644 --- a/oauth2_provider/settings.py +++ b/oauth2_provider/settings.py @@ -72,6 +72,8 @@ "OIDC_ISS_ENDPOINT": "", "OIDC_USERINFO_ENDPOINT": "", "OIDC_RSA_PRIVATE_KEY": "", + "OIDC_RSA_PRIVATE_KEYS_INACTIVE": [], + "OIDC_JWKS_MAX_AGE_SECONDS": 3600, "OIDC_RESPONSE_TYPES_SUPPORTED": [ "code", "token", @@ -99,6 +101,8 @@ # Whether to re-create OAuthlibCore on every request. # Should only be required in testing. "ALWAYS_RELOAD_OAUTHLIB_CORE": False, + "CLEAR_EXPIRED_TOKENS_BATCH_SIZE": 10000, + "CLEAR_EXPIRED_TOKENS_BATCH_INTERVAL": 0, } # List of settings that cannot be empty diff --git a/oauth2_provider/tasks.py b/oauth2_provider/tasks.py new file mode 100644 index 000000000..d86c33720 --- /dev/null +++ b/oauth2_provider/tasks.py @@ -0,0 +1,8 @@ +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/application_list.html b/oauth2_provider/templates/oauth2_provider/application_list.html index b8e4f3af4..807c050d3 100644 --- a/oauth2_provider/templates/oauth2_provider/application_list.html +++ b/oauth2_provider/templates/oauth2_provider/application_list.html @@ -13,7 +13,7 @@

{% trans "Your applications" %}

{% trans "New Application" %} {% else %} - +

{% trans "No applications defined" %}. {% trans "Click here" %} {% trans "if you want to register a new one" %}

{% endif %} diff --git a/oauth2_provider/views/introspect.py b/oauth2_provider/views/introspect.py index afb8ac627..26254da6b 100644 --- a/oauth2_provider/views/introspect.py +++ b/oauth2_provider/views/introspect.py @@ -1,8 +1,7 @@ import calendar -import json from django.core.exceptions import ObjectDoesNotExist -from django.http import HttpResponse +from django.http import JsonResponse from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt @@ -29,9 +28,7 @@ def get_token_response(token_value=None): get_access_token_model().objects.select_related("user", "application").get(token=token_value) ) except ObjectDoesNotExist: - return HttpResponse( - content=json.dumps({"active": False}), status=401, content_type="application/json" - ) + return JsonResponse({"active": False}, status=200) else: if token.is_valid(): data = { @@ -43,17 +40,9 @@ def get_token_response(token_value=None): data["client_id"] = token.application.client_id if token.user: data["username"] = token.user.get_username() - return HttpResponse(content=json.dumps(data), status=200, content_type="application/json") + return JsonResponse(data) else: - return HttpResponse( - content=json.dumps( - { - "active": False, - } - ), - status=200, - content_type="application/json", - ) + return JsonResponse({"active": False}, status=200) def get(self, request, *args, **kwargs): """ diff --git a/oauth2_provider/views/mixins.py b/oauth2_provider/views/mixins.py index 477d24e24..ebb654216 100644 --- a/oauth2_provider/views/mixins.py +++ b/oauth2_provider/views/mixins.py @@ -1,7 +1,7 @@ import logging from django.conf import settings -from django.core.exceptions import ImproperlyConfigured +from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation from django.http import HttpResponseForbidden, HttpResponseNotFound from ..exceptions import FatalClientError @@ -150,7 +150,14 @@ def verify_request(self, request): :param request: The current django.http.HttpRequest object """ core = self.get_oauthlib_core() - return core.verify_request(request, scopes=self.get_scopes()) + + try: + return core.verify_request(request, scopes=self.get_scopes()) + except ValueError as error: + if str(error) == "Invalid hex encoding in query string.": + raise SuspiciousOperation(error) + else: + raise def get_scopes(self): """ diff --git a/oauth2_provider/views/oidc.py b/oauth2_provider/views/oidc.py index ac3a2a172..e66b30a86 100644 --- a/oauth2_provider/views/oidc.py +++ b/oauth2_provider/views/oidc.py @@ -1,4 +1,5 @@ import json +from urllib.parse import urlparse from django.http import HttpResponse, JsonResponse from django.urls import reverse @@ -32,15 +33,23 @@ def get(self, request, *args, **kwargs): ) jwks_uri = request.build_absolute_uri(reverse("oauth2_provider:jwks-info")) else: - authorization_endpoint = "{}{}".format(issuer_url, reverse("oauth2_provider:authorize")) - token_endpoint = "{}{}".format(issuer_url, reverse("oauth2_provider:token")) + parsed_url = urlparse(oauth2_settings.OIDC_ISS_ENDPOINT) + host = parsed_url.scheme + "://" + parsed_url.netloc + authorization_endpoint = "{}{}".format(host, reverse("oauth2_provider:authorize")) + token_endpoint = "{}{}".format(host, reverse("oauth2_provider:token")) userinfo_endpoint = oauth2_settings.OIDC_USERINFO_ENDPOINT or "{}{}".format( - issuer_url, reverse("oauth2_provider:user-info") + host, reverse("oauth2_provider:user-info") ) - jwks_uri = "{}{}".format(issuer_url, reverse("oauth2_provider:jwks-info")) + jwks_uri = "{}{}".format(host, reverse("oauth2_provider:jwks-info")) + signing_algorithms = [Application.HS256_ALGORITHM] if oauth2_settings.OIDC_RSA_PRIVATE_KEY: signing_algorithms = [Application.RS256_ALGORITHM, Application.HS256_ALGORITHM] + + validator_class = oauth2_settings.OAUTH2_VALIDATOR_CLASS + validator = validator_class() + oidc_claims = list(set(validator.get_discovery_claims(request))) + data = { "issuer": issuer_url, "authorization_endpoint": authorization_endpoint, @@ -53,6 +62,7 @@ def get(self, request, *args, **kwargs): "token_endpoint_auth_methods_supported": ( oauth2_settings.OIDC_TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED ), + "claims_supported": oidc_claims, } response = JsonResponse(data) response["Access-Control-Allow-Origin"] = "*" @@ -67,12 +77,23 @@ class JwksInfoView(OIDCOnlyMixin, View): def get(self, request, *args, **kwargs): keys = [] if oauth2_settings.OIDC_RSA_PRIVATE_KEY: - key = jwk.JWK.from_pem(oauth2_settings.OIDC_RSA_PRIVATE_KEY.encode("utf8")) - data = {"alg": "RS256", "use": "sig", "kid": key.thumbprint()} - data.update(json.loads(key.export_public())) - keys.append(data) + for pem in [ + oauth2_settings.OIDC_RSA_PRIVATE_KEY, + *oauth2_settings.OIDC_RSA_PRIVATE_KEYS_INACTIVE, + ]: + + key = jwk.JWK.from_pem(pem.encode("utf8")) + data = {"alg": "RS256", "use": "sig", "kid": key.thumbprint()} + data.update(json.loads(key.export_public())) + keys.append(data) response = JsonResponse({"keys": keys}) response["Access-Control-Allow-Origin"] = "*" + response["Cache-Control"] = ( + "Cache-Control: public, " + + f"max-age={oauth2_settings.OIDC_JWKS_MAX_AGE_SECONDS}, " + + f"stale-while-revalidate={oauth2_settings.OIDC_JWKS_MAX_AGE_SECONDS}, " + + f"stale-if-error={oauth2_settings.OIDC_JWKS_MAX_AGE_SECONDS}" + ) return response diff --git a/setup.cfg b/setup.cfg index d7599ab20..d2e80b35e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = django-oauth-toolkit -version = 1.5.3 +version = 1.6.0 description = OAuth2 Provider for Django long_description = file: README.rst long_description_content_type = text/x-rst @@ -13,16 +13,16 @@ classifiers = Environment :: Web Environment Framework :: Django Framework :: Django :: 2.2 - Framework :: Django :: 3.0 - Framework :: Django :: 3.1 + Framework :: Django :: 3.2 + Framework :: Django :: 4.0 Intended Audience :: Developers License :: OSI Approved :: BSD License Operating System :: OS Independent Programming Language :: Python :: 3 - Programming Language :: Python :: 3.6 Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 + Programming Language :: Python :: 3.10 Topic :: Internet :: WWW/HTTP [options] @@ -32,11 +32,10 @@ zip_safe = False # jwcrypto has a direct dependency on six, but does not list it yet in a release # Previously, cryptography also depended on six, so this was unnoticed install_requires = - django >= 2.2 + django >= 2.2, != 4.0.0 requests >= 2.13.0 oauthlib >= 3.1.0 jwcrypto >= 0.8.0 - six [options.packages.find] exclude = tests diff --git a/tests/mig_settings.py b/tests/mig_settings.py new file mode 100644 index 000000000..8f77d1190 --- /dev/null +++ b/tests/mig_settings.py @@ -0,0 +1,125 @@ +""" +Django settings for CI testing if migrations have been missed. + +Generated by 'django-admin startproject' using Django 4.0.1. + +For more information on this file, see +https://docs.djangoproject.com/en/4.0/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/4.0/ref/settings/ +""" + +from pathlib import Path + + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/4.0/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = "django-insecure-9$j0^ot%41l5r(nj9hg02up-n+$59kld!0%l6pvqbd()u%z2as" + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "oauth2_provider", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": ["templates"], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "tutorial.wsgi.application" + +LOGIN_URL = "/admin/login/" + +# Database +# https://docs.djangoproject.com/en/4.0/ref/settings/#databases + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": ":memory:", + } +} + + +# Password validation +# https://docs.djangoproject.com/en/4.0/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/4.0/topics/i18n/ + +LANGUAGE_CODE = "en-us" + +TIME_ZONE = "UTC" + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/4.0/howto/static-files/ + +STATIC_URL = "static/" + +# Default primary key field type +# https://docs.djangoproject.com/en/4.0/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" diff --git a/tests/presets.py b/tests/presets.py index da1577bf4..438da1e03 100644 --- a/tests/presets.py +++ b/tests/presets.py @@ -9,9 +9,10 @@ DEFAULT_SCOPES_RO = {"DEFAULT_SCOPES": ["read"]} OIDC_SETTINGS_RW = { "OIDC_ENABLED": True, - "OIDC_ISS_ENDPOINT": "http://localhost", - "OIDC_USERINFO_ENDPOINT": "http://localhost/userinfo/", + "OIDC_ISS_ENDPOINT": "http://localhost/o", + "OIDC_USERINFO_ENDPOINT": "http://localhost/o/userinfo/", "OIDC_RSA_PRIVATE_KEY": settings.OIDC_RSA_PRIVATE_KEY, + "OIDC_RSA_PRIVATE_KEYS_INACTIVE": settings.OIDC_RSA_PRIVATE_KEYS_INACTIVE, "SCOPES": { "read": "Reading scope", "write": "Writing scope", diff --git a/tests/settings.py b/tests/settings.py index 1d295982e..d2fbe6a56 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -134,7 +134,28 @@ dTnvCVtA59ne4LEVie/PMH/odQWY0SxVm/76uBZv/1vY -----END RSA PRIVATE KEY-----""" +OIDC_RSA_PRIVATE_KEYS_INACTIVE = [ + """-----BEGIN RSA PRIVATE KEY----- +MIICXAIBAAKBgQDSpXNtxaD9+DKBnSWJNoV6h0PZuSKeGPyA8n0/as/O+oboiYj1 +gqQSTwPFxzt5Zy52fDmIQvzDH+2CihpGIeJh9SsUEFd8DXkP/Xk91f/mAbytBsnt +czFCtihFRxWbbBAMHh8i5HuxM+rH2nw5Hh/74GLE58zk5rtIRS1DyS+uUQIDAQAB +AoGAca57Ci4TQZ02XL8bp9610Le5hYIlzZ78fvbfY19YwYJxVoQLVzxnIb5k8dMh +JNbru2Q1hHVqhj/v5Xh0z46v5mTOeyQj8F1O6NCkzHtCfF029j8A9+pfNqyQhCa/ +nJqsNShFW+uhK67d7QfqtRRR6B30XsIHgND7QJuc14mDkdUCQQD3OpzLZugdTtuW +u+DdrdSjMBbW2p1+NFr8T20Rv+LoMvweZLSuMelAoog8fNxF6xQs7wLw+Tf5z56L +mptnur6TAkEA2h6WL3ippJ6/7H45suxP1dJI+Qal7V2KAMVGbv6Jal9rcKid0PpD +K1uPZwx2o/hkdobPY0HRIFaxpOtwC4FKCwJAYTmWodMFY0k4yA14wBT1c3uc77+n +ghM62NCvdvR8Wo56YcV+3KZaMYX5h7getAxfsdAI2xVXMxG4KvSROvjQqwJAaZ+W +KrbLr6QQXH1jg3lbz7ddDvphL2i0g1sEmIs6EADVDmEYyzHlhQF5l/U5Hn4SaDMw +Cmi81GQm8i3wvCGHsQJBAJC2VVcZ4VIehr3nAbI46w6cXGP6lpBbwT2FxSydRHqz +wfGZQ+qAAThGg3OInQNMqItypEEo3oZhKKvjD1N/iTw= +-----END RSA PRIVATE KEY-----""" +] + OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL = "oauth2_provider.AccessToken" OAUTH2_PROVIDER_APPLICATION_MODEL = "oauth2_provider.Application" OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL = "oauth2_provider.RefreshToken" OAUTH2_PROVIDER_ID_TOKEN_MODEL = "oauth2_provider.IDToken" + +CLEAR_EXPIRED_TOKENS_BATCH_SIZE = 1 +CLEAR_EXPIRED_TOKENS_BATCH_INTERVAL = 0 diff --git a/tests/test_auth_backends.py b/tests/test_auth_backends.py index 151fc30d2..8eeb8ef12 100644 --- a/tests/test_auth_backends.py +++ b/tests/test_auth_backends.py @@ -1,5 +1,9 @@ +from unittest.mock import patch + +import pytest from django.contrib.auth import get_user_model from django.contrib.auth.models import AnonymousUser +from django.core.exceptions import SuspiciousOperation from django.http import HttpResponse from django.test import RequestFactory, TestCase from django.test.utils import modify_settings, override_settings @@ -51,6 +55,23 @@ def test_authenticate(self): u = backend.authenticate(**credentials) self.assertEqual(u, self.user) + def test_authenticate_raises_error_with_invalid_hex_in_query_params(self): + auth_headers = { + "HTTP_AUTHORIZATION": "Bearer " + "tokstr", + } + request = self.factory.get("/a-resource?auth_token=%%7A", **auth_headers) + credentials = {"request": request} + + with pytest.raises(SuspiciousOperation): + OAuth2Backend().authenticate(**credentials) + + @patch("oauth2_provider.backends.OAuthLibCore.verify_request") + def test_value_errors_are_reraised(self, patched_verify_request): + patched_verify_request.side_effect = ValueError("Generic error") + + with pytest.raises(ValueError): + OAuth2Backend().authenticate(request={}) + def test_authenticate_fail(self): auth_headers = { "HTTP_AUTHORIZATION": "Bearer " + "badstring", diff --git a/tests/test_authorization_code.py b/tests/test_authorization_code.py index 9619c580a..3fb378571 100644 --- a/tests/test_authorization_code.py +++ b/tests/test_authorization_code.py @@ -258,6 +258,8 @@ def test_pre_auth_default_redirect(self): Test for default redirect uri if omitted from query string with response_type: code """ self.client.login(username="test_user", password="123456") + self.application.redirect_uris = "http://localhost" + self.application.save() query_data = { "client_id": self.application.client_id, @@ -270,6 +272,21 @@ def test_pre_auth_default_redirect(self): form = response.context["form"] self.assertEqual(form["redirect_uri"].value(), "http://localhost") + def test_pre_auth_missing_redirect(self): + """ + Test response if redirect_uri is missing and multiple URIs are registered. + @see https://datatracker.ietf.org/doc/html/rfc6749#section-3.1.2.3 + """ + self.client.login(username="test_user", password="123456") + + query_data = { + "client_id": self.application.client_id, + "response_type": "code", + } + + response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) + self.assertEqual(response.status_code, 400) + def test_pre_auth_forbibben_redirect(self): """ Test error when passing a forbidden redirect_uri in query string with response_type: code @@ -294,6 +311,7 @@ def test_pre_auth_wrong_response_type(self): query_data = { "client_id": self.application.client_id, "response_type": "WRONG", + "redirect_uri": "http://example.org", } response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) diff --git a/tests/test_client_credential.py b/tests/test_client_credential.py index 8b9aa3bc2..8159d55db 100644 --- a/tests/test_client_credential.py +++ b/tests/test_client_credential.py @@ -1,8 +1,10 @@ import json +from unittest.mock import patch from urllib.parse import quote_plus import pytest from django.contrib.auth import get_user_model +from django.core.exceptions import SuspiciousOperation from django.test import RequestFactory, TestCase from django.urls import reverse from django.views.generic import View @@ -101,6 +103,15 @@ def test_client_credential_user_is_none_on_access_token(self): self.assertIsNone(access_token.user) +class TestView(OAuthLibMixin, View): + server_class = BackendApplicationServer + validator_class = OAuth2Validator + oauthlib_backend_class = OAuthLibCore + + def get_scopes(self): + return ["read", "write"] + + class TestExtendedRequest(BaseTest): @classmethod def setUpClass(cls): @@ -108,14 +119,6 @@ def setUpClass(cls): super().setUpClass() def test_extended_request(self): - class TestView(OAuthLibMixin, View): - server_class = BackendApplicationServer - validator_class = OAuth2Validator - oauthlib_backend_class = OAuthLibCore - - def get_scopes(self): - return ["read", "write"] - token_request_data = { "grant_type": "client_credentials", } @@ -143,6 +146,21 @@ def get_scopes(self): self.assertEqual(r.client, self.application) self.assertEqual(r.scopes, ["read", "write"]) + def test_raises_error_with_invalid_hex_in_query_params(self): + request = self.request_factory.get("/fake-req?auth_token=%%7A") + + with pytest.raises(SuspiciousOperation): + TestView().verify_request(request) + + @patch("oauth2_provider.views.mixins.OAuthLibMixin.get_oauthlib_core") + def test_reraises_value_errors_as_is(self, patched_core): + patched_core.return_value.verify_request.side_effect = ValueError("Generic error") + + request = self.request_factory.get("/fake-req") + + with pytest.raises(ValueError): + TestView().verify_request(request) + class TestClientResourcePasswordBased(BaseTest): def test_client_resource_password_based(self): diff --git a/tests/test_hybrid.py b/tests/test_hybrid.py index d198988f6..4f9753979 100644 --- a/tests/test_hybrid.py +++ b/tests/test_hybrid.py @@ -370,6 +370,8 @@ def test_pre_auth_default_redirect(self): Test for default redirect uri if omitted from query string with response_type: code """ self.client.login(username="hy_test_user", password="123456") + self.application.redirect_uris = "http://localhost" + self.application.save() query_string = urlencode( { @@ -413,6 +415,7 @@ def test_pre_auth_wrong_response_type(self): { "client_id": self.application.client_id, "response_type": "WRONG", + "redirect_uri": "http://example.org", } ) url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) diff --git a/tests/test_implicit.py b/tests/test_implicit.py index a5863401c..5fcad62b0 100644 --- a/tests/test_implicit.py +++ b/tests/test_implicit.py @@ -110,6 +110,8 @@ def test_pre_auth_default_redirect(self): Test for default redirect uri if omitted from query string with response_type: token """ self.client.login(username="test_user", password="123456") + self.application.redirect_uris = "http://localhost" + self.application.save() query_data = { "client_id": self.application.client_id, diff --git a/tests/test_introspection_auth.py b/tests/test_introspection_auth.py index 9f871cdea..8b2a6daf0 100644 --- a/tests/test_introspection_auth.py +++ b/tests/test_introspection_auth.py @@ -2,6 +2,7 @@ import datetime import pytest +from django.conf import settings from django.conf.urls import include from django.contrib.auth import get_user_model from django.http import HttpResponse @@ -12,6 +13,7 @@ from oauth2_provider.models import get_access_token_model, get_application_model from oauth2_provider.oauth2_validators import OAuth2Validator +from oauth2_provider.settings import oauth2_settings from oauth2_provider.views import ScopedProtectedResourceView from . import presets @@ -154,6 +156,25 @@ def test_get_token_from_authentication_server_existing_token(self, mock_get): self.assertEqual(token.user.username, "foo_user") self.assertEqual(token.scope, "read write dolphin") + @mock.patch("requests.post", side_effect=mocked_requests_post) + def test_get_token_from_authentication_server_expires_timezone(self, mock_get): + """ + Test method _get_token_from_authentication_server for projects with USE_TZ False + """ + settings_use_tz_backup = settings.USE_TZ + settings.USE_TZ = False + try: + self.validator._get_token_from_authentication_server( + "foo", + oauth2_settings.RESOURCE_SERVER_INTROSPECTION_URL, + oauth2_settings.RESOURCE_SERVER_AUTH_TOKEN, + oauth2_settings.RESOURCE_SERVER_INTROSPECTION_CREDENTIALS, + ) + except ValueError as exception: + self.fail(str(exception)) + finally: + settings.USE_TZ = settings_use_tz_backup + @mock.patch("requests.post", side_effect=mocked_requests_post) def test_validate_bearer_token(self, mock_get): """ diff --git a/tests/test_introspection_view.py b/tests/test_introspection_view.py index 0f68320ca..95374cda5 100644 --- a/tests/test_introspection_view.py +++ b/tests/test_introspection_view.py @@ -199,7 +199,7 @@ def test_view_get_notexisting_token(self): reverse("oauth2_provider:introspect"), {"token": "kaudawelsch"}, **auth_headers ) - self.assertEqual(response.status_code, 401) + self.assertEqual(response.status_code, 200) content = response.json() self.assertIsInstance(content, dict) self.assertDictEqual( @@ -269,7 +269,7 @@ def test_view_post_notexisting_token(self): reverse("oauth2_provider:introspect"), {"token": "kaudawelsch"}, **auth_headers ) - self.assertEqual(response.status_code, 401) + self.assertEqual(response.status_code, 200) content = response.json() self.assertIsInstance(content, dict) self.assertDictEqual( diff --git a/tests/test_models.py b/tests/test_models.py index 7b37486ca..9ce1e5eb7 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,3 +1,5 @@ +from datetime import timedelta + import pytest from django.contrib.auth import get_user_model from django.core.exceptions import ImproperlyConfigured, ValidationError @@ -294,7 +296,11 @@ def test_str(self): class TestClearExpired(BaseTestModels): def setUp(self): super().setUp() - # Insert two tokens on database. + # Insert many tokens, both expired and not, and grants. + self.num_tokens = 100 + now = timezone.now() + earlier = now - timedelta(seconds=100) + later = now + timedelta(seconds=100) app = Application.objects.create( name="test_app", redirect_uris="http://localhost http://example.com http://example.org", @@ -302,23 +308,54 @@ def setUp(self): client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, ) - AccessToken.objects.create( - token="555", - expires=timezone.now(), - scope=2, - application=app, - user=self.user, - created=timezone.now(), - updated=timezone.now(), + # make 200 access tokens, half current and half expired. + expired_access_tokens = AccessToken.objects.bulk_create( + AccessToken(token="expired AccessToken {}".format(i), expires=earlier) + for i in range(self.num_tokens) ) - AccessToken.objects.create( - token="666", - expires=timezone.now(), - scope=2, - application=app, - user=self.user, - created=timezone.now(), - updated=timezone.now(), + current_access_tokens = AccessToken.objects.bulk_create( + AccessToken(token=f"current AccessToken {i}", expires=later) for i in range(self.num_tokens) + ) + # Give the first half of the access tokens a refresh token, + # alternating between current and expired ones. + RefreshToken.objects.bulk_create( + RefreshToken( + token=f"expired AT's refresh token {i}", + application=app, + access_token=expired_access_tokens[i].pk, + user=self.user, + ) + for i in range(0, len(expired_access_tokens) // 2, 2) + ) + RefreshToken.objects.bulk_create( + RefreshToken( + token=f"current AT's refresh token {i}", + application=app, + access_token=current_access_tokens[i].pk, + user=self.user, + ) + for i in range(1, len(current_access_tokens) // 2, 2) + ) + # Make some grants, half of which are expired. + Grant.objects.bulk_create( + Grant( + user=self.user, + code=f"old grant code {i}", + application=app, + expires=earlier, + redirect_uri="https://localhost/redirect", + ) + for i in range(self.num_tokens) + ) + Grant.objects.bulk_create( + Grant( + user=self.user, + code=f"new grant code {i}", + application=app, + expires=later, + redirect_uri="https://localhost/redirect", + ) + for i in range(self.num_tokens) ) def test_clear_expired_tokens(self): @@ -333,15 +370,21 @@ def test_clear_expired_tokens_incorect_timetype(self): assert result == "ImproperlyConfigured" def test_clear_expired_tokens_with_tokens(self): - self.client.login(username="test_user", password="123456") - self.oauth2_settings.REFRESH_TOKEN_EXPIRE_SECONDS = 0 - ttokens = AccessToken.objects.count() - expiredt = AccessToken.objects.filter(expires__lte=timezone.now()).count() - assert ttokens == 2 - assert expiredt == 2 + self.oauth2_settings.CLEAR_EXPIRED_TOKENS_BATCH_SIZE = 10 + self.oauth2_settings.CLEAR_EXPIRED_TOKENS_BATCH_INTERVAL = 0.0 + at_count = AccessToken.objects.count() + assert at_count == 2 * self.num_tokens, f"{2 * self.num_tokens} access tokens should exist." + rt_count = RefreshToken.objects.count() + assert rt_count == self.num_tokens // 2, f"{self.num_tokens // 2} refresh tokens should exist." + gt_count = Grant.objects.count() + assert gt_count == self.num_tokens * 2, f"{self.num_tokens * 2} grants should exist." clear_expired() - expiredt = AccessToken.objects.filter(expires__lte=timezone.now()).count() - assert expiredt == 0 + at_count = AccessToken.objects.count() + assert at_count == self.num_tokens, "Half the access tokens should not have been deleted." + rt_count = RefreshToken.objects.count() + assert rt_count == self.num_tokens // 2, "Half of the refresh tokens should have been deleted." + gt_count = Grant.objects.count() + assert gt_count == self.num_tokens, "Half the grants should have been deleted." @pytest.mark.django_db diff --git a/tests/test_oauth2_backends.py b/tests/test_oauth2_backends.py index 860cbb461..acff2cae9 100644 --- a/tests/test_oauth2_backends.py +++ b/tests/test_oauth2_backends.py @@ -4,6 +4,7 @@ from django.test import RequestFactory, TestCase from oauth2_provider.backends import get_oauthlib_core +from oauth2_provider.models import redirect_to_uri_allowed from oauth2_provider.oauth2_backends import JSONOAuthLibCore, OAuthLibCore @@ -110,3 +111,23 @@ def test_validate_authorization_request_unsafe_query(self): oauthlib_core = get_oauthlib_core() oauthlib_core.verify_request(request, scopes=[]) + + +@pytest.mark.parametrize( + "uri, expected_result", + # localhost is _not_ a loopback URI + [ + ("http://localhost:3456", False), + # only http scheme is supported for loopback URIs + ("https://127.0.0.1:3456", False), + ("http://127.0.0.1:3456", True), + ("http://[::1]", True), + ("http://[::1]:34", True), + ], +) +def test_uri_loopback_redirect_check(uri, expected_result): + allowed_uris = ["http://127.0.0.1", "http://[::1]"] + if expected_result: + assert redirect_to_uri_allowed(uri, allowed_uris) + else: + assert not redirect_to_uri_allowed(uri, allowed_uris) diff --git a/tests/test_oidc_views.py b/tests/test_oidc_views.py index 3e3a5538c..fa514ac92 100644 --- a/tests/test_oidc_views.py +++ b/tests/test_oidc_views.py @@ -12,10 +12,10 @@ class TestConnectDiscoveryInfoView(TestCase): def test_get_connect_discovery_info(self): expected_response = { - "issuer": "http://localhost", + "issuer": "http://localhost/o", "authorization_endpoint": "http://localhost/o/authorize/", "token_endpoint": "http://localhost/o/token/", - "userinfo_endpoint": "http://localhost/userinfo/", + "userinfo_endpoint": "http://localhost/o/userinfo/", "jwks_uri": "http://localhost/o/.well-known/jwks.json", "response_types_supported": [ "code", @@ -29,6 +29,7 @@ def test_get_connect_discovery_info(self): "subject_types_supported": ["public"], "id_token_signing_alg_values_supported": ["RS256", "HS256"], "token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"], + "claims_supported": ["sub"], } response = self.client.get(reverse("oauth2_provider:oidc-connect-discovery-info")) self.assertEqual(response.status_code, 200) @@ -55,6 +56,7 @@ def test_get_connect_discovery_info_without_issuer_url(self): "subject_types_supported": ["public"], "id_token_signing_alg_values_supported": ["RS256", "HS256"], "token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"], + "claims_supported": ["sub"], } response = self.client.get(reverse("oauth2_provider:oidc-connect-discovery-info")) self.assertEqual(response.status_code, 200) @@ -71,6 +73,7 @@ def test_get_connect_discovery_info_without_rsa_key(self): @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) class TestJwksInfoView(TestCase): def test_get_jwks_info(self): + self.oauth2_settings.OIDC_RSA_PRIVATE_KEYS_INACTIVE = [] expected_response = { "keys": [ { @@ -93,6 +96,31 @@ def test_get_jwks_info_no_rsa_key(self): self.assertEqual(response.status_code, 200) assert response.json() == {"keys": []} + def test_get_jwks_info_multiple_rsa_keys(self): + expected_response = { + "keys": [ + { + "alg": "RS256", + "e": "AQAB", + "kid": "s4a1o8mFEd1tATAIH96caMlu4hOxzBUaI2QTqbYNBHs", + "kty": "RSA", + "n": "mwmIeYdjZkLgalTuhvvwjvnB5vVQc7G9DHgOm20Hw524bLVTk49IXJ2Scw42HOmowWWX-oMVT_ca3ZvVIeffVSN1-TxVy2zB65s0wDMwhiMoPv35z9IKHGMZgl9vlyso_2b7daVF_FQDdgIayUn8TQylBxEU1RFfW0QSYOBdAt8", # noqa + "use": "sig", + }, + { + "alg": "RS256", + "e": "AQAB", + "kid": "AJ_IkYJUFWqiKKE2FvPIESroTvownbaj0OzL939oIIE", + "kty": "RSA", + "n": "0qVzbcWg_fgygZ0liTaFeodD2bkinhj8gPJ9P2rPzvqG6ImI9YKkEk8Dxcc7eWcudnw5iEL8wx_tgooaRiHiYfUrFBBXfA15D_15PdX_5gG8rQbJ7XMxQrYoRUcVm2wQDB4fIuR7sTPqx9p8OR4f--BixOfM5Oa7SEUtQ8kvrlE", # noqa + "use": "sig", + }, + ] + } + response = self.client.get(reverse("oauth2_provider:jwks-info")) + self.assertEqual(response.status_code, 200) + assert response.json() == expected_response + @pytest.mark.django_db @pytest.mark.parametrize("method", ["get", "post"]) @@ -120,11 +148,47 @@ def test_userinfo_endpoint_bad_token(oidc_tokens, client): assert rsp.status_code == 401 +EXAMPLE_EMAIL = "example.email@example.com" + + +def claim_user_email(request): + return EXAMPLE_EMAIL + + @pytest.mark.django_db -def test_userinfo_endpoint_custom_claims(oidc_tokens, client, oauth2_settings): +def test_userinfo_endpoint_custom_claims_callable(oidc_tokens, client, oauth2_settings): + class CustomValidator(OAuth2Validator): + def get_additional_claims(self): + return { + "username": claim_user_email, + "email": claim_user_email, + } + + oidc_tokens.oauth2_settings.OAUTH2_VALIDATOR_CLASS = CustomValidator + auth_header = "Bearer %s" % oidc_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_tokens.user.pk) + + assert "username" in data + assert data["username"] == EXAMPLE_EMAIL + + 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): def get_additional_claims(self, request): - return {"state": "very nice"} + return { + "username": EXAMPLE_EMAIL, + "email": EXAMPLE_EMAIL, + } oidc_tokens.oauth2_settings.OAUTH2_VALIDATOR_CLASS = CustomValidator auth_header = "Bearer %s" % oidc_tokens.access_token @@ -135,5 +199,9 @@ def get_additional_claims(self, request): data = rsp.json() assert "sub" in data assert data["sub"] == str(oidc_tokens.user.pk) - assert "state" in data - assert data["state"] == "very nice" + + assert "username" in data + assert data["username"] == EXAMPLE_EMAIL + + assert "email" in data + assert data["email"] == EXAMPLE_EMAIL diff --git a/tox.ini b/tox.ini index 3016d024c..9d5ebd7c4 100644 --- a/tox.ini +++ b/tox.ini @@ -1,16 +1,17 @@ [tox] envlist = flake8, + migrations, docs, - py{36,37,38,39}-dj{31,30,22}, - py{38,39}-djmain, + py{37,38,39}-dj22, + py{37,38,39,310}-dj32, [gh-actions] python = - 3.6: py36 3.7: py37 - 3.8: py38, docs, flake8 + 3.8: py38, docs, flake8, migrations 3.9: py39 + 3.10: py310 [pytest] django_find_project = false @@ -33,8 +34,8 @@ setenv = PYTHONWARNINGS = all deps = dj22: Django>=2.2,<3 - dj30: Django>=3.0,<3.1 - dj31: Django>=3.1,<3.2 + dj32: Django>=3.2,<3.3 + dj40: Django>=4.0.0,<4.1 djmain: https://github.com/django/django/archive/main.tar.gz djangorestframework oauthlib>=3.1.0 @@ -49,7 +50,7 @@ deps = passenv = PYTEST_ADDOPTS -[testenv:py{38,39}-djmain] +[testenv:py{38,39,310}-djmain] ignore_errors = true ignore_outcome = true @@ -64,9 +65,11 @@ deps = sphinx<3 oauthlib>=3.1.0 m2r>=0.2.1 + mistune<2 sphinx-rtd-theme livedocs: sphinx-autobuild jwcrypto + django [testenv:flake8] basepython = python3.8 @@ -78,17 +81,21 @@ deps = flake8-quotes flake8-black -[testenv:install] +[testenv:migrations] +setenv = + DJANGO_SETTINGS_MODULE = tests.mig_settings + PYTHONPATH = {toxinidir} + PYTHONWARNINGS = all +commands = django-admin makemigrations --dry-run --check + +[testenv:build] deps = - twine setuptools>=39.0 wheel whitelist_externals = rm commands = rm -rf dist python setup.py sdist bdist_wheel - twine upload dist/* - [coverage:run] source = oauth2_provider @@ -99,7 +106,7 @@ show_missing = True [flake8] max-line-length = 110 -exclude = docs/, oauth2_provider/migrations/, tests/migrations/, .tox/ +exclude = docs/, oauth2_provider/migrations/, tests/migrations/, .tox/, build/, dist/ application-import-names = oauth2_provider inline-quotes = double extend-ignore = E203, W503