pax_global_header00006660000000000000000000000064144357311220014514gustar00rootroot0000000000000052 comment=13a61435167d8ffe04dd6b79522d5d20007a08c5 django-oauth-toolkit-2.3.0/000077500000000000000000000000001443573112200155615ustar00rootroot00000000000000django-oauth-toolkit-2.3.0/.editorconfig000066400000000000000000000003361443573112200202400ustar00rootroot00000000000000root = true [*] charset = utf-8 end_of_line = lf indent_style = space indent_size = 4 insert_final_newline = true trim_trailing_whitespace = true [{Makefile,setup.cfg}] indent_style = tab [*.{yml,yaml}] indent_size = 2 django-oauth-toolkit-2.3.0/.github/000077500000000000000000000000001443573112200171215ustar00rootroot00000000000000django-oauth-toolkit-2.3.0/.github/ISSUE_TEMPLATE/000077500000000000000000000000001443573112200213045ustar00rootroot00000000000000django-oauth-toolkit-2.3.0/.github/ISSUE_TEMPLATE/bug_report.md000066400000000000000000000013551443573112200240020ustar00rootroot00000000000000--- name: Bug report about: Create a report to help us improve title: '' labels: bug assignees: '' --- **Describe the bug** **To Reproduce** **Expected behavior** **Version** - [ ] I have tested with the latest published release and it's still a problem. - [ ] I have tested with the master branch and it's still a problem. **Additional context** django-oauth-toolkit-2.3.0/.github/ISSUE_TEMPLATE/feature_request.md000066400000000000000000000012011443573112200250230ustar00rootroot00000000000000--- name: Feature request about: Suggest an idea for this project title: '' labels: enhancement assignees: '' --- **Is your feature request related to a problem? Please describe.** **Describe the solution you'd like** **Describe alternatives you've considered** **Additional context** django-oauth-toolkit-2.3.0/.github/ISSUE_TEMPLATE/question.md000066400000000000000000000002331443573112200234730ustar00rootroot00000000000000--- name: Question about: Ask a question about using django-oauth-toolkit title: '' labels: question assignees: '' --- django-oauth-toolkit-2.3.0/.github/pull_request_template.md000066400000000000000000000011511443573112200240600ustar00rootroot00000000000000 Fixes # ## Description of the Change ## Checklist - [ ] PR only contains one change (considered splitting up PR) - [ ] unit-test added - [ ] documentation updated - [ ] `CHANGELOG.md` updated (only for user relevant changes) - [ ] author name in `AUTHORS` django-oauth-toolkit-2.3.0/.github/workflows/000077500000000000000000000000001443573112200211565ustar00rootroot00000000000000django-oauth-toolkit-2.3.0/.github/workflows/release.yml000066400000000000000000000017641443573112200233310ustar00rootroot00000000000000name: Release on: push: tags: - '*' jobs: build: if: github.repository == 'jazzband/django-oauth-toolkit' runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 with: fetch-depth: 0 - name: Set up Python uses: actions/setup-python@v2 with: python-version: 3.8 - name: Install dependencies run: | python -m pip install -U pip python -m pip install -U setuptools twine wheel - name: Build package run: | python setup.py --version python setup.py sdist --format=gztar bdist_wheel twine check dist/* - name: Upload packages to Jazzband if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') uses: pypa/gh-action-pypi-publish@master with: user: jazzband password: ${{ secrets.JAZZBAND_RELEASE_KEY }} repository_url: https://jazzband.co/projects/django-oauth-toolkit/upload django-oauth-toolkit-2.3.0/.github/workflows/test.yml000066400000000000000000000043531443573112200226650ustar00rootroot00000000000000name: Test on: [push, pull_request] jobs: build: name: build (Python ${{ matrix.python-version }}, Django ${{ matrix.django-version }}) runs-on: ubuntu-latest strategy: fail-fast: false matrix: python-version: ['3.7', '3.8', '3.9', '3.10', '3.11'] django-version: ['2.2', '3.2', '4.0', '4.1', '4.2', 'main'] exclude: # https://docs.djangoproject.com/en/dev/faq/install/#what-python-version-can-i-use-with-django # Python 3.10+ is not supported by Django 2.2 - python-version: '3.10' django-version: '2.2' # Python 3.7 is not supported by Django 4.0+ - python-version: '3.7' django-version: '4.0' - python-version: '3.7' django-version: '4.1' - python-version: '3.7' django-version: '4.2' - python-version: '3.7' django-version: 'main' # < Python 3.10 is not supported by Django 5.0+ - python-version: '3.8' django-version: 'main' - python-version: '3.9' django-version: 'main' steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Get pip cache dir id: pip-cache run: | echo "::set-output name=dir::$(pip cache dir)" - name: Cache uses: actions/cache@v3 with: path: ${{ steps.pip-cache.outputs.dir }} key: ${{ matrix.python-version }}-v1-${{ hashFiles('**/setup.py') }}-${{ hashFiles('**/tox.ini') }} restore-keys: | ${{ matrix.python-version }}-v1- - name: Install Python dependencies run: | python -m pip install --upgrade pip python -m pip install --upgrade tox tox-gh-actions - name: Tox tests run: | tox -v env: DJANGO: ${{ matrix.django-version }} - name: Upload coverage uses: codecov/codecov-action@v3 with: name: Python ${{ matrix.python-version }} success: needs: build runs-on: ubuntu-latest name: Test successful steps: - name: Success run: echo Test successful django-oauth-toolkit-2.3.0/.gitignore000066400000000000000000000007201443573112200175500ustar00rootroot00000000000000*.py[cod] *.swp # C extensions *.so # Packages *.egg *.egg-info dist build eggs parts bin var sdist develop-eggs .installed.cfg lib lib64 __pycache__ # Installer logs pip-log.txt # Unit test / coverage reports .cache .pytest_cache .coverage .tox .pytest_cache/ nosetests.xml # Translations *.mo # Mr Developer .mr.developer.cfg .project .pydevproject # PyCharm stuff .idea # Sphinx build dir _build # Sqlite database files *.sqlite /venv/ /coverage.xml django-oauth-toolkit-2.3.0/.pre-commit-config.yaml000066400000000000000000000015411443573112200220430ustar00rootroot00000000000000repos: - repo: https://github.com/psf/black rev: 23.3.0 hooks: - id: black exclude: ^(oauth2_provider/migrations/|tests/migrations/) - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.4.0 hooks: - id: check-ast - id: trailing-whitespace - id: check-merge-conflict - id: check-json - id: check-xml - id: check-yaml - id: mixed-line-ending args: ['--fix=lf'] - repo: https://github.com/PyCQA/isort rev: 5.12.0 hooks: - id: isort exclude: ^(oauth2_provider/migrations/|tests/migrations/) - repo: https://github.com/PyCQA/flake8 rev: 6.0.0 hooks: - id: flake8 exclude: ^(oauth2_provider/migrations/|tests/migrations/) - repo: https://github.com/sphinx-contrib/sphinx-lint rev: v0.6.7 hooks: - id: sphinx-lint django-oauth-toolkit-2.3.0/.readthedocs.yml000066400000000000000000000013051443573112200206460ustar00rootroot00000000000000# .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 # 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: install: - requirements: docs/requirements.txt django-oauth-toolkit-2.3.0/AUTHORS000066400000000000000000000026261443573112200166370ustar00rootroot00000000000000Authors ------- Massimiliano Pippi Federico Frenguelli Contributors ------------ Abhishek Patel Adam Johnson Adheeth P Praveen Alan Crosswell Alejandro Mantecon Guillen Aleksander Vaskevich Alessandro De Angelis Alex Szabó Allisson Azevedo Andrea Greco Andrej Zbín Andrew Chen Wang Antoine Laurent Anvesh Agarwal Aristóbulo Meneses Aryan Iyappan Ash Christopher Asif Saif Uddin Bart Merenda Bas van Oostveen Brian Helba Carl Schwan Daniel 'Vector' Kerr Darrel O'Pry Dave Burkholder David Fischer David Hill David Smith Dawid Wolski Diego Garcia Dominik George Dulmandakh Sukhbaatar Dylan Giesler Dylan Tack Eduardo Oliveira Egor Poderiagin Emanuele Palazzetti Federico Dolce Frederico Vieira Hasan Ramezani Hiroki Kiyohara Hossein Shakiba Islam Kamel Jadiel Teófilo Jens Timmerman Jerome Leclanche Jesse Gibbs Jim Graham Jonas Nygaard Pedersen Jonathan Steffan Jordi Sanchez Joseph Abrahams Josh Thomas Jozef Knaperek Julian Mundhahs Julien Palard Jun Zhou Kaleb Porter Kristian Rune Larsen Ludwig Hähne Marcus Sonestedt Matias Seniquiel Michael Howitz Owen Gong Patrick Palacin Paul Dekkers Paul Oswald Pavel Tvrdík Peter Carnesciali Peter Karman Petr Dlouhý Rodney Richardson Rustem Saiargaliev Rustem Saiargaliev Sandro Rodrigues Shaheed Haque Shaun Stanworth Silvano Cerza Sora Yanai Spencer Carroll Stéphane Raimbault Tom Evans Vinay Karanam Víðir Valberg Guðmundsson Will Beaufoy pySilver Łukasz Skarżyński django-oauth-toolkit-2.3.0/CHANGELOG.md000066400000000000000000000603451443573112200174020ustar00rootroot00000000000000# Changelog All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [2.3.0] 2023-05-31 ### WARNING Issues caused by **Release 2.0.0 breaking changes** continue to be logged. Please **make sure to carefully read these release notes** before performing a MAJOR upgrade to 2.x. These issues both result in `{"error": "invalid_client"}`: 1. The application client secret is now hashed upon save. You must copy it before it is saved. Using the hashed value will fail. 2. `PKCE_REQUIRED` is now `True` by default. You should use PKCE with your client or set `PKCE_REQUIRED=False` if you are unable to fix the client. ### Added * Add Japanese(日本語) Language Support * #1244 implement [OIDC RP-Initiated Logout](https://openid.net/specs/openid-connect-rpinitiated-1_0.html) * #1092 Allow Authorization Code flow without a client_secret per [RFC 6749 2.3.1](https://www.rfc-editor.org/rfc/rfc6749.html#section-2.3.1) ### Changed * #1222 Remove expired ID tokens alongside access tokens in `cleartokens` management command * #1267, #1253, #1251, #1250, #1224, #1212, #1211 Various documentation improvements ## [2.2.0] 2022-10-18 ### Added * #1208 Add 'code_challenge_method' parameter to authorization call in documentation * #1182 Add 'code_verifier' parameter to token requests in documentation ### Changed * #1203 Support Django 4.1. ### Fixed * #1203 Remove upper version bound on Django, to allow upgrading to Django 4.1.1 bugfix release. * #1210 Handle oauthlib errors on create token requests ## [2.1.0] 2022-06-19 ### Added * #1164 Support `prompt=login` for the OIDC Authorization Code Flow end user [Authentication Request](https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest). * #1163 Add French (fr) translations. * #1166 Add Spanish (es) translations. ### Changed * #1152 `createapplication` management command enhanced to display an auto-generated secret before it gets hashed. * #1172, #1159, #1158 documentation improvements. ### Fixed * #1147 Fixed 2.0.0 implementation of [hashed](https://docs.djangoproject.com/en/stable/topics/auth/passwords/) client secret to work with swapped models. ## [2.0.0] 2022-04-24 This is a major release with **BREAKING** changes. Please make sure to review these changes before upgrading: ### Added * #1106 OIDC: Add "scopes_supported" to the [ConnectDiscoveryInfoView](https://django-oauth-toolkit.readthedocs.io/en/latest/oidc.html#connectdiscoveryinfoview). This completes the view to provide all the REQUIRED and RECOMMENDED [OpenID Provider Metadata](https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata). * #1128 Documentation: [Tutorial](https://django-oauth-toolkit.readthedocs.io/en/latest/tutorial/tutorial_05.html) on using Celery to automate clearing expired tokens. ### Changed * #1129 (**Breaking**) Changed default value of PKCE_REQUIRED to True. This is a **breaking change**. Clients without PKCE enabled will fail to authenticate. This breaks with [section 5 of RFC7636](https://datatracker.ietf.org/doc/html/rfc7636) in favor of the [OAuth2 Security Best Practices for Authorization Code Grants](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics#section-2.1). If you want to retain the pre-2.x behavior, set `PKCE_REQUIRED = False` in your settings.py * #1093 (**Breaking**) Changed to implement [hashed](https://docs.djangoproject.com/en/stable/topics/auth/passwords/) client_secret values. This is a **breaking change** that will migrate all your existing cleartext `application.client_secret` values to be hashed with Django's default password hashing algorithm and can not be reversed. When adding or modifying an Application in the Admin console, you must copy the auto-generated or manually-entered `client_secret` before hitting Save. * #1108 OIDC: (**Breaking**) Add default configurable OIDC standard scopes that determine which claims are returned. If you've [customized OIDC responses](https://django-oauth-toolkit.readthedocs.io/en/latest/oidc.html#customizing-the-oidc-responses) and want to retain the pre-2.x behavior, set `oidc_claim_scope = None` in your subclass of `OAuth2Validator`. * #1108 OIDC: Make the `access_token` available to `get_oidc_claims` when called from `get_userinfo_claims`. * #1132: Added `--algorithm` argument to `createapplication` management command ### Fixed * #1108 OIDC: Fix `validate_bearer_token()` to properly set `request.scopes` to the list of granted scopes. * #1132: Fixed help text for `--skip-authorization` argument of the `createapplication` management command. ### Removed * #1124 (**Breaking**, **Security**) Removes support for insecure `urn:ietf:wg:oauth:2.0:oob` and `urn:ietf:wg:oauth:2.0:oob:auto` which are replaced by [RFC 8252](https://datatracker.ietf.org/doc/html/rfc8252) "OAuth 2.0 for Native Apps" BCP. Google has [deprecated use of oob](https://developers.googleblog.com/2022/02/making-oauth-flows-safer.html?m=1#disallowed-oob) with a final end date of 2022-10-03. If you still rely on oob support in django-oauth-toolkit, do not upgrade to this release. ## [1.7.1] 2022-03-19 ### Removed * #1126 Reverts #1070 which incorrectly added Celery auto-discovery tasks.py (as described in #1123) and because it conflicts with Huey's auto-discovery which also uses tasks.py as described in #1114. If you are using Celery or Huey, you'll need to separately implement these tasks. ## [1.7.0] 2022-01-23 ### Added * #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 * #915 Add optional OpenID Connect support. ### Changed * #942 Help via defunct Google group replaced with using GitHub issues ## [1.4.1] 2021-03-12 ### Changed * #925 OAuth2TokenMiddleware converted to new style middleware, and no longer extends MiddlewareMixin. ### Removed * #936 Remove support for Python 3.5 ## [1.4.0] 2021-02-08 ### Added * #917 Documentation improvement for Access Token expiration. * #916 (for DOT contributors) Added `tox -e livedocs` which launches a local web server on `locahost:8000` to display Sphinx documentation with live updates as you edit. * #891 (for DOT contributors) Added [details](https://django-oauth-toolkit.readthedocs.io/en/latest/contributing.html) on how best to contribute to this project. * #884 Added support for Python 3.9 * #898 Added the ability to customize classes for django admin * #690 Added pt-PT translations to HTML templates. This enables adding additional translations. ### Fixed * #906 Made token revocation not apply a limit to the `select_for_update` statement (impacts Oracle 12c database). * #903 Disable `redirect_uri` field length limit for `AbstractGrant` ## [1.3.3] 2020-10-16 ### Added * added `select_related` in intospect view for better query performance * #831 Authorization token creation now can receive an expire date * #831 Added a method to override Grant creation * #825 Bump oauthlib to 3.1.0 to introduce PKCE * Support for Django 3.1 ### Fixed * #847: Fix inappropriate message when response from authentication server is not OK. ### Changed * few smaller improvements to remove older django version compatibility #830, #861, #862, #863 ## [1.3.2] 2020-03-24 ### Fixed * Fixes: 1.3.1 inadvertently uploaded to pypi with an extra migration (0003...) from a dev branch. ## [1.3.1] 2020-03-23 ### Added * #725: HTTP Basic Auth support for introspection (Fix issue #709) ### Fixed * #812: Reverts #643 pass wrong request object to authenticate function. * Fix concurrency issue with refresh token requests (#[810](https://github.com/jazzband/django-oauth-toolkit/pull/810)) * #817: Reverts #734 tutorial documentation error. ## [1.3.0] 2020-03-02 ### Added * Add support for Python 3.7 & 3.8 * Add support for Django>=2.1,<3.1 * Add requirement for oauthlib>=3.0.1 * Add support for [Proof Key for Code Exchange (PKCE, RFC 7636)](https://tools.ietf.org/html/rfc7636). * Add support for custom token generators (e.g. to create JWT tokens). * Add new `OAUTH2_PROVIDER` [settings](https://django-oauth-toolkit.readthedocs.io/en/latest/settings.html): - `ACCESS_TOKEN_GENERATOR` to override the default access token generator. - `REFRESH_TOKEN_GENERATOR` to override the default refresh token generator. - `EXTRA_SERVER_KWARGS` options dictionary for oauthlib's Server class. - `PKCE_REQUIRED` to require PKCE. * Add `createapplication` management command to create an application. * Add `id` in toolkit admin console applications list. * Add nonstandard Google support for [urn:ietf:wg:oauth:2.0:oob] `redirect_uri` for [Google OAuth2](https://developers.google.com/identity/protocols/OAuth2InstalledApp) "manual copy/paste". **N.B.** this feature appears to be deprecated and replaced with methods described in [RFC 8252: OAuth2 for Native Apps](https://tools.ietf.org/html/rfc8252) and *may* be deprecated and/or removed from a future release of Django-oauth-toolkit. ### Changed * Change this change log to use [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) format. * **Backwards-incompatible** squashed migrations: If you are currently on a release < 1.2.0, you will need to first install 1.2.0 then `manage.py migrate` before upgrading to >= 1.3.0. * Improved the [tutorial](https://django-oauth-toolkit.readthedocs.io/en/latest/tutorial/tutorial.html). ### Removed * Remove support for Python 3.4 * Remove support for Django<=2.0 * Remove requirement for oauthlib<3.0 ### Fixed * Fix a race condition in creation of AccessToken with external oauth2 server. * Fix several concurrency issues. (#[638](https://github.com/jazzband/django-oauth-toolkit/issues/638)) * Fix to pass `request` to `django.contrib.auth.authenticate()` (#[636](https://github.com/jazzband/django-oauth-toolkit/issues/636)) * Fix missing `oauth2_error` property exception oauthlib_core.verify_request method raises exceptions in authenticate. (#[633](https://github.com/jazzband/django-oauth-toolkit/issues/633)) * Fix "django.db.utils.NotSupportedError: FOR UPDATE cannot be applied to the nullable side of an outer join" for postgresql. (#[714](https://github.com/jazzband/django-oauth-toolkit/issues/714)) * Fix to return a new refresh token during grace period rather than the recently-revoked one. (#[702](https://github.com/jazzband/django-oauth-toolkit/issues/702)) * Fix a bug in refresh token revocation. (#[625](https://github.com/jazzband/django-oauth-toolkit/issues/625)) ## 1.2.0 [2018-06-03] * **Compatibility**: Python 3.4 is the new minimum required version. * **Compatibility**: Django 2.0 is the new minimum required version. * **New feature**: Added TokenMatchesOASRequirements Permissions. * validators.URIValidator has been updated to match URLValidator behaviour more closely. * Moved `redirect_uris` validation to the application clean() method. ## 1.1.2 [2018-05-12] * Return state with Authorization Denied error (RFC6749 section 4.1.2.1) * Fix a crash with malformed base64 authentication headers * Fix a crash with malformed IPv6 redirect URIs ## 1.1.1 [2018-05-08] * **Critical**: Django OAuth Toolkit 1.1.0 contained a migration that would revoke all existing RefreshTokens (`0006_auto_20171214_2232`). This release corrects the migration. If you have already ran it in production, please see the following issue for more details: https://github.com/jazzband/django-oauth-toolkit/issues/589 ## 1.1.0 [2018-04-13] * **Notice**: The Django OAuth Toolkit project is now hosted by JazzBand. * **Compatibility**: Django 1.11 is the new minimum required version. Django 1.10 is no longer supported. * **Compatibility**: This will be the last release to support Django 1.11 and Python 2.7. * **New feature**: Option for RFC 7662 external AS that uses HTTP Basic Auth. * **New feature**: Individual applications may now override the `ALLOWED_REDIRECT_URI_SCHEMES` setting by returning a list of allowed redirect uri schemes in `Application.get_allowed_schemes()`. * **New feature**: The new setting `ERROR_RESPONSE_WITH_SCOPES` can now be set to True to include required scopes when DRF authorization fails due to improper scopes. * **New feature**: The new setting `REFRESH_TOKEN_GRACE_PERIOD_SECONDS` controls a grace period during which refresh tokens may be re-used. * An `app_authorized` signal is fired when a token is generated. ## 1.0.0 [2017-06-07] * **New feature**: AccessToken, RefreshToken and Grant models are now swappable. * #477: **New feature**: Add support for RFC 7662 (IntrospectTokenView, introspect scope) * **Compatibility**: Django 1.10 is the new minimum required version * **Compatibility**: Django 1.11 is now supported * **Backwards-incompatible**: The `oauth2_provider.ext.rest_framework` module has been moved to `oauth2_provider.contrib.rest_framework` * #177: Changed `id` field on Application, AccessToken, RefreshToken and Grant to BigAutoField (bigint/bigserial) * #321: Added `created` and `updated` auto fields to Application, AccessToken, RefreshToken and Grant * #476: Disallow empty redirect URIs * Fixed bad `url` parameter in some error responses. * Django 2.0 compatibility fixes. * The dependency on django-braces has been dropped. * The oauthlib dependency is no longer pinned. ## 0.12.0 [2017-02-24] * **New feature**: Class-based scopes backends. Listing scopes, available scopes and default scopes is now done through the class that the `SCOPES_BACKEND_CLASS` setting points to. By default, this is set to `oauth2_provider.scopes.SettingsScopes` which implements the legacy settings-based scope behaviour. No changes are necessary. * **Dropped support for Python 3.2 and Python 3.3**, added support for Python 3.6 * Support for the `scopes` query parameter, deprecated in 0.6.1, has been dropped * #448: Added support for customizing applications' allowed grant types * #141: The `is_usable(request)` method on the Application model can be overridden to dynamically enable or disable applications. * #434: Relax URL patterns to allow for UUID primary keys ## 0.11.0 [2016-12-1] * #315: AuthorizationView does not overwrite requests on get * #425: Added support for Django 1.10 * #396: added an IsAuthenticatedOrTokenHasScope Permission * #357: Support multiple-user clients by allowing User to be NULL for Applications * #389: Reuse refresh tokens if enabled. ## 0.10.0 [2015-12-14] * **#322: dropping support for python 2.6 and django 1.4, 1.5, 1.6** * #310: Fixed error that could occur sometimes when checking validity of incomplete AccessToken/Grant * #333: Added possibility to specify the default list of scopes returned when scope parameter is missing * #325: Added management views of issued tokens * #249: Added a command to clean expired tokens * #323: Application registration view uses custom application model in form class * #299: `server_class` is now pluggable through Django settings * #309: Add the py35-django19 env to travis * #308: Use compact syntax for tox envs * #306: Django 1.9 compatibility * #288: Put additional information when generating token responses * #297: Fixed doc about SessionAuthenticationMiddleware * #273: Generic read write scope by resource ## 0.9.0 [2015-07-28] * ``oauthlib_backend_class`` is now pluggable through Django settings * #127: ``application/json`` Content-Type is now supported using ``JSONOAuthLibCore`` * #238: Fixed redirect uri handling in case of error * #229: Invalidate access tokens when getting a new refresh token * added support for oauthlib 1.0 ## 0.8.2 [2015-06-25] * Fix the migrations to be two-step and allow upgrade from 0.7.2 ## 0.8.1 [2015-04-27] * South migrations fixed. Added new django migrations. ## 0.8.0 [2015-03-27] * Several docs improvements and minor fixes * #185: fixed vulnerabilities on Basic authentication * #173: ProtectResourceMixin now allows OPTIONS requests * Fixed `client_id` and `client_secret` characters set * #169: hide sensitive informations in error emails * #161: extend search to all token types when revoking a token * #160: return empty response on successful token revocation * #157: skip authorization form with ``skip_authorization_completely`` class field * #155: allow custom uri schemes * fixed ``get_application_model`` on Django 1.7 * fixed non rotating refresh tokens * #137: fixed base template * customized ``client_secret`` length * #38: create access tokens not bound to a user instance for *client credentials* flow ## 0.7.2 [2014-07-02] * Don't pin oauthlib ## 0.7.1 [2014-04-27] * Added database indexes to the OAuth2 related models to improve performances. **Warning: schema migration does not work for sqlite3 database, migration should be performed manually** ## 0.7.0 [2014-03-01] * Created a setting for the default value for approval prompt. * Improved docs * Don't pin django-braces and six versions **Backwards incompatible changes in 0.7.0** * Make Application model truly "swappable" (introduces a new non-namespaced setting `OAUTH2_PROVIDER_APPLICATION_MODEL`) ## 0.6.1 [2014-02-05] * added support for `scope` query parameter keeping backwards compatibility for the original `scopes` parameter. * __str__ method in Application model returns content of `name` field when available ## 0.6.0 [2014-01-26] * oauthlib 0.6.1 support * Django dev branch support * Python 2.6 support * Skip authorization form via `approval_prompt` parameter **Bugfixes** * Several fixes to the docs * Issue #71: Fix migrations * Issue #65: Use OAuth2 password grant with multiple devices * Issue #84: Add information about login template to tutorial. * Issue #64: Fix urlencode clientid secret ## 0.5.0 [2013-09-17] * oauthlib 0.6.0 support **Backwards incompatible changes in 0.5.0** * `backends.py` module has been renamed to `oauth2_backends.py` so you should change your imports whether you're extending this module **Bugfixes** * Issue #54: Auth backend proposal to address #50 * Issue #61: Fix contributing page * Issue #55: Add support for authenticating confidential client with request body params * Issue #53: Quote characters in the url query that are safe for Django but not for oauthlib ## 0.4.1 [2013-09-06] * Optimize queries on access token validation ## 0.4.0 [2013-08-09] **New Features** * Add Application management views, you no more need the admin to register, update and delete your application. * Add support to configurable application model * Add support for function based views **Backwards incompatible changes in 0.4.0** * `SCOPE` attribute in settings is now a dictionary to store `{'scope_name': 'scope_description'}` * Namespace `oauth2_provider` is mandatory in urls. See issue #36 **Bugfixes** * Issue #25: Bug in the Basic Auth parsing in Oauth2RequestValidator * Issue #24: Avoid generation of `client_id` with ":" colon char when using HTTP Basic Auth * Issue #21: IndexError when trying to authorize an application * Issue #9: `default_redirect_uri` is mandatory when `grant_type` is implicit, `authorization_code` or all-in-one * Issue #22: Scopes need a verbose description * Issue #33: Add django-oauth-toolkit version on example main page * Issue #36: Add mandatory namespace to urls * Issue #31: Add docstring to OAuthToolkitError and FatalClientError * Issue #32: Add docstring to `validate_uris` * Issue #34: Documentation tutorial part1 needs corsheaders explanation * Issue #36: Add mandatory namespace to urls * Issue #45: Add docs for AbstractApplication * Issue #47: Add docs for views decorators ## 0.3.2 [2013-07-10] * Bugfix #37: Error in migrations with custom user on Django 1.5 ## 0.3.1 [2013-07-10] * Bugfix #27: OAuthlib refresh token refactoring ## 0.3.0 [2013-06-14] * [Django REST Framework](http://django-rest-framework.org/) integration layer * Bugfix #13: Populate request with client and user in `validate_bearer_token` * Bugfix #12: Fix paths in documentation **Backwards incompatible changes in 0.3.0** * `requested_scopes` parameter in ScopedResourceMixin changed to `required_scopes` ## 0.2.1 [2013-06-06] * Core optimizations ## 0.2.0 [2013-06-05] * Add support for Django1.4 and Django1.6 * Add support for Python 3.3 * Add a default ReadWriteScoped view * Add tutorial to docs ## 0.1.0 [2013-05-31] * Support OAuth2 Authorization Flows ## 0.0.0 [2013-05-17] * Discussion with Daniel Greenfeld at Django Circus * Ignition django-oauth-toolkit-2.3.0/CODE_OF_CONDUCT.md000066400000000000000000000045071443573112200203660ustar00rootroot00000000000000# 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/ django-oauth-toolkit-2.3.0/CONTRIBUTING.md000066400000000000000000000010261443573112200200110ustar00rootroot00000000000000[![Jazzband](https://jazzband.co/static/img/jazzband.svg)](https://jazzband.co/) This is a [Jazzband](https://jazzband.co/) project. By contributing you agree to abide by the [Contributor Code of Conduct](https://jazzband.co/about/conduct) and follow the [guidelines](https://jazzband.co/about/guidelines). # Contribute to Django OAuth Toolkit Thanks for your interest, we love contributions! Please [follow these guidelines](https://django-oauth-toolkit.readthedocs.io/en/latest/contributing.html) when submitting pull requests. django-oauth-toolkit-2.3.0/LICENSE000066400000000000000000000030241443573112200165650ustar00rootroot00000000000000Copyright (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: 1. Redistributions of source code must retain the above copyright notice, this 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. 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 WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 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, either expressed or implied, of the FreeBSD Project. django-oauth-toolkit-2.3.0/MANIFEST.in000066400000000000000000000001161443573112200173150ustar00rootroot00000000000000include README.rst LICENSE recursive-include oauth2_provider/templates *.html django-oauth-toolkit-2.3.0/README.rst000066400000000000000000000105001443573112200172440ustar00rootroot00000000000000Django OAuth Toolkit ==================== .. image:: https://jazzband.co/static/img/badge.svg :target: https://jazzband.co/ :alt: Jazzband *OAuth2 goodies for the Djangonauts!* .. image:: https://badge.fury.io/py/django-oauth-toolkit.svg :target: http://badge.fury.io/py/django-oauth-toolkit .. image:: https://github.com/jazzband/django-oauth-toolkit/workflows/Test/badge.svg :target: https://github.com/jazzband/django-oauth-toolkit/actions :alt: GitHub Actions .. image:: https://codecov.io/gh/jazzband/django-oauth-toolkit/branch/master/graph/badge.svg :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, Django OAuth Toolkit can help you providing out of the box all the endpoints, data and logic needed to add OAuth2 capabilities to your Django projects. Django OAuth Toolkit makes extensive use of the excellent `OAuthLib `_, so that everything is `rfc-compliant `_. Reporting security issues ------------------------- Please report any security issues to the JazzBand security team at . Do not file an issue on the tracker. Requirements ------------ * Python 3.7+ * Django 2.2, 3.2, 4.0 (4.0.1+ due to a regression), 4.1, or 4.2 * oauthlib 3.1+ Installation ------------ Install with pip:: pip install django-oauth-toolkit Add `oauth2_provider` to your `INSTALLED_APPS` .. code-block:: python INSTALLED_APPS = ( ... 'oauth2_provider', ) If you need an OAuth2 provider you'll want to add the following to your urls.py. Notice that `oauth2_provider` namespace is mandatory. .. code-block:: python urlpatterns = [ ... path('o/', include('oauth2_provider.urls', namespace='oauth2_provider')), ] Changelog --------- See `CHANGELOG.md `_. Documentation -------------- The `full documentation `_ is on *Read the Docs*. License ------- django-oauth-toolkit is released under the terms of the **BSD license**. Full details in ``LICENSE`` file. Help Wanted ----------- We need help maintaining and enhancing django-oauth-toolkit (DOT). Join the team ~~~~~~~~~~~~~ Please consider joining `Jazzband `__ (If not already a member) and the `DOT project team `__. How you can help ~~~~~~~~~~~~~~~~ See our `contributing `__ info and the open `issues `__ and `PRs `__, especially those labeled `help-wanted `__. Submit PRs and Perform Reviews ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ PR submissions and reviews are always appreciated! Since we require an independent review of any PR before it can be merged, having your second set of eyes looking at PRs is extremely valuable. Please don’t merge PRs ~~~~~~~~~~~~~~~~~~~~~~ Please be aware that we don’t want *every* Jazzband member to merge PRs but just a handful of project team members so that we can maintain a modicum of control over what goes into a release of this security oriented code base. Only `project leads `__ are able to publish releases to Pypi and it becomes difficult when creating a new release for the leads to deal with “unexpected” merged PRs. Become a Project Lead ~~~~~~~~~~~~~~~~~~~~~ If you are interested in stepping up to be a Project Lead, please join the `discussion `__. django-oauth-toolkit-2.3.0/docs/000077500000000000000000000000001443573112200165115ustar00rootroot00000000000000django-oauth-toolkit-2.3.0/docs/Makefile000066400000000000000000000153471443573112200201630ustar00rootroot00000000000000# Makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build PAPER = BUILDDIR = _build # User-friendly check for sphinx-build ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) endif # Internal variables. PAPEROPT_a4 = -D latex_paper_size=a4 PAPEROPT_letter = -D latex_paper_size=letter ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . # the i18n builder cannot share the environment and doctrees with the others I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext help: @echo "Please use \`make ' where is one of" @echo " html to make standalone HTML files" @echo " dirhtml to make HTML files named index.html in directories" @echo " singlehtml to make a single large HTML file" @echo " pickle to make pickle files" @echo " json to make JSON files" @echo " htmlhelp to make HTML files and a HTML help project" @echo " qthelp to make HTML files and a qthelp project" @echo " devhelp to make HTML files and a Devhelp project" @echo " epub to make an epub" @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" @echo " latexpdf to make LaTeX files and run them through pdflatex" @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" @echo " text to make text files" @echo " man to make manual pages" @echo " texinfo to make Texinfo files" @echo " info to make Texinfo files and run them through makeinfo" @echo " gettext to make PO message catalogs" @echo " changes to make an overview of all changed/added/deprecated items" @echo " xml to make Docutils-native XML files" @echo " pseudoxml to make pseudoxml-XML files for display purposes" @echo " linkcheck to check all external links for integrity" @echo " doctest to run all doctests embedded in the documentation (if enabled)" clean: rm -rf $(BUILDDIR)/* html: $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." dirhtml: $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." singlehtml: $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml @echo @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." pickle: $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle @echo @echo "Build finished; now you can process the pickle files." json: $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json @echo @echo "Build finished; now you can process the JSON files." htmlhelp: $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp @echo @echo "Build finished; now you can run HTML Help Workshop with the" \ ".hhp project file in $(BUILDDIR)/htmlhelp." qthelp: $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp @echo @echo "Build finished; now you can run "qcollectiongenerator" with the" \ ".qhcp project file in $(BUILDDIR)/qthelp, like this:" @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/DjangoOAuthToolkit.qhcp" @echo "To view the help file:" @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/DjangoOAuthToolkit.qhc" devhelp: $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp @echo @echo "Build finished." @echo "To view the help file:" @echo "# mkdir -p $$HOME/.local/share/devhelp/DjangoOAuthToolkit" @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/DjangoOAuthToolkit" @echo "# devhelp" epub: $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub @echo @echo "Build finished. The epub file is in $(BUILDDIR)/epub." latex: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." @echo "Run \`make' in that directory to run these through (pdf)latex" \ "(use \`make latexpdf' here to do that automatically)." latexpdf: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through pdflatex..." $(MAKE) -C $(BUILDDIR)/latex all-pdf @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." latexpdfja: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through platex and dvipdfmx..." $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." text: $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text @echo @echo "Build finished. The text files are in $(BUILDDIR)/text." man: $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man @echo @echo "Build finished. The manual pages are in $(BUILDDIR)/man." texinfo: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." @echo "Run \`make' in that directory to run these through makeinfo" \ "(use \`make info' here to do that automatically)." info: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo "Running Texinfo files through makeinfo..." make -C $(BUILDDIR)/texinfo info @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." gettext: $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale @echo @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." changes: $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes @echo @echo "The overview file is in $(BUILDDIR)/changes." linkcheck: $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck @echo @echo "Link check complete; look for any errors in the above output " \ "or in $(BUILDDIR)/linkcheck/output.txt." doctest: $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest @echo "Testing of doctests in the sources finished, look at the " \ "results in $(BUILDDIR)/doctest/output.txt." xml: $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml @echo @echo "Build finished. The XML files are in $(BUILDDIR)/xml." pseudoxml: $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml @echo @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." livehtml: sphinx-autobuild "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) django-oauth-toolkit-2.3.0/docs/_images/000077500000000000000000000000001443573112200201155ustar00rootroot00000000000000django-oauth-toolkit-2.3.0/docs/_images/application-authorize-web-app.png000066400000000000000000000417671443573112200265060ustar00rootroot00000000000000PNG  IHDR5- sBITOtEXtSoftwaregnome-screenshot> IDATxwXSO&a2āPԅŁ8pPu Jub J-*u{VDpTD$@HHr㴷i@TpsO}{p***!bnCW!B!6|B!FO!؈(BT*in !p8@  ST*e2 x^CB#JEQׯYa旓pn"^ԟhV(&&& Z1B5ccJr|(Js!g@RiMx !Px$#b''BlB0BB!6|B!FO! !a>!b#'BlB0BB!6|B!FO! !a>!b#'BlB0BB!6|B!FO! !a>!b#'BlB0BB!6|B!FO! !a>!b#'Blϧ-[~vRTy)S i r֣Gnܸ,s8X++ ]tԩS=iӦLj}&eϜ93}/͛doF/,iӦkޤrW\aP~j X=P\333SRRj|>:'Mе@5Y~=NfffڵHNNVTE_ɩ} [I>f pdaFFFٳ,VP\tC+>iiiL'ɓ[lٻwCD"4믿6h5@̙3dS(@\\ܢE7oޝ;w`+Wd#wJf={@@@3\.]jggٳgj7???**ƍ999rȨM66\jթS`ȑK,Yz+WϞ=+JCv 8p l߾նIÙR4::ŋ殮3f̨ǏN .]p8dW_}u 6>}o%mcÇQQQIIIEEE"E>>>r\\~sco#G\v-++KPuqĉÇ3߻w[b!CfΜI}wNHH _y';w222 %O<]]޽{|:sN"_"NLHHؼyP(:#Y`ARRyP(~۷o5Ν+5_NLLLLL/}}}J]]]fm۶I$0''?{ߝ}||țPȄDE5cU'z٤I(,,Լ۷ϝ;y?ڵKsD2uTRRm߾ɓ'˗/'<~x599yŊL"??ԩS:::mڴ|S%LٳM͚5# jzҥ$ڶm{_~% *ׯ_W[8֭Y&ӧ@Qo߾S 11 ޳gO۶m͛aaaUx̙QF%%%[lyS 0VZE©y;v8x ٲeKQQYmݺiӦ~ի;uDV޻w{͛7o޼ys͊ݿ)Q'RIFN`Ȑ! qqqZY ]t!bk׮U222 FO4M/^իɓRr߱cRkk}[hA&߸q#))3کS[P:tЁ,j2Րkɒ%dڵk?jmm 3f_cbb-Օ [1nݺE1bÇ߿OQϗJϟ?CCCңڻw/)dȑdPy桡>>>E=zϏ#bqXXi OOOB/_L/*XlddD`'L@ZjRJ۷ CZx~iNNČ7}wֹsV^M츸;M{"A]VVV ^ŋ5G # LJY_XXah0@/_ZNZ8@֭[G:p5`ooonnNW^dT&3RJ)&LFV)))*r_~r+sRsss2N(Hɉȑ#ɂJzQ x_<<d!Ct]3SxSRRtttttt1b'f^-)Ugd2JuYfz1s׺!ŋ-[d b6IL5^"##YTkf͚I 9994Mk[1JMM%:+[xx8EQO&ofNs#xozH&V=d}L8I$ 41-x_^;׏8B=)rrrhbf1daaA)vbX%k̍000޽͛7 &&f{h%O<ٺu+Yvb^;%%%W\a>{WvرvvvM6}VuY (hOallܶm[2EԩScǎ%r|Ŋ&LjG=yA'@\C;00b_~an;aI-qqqs!=3\|w碢"ox5!T)DD̔-M "+yݻ8q ŢENJtXX13t24C566f>,Xp]̙3i$[n=z0bĈh^|9366ܡgϞNNNXbܹ[~?Hn0ԩS̍&D"QNݻ-4oҥѣ/b\.&`,^x֬YLxyyUǏcbb:vx]vhт_>h +++T*?I\nTTϫv{|QrI$1HB d۷ի\\gDDW_e˘#JE:::ZZZ@BBBBB¨Q7ڵkaÆ+VTVV>~XӸ>dY-T*fP*~wZ;^nܸqZ͛7ttetuuO\M#%0`Yp^yn֬ن `cǎ~ 4nkk\ &:uZv-SiӦ^~۶mӜ>cƌ~ךEۻ)kg;RRRiӚ7u4 ~e |Ν;}}}-,,|ѣ<|hܖD7oҥH$j޼yͣLoҿ)SjJWW[XX˖-!!!UGꜝ]DDļyڶm' mmm6s̷ΌA)kv4w#ΝgϞ666vvv^hO?}E\kkZ[[V7n\xxx՛jZ5kw޽{8::.Y$88C&&&|>y.Ѳe 0X hb…!!!ޢ{B!T*Uyyw ԰:!Z999<!#'BlB0BΏ@!"8?!a>!b#'BlB0BB!6|B!FO! !a>!b#'BlB0BB!6|B!FO! !a>!b#'BlBY>1 .*!!ӳVWBCC.]ZVz?MXۤڽl&N/U !&<ٽ{kV#]]]vھW^wpppqq>___BQ߿?1bDY-6)v/+VKUBIbbb rDE|WWW]]=^ccc*޾UVuUJh۶H$ઽE6{iZVuڽl---k[5P-_>_x>sppߘ~ixxx@@СCǍwMŮSL}z>}|||8P5111'N$[>|zypɒ%vbMMT*S6lX||O>uj_ojDٳg[lYreppȑ#.]9~Akl޽;e+7d*++7nݿgddFvuuMLL1c'ǑAUS\\\fz f=Nj\l-Yɓ'{KKoM%ܹ3333**СCɻwв_~%..NWWwƍk.5k04hbbrĉo\naaG9믿>ɓZׯ_wuu%We˖۟;w.222???44BBB-[v;9saUw3g8;;/ZϟoٲeƍW^]~]4o۵kʕ+ |||Ο?Y---ޢEwHHHؽ{wTTTBBB~~~զb8++-[x.]|ŋI嫪zxSr8Dgmm-Yf]v.]H$___pqqQT)(dM4VVVιծdiɓ3glڴ9sJKK1c ݝ< $ܼ{111K,![ obbRXXZ[[k\\\\YY\۴iC~Ǵ4J%@MC;;;ɵL$1\.z8SLzꥹiӦr}GL2lܹ[sss 9f̘]{{{3*;;A&ZJJ)))6lسgD"~.]H<0m/emmV}=(jۖټdA(|###?|Ŋիwͫ]R\\,˛5kF陚feeC+S TVViBUS>Ĩj򐢨G-\P(JbTdzrY_}2d׻wu։DD2d.h~gY+.k``@Ӵ ,M|=/S8&k֬9s˗'RΛuO77SN666zzzѣǛѤV5%#cUU}=m322F5˯'NܷojW|UOR{2ٳ˖-;AR1LPYYkfTVVY 歱&333H 0555&&&???//oʔ)dǏkX,f_|rmlliK$vZpvv ܸqcyyƍƍ3228qbӦMkT*9s\.oݺnj7oq~ssM6mݺ5$$z֬Ycǎ}=ܢI>o>##51rrr&OlbbT_ÛڶZj|М==O>dܹUWj5M6Xy IDATrN:ڵMw%48dJ*//Šև8qD)(//߿ddoSPPo*++IPշoߝ;wj}B㑓chhF-::'==]*m۶+bz=yꕿL&srr[BTÏ!B C!jO! !a>!b#'BlB0BB!6|B!FO! !a>!b#'BlB0B߂֯_K.m G~رc_|D[tP(C4M -((ظq-[JKK^Z^^>lذE@QQʕ+}zdddiiE"DEE:tH,;88̛7ã!STdyٲeΝ .]کSSXr%EQ'O ٵkHϟoٲeƍW^]~]hꫯZjv۷_v@'NDDDdgg¬cǎ?~<%%%,, ^M6%$$/]433_obw O &M1bDBBB-޷o;OMM}Etuu)p87oH$uww\x}BpȑUoڴu֖/_oߞ>}IfƍW(T FFF<CϟwttLMMMOO7oƍ---SSS.\h```ffw劊 (jԩpСW\cǎ3]v|>O>ݺuE!5^hhh^^ٳ_~rΝ;w 4MSEKs炂CMM6e樝@ EEEj 3ggG9;;{zz3k׮YYYFFF<www8s挱1YiggGtnn.ꚙVVV9^vm޽̹}B迤y)S5 }ժURnoo/HH' d2YII >dǏ9mȔ(XܼyiӦ:tŋvvvbT Ο?-{{{X,:p\充d}^^%٥1'ӌ1h|ׯߑ#GbccqpOҥK??@v휝CCCKKK%IHH_ VVV%%% bΝ2SSS$IdddqqQOOC$++_~ϯ:k֬ Zˢ"[[;::~EEEIIIk׮(}mڴٱcT* P{nrqΞ={uJu &$''עB>>_|?x6lؠT*^QQvZpuu5j+,Yҭ[76,##cEIW^]ZZUFƌӷoy?8qb~8Nppp^^ވ#曹ssrr gmvŊ==Ν;SLqss#czׯXf͒%K\\\EB8dJ*//'JjR$7Ν{joDZBBš5k.\Pe"x<͏hDk4~ 'BxU/RGGgzzz˖-k! !bf|O! !a>!b#'BlB>| ]t黬D!,LyfaVVɓ'5.\ٳ'4e|رcJRk%B!idԻw7o2o޼{-fͭ[\\\5*޾UVPYYwߑ|bV"bFO̯Nܼysnb~6566vQQQd(OPxzzR5xǏ3{W^5jT||  (H@T{zz6,>>~O֪ݻwL1p`|Rtʕ}0` TVVnܸۻX?eʔϞ=0)yɽ{駟G4BFO:t044$](Ju1c43"gΜٷo9V(߿Ξ=;fL.[XXѣ#Gϟ?' wJHH'jZ|ѣ"""RSS?;w̌:tPrr2߭[&''999WVVx<8y޽{Ϝ9cii7ׯ⋩S&&&n޼ѣU!>&M4bĈ{[ѣɧG ֭[w֍ ݺuё-)3gr8==.]*| /^,J(J&5i҄[YY4}ĉ>vժUnnn4MjÈjA7>>~ܸqiii'NLLLdfi4M3U*QBy.!4|>-Yu8ܼM6wMMMѣ888:u$V033#;(((x4M|ƍ;qℙH$"? 111d%6DRRRtBnn.XXX?9Qqq1!ǣ_~G}=رcVVV̨W#"":wl``PÁo322> U=z>}:++K"l۶Ms:>jԨ7oRURRs[[[3bĈ 6z=z;v077wss#DGGI$ÇSOOoذaEEEr<''g…zAϧZݻwff&<nnn^ܫVfz1gΜ_~gرɓL2p@CCC!VZ-_<44O>Ǐ;w.899M6mڴiڵ#nwĈyyyv@///L7o>w\~>ݻϘ1!9}šTVVD"(o߾;wtqqX"PㅿOv>>>R4,,аm۶ ])X4ryꕿL&srrH_ !P==B,{!X !a>!b#'BlB0BB!6|B!FO! !a>!bz~⒒ ,MLLj൬L/-}?Mtz4rzo BCr涶:::<_:T*\ 666Uk_SnlMBij6bJP(_3C SB1[DӧOc={LViӆ;hu+]U4݀lx@P!2+s{h0BQ`>9\ptt|Yqq9rJ#S8WK**\nk ۙz4kJ!j_'1XYY盙74M} LU/.S}7=vj#d2}EWWW&4Ӄ 1fe RBN}JxT*&4MH<#`+ՑJ $%ju~9T^L 3PVOocN x=c؞5fOA`#-lO3c ?9lO3?'BXu O{u 5p|,#hҴQC}h+RuP{BY/_?~#G\`ST*UCҥK|Ijjj /G|3,Ɇ ,rsVlS'UJ4:vɀ:{!h5SBBš5kA˯_qĵkU=kκut=uБ`S*iRhǛ`FtJ%F=pDJ5@Z`>!Ԙ5^Zn7͙3g{=rȌ3Vunu47JM6݆^-/"0k!w0#[Ⱦ׫ȼf-*I<J逑yr@Q"0=? ԃ#KwG.OA^h fC* A CjA)**J.\D9U*U׮]I2/_...611qww5k ۷'&&nݺ𡮮n޽o=|DDDfff̘!@VGFF>}://I&g666|Fu{y>]/<~}v{Z_?Q?GqӶI* #PAg˩csx\}R rc45#tIzRfz>X x?r˷Jw- QwI+Z;q˹b RBGfI~ZuXZhb)д)|z0*_x2}s`>!Ԙ5|qF۶m[jyiZVj5lܸʕ+smѢEzz={VXBP"_~|ͽ{֬YcjjΝ[nݐ!CWXX;v숉2eJ׮]srr3ը|6㶴nt\}"ȷwEW*'rn9^yZJ^)>;ZDAyjU̲KFĵ# G^TL{$RBKw'JzEBH EQdARQ5c ___w_x<.+ƍA!C"##߿O=p@=j#Ɏ;& ǧO>{lZݭ[7 %K\~O>uT?ZGSI%uRPOK'q֍|f/M&ӃLJM.W$ҺI땏eJhEvn οyR?+ aCzLo?: Jl*q^,P\Na^٩x&<= .<*(u^RPcVOڒ[n\.wӦMS*EQeggGQTII ڵ+EQ$h޽;9PTΚ5ܹs'Q'H***$IArr2Sf]s ec{NT:$r'T#PV29MfVЯէEb[3SD i4i„ %`llsl?։{W5'o65ieRYhJ& MM~{PYNN_ O\YY*[mfa;TTO_ė2S [ SxTm3"!x666=¯/?橫Zrjdڵk4i\맦SoG 2+9 MR]ek?9U><FMh99W0iUBkbEO5fl?4qѻwﺺjmgcc3wܻwoݺёPXXȬQ(bؘlwrrҬOs6l/1?NloCi߿g)kYV^j.  [LuZ5ミ9OV 5@G ?WA^ M˫e` ƌh yRV8pի4MKRDJRR9s~CjXXX]|Ittt b ߨmذ!##)CW6}b ϧk;pع hKUVFBJ2gkiMgk֖+Ly|MZ4#U LePYe.<󸜉]u@e )o/}hѢG;vJر#ٳǧ(22+""… YU+HlaapƎ- {!HJJJ͛Gl|(6Ǎ?ʵ6E( {X9&DwI]fv=Lbz6y~?I11 0m g^ܜ,&k.5?bRf/kƜ 2Q?xXܿٷS}!|VFCqcGykZn߫W/2M6/矯_޼ysv)))ȑ#FFF5NÛ6m2337nItXXX:u*..N__k׮uIJjok˪%䊕R'okOY ^C2c߶3V ,?ǵb!x+Om}џ<̔]{?j4tmk8NwR'3NEETrRTIII-[r\@ rdڂZ&ř|>MjZGG( .e2Pֱ4MT*RIp8㑓 rQH@iRF5999<^?_j\.gj|IsL&#CjB>VUXiV(՞T+~^a{"Ԙ5O^Nխ#j]6(G!_Y>aa{"Ԙ5>]=j̰s؞5fb ̧:Pc':Pc{C1=:PcVT*[OE(j54V#HЕ@^}䓮Rѩs5JRWWWsMSUj̧:¥U곭rIDAT4t-B133377ՈY[[3ߜpfm=PÜ8CP#RdddTXX(k*X,FFF4@Фa_d'VO5^O\.ڵkBʪI&BG(|B'0_RVݧ($BYx8"Trʡ2__0FBο@~2BoBa&Mlllx<ՓiÉieN+nwGk]BO@~?NlF_}x\.#rJF5"\p l% B!6|B!FO! !a>!b#'BlB0BB!6|B!FO! !a>!b#'BlB0BB!6|B!FO! !a>!b#'BlB0BB!6|B!FO! !a>!b#'BlB0BB!6|B!FO! !a>!b#'BlB0BB!6|B!FQT XB9Jp(j*!BT*?p8WRRpB!+)) ũ)(JT|D!QPTJR,|P8l#'ij醭.B >\.?\.lP՘O! j +{"0BՏ?o8q8=ʧBE!FYPIENDB`django-oauth-toolkit-2.3.0/docs/_images/application-register-auth-code.png000066400000000000000000001103221443573112200266160ustar00rootroot00000000000000PNG  IHDR>UsBITOtEXtSoftwaregnome-screenshot> IDATxy\?kiڷiSI(T,'GR$ ұq)[TD,dW!%RR I2-j,?ޟ6q093\s}ϸ_s]uQx<U > D9@@t3] D9CoT$ >Ho N EAA`g?|555t:F:-X,Duuu,KAAV$-W]]b:{׹.T* 555~ihhNX.HAFQPPş]Ro! 09 g .r  g .r  g .r  g .r  g .r  g .r  g .r  g .r  g_.k+qm;4 ˕aaa1jԨ1cho0rFW'>|ݻweff:u*::ZMM뭎`,]?%ܻw/88?ӻn_=QRR_p!.RdvuttTVVƏRimmmIIɇB6lzk>>>g W\Rݖtћ7o[o)T2e˖ݛ\ϟ#ӫeԺ iiin_B|n4ҥ7w5kϞ=#GǏO8pLf^ƍj0!!!??_ N87,,͛!<ٳiii\.WYYtСުD#ϲ׳gOoooooN6nEBHGGxB<>pݻ\.Ǐ?NOO߶mג%KD"~;w566EEN.CCC/ɓ#F&ܻwoŊDϟ?~常8###\bU\677=yrsssssݻiӦNҋXB:""xT\\Co޼Yh˗/JIIzjdd!C,--UUU\nEEE^B$;;xKVV]P@rFGGs\111x'U$K.%9|ׯ/^qFwww\XZZDVWW|S~zJ1?????ĉ۷oӧ.'>}:|Gm͵ d{M%^322pnQx+++н{pp[L&388xǎ~~~.]*,,u:?HbׯOHH6mBÇ3f B:,, رc9rѱcȳ+͛7,--;j[||<>3̍7&%%{lcݻSz{{;wdŊ8N>LRB5kTpӧ߿GxR"77-;w400Xn݆  >|xϨu ˖-ÁԫWoÇkڵ+11ر FkllǎK ]x1- 9**jp8K,!~{o~~~G&~rם8q!rww:u/BcV\Р7jڴiKb|I{YtL=B?vtt$uttz3.?pGGM,ۛ' Ν;?h [FeffD"=~rVѥxs !pNdG;B¢!TSSjz衣Cݻw?GYNNNIII|>ήGGGbLlرcd3fO V'>r.Apn!t-[n'5544ڞc2xs&0faaaoolo߾*B~~~D@zyyB9]z]F>|… +˚5kId?B=zPPP ^ԬǏر͛7#455 N>EgΜ9sL mi߿3pT*MMMpBIIISST*%*?ښT__G,! wsssmmgϞ  0`-JH$YYYvvv]- ?533#V%>r3ķݻwI|>ѣiii/^C$WmGWEEE7?zeee|ދ#2.{{{riiiuu5BH[[_% o ^:ޕ255ݽ{ƍiiiiii۷oI&uvP!NB3S:jkkSԚbA ඩ$HT+WbqqqqKK V䤢ҧO¬yuem`Sy gTP($zx<ܹsKKKSUUU ŋhv"i~ωb.&b 2+W?Q1BHWWU6mT F!#m8tmۜqѣkkkVkWPP#u?~|kH1$."W^B5554BX[[Ϟ=;**ҥK211/:A켩8;w.qbիW_p-yZڵB&(,,dcccwD"ŋqft>ZyHth0bFʝ#v>IJHHHHHHNNF۷/[\.+077ZmB"Z[- !pBblƍDICC8}@KKˊ+6lذk.|  N<ٕDm=P\GӧOD9 6\bBv/^LPQQYlYXXBrϞ=e=+V@=ydҥ&MRGBY[[/Z!4|p $6mիףGN8d2?z>N߿/344tԩ"ٳgΝuC3͛ǎ1`iӦ9s練̟?_WW͛NB)))͘13vGttt)SJJJnګW/ӧ?644哹׮]w=}tϞ=xنrttpq&߿ÇA置o ypy…>>>T*ĉDOwmWI;7}gr\P|xk>s nŋKKK^zyyy}F _Kt!<<<޽:zȑ#mllBnnnx;wgB&&&}X,VXX/"J={pooG}vEUWWggg-I&wݻ7ƳDDD/<[II⤌Vnnn7!99weLLLry<ޜ9sfϞyRE^~nJ~uD!tXII ^K>ď{fΝ;x_bb"(^Ըqڮ*wf˗/xWذaӛ6mڅ I7 nnnfVXX8eʔhggg|9<ó[/^HAEE%::zů_$jnn}O<.ʕ+T* 'Ǝ=<99ܹso߾mllTVV633suuM{M>  g .r  g .r  g .r  g ș_IK(r8z@ 부)&`0d]@BaYYͦ; dC$x2sssHn@8SPPP( E-:RT*}ʺE3]@덍!JШ`=@dB  t [\/@7_E@@t3]M2˗n+p8Ç*WTTtuY$$n޼y۶msvvfX^xf̘1m4Ў;LݻwBʕ+߾}{醆___???PKKۯ]&H|={~8!on۶ !TQQjժ>!!ɓ',~֬Y6VTTDGGWWWV ollܻwoAAX,7otaÆTVV29s666)))uuu-jL78OOO6ߪŋ֭ NOO߱cGaa!BF5&Loll}H$B !T\\lii`0on``wȺ|rhhhLL ܹsgۅ{ gY@@Y, y8::#ܤRiAA.`2T*u.??erߋJ\r9P(/ N#t:JmiiAAAUy7&>>^(YZZjjj;997Z$R[["n@.)))##!dffp?KD" ɽF[xZkiie~tD׿… TNϟOJJڹsg߾}BDeY9rYGGG[[ɓ'#Gи}۷owX,[!T* SN7nB`H͛7ۇ2]vKH$ UUŋرh^^ޠApnqܮ_bJKK/`Ʀӧ={ϷR񃚚~mm-Ù0aVC{Bacc#cJro1n8'Ozzz\.al6ݻw]\ɓWQQ!N::_MFF2F355|rBfffxEss}vAի9%%֖dPXX(J|X`… |~HHѼcFDD Ve0'N3gNVVʕ+BCC/ww3g̙3'33sٲeĀ͛[ZZ.-QȿbqSS5=|;ܱ{cƌ!x9sDEEȪaJ999 uCTUU)mA  g`r<'22m21@  g 1L>/VB|>. ijj˺ P}}[> ȞNSSSUU &Y|0`08Nyy9qE1&iffFBt[][`0p'@W!9@@t3] D9@@>:¥ IDATt3] D9@@t3] D9@@t3] D9@@t3tY7 ^ Ⱥ-_djjj0 Y|D=PXVVftND<ҫ=b( BuT*Jo߾p8n DzcccEEE- +GR544*++!9dO @nP(***pµ&( @ MWQ.@t3] D?MTTԊ+B_jMMM_zpԊ;sLYOD"9q_|`03f 6\n֭򚚚A bccV;vlٲhaAAADDDDDP(<}_U[[`0zmcck'''WTT477kjj8p a30c ϋx?((/9,,gϞbqqqT*5 `ĈɅD!233>.\HJJ3fɓ544O:ѣwrcѣGO4I]]իWN*,,644LOO2e QHOuŚ=z% D":p@lle>(XL>Z~B577?~ܸq)y,ݻFłKܹsʕ+qqq %666l6\-==?ύ7B۷og̘ARw{uu;w455yzz.^866T*)))ҚFq޽{s8ϙ3miiiibbbEEΝ;BB7n400={+Ξ=흔?<~ӳO>!UUYfSII 駟p~?յBl6BH[[.==]ÇrϏG]|ƍׯ7o/ү_?\y͚5֣F:r̙3Ǐ{ѷo+WΟ?ʕǯ?>))i/kkk]|~":fffϟOJJ"o޽{B4 a2?njSXXHB݂پ}H$Çq!uVmmm??f.[TTVRRa\'//o˖- 7nJlD"9z۷\ٳohhصkWIIOTTTLL!ZHֿt" uɟ7nM:544uuuK.9sfFFƶmNڵ+))ӧAAAC6m9ZٲeT*MMMݽ{R֭[---""" /]3gNqqqnnD"ٿBd;w… n R8==Xȑ#x˽zX,/ݻU%:U)ttrQYYYYYi}}}IIɐ!C[u,ttt H |566 6,''!I~ܹsյ$Hnݺ0a±cǎ;h``DM6EFF޼yXǍBt:H 4-77o߾۶m}I^|YUUfffv]ۂ?_633z++1ct333oo . cٸK9Ht-___UUU6M(ʖ-[NfBjjjs9p˗B&L һw &>|xڴizzzDy~e˖%$$dggAիW ?~|ܹ7o><0 9sfpp3h{]4==O>=hDt=xȨgϞo߾500h;0أGo"\]]|!$Hq5@ywM:vW cĈ\.۷b8;;{ZZZ_ y˗/D6l %&9p |J 0?4M"tWrrrgXBHAA";WWW'cI[JJJΟ?x<^߾}q˭[:A>!HsrrȝMMUVq8'OĨ]VKK !4bĈC>>'N X[[{وիWSTꚖ53fd[߿R*>[[[&r}<ջΜ9sIOpwwlnnfXJJJ[_qF" CBBچkKK (JBqv#/<..nڴio޼?܁?=z(**j[.=ORlϸq>vCCxkںu+RQF$1jr… ߿\bիW[>|gϞjժt\]]Kxhf߿!~ܹ[0bGX,[['Ơ7!5Pت UYYGATaܹcoo_XX4KNO 677Ց/R...luI ]g&LXjաCJJJ fΜogOOOsÇw|EEׯ_ d2IIIuuu/_LNNbߴ߿_YYÇcǎ ׯϟ?0` DP(0a9B)&&F"蔗GFFUUU?>++GXĔxcccbD{ݻ7HÇ yQ͛77o$~khh̞=G-//\~ȑ#qcc={8q8ȲXy楤۷ٳgo޼Ϗ$x1bDIIɓG޽pBT*nWTT֭[######{#ϟ?G!4hPjj*dzmj޽SNQQD"pFvvv]UUEQRRzy||ׯ߼yf{!ꚙIPȡ ʥ_ٳ 666111VڶmΝ;<==vyzzYfʔ))))x_nĉ ~ܯ1lطo_ww5k01c,X`Æ F͙3gɒ%...YYYt:}.]2qSN444())cɒ%8/]ti|m,7o,--׬YC 8p߾}xQ8o޼+Wxzz^v +))feeGfSRR_. ƏO4!4|$`lСϟ߲eKssF~lBJKK6//ܱsP(\d QrС>tzɌ~M6X~tΝsss577򨨨M6͘1#&&O9s >k׮EN<wٲeXf B^fMuKA(<x"ooÇp$&&rEɺ!ZuuuHHH||<1HҜVoJUU aK <|tgs566u[F 辈%'ox0`@@t3]@L&q[dwD=MMM}zzm{TUU}/ C|>IGGGm3 1 333S^^.d/d2555:2044; .r  g .r  g .r  g .r  g .r ˺ P(p8@mRL&SSSSGG`Ⱥ-# ²2%%%6MwȆH$xeee^&q8@P([uRT*UUU}-144u@g +**BhY=*QYY 4 {@EEr BQQQD @7_E8^nr  g dl'N@EEEXB͑YbbbdݐӭLKY7D/_~eY|u09[hllLHHHOOQWWݻ7ԩSBg,Ν;=zٳjlZgϢ544gΜ1b'-*--mϞ=WDaPPMpp0~{ȑ]vlVZeff@4aDfG9n8 rȑ?ccm۶Oׯ_ɓ?COO(\p!!Wt;88̘1bVbÆ VVV%%%򥆆רQ:߁w?vFm555q7mdnnNnwV^ѝCQCCC&N6yq\*bffV[[{9s۷‚֣G[^xK5;_uׯ_?sLϞ=\.Q(H|~^>~<bɒ%D}JJJccBDǏ3cǦO2xt&577#>CqqqBBѣ'M4fb !VWW:99M6K~w"^X,~u\\J h} $hiiq܈#G.]]G{4**mj۶x< {nbʃJGK&tsڮU 0W__w^333''ѣG O81~x__;w™3g;vlٲe^^^ǏvB((((//oʕk׮%/M$mڴiȑC 1cFvv6.2dסC$ .s玷}||p匌 //!C<{e#F~E'kRT͛S@0{lІ vޝDuփ|f m*ڧO&+ىW^Ǐۗ`KRbufff&*]]]]]]߿bXkjjJJJYxM{{#F E+b}eeeݨ _f Ftή\:k֬+VF^rGmx,KGG('mG:9'""oSyƌ DWw ooouuur5k<==ۭΝGFFXFi4ڑ#Gϝ;7k֬TexBٳyyyǎ^zH$[t̙3322mvɋ/"޽{b k׮yyy-YN744TVV9s,66/_^xH$h_ LBE}W qaXr 7n _q׊NVVVD+??!PPP%ɭ[<<<$&%RӦM#bqzzСCB!~܍vŋ{lMMMnnS{󿕗ݻ!t޽Ǐ߿嶶Qx<FYp/]Djs:jĮ]EҲe˖7 t]_D"y捙YrԩO>t:}ذa HMM/ 6 >:Zǣ***t:+55NXYY3Ny{{_p!jnn驢2eʔ0HDPP>NONN UTT p8=++t:)++ ?Ah4J#G\zo'2^H$z䉍 .p8xMMM1-- /6772H⌌߿]]]D":|M\.(Jhhh@@@nnnll,p??3g[XX:D566"n޼9l0+++:>hР޽{gffv& iXXظq= իWO#i{*L&SIIi޼y?DΎbyyyI$ŽSqs]_BhOWz*33s޽D6~@VPP@t'^zuرGHT 422" Ǎ*))Z>OCy9;;ܹS,_~!GTy͛7^zrL1Bѣ"(??wGFF677Xtggg%%vn:|G,+((xxx?\!%%e̘137npvv76lغu޿CΪ7Λ7? 111A֞={6""bխCQ+2f IDAT>sڻwoGݠo:88O޾}nMPGmsvvvvvƅ&&&o޼~:1 $$$dӸ=zdgg7x`" C]]ֶyuQ(##O;\.:T*uҥ>>>.:rH^^۷d|[nm[B#GXZZv2AlE~(ʓ'OZZZX,UFFF!6mhhc.!+))>~I_DSb'h>|0++=zDT$f]Bqsssν{ D[4g̘񒓓ɯ>zgϞ7o߶m[aEEEBBʕ EWWU:RRR2oT*ٳgDb|N>v*ɓWQQ!N::ZRWW*E"Qmmmmmm744lllĹC><8///''8%[\\\[[{̙ϟptկ_}8bĈׯ |8<!WCbaaUïNIIi{A:rqq 500XlY'W &MC \r͛'Oҳg_m۶;wũyzzsl6{˖-;w455ݶm[smii<(ڿKCI ,@ٳGCC`dd?nڴ !tqq!666կӦMH$۷o&&&k׮%}inn%6&&&ֶpm^^ްaú6lخ]~w:|ϯ>|8]ڶ7nXpU^ߓO\m޼?PSS\fU9jժ}]tIKK+((:99M4i۶m<h8HCL) n ]]Yfu}w94mŊ:}4JݻwHHӔH$ź˖-#>gϞ={XҥK ņnB⦦&k{𡃃u^۳R4''ՌPUUUmi:)DѥxKQT|;]N  g 1L>/VB|>dʺ# ijjw@Ou]@tttdWUU555u'.A7w`08Nyy9 +L&SSSS7 @`0 N.Cr  g .r  g .r  g .r  g .r  gn!$ 9N}}@ u[a0n . {B\MMLIIIR|> ҫp e𦰶9q86vsp ^}}[Bijj˺# '݄p .r  g &GGR[OΚ5+&&[nm[!:::>>flM2˗UxYZp]Wwyᢢ"Xlll$MEܗUTE[к\ZhuJE"nU-EEѪU} $BB򼘻yGAdbr2ss9Ɔf=<*iժU6mv]UU[rrwwٸqٳg5M.\*˽v-,--#""\\\R|}}t}7n̝;WVw޼yQQQ)))TFP 4hڵiii2޼yvر_}!{giǎ-b0f͚uyӦMnnn"h׮]O<111裏&N ń -ZtqXlnn>k,;;iӦB޽{͟?(ɼZnקO>ydeequFYdI~srrjuPPP>}""">~fffLU24Z=aN:mذѰm۶=СCOn߾ܹsU*5pbcc !)))cz3gN6mAjj*ԵkLMM{bΝ;uĉ1&&ڵkH$zQܢKi5kkזDGGBrof֭xqqq+V]vM:uΝ-[sU~cǎ-X ... **<)))00p˖-#GܱcV]r% ={fVզM;w^:==ԩStA}"""d2U""""9oضAt^~~~ӦM?j4irE"gGM^>}Tb {?MMM2d&899ߟҪU]^|὿#ńF233srr|ըQܹ#˩g  !]t)((o7nhٲenLLL Fu̙AX,:\tzÃ]׮]e2YEE+kf0k׮;v,˵ҥKVV[gꚝM[ٮÇ7t `X,Zneeer<$$DSNĤ1bľ}ҶnJ;;;ֱ/))oo[`XFg-,,,,,tjԩlRoeeeS*t޽{ ,-- ! Tӓ'OD"BL&СC Yxxxxx8^*! YfUUUuU*X5hv6mSEڵKIIiѢMrP\.Slkkkr)jyBT*-݁3|$/(( f2{:`EAn dΝO>{{ W`7bĈT???]annnaBM6 Q*,XpMTZYYk|YBTPwPDuꓙiiiS0ZhpTz" ex/>p5kuD"}}}\r}F <}z8Naa ˫=xBU{!h FaĈ{ U*͛7;w~ZSLiժՖ-[K^O>}ty^^^{߸qc׮]A߾}陝̙C]N{j...k֬IHHغukUUm>}FEmXX؎;Njnno~vׯ_>} ݺu/gDҤI'm۶z tС?S7}C\l:t3~ՙBjuee%~8nܸ#FP###KJJ֭[{Νݻ4^Ơ‚2B*++<(J b \.utt\n5`]@LMMdԱ` CD n9`Xr?Uc]`xBP,"B[óJ{T2-0p8Efee5n.' pp'h  f]@3.D h4At f]@3.D h4At f]@3lC7BJH$ m m@t)ʬ,@fjjjJ.⬬,777CtD"@dԔ$j4r'B[@!BP,.0|R xmpӧOe2Y@@@NN[\rP{x4ry~Fj_rVVN/_>`7JoW{pȑ: رci[?ݻw NCBBz[rJ;;it9sUV͛]0ZFi߾[jx#*M7Q(QQQ/GAJMM~zuukW>~xaa?ٝZ&lMMKKbuЁ>#Fbb_~~'zE^zժUÁ._<###..d;w.))T*/^& CCC D)**ZvLLLz=|ss/nܸqԨQ۷o߽{wxxرcCCCtydÆ ;w8;;Ϛ5Yf?syyyVVV OL={?~p:t555L:W*رѣGjM6ӦMOLLd0ׯ__~I"##]\\JoݺP(&OܴiSB1a„E?~\,Ϛ5ť'&&:uzEM6I&3gΜ:u! ÃR^jbbjP*{zVmٲɓ?{F>LW"ɒO?cu=<##…  =n8*!F:իZV  'N8p@~~ʕ+ !l6[";vMW _~ 2r_5&&yŵ/?>x`Νɭ[V(L&Sp>|f͚9sٳl6jիW=<[nsƦMmrЭ[7??[װo߾O~111JÝ>}oٺu<}7nHJJZ`ALLƍ !ׯ_?tPHHHllѣ###!gϞ}U6mڔ]YYIU}vww~V~8"""d2U""""9oض1:~P(l׮3nx{r#GJ陙s177ҥK/ѥ~ǭZ277?~FNH۶ml;5ƍNNNcǎƊZmJJJ@@5 H$otnᮮTzQ:|pCހ *䯿z8|q5MR Խkjj ېVlGGW?WTT̟?zP(bbbܹIؑ݀Ϸѣ!*711y~3%%%2h۶)jRTTVkjjc5\}Fo߾bkk봴4]_AB}IIIYY\. ^Rj̜}B:uꔚ:iҤ`ooO-(JBHIIùst˯DO;wB":hJePDT*u7';vHf+.. !p"!‚(#H EDD~?,<<<<_!շxÇݻuV :***''g۶mUUUT_ C/h4Ujx/^?S}ZhB7 IDAT]-Zζ~{j5˥?p@6mbGofΜ9CJ r^{f_UTXX@j׬YӵkPs~ӌF0KҽLɡF֭[׼ywߦ^M݀abb'|ro޽'!\.WZ*6f͚I$DB=믿Lfr˿ 񇿿? Ɔ몪b~mt_9S\\\TTDMZk17bĈT???]annnƆgff S(W\:uz=j֭[{aDW:[ڒ@ JD"ш# uʊX]]]QQuuǎST_(SbX.S{x%%%^-2˻}v@@@3=F:sBpuu}aUU!$66R[qa:thӦMtttuu55luWڴiÙ>}JRTԇV{{wj4l3QFdh[[[jzׯTݻw.&볲p8wܡ\~].S啨]6.[VV&H!C<}[,!!AT^~>>>>l׮]*ȑ#lի,ksvv&k899QkjjRRRt[q8֯_ϫT .4ЙVT[lׯG}VjF&&&Z͛UT/^|5H]ɑJѣG T*ՙ3g/^LHBnєqEWbb{Aj۶m;f̘c:;;;::R v֭.3Y`&Mj۶hƙ3g======s}?,\pС˖-+>sVZ;vĈEEE۶mkV޽MrʡCT޽{<,Y3`/nڴy}ܢkG/p8,@зoVZ.B<==}}}̙өS'BHppp=Lҿ-[ϟs}||L7eyyy9ԩS> ,xừ͛'Onw>tb…O&EEEY(aru̙oFlr^^^oZmۚ6mtC5;s///7Fzz Ϟ=ۼyw}[ѣG ҥK!CL_Vw嚚:L>ƦNKb;}zl~JzJ ETTTmm]R_MMM\\uˍѣ;Y>wƍ,..^fM=6m4uԤTBȒ%Kbe[[N:ѣGo޼puֱX7ܒM6֮YfҥG|||.]*˙zn߾jÆ ;wl׮OWV^UUzN:mڴi„ {~儐twwwzk @жm'J[~wZ@tsss3gNf.]D=uO>o߾iiiTH$5k ܷoߘ1c,YB}v``޽{5 IZZژ1cz_޾}[T8PR;vLW^l:;o@||ȑ#{=rÇSW\\_.^877vʔ)jzڴiO-;wn׮]?YBBBN8AՐ8{l]۷d2׬YkHԯ_ѣGv֭gϞ?&5k֬gϞu}ܹm:99<{,''GǏϜ9=(((55U(QFשӧeee/^xzz ӿ*HT_׮] 9w!rBȣG:t@YdIrrgϞ=s̫Wڵk'Ouj~?~|ppmۨq AAACJcǎS-^ѣGnIQ<~իWB4.\ѣ׾]bOiiiׯ߃-ZKYvVMNN駟N|ӦM֭KKK[zmYfرСC&&&V"رcСAXk׮u%::믿>tuh۶UD")..>/:t _}U %.]vYYY666Bz^YY)ױӧf̘1T{iiiDT^xήʳ=++j+ˉ^t1̤-[9rǎԝ֬Y#׮][RRMmqFGG;v_|݄I&eddܿ_Ǝ3Q'ODFFٳgȐ!7of2bܸq#))i111T… waÆ7@tW**!!!33_~#G=]vl6_~ݻwONNVT.] xѹsg@ԶmۡCl771c믄-[6< `ɒ%oXGB%رsWEnڴ)ox&MP WWvݽ{ҶmaÆQ+jJwKZϟCILLٳw $XXXJ,,,R:R Tacccc !Ǐh4ӧO :9sLf}WVV%IiiiVr`v****233srr|ըQܹ#333x<@ /_VIvMM1bqr6Y,϶mX,VcOOOxSSS?VZ?OPqp PUUU\\ܘS iB۷/T*4iNS^reǎmllj 6k֌Zͽ{)BH^^ÇBQ7ۑN8znݺgVVVoZm#S &&& H8:uVx兮JJJ:yekڗ ]mro󓓓W^aÆV~e(Q֭unkkK-P3&Jeqq.&ZmiiiQQJ/k+++۷Kݻz:4`WSz]"_yyH$XttK-.- 9wAj믿ر'|B=d2aaauwJ7&ro}oIwM2ҥKNۻwϿi4gQ܀؇ZN֭[/obiiIJTLjZ3 Vmx2,--m͚5Q~{WerjF?qqq/?R9NQQSk׮}ɝ;w8QY\$\R(VWWc" ߇f[bEbbשrgggDEEE֖`Rٯ\:JYYYMM !iӦ/^Е߿L}\.W \T*DҼy &۷ 7(**-ֹ4UVVV^^N}f_n333_-BȽ{^9[" uHbqIII}S}'OtݚFbCsFa0U޲egϞVȠZU_k0L;;;j] hff6wت* EMMM۶m###>}Z{{{rEEűcT*UFFuzZn=~O;f̘W{7<[n///IHHhaÆD9s۷*=tD"9z͹\.!$;;[&WCUj9~ԩS5_%痽^`~~\._&H$?ӧud]R^^^VV&U*UYYYYYF߶mۄTZ[[믿Wy޽e2پ}JJJ._:rH$HNwww͛/TS=ZlWUUU]]k׮M6Q'ON:{[޳g~m[nݺuT*h4,FOE|ÇRn߾fmmm?~h~W17`}6mڅ "##;k֬իW9::.XK.+W9qWTʼn ~۷ aÆQ$vvvk׮ݼy?ؼy 6X[[[YYkӧO0aÇ_vm̘1_|񅣣cs:F]TT4c Dbkk_=K pppܹʕ+>2!䣏>ϟ6m ffdd6,==]]=c BHLL̵kjkk3쬻$J$s}gfff2lݺuJK.w޵&̙3gǎK,p8>>>:ԴwwTݛ?H )++KHH8p  ݿXXXmm_M]{e|>ҥv:uꔵuHHW[~w¨AAATyv5c j.Ϝ9sZm|}}ȟ4iҼyt !SNݹsghhRtrr ԟxذa6=== lR]]ݮ]PBHPP֭['NآE3f;vlH?[VWVV7=sNnD[V{n ݐ]AA˳L)00lL!7ro.x FC !C0e_C#COAt f]@3.D)L}#..7&ZCt fp]0jٛ7o~i֬Yԏ\tرc|NjO:EݏuڴiM4ѯ*>>ʕ+/Fӡ[nx ˸Yf۷7n$%%-X &&bƍ\z5%%eҥ-D5___ggg@0rgϞUVV:88l߾gϞ&&&۷wrr~T055?nժtsŋh߁C0j;jR/^r uMDR[[K)..VbuD@ (us^ .$%%S?[Yٳ.];w5D5]ޔBؼx񢺺r֭bsJܾ}qz6ydHt p C[zuiii˖-g͚EdddL0lϞ=VVV|IAA-[񡡡3gYk׮͛77?5 た4IOAt f]@3.D h4At f]@3.D h4At f]@3.D h4At f]@3.D h4At f]@3.D h4At f]@3.D h4At f]@3.D h4At f]@3.D h4At f]@3.D h4At f]@3.D h4At f]@3.D h4At f]@3.D h4At f]@3.D h4At f]@3.D h4At f]@3.D h4At f]@3.D h4At f]@3.D h4At f]@3.D h4At f]@3.D h4At f]@3.D h4`C7@!۷o7t+Q]-AtE;.U25IDATu rC>DurkrUZ.0~ϭxqssDYYY ́ -Z@tQp8NNN@cZ h4At f]@3.D h4At f]@3.D h4At f]@3.D h4At f]@3.D h4At f]@3lC7B*** jjjjNX,.diiY穓&H/ ҶY;8/bdW{C]`xbj֬@ `IJ%J={ֲeKP+?pGMm.]8l[XJ'S>0g B]`pZYfV5t E`aa hupҘ*1;pMbtGjJZ,,,rssZ.2d mE1RTjmyJegIt-0,DVUL&C.&VuGj 5%öjjC- (|V+|8]&.s*N (|Ƚ.fN (|E  ҥKk׮iEuTi!QV0M_H,=FFѕw{IRKK.]L4յ;w.""Zr~~~AAAo`7ݰF^RJW j% +j_Zz6; B#3 ==}޼y}ђ%KD"Çwٴi`0x<Ç !Jۑ>>>o!ja9ߺ%ӎdTը8wuXNK("HrI0gE> 6'_%kN,IM%# 2!f `]`QQQ]tjZy=zغukYY!xӦMl6gϞf277kkk۵#F8zhNNՀǏOϞ=rsp8k׮Κ5k׮;s###۷oGިHn+W"< |[`fT՚KmRq_V5/fznjX|hʰWS !k쫣~,_̣Emc `px0 F TT*JVS 3fܹVh4K, qqqEEE?B4FQշn6_bENN:k׮{;vLՖX⫯JNN9r_-HtΎX|ysu\ݨ5漐$Tz&N#&jP|f+k]ƀMgwTϦdCmȖ{?xGjǔXH0ާ۞aBwF`<И^Wqq1!Y)TPˏ?ڼy Ù0aBXXXUU bᄐBٳ۴iCU F&MzV{Y77k4QFT*|ͤIo܀z0Wړ^~B* ZB.-ͯyA;,7!I+Sj;c6EFAՃ\BoK&%C&UMy܃68 6X,!D>|J-޽FjZEx={P7Ύ&L ܿ%%%nݺB 8i4*!j:<<>ӵ57;-K\EE4M g5B|慇DMUMII0`yz3b`*M.fT]̨dt/˭{sӆMz=9=hɆO,H5)Z8[q'ӉhswZu-a;׺hZ\%ڵ޽{UIӦM%D"e2/_jRh4O< TÜu5B,,,9slڴ1׺†eAŤFAJ"z'd4liS"pI~w7kR[ߔh:I51:Z"e:'/ŵ.DƼk4.Y֭[yyy>ݲeKϞ=Zm6mZnsNLV\\o߾xã֭[;w211ř bΝ B$i;vL"|(BBB+d:/^\jd24iҷo)SX-[={=s޼y;voo߾O?̙3cǎW^S8׾]S)_LW¢ !dD/XW)4[RJ<8ћBT=;X¹}SSL/|i|T]:2xG ,,,{!3,fX,AuŨTb0LfRYf\V 5 p8,*h4GV԰lj#UP(Z-rTkŋ'>;KL]'/UEt{ptaWՃ.ooKXu0մZBпÓnV%ڿFqti'|]`}_[U+?[rcm,MK;ot^Ct~ !(Eti'|]`xL&SVS4MXR:1RZ`,]`x\.WP!BПOifV a դԴu1{j `d2@`bbbRTR9;;f3 CD@FyZR+%ϻ3wD/^}˟1z,̬E5}š'>ϯ5l#_ɒoҮ傑FGt4_}_`0 dXk9;ic2=~!--0 DC>v? h4At f]@3.D h4At f]@3.D h4At f]@3.D h4At f]@3.D h4At f]@3.D hnt1 ZmBj5h`bT*ջl@Cjkk̆ Ul6C&hZJRjkkB!fX諾Sյfs8U_zP.Vj%Fj2fl&@]1ɤh4.xo SOk֍.Buf.x?nhxz!yet5rKWf?sdxIENDB`django-oauth-toolkit-2.3.0/docs/_images/application-register-client-credential.png000066400000000000000000001013641443573112200303410ustar00rootroot00000000000000PNG  IHDR8ôDsBITOtEXtSoftwaregnome-screenshot> IDATxy\?,M4{RiQZHI=˱t;IG!z8XH"$B%*ŴhoL3?^Hgs<]5f^\QZZZ@VTIE|>}A(P(RRR N&>)))tOwձX,)))ї(}T8455!TUUbDUP(xJJJ(B}OT|>A#%%ɠ /@h4#b O@jTH AP 5*A R@jTH AP 5*A R@jTH AP 5*A R@jTH AP 5*A R@jTH{ Vs=IH^_dbaCC͛{/III)**wرǏhX?Tûw޽{rC15kiǏN 7={NNҥK-?C2YYYY<- kkk>|zN̙Wp0z'=TYY#KϐLP.~B(11JSSS"e, R{diXh͛˗/E.]rL&O>'N:uX[nnnHHHNNN{{)SΝaÆ#̙RZ[[G7=z47oތ+**jllQWWsqqݻ7^gذaBnnnxIUU=zTUU% ,X ژ9e577ߺukAAݻ?xW޽{bM4P(ݼUUU&Mӱ222'OLLLrGkjmm~YYY{{>B(??!6@}xDkՍ7n݊߫rryYYٓ'O>y򤡡A]]}ܸq-b2_rW^ݻHTWWw…r8akk;|====^x1%%)((XZZΞ=pܹ@bSN:uJJJѣG"ڮ^PZZ,++?|p999bm۶!z?~<33EOOť<T!UUUb>}#Gϟ?<11ߟ`=Zz5dz^ ,))illK9zggU=zD,ijjjjj*))ܵkȑ#?{ ?^n]kk+իW^;~x^BEb%%%ﶵ+//OMzݻ*X]vT7rٲeo޼*66Ν;:t\SSSsssYYY>}B ##$==*Q/h:tr8ӧO8_Yf87nرcԨQxaqqjuuuIIIIIIk׮9s+++[jUyy91'''''ҥKׯ^N766.^(_mmgeeĝiRRN)*u SSSǏCBBj8Lw@@ۭ[rss:_n޼SJ]]}۶m!!!fB}_>El|ر#6l؀Sj„ ϟp„ K#KJJMLL>U'N8ܱcGxx~RRR7%Za„cǎޖinn^r%N)EE˗߿ɒ%rW&̈wmΝ***nnnƍ#nka|RԨ\nbbӧl߾}ɓ'Ĕ)Sp󚾾N###ݙLfrr2ڰa" 6Na}quvv&.(nnnVSSkiiaWdښDDD0um۶z*;;;;;{tuu]vmekllėB8ٜE܂7eʔ OYf CC[nuvv~!--YSSSSSiii]@QԠ |8|wvvrHѭ|~[[Cff$T%ܰ+((666Dj>}^|ܜ3xgϞᗤǍGŅ111vvv>w=<RSS݉FzȌ5*F988,]l޼j!t!Իwo)))UUUU%%}ҤI/^hhhC)))YYYٍ5J#SFGGGGG}Q  7o޼~zQQQSSP(}{Dg555qP ^~'p%S044TQQ}e{{;BF 4hT*U [YYNo߾Į5|#zo)fdd$6… _nkkqESCCCTeeeo(ѳlYYY_E|djDOoqqqUUBHEE'NBn#F}L#GvA?OHHHHH8x௿:mڴn6x :~xZNNNEEJzH,n?ϾMMMJܾ}~eMOO_z2NPKOV$:,+V#jZZZ-ZT\\gUUU(ׯ{~cщ\{N,!FLRRRr5I2A~z3// 555ڴi舵C~j5Nϻ._w}ܹ@N*`+V䈢O".pӦMwQz{077?sL7k_xt:sXCם`SSh~|`0h4Nѱ)EqbGYꢷz7H~ԟO?EG9{]"~t[!TSSE(?[n={VWW b7ТEoGDOAM 矛_q߸q'D~_wDe#Zh4_Y{[ !!!!!!QQQH͉jnnIxBB!$  !tRlǎDHQQ1~:Ѭѱnݺ۷Ni.;Ȟ燅z{{̈u< ESpȐ!x"99 :e˖%Z~^x}`_8db76nܸo߾8DGGGKK !s>>6l@=zuByyyk֬6mJpBzz:Blٲe!EEEwﮪӧϳg.]d2?Aӟ\[[[EE+/jذaP-[xyy=zD!lll_N\dӧ]5h3]kkҥK̙CR/]Dbg̘!h͞=ի<{…L&366F?022DXZZ>xرcI3FCCCVVܹcbb"++]\\qW\S\\Lgԩ_zR) !4zh{{TЅ ƌcaarrrC>|H4!Kzzz .Z̙3=xW'N_rݻvږ2t(`l߾͚53lǎx'' $uuu=t落a[ZZ7n> f:rʊ}jhhx/m)vPKyyy8qbjjjt:]QQlʕgϞ%J=z#GLƍ7l@ zzzΝ[hJ3fLPPoFd2(##d2Ç駟tuuY,V>}̙sEѺǎUTTtܹsϜ9뫣CԈ!,Ybjjb F^Ǝ"D }WӧbŊ={t/Z9w>}0Liii]]ݙ3g^|hv=yСCeee|||,,,<==t>%NWRRf~DDĪU fi4͆ Ξ=gQQMMM/#ax Z7i+H 4E111UUU555s̱322a+  *ܱl2`BJF?VZR]]-8]vIlHP!T]](++k``8cƌ/z"6DAP 5*A R@jTH AP 5*A R@jTH~r. N1L%%%UUU!qT@x<^II:_H |>l$X, ))) BP$]" BPNNjkkKD@P ё{T*UQQl`0v6 )$Bl(%!* yR$HNT@HA .8p@ҥ K1wor666ޢ ϝ;'1~xWWWI5 ݻ'RsB@ %MM޽{}Vgg[]e򋿿={ƍOp8͛5kB( dxWWW_raܹnnn޽{W ]VOO[>...66NYYyҤIǏG{.$$$//b 8p,+##ٳF7o^xxxhh(D-ZhݺuYYY+B(&&֭[_]Ԥ5k,kkkxyy<!sZPYY٦MCCC  bZ[[ܖ-[vӧ0222%%eΝl6ˣ[nذa k׮o2 P/svvVWW?qׯ_oݺ;117 77!DƎ{ڵɓ'666^|yGmhh@;v$"""..lʕ|>o/۷oϝ;믿ZҥKo޼A>ؘfϟ?„ ۣG600h666<+TKK7ơ7b)**N6ӧ,BCƕ?g5kkkB4(}```ggggggVVƍEׯӊÇGUUU={,**XMAAݻw˗/dz֭8q711QRR*((-((8qbuu^_CCC({NSS!hlltyt͛į]hnn;w./?>^ryoZf*|3fddd<|0&&fT*bpD>%B`Xb>>EEE"))IYYYVVF!ddd…+WRT*Bݝ IDAT4,| o̙YYY&LXzuMḾTTTz+OOEutt>|XVVo/1c\mmmBW ]d FXbE[XX466dggp>Z !dmm=gΜ'N444}`w8vXl$X, ))) BP$]" BPNNjkkKD@P ё{T*UQQl`0v6 )$Bl(%!* yR$HNT@HA ׭[JLL5jߵۦ&bo߾rׁ---o޼!&Y/ʚ??VHrZvm\\Ki0,//?i$Psss߾}Y,V7%gÆ SSSr]]]%***=gzbٲex>22rΝҟ̙3K,imm}]<9rРAm۶mܸQKK !f|>Bݻw:::x}>ѻ@~T2>}NzYYjG|||֬Y)%fϞ=B͛Gv{vb޼yplJJJر)44t͚5nݺuBJ>~( ~~zYYِ!C ߿}䉵h5e֭,k͚5˗/K<<<"##O:/|\gƳg)SDwݺu]֍|>JhxVEEtttl߾o߾AAAxݻw۷y! N̦ :۷Ν_WZuҥ7oXXX"AYYkjjBtd<sỄGx>>!!!pzzz[n=|ݺu=yz9BGlnn:tP(솀<_F=L&aD-HJJ{$zź=ǏϚ5:55TSS#WI%Nk!CҜj]SPPx%nK$gff8y _1+V?~<**f;;;,PTT$2i 111@XBÒBO<ɓ'JJJ vvv+W433rpp8q711QRR*((-((8q"ާĎ72zBL&S^^!i&.e\y~霜LVR(Ç'%%ѣG =տL޽?^T*_#||e$Fqg7eii)rss_xaooGԆ r'O|STTzj~-ZHteYYYYMM͚VO}L!kkǏ777Xl:Jzxxl߾]l(DB>--  R˗/1cFFFÇcbbvޭRZZ7fEEvb`~۷㏢ J.:?oڴڵknnn!Ѥ8\B ߅qtt\nGrr{ h=zt~~>ƅEEEwٚѻwb'5OQUUP(xVlǷ@R 㔔믦&}~JNNNzzСC?n[[ۡCf͚陕EB222 w(%%%˫n|>ö6S}СCO:+!CJKKPO>Ԥ=eʔݻw+++?y!daaQPPPXXhbbprrr̈B&FFFs9y$ˠ(--M|R߿Ԭ hnn& bz]__gq$;bʕ+|Kzhkk޻w͛7 "O8!!ӧݼye`0*++qЩSTUU'LRQQY`AHHZhэ7BBB^~}۷w9.NGEE>---!!Al F͛7˗KP___Z[[={xv||-[8@ p8 xERR,F׏ƍsqB5jTtttmmm[[ۅ }x񢮮\v-==~;ihhs·pBJ2 oo7nலppp?oM>իWO8!%%eaalmmǏwvv*yfWWXE֭[L|򬬬o񍰢ϹsBCCUUUg̘1f̘weooecyyyÇ5jǏ;aÆQEE?C^͛WZuĉK*))yxx 8#//?{lё{6s3f ¼1cp۷766*))9;;",,, \zS?ΥP(^^^k׮MLLttt5kӧW*ڷo߭[ 222{^z5T _Ν+vbdd4eʔ}|m:t3g>n bdQgg'K@{Skkkxj5V[[믿 =h)#''G'?AԠ @: ԨRd26@z@*T@” YJJJ.A$LUU@ HP[[ijj}1 $`pvI|L|x˗\(丸JIIEFFVVV.Z苊=}SNYZZJKK#\+W-[liiP(G!ґfÇgeeO>|x/^4gΜϟ_xgOYYٟwR!++4b6''dAcjj*##E'H VPP@رX4_SSw^ХKΝ;РdɒaÆ!ϟb>r1cxyyegg_~ԨQ>o߾ﷶgժU5##eeejjjӧO7oNTTTM'%%,Zݻ&M:uj777Ϟ={ɓ'ߺuHGG ǫ999/UzzzZZڎ;oT>}铝-n2"##SRRvfo߾}֭:MMMWWW%:ujjj+W̙¡eggo>PQQ͛W^k{333cXbSfffJJʶmp"TTT***BRRR+Vشiɑ#G&M6n8`Yf.\`躺:eeePvvãGB!ϟo&F.\(%%E,n͚5OJJqBݻw֭sww{ԩSW^@ˣ ;VRRgffrJ>Z[[>|hmmɉQ|>?##c+..gmMNN>|T*ɓ'AAAVhjj~ߍf:::yyyĚ8D{Bfffg{CRSSܹi&6㈈//SNM>}!:x򌌌/^j7oZ[[B:::x---4-88xҥk׮uP(D=~ԔH)4~A]]Yf_pBC IOO'6IKK:tlNNBH =Z ~!TSSS]]mee%z .TUU8pϯ(22RtW7$*++ z˗Oޯ_?:>bĈA$#FG755}j'---t:fS޼yNƚ?N\~!tMCCCggg6a>OPp&NTSSrb5oDmzإK~g77w/_} 7g\7P( ӧĉE7>nT>|W޼yCkmm0aBT {=y򤡡aBǏ1bĎ;,Yrﷴ5kjjzȟ␐khh055vvvFFF)))xMSSSGG'N>}zΜ98)UUU_|?~|AABo߾ C__Æ 'Npݻwqttt/ĉUUUccc.]J<۷UUU!CPggg3zzz8rrruuuEO6MUUU]]Btg7@ Q(&z۷oSRRN'Z&uuu+++ݻ7vXu n !jdd>y'OwB0''!dii6eʔTCCSWWdzx AP}C W^?,T5kp{qϟ?|ᨨǏST.:pϟ711a.wǸ ++5999+[n%`+V|>d޼y⳺߼u׮][n{,v>s̾}pxbݻw˗/prwoaSSS@@YZޞODgghJ7ߜYH)@p1* ҷozаaÊ333K꺺bccctoXYY):KŹCA{=r|1dȐҧOΝ;7oQ ;6(( RTTTZZZ^^ngg***vQmmm6* hb AP}[ .uVIIIFFOzz:1K˝;wRSS;;;={6k,)o޼!۷wvvfggh4 ggg.pVXq9۷o#""\rqBhƌeee|>s$]Z[[[[[r[[7-|>oPxee׈hjjJMMh#G?:::C)o~͆SPT>СGԼ{ѣG?B|۷o ~9CݝѣG?x𠨨'%%zjݗ\IIIYYxTIzzz[[> <|rrr}}Ǐ~KKKGGk׮MGiwyLttttt4BHFFwׯ744~5kqk%K =嚭"#V''Ç999mڴž~7VUU-[xiܸq-?/_; >i$b|ŋ\٩nkkj*!,kҥ~~~6mKKK^~7VOs*##~Z[[{xx{55 "%K_|No޼ #! ҥK}}}mll8F{{0OaW^?LJJ"~M%jΜ96lFBbٳg?{@(ءΦ&aSkkkxj5 PI<|H Ñ#z1AP 5*A 0&&RBmmm #@ )))', q_HMAP SUUmjjp8Pp$O`0 \nii)~*<&d`` `@T@ 6<%h@jTH AP 5*A R@jTH AP 5*A y|SUM7ʾȰʎ""" ȢE@ :UBMd.-iN~HNn9FPFPFPtZFd2Y,Oׂ{^7 ޞT0՚rp2&)===66 A3.4$ȻQ6L&*!0 &U" fXX$|||8P*! 5 5 5 5JjEEEmڴIMMutE۷o9r%K֬YxG}{())2dȅ nÇy3p{TRo֭[wկ_'xBF,[f͚M hX {KS m<ڲeûuvʕ?cǎGFF_=,,g1򼤤ɓ'7j>XzWZヘVmӦ-mX8l{ʾg̘QVV6iҤs>SO9sfYYvp8fs:ukժֹu֜!Fiڴ^;Y̨<`…>q㔖7lpŮ͊u~zꕕ-\piӦ/rLLlҥKbbիFcPPPBBBtttm6[Ϟ=L?}&M;wrHo7ZZlYZZw٤II&l6epƍ//.]j*!DJJ*_v6lxݻWyi׮FٳgONo.]ᄏQƴ*)1cFll3gN``ŋΝ{ҥŋ !dΝڒ%Kׯ_׬Y#={7Wo.\Xr>bŊu=zTi?vؖ-[^z5k 2$11177W/>|߿˗;_wʔ)/gәذa+V[O/o˅ٸqcRRRBBBIIRRRiӦ7z6T/֬YkDN3))iիWtǏ7'NP4ho;v_ҡFyG\{իVBDFF>Bl~ !nFz…#F8ѣfYyGB-ZdgghnݺZ֭Ү]zhڴiӴi+iF"\˖-KJJ^zݞU*ܹsZh~/~}ddd(YTFFFLLL߾}=]*ҟi4^f srr>!DxxU+oݺuyyy!!!\dڦF.]Uh4!yyy:N˗"44TijVFC统zsss?z(((H޳ϟ?c(())iڴi/>>>>>^*!RAaj)..gٔO O?aÆicUWNƍԩSZjM4Qݳnt~[ըQCz** 6kU*O?{NLL0aB.]jLY% ,yXNVXx>}\Zj_dEw޽{՘麝vF`hذaRR{p^zᅅEEEzj͞n0._کpBMn$555((PqJVĐRw_yO?tΜ9'O7m4a„={*3k=c+WȰl6m>|t:"##6:.??@_}{P'%%YCb:U݂?ׯ_5֮]/ZڵkO4덶?~|ii1cׯEڵ7nܸq-ԩڵk p–-[vʕ+)n߾}FFFϞ=z}\\r캣q/EGGϙ3'))iɒ%š;v8pKSLY|>ߦM#FlM4yꩧV\YZZڵk׎;*[jO~GիW5jTF*Gׯ駟\6mڴ{3g{~ϟ?k׮JAD]Tv{QQ?my>|aܝ?K͛w=zh֭o(@Avvv@@r"`F!DQQg}VXXثW/OT:ܳgψy)+i ^\r^z?**x^w],̏H *!0LU" ZXXͼ d6 ]<8azzzկz`K"yޑ\uAAAAAAAAAAAAAAAAAAjFdX<] Qz`0z{{{TDPìVkzzz```ll=l6LXJ6ܹСCg8p84ivuz)6fbXluۥϘ1lҤIs}ꩧN>=s̲2֭r oݺ5''禞mna;wܞ={jպҒqFm۶>}\xqڵSz7|S۽{Yf={v͚5jzϞ=_|j+4 /hnnܹsO<աCS۷o…W^?`CtcɅ111SLi۶eG',{キo߾ƍO6vfK.W6AAA իIIIk֬)..0`bZg^`Aqqqaa[PP[ovy3f̈nݻwl={2ekO4nwyɉ)))bcܹ,))t.//:tO?-(++[pݻGӦM_~嘘b՛6m2îUV-Zt5j6{嗻w޻woպvot֭[gX,#G6m֭[M&ĉΝ;`ۿ ;vlݺuyyyQQQ#Glّ֬#G֮]۽{7v !DAAŋϟ?:`W/_^bO?۲eˑ#G;vlժUCݾ}{aaalll\\\jj9svSO=oٳ'??ԩ###~f͚l4?;Ά 7NΉGlܸo߾~~~܃U7I7JKK;{lϞ=__z'LХKaÆ))%ضm>}[Nta~ŋ_u!V-((ڲeKll :tСC}W^;vϗ.]+_;w=y+4h0~xŢV7n\x_|t sԩ9sL:u׮]wjJ~m6mc`/,,ܼysrrϼy{ժUBUv5l  ܻww}Ӯ];FgϞN:m߾w]tw}'XlYZZw٤II&<ƴΘ1ƍ333ϟ֫W߶Ks^{mɒ%ׯB뾾SLRW\ILL8p+zyj4¼w}7** !VX!Xt̙3kW .X|;sʕիW !jdJKK{w{̯yÇSκu뢢\=|g͚z.],Xvڠe˖-[ZjظqcRRRBBBIIRRRiӦ7z6TNںuk Cƍ˛zzڷo_XXxʕӧOWVgٿIIJ*..~gݏߨj% !iӦ:uz/^ӹcǎ1cDDD7իGQ4h2qر㯿Zo֬Ynt:ݘ1cvJ5l0Oӧϟ??$$߿w?.F#^{Ky"##ݯ_~˖-8t:Ə_zuN7~xxĉ tU_r:)))C VNd8{h=|}}-ZήԪUk׮{~gJ*--8p`hhF@vȑ#>_HHH޽SSS322F(V^^cT*^߸qJ S)**ۻ[nb6u:^;vԩS7&&&##C*%222bbbPVkrr򫯾۷mۆ Vj֬P\VVGEE9N倄Vn?}իW][,K=zuy GG\ʢ!!!YYYm۶B+^^^^ҥKU*UF\L.]455nׯr%K(רQҥKfy„ Oّ'C IDAT;7K!~@b$$$7^|~B:VZ羮صkWW=իWdál#prssm6ۓO>}~~wIY 6>}ڵM7o[hѡC 2??x%JJǻ/BBrW_}UXXj駟4iRaKyJ?Q7\ԩSk׮]d훘x…e˖+J/R9NObY쾱wN:SV2ԩSq)))uԩVvnt:O?aÆ0ЭQ"##‰6Jͫ]{~S/ufAo42q=W3 !j5k*t]֬Y QFUؠaÆK.=qıcΝ۫W#FT?=BRw m裏~_6lhݺۅ:ε_ZZZXXX>kժUPP˯V+9l6Ϛ5'hժ0`3g*Zjz޵V\\l2lUZ\]j\e7~ݻw޽}q5fffn䄅UV755^`04l0))=8fK/tBz*ťKnaܰ,?FT*Ѩ4VKKK]ocg>CYnPPPuO0!..n7;ğU111]D:rȐ!Cj8p],KLL̩SVtʳt:ŋ^z9VӦM6lxҼ+Vt]9Jq]-7nflʟ5j8vHOOOZ˗;J8pUJJJ/^vڝ##gϞqqqwbm۶3f̃>߁ۣ̙dɒiӦlӦM||~uȐ!͚59sQ80cƌIGׯCBBL2~iӦUq'oN7y{7qqqTղeKVj1bDzzQ/_>z.]{ɷF6mѣGǎ;nܸʇgRwH^TT$ۗ5EYB<[3f̝zÆ ׯrw.]R}=ںu= PE!eH%QA۷Ovαc:ut-**裏 /T:;s̅ ^t)22rg&fgϞͻJxK0VZ ސ2`p7!R#az/9wlvv yT0`2<] &~{A -,,f^2م|8=k4ӫ~w Cll,_ҐA?******************Դ.Vh4L&Zp!44ӵ" fZ|||´Zސ VRRVn]J6|.ÌFo5T*JtE8Ng4###=]A3LQQQ:(=ZEPɆ)aߟǩT*JRoE9T<> ފr"R#R#٦MXTTԦMTOUܳ8=6ڵkk溫֭;nܸN:lW>>>˖-Y,iӦM TpKo޼Y]RR_L:uڵ 4~Zm6m*4lbILL۷2еmWbccjժ~ 6<裝;w>|F8q.]]qȑÇwik׮u8S<8x:1--mѢEV_3g^x֭[׫Wz}fTwfKJJJMMڵbӦM jܸVڵk֭m66dȐkQT͛7oӦM``_|ѨQ޽{kBu>#C >}7} Wcii"((H4klϞ=ۊeX:wܶZիWoҤ"33oY|kjժ]rn.5}nkժrWVO2't>++Kt:7~@V?nf?;h4oƘ1c_Yfڵ6&nZ5{m۶:tHirZRnn UT999JcFFov溛_VV&Y/jO.\pNt:+KKK +fԮ]{ȑ֭ ꫯ*/~iРSO=o !Ǐ6ɓzUV|ɕ+W.\Typ8~WȗV5vX !:wIII , n׮ݸq\_R۷QF:thTTTDDD%#4x:u /<>\\"~1 wѣG[jU!yر֭[{{]vvv@@FQ2HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH!?~KT Əz \A^GJIpO#GPwǨEPzltUH?l6zVkT0`2<]9צd2 օP(v{QQQddg ½j O{l6Lޞ.^hZVx{{trpAd68FAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAիeeevӵFEFFUxhɿ.jyhFH-kxܻ*xd_kժ9ߐ6[`po>3tj[{k5F6{Ve/\tԪUKv:h4T*!_"B^F!kժ@WDH2YR9pT$iZ. 33tln*0j~ yfi*0 t:vZJVvמ:B]s gmeN~/AN<t.w.HST؅[s[ğAϻgTwLnF<eBPs*x]T;wQgTeŢ0GXK.^ "^YTUzzڵk?^XXԢEѣGT{IHHPnt5jgĈ7[}bEU4v2{(Cc& !DdrP>XN9)YAQH\~" &Jt]t4i[tivvvLLĉ[l^ѣGg͚5&MW): 5[m N PR=[4t/1!:m NӢvpi}>'$"oGvoB{=Rꎋj&k&Go7y*JMM>|flv]s5ot:ӧ 5k#p8n/^l߾ٳgw}_~ʕ+?eyٳg?W \egdd$$$̚5iӦUً{ng! hsNKj?]bfX^ݼݚ|ȂbRMyzܾAώ Oc]6#[UĄyUQ !?QF}̙{{ȑSL)..sbWQVV&x6ltj*___JUz:;wt޽;66v8lJW^}Fݱc[{Ֆ_0YΈvolw\S78Kaw C/"Σ6qƣEo~!?ZsNTYL+la-aқ/s #yUw8SN̜>}?uԩUVlذa VXQRRnݺnݺk"//////77~XbENL&SjjbYbb1NڲeKAA;>c堗ZZ{n޼;sAV݂PSӞ`_x:|.ےY m⛫3'FtJp D^{uߘC??U[e/=*1*xDeۋ|;n8q"66*k4+WYW\kڴAڶmkZU*˗O:ةS &(G\o*ju;w>>.䶳X,' !;^&zOTUF~p{T$JURRnB:^RM}Z%R v documentation". # html_title = None # A shorter title for the navigation bar. Default is the same as html_title. # html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. # html_logo = None # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. # html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". # html_static_path = ['_static'] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. # html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. # html_use_smartypants = True # Custom sidebar templates, maps document names to template names. # html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. # html_additional_pages = {} # If false, no module index is generated. # html_domain_indices = True # If false, no index is generated. # html_use_index = True # If true, the index is split into individual pages for each letter. # html_split_index = False # If true, links to the reST sources are added to the pages. # html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. # html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. # html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. # html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). # html_file_suffix = None # Output file base name for HTML help builder. htmlhelp_basename = "DjangoOAuthToolkitdoc" # -- Options for LaTeX output -------------------------------------------------- latex_elements = { # The paper size ('letterpaper' or 'a4paper'). # 'papersize': 'letterpaper', # The font size ('10pt', '11pt' or '12pt'). # 'pointsize': '10pt', # Additional stuff for the LaTeX preamble. # 'preamble': '', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass [howto/manual]). latex_documents = [ ("index", "DjangoOAuthToolkit.tex", "Django OAuth Toolkit Documentation", "Evonove", "manual"), ] # The name of an image file (relative to this directory) to place at the top of # the title page. # latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. # latex_use_parts = False # If true, show page references after internal links. # latex_show_pagerefs = False # If true, show URL addresses after external links. # latex_show_urls = False # Documents to append as an appendix to all manuals. # latex_appendices = [] # If false, no module index is generated. # latex_domain_indices = True # -- Options for manual page output -------------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [("index", "djangooauthtoolkit", "Django OAuth Toolkit Documentation", ["Evonove"], 1)] # If true, show URL addresses after external links. # man_show_urls = False # -- Options for Texinfo output ------------------------------------------------ # Grouping the document tree into Texinfo files. List of tuples # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ ( "index", "DjangoOAuthToolkit", "Django OAuth Toolkit Documentation", "Evonove", "DjangoOAuthToolkit", "One line description of project.", "Miscellaneous", ), ] # Documents to append as an appendix to all manuals. # texinfo_appendices = [] # If false, no module index is generated. # texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. # texinfo_show_urls = 'footnote' # If true, do not generate a @detailmenu in the "Top" node's menu. # texinfo_no_detailmenu = False django-oauth-toolkit-2.3.0/docs/contributing.rst000066400000000000000000000306641443573112200217630ustar00rootroot00000000000000============ Contributing ============ .. image:: https://jazzband.co/static/img/jazzband.svg :target: https://jazzband.co/ :alt: Jazzband This is a `Jazzband `_ project. By contributing you agree to abide by the `Contributor Code of Conduct `_ and follow the `guidelines `_. Setup ===== Fork `django-oauth-toolkit` repository on `GitHub `_ and follow these steps: * Create a virtualenv and activate it * Clone your repository locally Issues ====== You can find the list of bugs, enhancements and feature requests on the `issue tracker `_. If you want to fix an issue, pick up one and add a comment stating you're working on it. Code Style ========== The project uses `flake8 `_ for linting, `black `_ for formatting the code, `isort `_ for formatting and sorting imports, and `pre-commit `_ for checking/fixing commits for correctness before they are made. You will need to install ``pre-commit`` yourself, and then ``pre-commit`` will take care of installing ``flake8``, ``black`` and ``isort``. After cloning your repository, go into it and run:: pre-commit install to install the hooks. On the next commit that you make, ``pre-commit`` will download and install the necessary hooks (a one off task). If anything in the commit would fail the hooks, the commit will be abandoned. For ``black`` and ``isort``, any necessary changes will be made automatically, but not staged. Review the changes, and then re-stage and commit again. Using ``pre-commit`` ensures that code that would fail in QA does not make it into a commit in the first place, and will save you time in the long run. You can also (largely) stop worrying about code style, although you should always check how the code looks after ``black`` has formatted it, and think if there is a better way to structure the code so that it is more readable. Documentation ============= You can edit the documentation by editing files in ``docs/``. This project uses sphinx to turn ``ReStructuredText`` into the HTML docs you are reading. In order to build the docs in to HTML, you can run:: tox -e docs This will build the docs, and place the result in ``docs/_build/html``. Alternatively, you can run:: tox -e livedocs This will run ``sphinx`` in a live reload mode, so any changes that you make to the ``RST`` files will be automatically detected and the HTML files rebuilt. It will also run a simple HTTP server available at ``_ serving the HTML files, and auto-reload the page when changes are made. This allows you to edit the docs and see your changes instantly reflected in the browser. * `ReStructuredText primer `_ Translations ============ You can contribute international language translations using `django-admin makemessages `_. For example, to add Deutsch:: cd oauth2_provider django-admin makemessages --locale de Then edit ``locale/de/LC_MESSAGES/django.po`` to add your translations. 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 ============= Please avoid providing a pull request from your `master` and use **topic branches** instead; you can add as many commits as you want but please keep them in one branch which aims to solve one single issue. Then submit your pull request. To create a topic branch, simply do:: git checkout -b fix-that-issue Switched to a new branch 'fix-that-issue' When you're ready to submit your pull request, first push the topic branch to your GitHub repo:: git push origin fix-that-issue Now you can go to your repository dashboard on GitHub and open a pull request starting from your topic branch. You can apply your pull request to the `master` branch of django-oauth-toolkit (this should be the default behaviour of GitHub user interface). When you begin your PR, you'll be asked to provide the following: * Identify the issue number that this PR fixes (if any). That issue will automatically be closed when your PR is accepted and merged. * Provide a high-level description of the change. A reviewer should be able to tell what your PR does without having to read the commit(s). * Make sure the PR only contains one change. Try to keep the PR as small and focused as you can. You can always submit additional PRs. * Any new or changed code requires that a unit test be added or updated. Make sure your tests check for correct error behavior as well as normal expected behavior. Strive for 100% code coverage of any new code you contribute! Improving unit tests is always a welcome contribution. If your change reduces coverage, you'll be warned by `Codecov `_. * Update the documentation (in `docs/`) to describe the new or changed functionality. * Update `CHANGELOG.md` (only for user relevant changes). We use `Keep A Changelog `_ format which categorizes the changes as: * `Added` for new features. * `Changed` for changes in existing functionality. * `Deprecated` for soon-to-be removed features. * `Removed` for now removed features. * `Fixed` for any bug fixes. * `Security` in case of vulnerabilities. (Please report any security issues to the JazzBand security team ``. Do not file an issue on the tracker or submit a PR until directed to do so.) * Make sure your name is in `AUTHORS`. We want to give credit to all contributors! 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. 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 of the pull request. Pull upstream changes into your fork regularly ============================================== It's a good practice to pull upstream changes from master into your fork on a regular basis, in fact if you work on outdated code and your changes diverge too far from master, the pull request has to be rejected. To pull in upstream changes:: git remote add upstream https://github.com/jazzband/django-oauth-toolkit.git git fetch upstream Then merge the changes that you fetched:: git merge upstream/master For more info, see http://help.github.com/fork-a-repo/ .. note:: Please be sure to rebase your commits on the master when possible, so your commits can be fast-forwarded: we try to avoid *merge commits* when they are not necessary. How to get your pull request accepted ===================================== We really want your code, so please follow these simple guidelines to make the process as smooth as possible. The Checklist ------------- A checklist template is automatically added to your PR when you create it. Make sure you've done all the applicable steps and check them off to indicate you have done so. This is what you'll see when creating your PR: Fixes # ## Description of the Change ## Checklist - [ ] PR only contains one change (considered splitting up PR) - [ ] unit-test added - [ ] documentation updated - [ ] `CHANGELOG.md` updated (only for user relevant changes) - [ ] author name in `AUTHORS` Any PRs that are missing checklist items will not be merged and may be reverted if they are merged by mistake. Run the tests! -------------- Django OAuth Toolkit aims to support different Python and Django versions, so we use **tox** to run tests on multiple configurations. At any time during the development and at least before submitting the pull request, please run the testsuite via:: tox The first thing the core committers will do is run this command. Any pull request that fails this test suite will be **immediately rejected**. Add the tests! -------------- Whenever you add code, you have to add tests as well. We cannot accept untested code, so unless it is a peculiar situation you previously discussed with the core committers, if your pull request reduces the test coverage it will be **immediately rejected**. You can check your coverage locally with the `coverage `_ package after running tox:: pip install coverage coverage html -d mycoverage Open mycoverage/index.html in your browser and you can see a coverage summary and coverage details for each file. There's no need to wait for Codecov to complain after you submit your PR. Code conventions matter ----------------------- There are no good nor bad conventions, just follow PEP8 (run some lint tool for this) and nobody will argue. Try reading our code and grasp the overall philosophy regarding method and variable names, avoid *black magics* for the sake of readability, keep in mind that *simple is better than complex*. If you feel the code is not straightforward, add a comment. If you think a function is not trivial, add a docstrings. To see if your code formatting will pass muster use: `tox -e flake8` The contents of this page are heavily based on the docs from `django-admin2 `_ Maintainer Checklist ==================== The following notes are to remind the project maintainers and leads of the steps required to review and merge PRs and to publish a new release. Reviewing and Merging PRs ------------------------- - Make sure the PR description includes the `pull request template `_ - Confirm that all required checklist items from the PR template are both indicated as done in the PR description and are actually done. - Perform a careful review and ask for any needed changes. - Make sure any PRs only ever improve code coverage percentage. - All PRs should be be reviewed by one individual (not the submitter) and merged by another. PRs that are incorrectly merged may (reluctantly) be reverted by the Project Leads. Publishing a Release -------------------- 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. 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. - `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. django-oauth-toolkit-2.3.0/docs/getting_started.rst000066400000000000000000000356321443573112200224430ustar00rootroot00000000000000Getting started =============== Build a OAuth2 provider using Django, Django OAuth Toolkit, and OAuthLib. What we will build? ------------------- The plan is to build an OAuth2 provider from ground up. On this getting started we will: * Create the Django project. * Install and configure Django OAuth Toolkit. * Create two OAuth2 applications. * Use Authorization code grant flow. * Use Client Credential grant flow. What is OAuth? ---------------- OAuth is an open standard for access delegation, commonly used as a way for Internet users to grant websites or applications access to their information on other websites but without giving them the passwords. -- `Whitson Gordon`_ Django ------ Django is a high-level Python Web framework that encourages rapid development and clean, pragmatic design. Built by experienced developers, it takes care of much of the hassle of Web development, so you can focus on writing your app without needing to reinvent the wheel. -- `Django website`_ Let's get start by creating a virtual environment:: mkproject iam This will create, activate and change directory to the new Python virtual environment. Install Django:: pip install Django Create a Django project:: django-admin startproject iam This will create a mysite directory in your current directory. With the following estructure:: . └── iam ├── iam │   ├── asgi.py │   ├── __init__.py │   ├── settings.py │   ├── urls.py │   └── wsgi.py └── manage.py Create a Django application:: cd iam/ python manage.py startapp users That’ll create a directory :file:`users`, which is laid out like this:: . ├── iam │   ├── asgi.py │   ├── __init__.py │   ├── settings.py │   ├── urls.py │   └── wsgi.py ├── manage.py └── users ├── admin.py ├── apps.py ├── __init__.py ├── migrations │   └── __init__.py ├── models.py ├── tests.py └── views.py If you’re starting a new project, it’s highly recommended to set up a custom user model, even if the default `User`_ model is sufficient for you. This model behaves identically to the default user model, but you’ll be able to customize it in the future if the need arises. -- `Django documentation`_ Edit :file:`users/models.py` adding the code below: .. code-block:: python from django.contrib.auth.models import AbstractUser class User(AbstractUser): pass Change :file:`iam/settings.py` to add ``users`` application to ``INSTALLED_APPS``: .. code-block:: python INSTALLED_APPS = [ 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', 'users', ] Configure ``users.User`` to be the model used for the ``auth`` application by adding ``AUTH_USER_MODEL`` to :file:`iam/settings.py`: .. code-block:: python AUTH_USER_MODEL='users.User' Create inital migration for ``users`` application ``User`` model:: python manage.py makemigrations The command above will create the migration:: Migrations for 'users': users/migrations/0001_initial.py - Create model User Finally execute the migration:: python manage.py migrate The ``migrate`` output:: Operations to perform: Apply all migrations: admin, auth, contenttypes, sessions, users Running migrations: Applying contenttypes.0001_initial... OK Applying contenttypes.0002_remove_content_type_name... OK Applying auth.0001_initial... OK Applying auth.0002_alter_permission_name_max_length... OK Applying auth.0003_alter_user_email_max_length... OK Applying auth.0004_alter_user_username_opts... OK Applying auth.0005_alter_user_last_login_null... OK Applying auth.0006_require_contenttypes_0002... OK Applying auth.0007_alter_validators_add_error_messages... OK Applying auth.0008_alter_user_username_max_length... OK Applying auth.0009_alter_user_last_name_max_length... OK Applying auth.0010_alter_group_name_max_length... OK Applying auth.0011_update_proxy_permissions... OK Applying users.0001_initial... OK Applying admin.0001_initial... OK Applying admin.0002_logentry_remove_auto_add... OK Applying admin.0003_logentry_add_action_flag_choices... OK Applying sessions.0001_initial... OK Django OAuth Toolkit -------------------- Django OAuth Toolkit can help you by providing, out of the box, all the endpoints, data, and logic needed to add OAuth2 capabilities to your Django projects. Install Django OAuth Toolkit:: pip install django-oauth-toolkit Add ``oauth2_provider`` to ``INSTALLED_APPS`` in :file:`iam/settings.py`: .. code-block:: python INSTALLED_APPS = [ 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', 'users', 'oauth2_provider', ] Execute the migration:: python manage.py migrate The ``migrate`` command output:: Operations to perform: Apply all migrations: admin, auth, contenttypes, oauth2_provider, sessions, users Running migrations: Applying oauth2_provider.0001_initial... OK Applying oauth2_provider.0002_auto_20190406_1805... OK Include ``oauth2_provider.urls`` to :file:`iam/urls.py` as follows: .. code-block:: python from django.contrib import admin from django.urls import include, path urlpatterns = [ path('admin/', admin.site.urls), path('o/', include('oauth2_provider.urls', namespace='oauth2_provider')), ] This will make available endpoints to authorize, generate token and create OAuth applications. Last change, add ``LOGIN_URL`` to :file:`iam/settings.py`: .. code-block:: python LOGIN_URL='/admin/login/' We will use Django Admin login to make our life easy. Create a user:: python manage.py createsuperuser Username: wiliam Email address: me@wiliam.dev Password: Password (again): Superuser created successfully. OAuth2 Authorization Grants --------------------------- An authorization grant is a credential representing the resource owner's authorization (to access its protected resources) used by the client to obtain an access token. -- `RFC6749`_ The OAuth framework specifies several grant types for different use cases. -- `Grant types`_ We will start by given a try to the grant types listed below: * Authorization code * Client credential These two grant types cover the most initially used use cases. Authorization Code ------------------ The Authorization Code flow is best used in web and mobile apps. This is the flow used for third party integration, the user authorizes your partner to access its products in your APIs. Start the development server:: python manage.py runserver Point your browser to http://127.0.0.1:8000/o/applications/register/ lets create an application. Fill the form as show in the screenshot bellow and before save take note of ``Client id`` and ``Client secret`` we will use it in a minute. .. image:: _images/application-register-auth-code.png :alt: Authorization code application registration Export ``Client id`` and ``Client secret`` values as environment variable: .. sourcecode:: sh export ID=vW1RcAl7Mb0d5gyHNQIAcH110lWoOW2BmWJIero8 export SECRET=DZFpuNjRdt5xUEzxXovAp40bU3lQvoMvF3awEStn61RXWE0Ses4RgzHWKJKTvUCHfRkhcBi3ebsEfSjfEO96vo2Sh6pZlxJ6f7KcUbhvqMMPoVxRwv4vfdWEoWMGPeIO Now let's generate an authentication code grant with PKCE (Proof Key for Code Exchange), useful to prevent authorization code injection. To do so, you must first generate a ``code_verifier`` random string between 43 and 128 characters, which is then encoded to produce a ``code_challenge``: .. sourcecode:: python import random import string import base64 import hashlib code_verifier = ''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(random.randint(43, 128))) code_verifier = base64.urlsafe_b64encode(code_verifier.encode('utf-8')) code_challenge = hashlib.sha256(code_verifier).digest() code_challenge = base64.urlsafe_b64encode(code_challenge).decode('utf-8').replace('=', '') Take note of ``code_challenge`` since we will include it in the code flow URL. It should look something like ``XRi41b-5yHtTojvCpXFpsLUnmGFz6xR15c3vpPANAvM``. Export ``code_verifier`` value as environment variable, it should be something like: .. sourcecode:: sh export CODE_VERIFIER=N0hHRVk2WDNCUUFPQTIwVDNZWEpFSjI4UElNV1pSTlpRUFBXNTEzU0QzRTMzRE85WDFWTzU2WU9ESw== To start the Authorization code flow go to this `URL`_ which is the same as shown below:: http://127.0.0.1:8000/o/authorize/?response_type=code&code_challenge=XRi41b-5yHtTojvCpXFpsLUnmGFz6xR15c3vpPANAvM&code_challenge_method=S256&client_id=vW1RcAl7Mb0d5gyHNQIAcH110lWoOW2BmWJIero8&redirect_uri=http://127.0.0.1:8000/noexist/callback Note the parameters we pass: * **response_type**: ``code`` * **code_challenge**: ``XRi41b-5yHtTojvCpXFpsLUnmGFz6xR15c3vpPANAvM`` * **code_challenge_method**: ``S256`` * **client_id**: ``vW1RcAl7Mb0d5gyHNQIAcH110lWoOW2BmWJIero8`` * **redirect_uri**: ``http://127.0.0.1:8000/noexist/callback`` This identifies your application, the user is asked to authorize your application to access its resources. Go ahead and authorize the ``web-app`` .. image:: _images/application-authorize-web-app.png :alt: Authorization code authorize web-app Remember we used ``http://127.0.0.1:8000/noexist/callback`` as ``redirect_uri`` you will get a **Page not found (404)** but it worked if you get a url like:: http://127.0.0.1:8000/noexist/callback?code=uVqLxiHDKIirldDZQfSnDsmYW1Abj2 This is the OAuth2 provider trying to give you a ``code``. in this case ``uVqLxiHDKIirldDZQfSnDsmYW1Abj2``. Export it as an environment variable: .. code-block:: sh export CODE=uVqLxiHDKIirldDZQfSnDsmYW1Abj2 Now that you have the user authorization is time to get an access token:: curl -X POST -H "Cache-Control: no-cache" -H "Content-Type: application/x-www-form-urlencoded" "http://127.0.0.1:8000/o/token/" -d "client_id=${ID}" -d "client_secret=${SECRET}" -d "code=${CODE}" -d "code_verifier=${CODE_VERIFIER}" -d "redirect_uri=http://127.0.0.1:8000/noexist/callback" -d "grant_type=authorization_code" To be more easy to visualize:: curl -X POST \ -H "Cache-Control: no-cache" \ -H "Content-Type: application/x-www-form-urlencoded" \ "http://127.0.0.1:8000/o/token/" \ -d "client_id=${ID}" \ -d "client_secret=${SECRET}" \ -d "code=${CODE}" \ -d "code_verifier=${CODE_VERIFIER}" \ -d "redirect_uri=http://127.0.0.1:8000/noexist/callback" \ -d "grant_type=authorization_code" The OAuth2 provider will return the follow response: .. code-block:: javascript { "access_token": "jooqrnOrNa0BrNWlg68u9sl6SkdFZg", "expires_in": 36000, "token_type": "Bearer", "scope": "read write", "refresh_token": "HNvDQjjsnvDySaK0miwG4lttJEl9yD" } To access the user resources we just use the ``access_token``:: curl \ -H "Authorization: Bearer jooqrnOrNa0BrNWlg68u9sl6SkdFZg" \ -X GET http://localhost:8000/resource Client Credential ----------------- The Client Credential grant is suitable for machine-to-machine authentication. You authorize your own service or worker to change a bank account transaction status to accepted. Point your browser to http://127.0.0.1:8000/o/applications/register/ lets create an application. Fill the form as show in the screenshot below, and before saving take note of ``Client id`` and ``Client secret`` we will use it in a minute. .. image:: _images/application-register-client-credential.png :alt: Client credential application registration Export ``Client id`` and ``Client secret`` values as environment variable: .. code-block:: sh export ID=axXSSBVuvOyGVzh4PurvKaq5MHXMm7FtrHgDMi4u export SECRET=1fuv5WVfR7A5BlF0o155H7s5bLgXlwWLhi3Y7pdJ9aJuCdl0XV5Cxgd0tri7nSzC80qyrovh8qFXFHgFAAc0ldPNn5ZYLanxSm1SI1rxlRrWUP591wpHDGa3pSpB6dCZ The Client Credential flow is simpler than the Authorization Code flow. We need to encode ``client_id`` and ``client_secret`` as HTTP base authentication encoded in ``base64`` I use the following code to do that. .. code-block:: python >>> import base64 >>> client_id = "axXSSBVuvOyGVzh4PurvKaq5MHXMm7FtrHgDMi4u" >>> secret = "1fuv5WVfR7A5BlF0o155H7s5bLgXlwWLhi3Y7pdJ9aJuCdl0XV5Cxgd0tri7nSzC80qyrovh8qFXFHgFAAc0ldPNn5ZYLanxSm1SI1rxlRrWUP591wpHDGa3pSpB6dCZ" >>> credential = "{0}:{1}".format(client_id, secret) >>> base64.b64encode(credential.encode("utf-8")) b'YXhYU1NCVnV2T3lHVnpoNFB1cnZLYXE1TUhYTW03RnRySGdETWk0dToxZnV2NVdWZlI3QTVCbEYwbzE1NUg3czViTGdYbHdXTGhpM1k3cGRKOWFKdUNkbDBYVjVDeGdkMHRyaTduU3pDODBxeXJvdmg4cUZYRkhnRkFBYzBsZFBObjVaWUxhbnhTbTFTSTFyeGxScldVUDU5MXdwSERHYTNwU3BCNmRDWg==' >>> Export the credential as an environment variable .. code-block:: sh export CREDENTIAL=YXhYU1NCVnV2T3lHVnpoNFB1cnZLYXE1TUhYTW03RnRySGdETWk0dToxZnV2NVdWZlI3QTVCbEYwbzE1NUg3czViTGdYbHdXTGhpM1k3cGRKOWFKdUNkbDBYVjVDeGdkMHRyaTduU3pDODBxeXJvdmg4cUZYRkhnRkFBYzBsZFBObjVaWUxhbnhTbTFTSTFyeGxScldVUDU5MXdwSERHYTNwU3BCNmRDWg== 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" To be easier to visualize:: 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" The OAuth2 provider will return the following response: .. code-block:: javascript { "access_token": "PaZDOD5UwzbGOFsQr34LQ7JUYOj3yK", "expires_in": 36000, "token_type": "Bearer", "scope": "read write" } Next step is :doc:`first tutorial `. .. _Django website: https://www.djangoproject.com/ .. _Whitson Gordon: https://en.wikipedia.org/wiki/OAuth#cite_note-1 .. _User: https://docs.djangoproject.com/en/3.0/ref/contrib/auth/#django.contrib.auth.models.User .. _Django documentation: https://docs.djangoproject.com/en/3.0/topics/auth/customizing/#using-a-custom-user-model-when-starting-a-project .. _RFC6749: https://tools.ietf.org/html/rfc6749#section-1.3 .. _Grant Types: https://oauth.net/2/grant-types/ .. _URL: http://127.0.0.1:8000/o/authorize/?response_type=code&client_id=vW1RcAl7Mb0d5gyHNQIAcH110lWoOW2BmWJIero8&redirect_uri=http://127.0.0.1:8000/noexist/callback django-oauth-toolkit-2.3.0/docs/glossary.rst000066400000000000000000000040741443573112200211130ustar00rootroot00000000000000Glossary ======== .. Put definition of specific terms here, and reference them inside docs with :term:`My term` syntax .. glossary:: Authorization Server The authorization server asks resource owners for their consensus to let client applications access their data. It also manages and issues the tokens needed for all the authorization flows supported by OAuth2 spec. Usually the same application offering resources through an OAuth2-protected API also behaves like an authorization server. Resource Server An application providing access to its own resources through an API protected following the OAuth2 spec. Application An Application represents a Client on the Authorization server. Usually an Application is created manually by client's developers after logging in on an Authorization Server. Client A client is an application authorized to access OAuth2-protected resources on behalf and with the authorization of the resource owner. Resource Owner The user of an application which exposes resources to third party applications through OAuth2. The resource owner must give her authorization for third party applications to be able to access her data. Access Token A token needed to access resources protected by OAuth2. It has a lifetime which is usually quite short. Authorization Code The authorization code is obtained by using an authorization server as an intermediary between the client and resource owner. It is used to authenticate the client and grant the transmission of the Access Token. Authorization Token A token the authorization server issues to clients that can be swapped for an access token. It has a very short lifetime since the swap has to be performed shortly after users provide their authorization. Refresh Token A token the authorization server may issue to clients and can be swapped for a brand new access token, without repeating the authorization process. It has no expire time. django-oauth-toolkit-2.3.0/docs/index.rst000066400000000000000000000026141443573112200203550ustar00rootroot00000000000000.. Django OAuth Toolkit documentation master file, created by sphinx-quickstart on Mon May 20 19:40:43 2013. You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. Welcome to Django OAuth Toolkit Documentation ============================================= Django OAuth Toolkit can help you by providing, out of the box, all the endpoints, data, and logic needed to add OAuth2 capabilities to your Django projects. Django OAuth Toolkit makes extensive use of the excellent `OAuthLib `_, so that everything is `rfc-compliant `_. See our :doc:`Changelog ` for information on updates. Support ------- If you need help please submit a `question `_. Requirements ------------ * Python 3.7+ * Django 2.2, 3.2, 4.0.1+ * oauthlib 3.1+ Index ===== .. toctree:: :maxdepth: 2 install getting_started tutorial/tutorial rest-framework/rest-framework views/views templates views/details models advanced_topics oidc signals settings resource_server management_commands glossary .. toctree:: :maxdepth: 1 contributing changelog Indices and tables ================== * :ref:`genindex` * :ref:`modindex` django-oauth-toolkit-2.3.0/docs/install.rst000066400000000000000000000016361443573112200207170ustar00rootroot00000000000000Installation ============ Install with pip :: pip install django-oauth-toolkit Add `oauth2_provider` to your `INSTALLED_APPS` .. code-block:: python INSTALLED_APPS = ( ... 'oauth2_provider', ) If you need an OAuth2 provider you'll want to add the following to your urls.py .. code-block:: python from django.urls import include, path urlpatterns = [ ... path('o/', include('oauth2_provider.urls', namespace='oauth2_provider')), ] Or using `re_path()` .. code-block:: python from django.urls import include, re_path urlpatterns = [ ... re_path(r'^o/', include('oauth2_provider.urls', namespace='oauth2_provider')), ] Sync your database ------------------ .. sourcecode:: sh $ python manage.py migrate oauth2_provider Next step is :doc:`getting started ` or :doc:`first tutorial `. django-oauth-toolkit-2.3.0/docs/management_commands.rst000066400000000000000000000112261443573112200232420ustar00rootroot00000000000000Management commands =================== Django OAuth Toolkit exposes some useful management commands that can be run via shell or by other means such as cron or :doc:`Celery `. .. _cleartokens: .. _createapplication: cleartokens ~~~~~~~~~~~ The ``cleartokens`` management command allows the user to remove those refresh tokens whose lifetime is greater than the amount specified by ``REFRESH_TOKEN_EXPIRE_SECONDS`` settings. It is important that this command is run regularly (eg: via cron) to avoid cluttering the database with expired refresh tokens. If ``cleartokens`` runs daily the maximum delay before a refresh token is removed is ``REFRESH_TOKEN_EXPIRE_SECONDS`` + 1 day. This is normally not a problem since refresh tokens are long lived. To prevent the CPU and RAM high peaks during deletion process use ``CLEAR_EXPIRED_TOKENS_BATCH_SIZE`` and ``CLEAR_EXPIRED_TOKENS_BATCH_INTERVAL`` settings to adjust the process speed. The ``cleartokens`` management command will also delete expired access and ID tokens alongside expired refresh tokens. Note: Refresh tokens need to expire before AccessTokens can be removed from the database. Using ``cleartokens`` without ``REFRESH_TOKEN_EXPIRE_SECONDS`` has limited effect. createapplication ~~~~~~~~~~~~~~~~~ The ``createapplication`` management command provides a shortcut to create a new application in a programmatic way. .. code-block:: sh usage: manage.py createapplication [-h] [--client-id CLIENT_ID] [--user USER] [--redirect-uris REDIRECT_URIS] [--post-logout-redirect-uris POST_LOGOUT_REDIRECT_URIS] [--client-secret CLIENT_SECRET] [--name NAME] [--skip-authorization] [--algorithm ALGORITHM] [--version] [-v {0,1,2,3}] [--settings SETTINGS] [--pythonpath PYTHONPATH] [--traceback] [--no-color] [--force-color] [--skip-checks] client_type authorization_grant_type Shortcut to create a new application in a programmatic way positional arguments: client_type The client type, one of: confidential, public authorization_grant_type The type of authorization grant to be used, one of: authorization-code, implicit, password, client- credentials, openid-hybrid optional arguments: -h, --help show this help message and exit --client-id CLIENT_ID The ID of the new application --user USER The user the application belongs to --redirect-uris REDIRECT_URIS The redirect URIs, this must be a space separated string e.g 'URI1 URI2' --post-logout-redirect-uris POST_LOGOUT_REDIRECT_URIS The post logout redirect URIs, this must be a space separated string e.g 'URI1 URI2' --client-secret CLIENT_SECRET The secret for this application --name NAME The name this application --skip-authorization If set, completely bypass the authorization form, even on the first use of the application --algorithm ALGORITHM The OIDC token signing algorithm for this application, one of: RS256, HS256 --version Show program's version number and exit. -v {0,1,2,3}, --verbosity {0,1,2,3} Verbosity level; 0=minimal output, 1=normal output, 2=verbose output, 3=very verbose output --settings SETTINGS The Python path to a settings module, e.g. "myproject.settings.main". If this isn't provided, the DJANGO_SETTINGS_MODULE environment variable will be used. --pythonpath PYTHONPATH A directory to add to the Python path, e.g. "/home/djangoprojects/myproject". --traceback Raise on CommandError exceptions. --no-color Don't colorize the command output. --force-color Force colorization of the command output. --skip-checks Skip system checks. If you let `createapplication` auto-generate the secret then it displays the value before hashing it. django-oauth-toolkit-2.3.0/docs/models.rst000066400000000000000000000001041443573112200205210ustar00rootroot00000000000000Models ====== .. automodule:: oauth2_provider.models :members: django-oauth-toolkit-2.3.0/docs/oidc.rst000066400000000000000000000415701443573112200201700ustar00rootroot00000000000000OpenID Connect ++++++++++++++ OpenID Connect support ====================== ``django-oauth-toolkit`` supports OpenID Connect (OIDC), which standardizes authentication flows and provides a plug and play integration with other systems. OIDC is built on top of OAuth 2.0 to provide: * Generating ID tokens as part of the login process. These are JWT that describe the user, and can be used to authenticate them to your application. * Metadata based auto-configuration for providers * A user info endpoint, which applications can query to get more information about a user. Enabling OIDC doesn't affect your existing OAuth 2.0 flows, these will continue to work alongside OIDC. We support: * OpenID Connect Authorization Code Flow * OpenID Connect Implicit Flow * OpenID Connect Hybrid Flow Furthermore ``django-oauth-toolkit`` also supports `OpenID Connect RP-Initiated Logout `_. Configuration ============= OIDC is not enabled by default because it requires additional configuration that must be provided. ``django-oauth-toolkit`` supports two different algorithms for signing JWT tokens, ``RS256``, which uses asymmetric RSA keys (a public key and a private key), and ``HS256``, which uses a symmetric key. It is preferrable to use ``RS256``, because this produces a token that can be verified by anyone using the public key (which is made available and discoverable by OIDC service auto-discovery, included with ``django-oauth-toolkit``). ``HS256`` on the other hand uses the ``client_secret`` in order to verify keys. This is simpler to implement, but makes it harder to safely verify tokens. Using ``HS256`` also means that you cannot use the Implicit or Hybrid flows, or verify the tokens in public clients, because you cannot disclose the ``client_secret`` to a public client. If you are using a public client, you must use ``RS256``. Creating RSA private key ~~~~~~~~~~~~~~~~~~~~~~~~ To use ``RS256`` requires an RSA private key, which is used for signing JWT. You can generate this using the `openssl`_ tool:: openssl genrsa -out oidc.key 4096 This will generate a 4096-bit RSA key, which will be sufficient for our needs. .. _openssl: https://www.openssl.org .. warning:: The contents of this key *must* be kept a secret. Don't put it in your settings and commit it to version control! If the key is ever accidentally disclosed, an attacker could use it to forge JWT tokens that verify as issued by your OAuth provider, which is very bad! If it is ever disclosed, you should immediately replace the key. Safe ways to handle it would be: * Store it in a secure system like `Hashicorp Vault`_, and inject it in to your environment when running your server. * Store it in a secure file on your server, and use your initialization scripts to inject it in to your environment. .. _Hashicorp Vault: https://www.hashicorp.com/products/vault Now we need to add this key to our settings and allow the ``openid`` scope to be used. Assuming we have set an environment variable called ``OIDC_RSA_PRIVATE_KEY``, we can make changes to our ``settings.py``:: import os OAUTH2_PROVIDER = { "OIDC_ENABLED": True, "OIDC_RSA_PRIVATE_KEY": os.environ.get("OIDC_RSA_PRIVATE_KEY"), "SCOPES": { "openid": "OpenID Connect scope", # ... any other scopes that you use }, # ... any other settings you want } If you are adding OIDC support to an existing OAuth 2.0 provider site, and you are currently using a custom class for ``OAUTH2_SERVER_CLASS``, you must change this class to derive from ``oauthlib.openid.Server`` instead of ``oauthlib.oauth2.Server``. With ``RSA`` key-pairs, the public key can be generated from the private key, so there is no need to add a setting for the public key. Rotating the RSA private key ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Extra keys can be published in the jwks_uri with the ``OIDC_RSA_PRIVATE_KEYS_INACTIVE`` setting. For example::: OAUTH2_PROVIDER = { "OIDC_RSA_PRIVATE_KEY": os.environ.get("OIDC_RSA_PRIVATE_KEY"), "OIDC_RSA_PRIVATE_KEYS_INACTIVE": [ os.environ.get("OIDC_RSA_PRIVATE_KEY_2"), os.environ.get("OIDC_RSA_PRIVATE_KEY_3") ] # ... other settings } To rotate, follow these steps: #. Generate a new key, and add it to the inactive set. Then deploy the app. #. Swap the active and inactive keys, then re-deploy. #. After some reasonable amount of time, remove the inactive key. At a minimum, you should wait ``ID_TOKEN_EXPIRE_SECONDS`` to ensure the key isn't removed before valid tokens expire. Using ``HS256`` keys ~~~~~~~~~~~~~~~~~~~~ If you would prefer to use just ``HS256`` keys, you don't need to create any additional keys, ``django-oauth-toolkit`` will just use the application's ``client_secret`` to sign the JWT token. In this case, you just need to enable OIDC and add ``openid`` to your list of scopes in your ``settings.py``:: OAUTH2_PROVIDER = { "OIDC_ENABLED": True, "SCOPES": { "openid": "OpenID Connect scope", # ... any other scopes that you use }, # ... any other settings you want } .. note:: If you want to enable ``RS256`` at a later date, you can do so - just add the private key as described above. RP-Initiated Logout ~~~~~~~~~~~~~~~~~~~ This feature has to be enabled separately as it is an extension to the core standard. .. code-block:: python OAUTH2_PROVIDER = { # OIDC has to be enabled to use RP-Initiated Logout "OIDC_ENABLED": True, # Enable and configure RP-Initiated Logout "OIDC_RP_INITIATED_LOGOUT_ENABLED": True, "OIDC_RP_INITIATED_LOGOUT_ALWAYS_PROMPT": True, # ... any other settings you want } Setting up OIDC enabled clients =============================== Setting up an OIDC client in ``django-oauth-toolkit`` is simple - in fact, all existing OAuth 2.0 Authorization Code Flow and Implicit Flow applications that are already configured can be easily updated to use OIDC by setting the appropriate algorithm for them to use. You can also switch existing apps to use OIDC Hybrid Flow by changing their Authorization Grant Type and selecting a signing algorithm to use. You can read about the pros and cons of the different flows in `this excellent article`_ from Robert Broeckelmann. .. _this excellent article: https://medium.com/@robert.broeckelmann/when-to-use-which-oauth2-grants-and-oidc-flows-ec6a5c00d864 OIDC Authorization Code Flow ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ To create an OIDC Authorization Code Flow client, create an ``Application`` with the grant type ``Authorization code`` and select your desired signing algorithm. When making an authorization request, be sure to include ``openid`` as a scope. When the code is exchanged for the access token, the response will also contain an ID token JWT. If the ``openid`` scope is not requested, authorization requests will be treated as standard OAuth 2.0 Authorization Code Grant requests. With ``PKCE`` enabled, even public clients can use this flow, and it is the most secure and recommended flow. OIDC Implicit Flow ~~~~~~~~~~~~~~~~~~ OIDC Implicit Flow is very similar to OAuth 2.0 Implicit Grant, except that the client can request a ``response_type`` of ``id_token`` or ``id_token token``. Requesting just ``token`` is also possible, but it would make it not an OIDC flow and would fall back to being the same as OAuth 2.0 Implicit Grant. To setup an OIDC Implicit Flow client, simply create an ``Application`` with the a grant type of ``Implicit`` and select your desired signing algorithm, and configure the client to request the ``openid`` scope and an OIDC ``response_type`` (``id_token`` or ``id_token token``). OIDC Hybrid Flow ~~~~~~~~~~~~~~~~ OIDC Hybrid Flow is a mixture of the previous two flows. It allows the ID token and an access token to be returned to the frontend, whilst also allowing the backend to retrieve the ID token and an access token (not necessarily the same access token) on the backend. To setup an OIDC Hybrid Flow application, create an ``Application`` with a grant type of ``OpenID connect hybrid`` and select your desired signing algorithm. Customizing the OIDC responses ============================== This basic configuration will give you a basic working OIDC setup, but your ID tokens will have very few claims in them, and the ``UserInfo`` service will just return the same claims as the ID token. To configure all of these things we need to customize the ``OAUTH2_VALIDATOR_CLASS`` in ``django-oauth-toolkit``. Create a new file in our project, eg ``my_project/oauth_validator.py``:: from oauth2_provider.oauth2_validators import OAuth2Validator class CustomOAuth2Validator(OAuth2Validator): pass and then configure our site to use this in our ``settings.py``:: OAUTH2_PROVIDER = { "OAUTH2_VALIDATOR_CLASS": "my_project.oauth_validators.CustomOAuth2Validator", # ... other settings } Now we can customize the tokens and the responses that are produced by adding methods to our custom validator. Adding claims to the ID token ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ By default the ID token will just have a ``sub`` claim (in addition to the required claims, eg ``iss``, ``aud``, ``exp``, ``iat``, ``auth_time`` etc), and the ``sub`` claim will use the primary key of the user as the value. You'll probably want to customize this and add additional claims or change what is sent for the ``sub`` claim. To do so, you will need to add a method to our custom validator. It takes one of two forms: The first form gets passed a request object, and should return a dictionary mapping a claim name to claim data:: class CustomOAuth2Validator(OAuth2Validator): # Set `oidc_claim_scope = None` to ignore scopes that limit which claims to return, # otherwise the OIDC standard scopes are used. def get_additional_claims(self, request): return { "given_name": request.user.first_name, "family_name": request.user.last_name, "name": ' '.join([request.user.first_name, request.user.last_name]), "preferred_username": request.user.username, "email": request.user.email, } The second form gets no request object, and should return a dictionary mapping a claim name to a callable, accepting a request and producing the claim data:: class CustomOAuth2Validator(OAuth2Validator): # Extend the standard scopes to add a new "permissions" scope # which returns a "permissions" claim: oidc_claim_scope = OAuth2Validator.oidc_claim_scope oidc_claim_scope.update({"permissions": "permissions"}) def get_additional_claims(self): return { "given_name": lambda request: request.user.first_name, "family_name": lambda request: request.user.last_name, "name": lambda request: ' '.join([request.user.first_name, request.user.last_name]), "preferred_username": lambda request: request.user.username, "email": lambda request: request.user.email, "permissions": lambda request: list(request.user.get_group_permissions()), } Standard claim ``sub`` is included by default, to remove it override ``get_claim_dict``. Supported claims discovery -------------------------- In order to help clients discover claims early, they can be advertised in the discovery info, under the ``claims_supported`` key. In order for the discovery info view to automatically add all claims your validator returns, you need to use the second form (producing callables), because the discovery info views are requested with an unauthenticated request, so directly producing claim data would fail. If you use the first form, producing claim data directly, your claims will not be added to discovery info. In some cases, it might be desirable to not list all claims in discovery info. To customize which claims are advertised, you can override the ``get_discovery_claims`` method to return a list of claim names to advertise. If your ``get_additional_claims`` uses the first form and you still want to advertise claims, you can also override ``get_discovery_claims``. Using OIDC scopes to determine which claims are returned -------------------------------------------------------- The ``oidc_claim_scope`` OAuth2Validator class attribute implements OIDC's `5.4 Requesting Claims using Scope Values`_ feature. For example, a ``given_name`` claim is only returned if the ``profile`` scope was granted. To change the list of claims and which scopes result in their being returned, override ``oidc_claim_scope`` with a dict keyed by claim with a value of scope. The following example adds instructions to return the ``foo`` claim when the ``bar`` scope is granted:: class CustomOAuth2Validator(OAuth2Validator): oidc_claim_scope = OAuth2Validator.oidc_claim_scope oidc_claim_scope.update({"foo": "bar"}) Set ``oidc_claim_scope = None`` to return all claims irrespective of the granted scopes. You have to make sure you've added additional claims via ``get_additional_claims`` and defined the ``OAUTH2_PROVIDER["SCOPES"]`` in your settings in order for this functionality to work. .. note:: This ``request`` object is not a ``django.http.Request`` object, but an ``oauthlib.common.Request`` object. This has a number of attributes that you can use to decide what claims to put in to the ID token: * ``request.scopes`` - the list of granted scopes. * ``request.claims`` - the requested claims per OIDC's `5.5 Requesting Claims using the "claims" Request Parameter`_. These must be requested by the client when making an authorization request. * ``request.user`` - the `Django User`_ object. .. _5.4 Requesting Claims using Scope Values: https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims .. _5.5 Requesting Claims using the "claims" Request Parameter: https://openid.net/specs/openid-connect-core-1_0.html#ClaimsParameter .. _Django User: https://docs.djangoproject.com/en/stable/ref/contrib/auth/#user-model What claims you decide to put in to the token is up to you to determine based upon what the scopes and / or claims means to your provider. Adding information to the ``UserInfo`` service ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The ``UserInfo`` service is supplied as part of the OIDC service, and is used to retrieve information about the user given their Access Token. It is optional to use the service. The service is accessed by making a request to the ``UserInfo`` endpoint, eg ``/o/userinfo/`` and supplying the access token retrieved at login as a ``Bearer`` token or as a form-encoded ``access_token`` body parameter for a POST request. Again, to modify the content delivered, we need to add a function to our custom validator. The default implementation adds the claims from the ID token, so you will probably want to re-use that:: class CustomOAuth2Validator(OAuth2Validator): def get_userinfo_claims(self, request): claims = super().get_userinfo_claims(request) claims["color_scheme"] = get_color_scheme(request.user) return claims Customizing the login flow ========================== Clients can request that the user logs in each time a request to the ``/authorize`` endpoint is made during the OIDC Authorization Code Flow by adding the ``prompt=login`` query parameter and value. Only ``login`` is currently supported. See OIDC's `3.1.2.1 Authentication Request `_ for details. OIDC Views ========== Enabling OIDC support adds three views to ``django-oauth-toolkit``. When OIDC is not enabled, these views will log that OIDC support is not enabled, and return a ``404`` response, or if ``DEBUG`` is enabled, raise an ``ImproperlyConfigured`` exception. In the docs below, it assumes that you have mounted the ``django-oauth-toolkit`` at ``/o/``. If you have mounted it elsewhere, adjust the URLs accordingly. ConnectDiscoveryInfoView ~~~~~~~~~~~~~~~~~~~~~~~~ Available at ``/o/.well-known/openid-configuration/``, this view provides auto discovery information to OIDC clients, telling them the JWT issuer to use, the location of the JWKs to verify JWTs with, the token and userinfo endpoints to query, and other details. JwksInfoView ~~~~~~~~~~~~ Available at ``/o/.well-known/jwks.json``, this view provides details of the keys used to sign the JWTs generated for ID tokens, so that clients are able to verify them. UserInfoView ~~~~~~~~~~~~ Available at ``/o/userinfo/``, this view provides extra user details. You can customize the details included in the response as described above. RPInitiatedLogoutView ~~~~~~~~~~~~~~~~~~~~~ Available at ``/o/rp-initiated-logout/``, this view allows a :term:`Client` (Relying Party) to request that a :term:`Resource Owner` is logged out at the :term:`Authorization Server` (OpenID Provider). django-oauth-toolkit-2.3.0/docs/requirements.txt000066400000000000000000000000611443573112200217720ustar00rootroot00000000000000Django oauthlib>=3.1.0 m2r>=0.2.1 mistune<2 -e . django-oauth-toolkit-2.3.0/docs/resource_server.rst000066400000000000000000000051701443573112200224630ustar00rootroot00000000000000Separate Resource Server ======================== Django OAuth Toolkit allows to separate the :term:`Authorization Server` and the :term:`Resource Server`. Based on the `RFC 7662 `_ Django OAuth Toolkit provides a rfc-compliant introspection endpoint. As well the Django OAuth Toolkit allows to verify access tokens by the use of an introspection endpoint. Setup the Authentication Server ------------------------------- Setup the :term:`Authorization Server` as described in the :doc:`tutorial/tutorial`. Create a OAuth2 access token for the :term:`Resource Server` and add the ``introspection``-Scope to the settings. .. code-block:: python 'SCOPES': { 'read': 'Read scope', 'write': 'Write scope', 'introspection': 'Introspect token scope', ... }, The :term:`Authorization Server` will listen for introspection requests. The endpoint is located within the ``oauth2_provider.urls`` as ``/introspect/``. Example Request:: POST /o/introspect/ HTTP/1.1 Host: server.example.com Accept: application/json Content-Type: application/x-www-form-urlencoded Authorization: Bearer 3yUqsWtwKYKHnfivFcJu token=uH3Po4KXWP4dsY4zgyxH Example Response:: HTTP/1.1 200 OK Content-Type: application/json { "active": true, "client_id": "oUdofn7rfhRtKWbmhyVk", "username": "jdoe", "scope": "read write dolphin", "exp": 1419356238 } Setup the Resource Server ------------------------- Setup the :term:`Resource Server` like the :term:`Authorization Server` as described in the :doc:`tutorial/tutorial`. Add ``RESOURCE_SERVER_INTROSPECTION_URL`` and **either** ``RESOURCE_SERVER_AUTH_TOKEN`` **or** ``RESOURCE_SERVER_INTROSPECTION_CREDENTIALS`` as a ``(id,secret)`` tuple to your settings. The :term:`Resource Server` will try to verify its requests on the :term:`Authorization Server`. .. code-block:: python OAUTH2_PROVIDER = { ... 'RESOURCE_SERVER_INTROSPECTION_URL': 'https://example.org/o/introspect/', 'RESOURCE_SERVER_AUTH_TOKEN': '3yUqsWtwKYKHnfivFcJu', # OR this but not both: # 'RESOURCE_SERVER_INTROSPECTION_CREDENTIALS': ('rs_client_id','rs_client_secret'), ... } ``RESOURCE_SERVER_INTROSPECTION_URL`` defines the introspection endpoint and ``RESOURCE_SERVER_AUTH_TOKEN`` an authentication token to authenticate against the :term:`Authorization Server`. As allowed by RFC 7662, some external OAuth 2.0 servers support HTTP Basic Authentication. For these, use: ``RESOURCE_SERVER_INTROSPECTION_CREDENTIALS=('client_id','client_secret')`` instead of ``RESOURCE_SERVER_AUTH_TOKEN``. django-oauth-toolkit-2.3.0/docs/rest-framework/000077500000000000000000000000001443573112200214615ustar00rootroot00000000000000django-oauth-toolkit-2.3.0/docs/rest-framework/getting_started.rst000066400000000000000000000171671443573112200254160ustar00rootroot00000000000000Getting started =============== Django OAuth Toolkit provide a support layer for `Django REST Framework `_. This tutorial is based on the Django REST Framework example and shows you how to easily integrate with it. **NOTE** The following code has been tested with Django 2.0.3 and Django REST Framework 3.7.7 Step 1: Minimal setup --------------------- Create a virtualenv and install following packages using `pip`... :: pip install django-oauth-toolkit djangorestframework Start a new Django project and add `'rest_framework'` and `'oauth2_provider'` to your `INSTALLED_APPS` setting. .. code-block:: python INSTALLED_APPS = ( 'django.contrib.admin', ... 'oauth2_provider', 'rest_framework', ) Now we need to tell Django REST Framework to use the new authentication backend. To do so add the following lines at the end of your `settings.py` module: .. code-block:: python REST_FRAMEWORK = { 'DEFAULT_AUTHENTICATION_CLASSES': ( 'oauth2_provider.contrib.rest_framework.OAuth2Authentication', ) } Step 2: Create a simple API --------------------------- Let's create a simple API for accessing users and groups. Here's our project's root `urls.py` module: .. code-block:: python from django.urls import path, include from django.contrib.auth.models import User, Group from django.contrib import admin admin.autodiscover() from rest_framework import generics, permissions, serializers from oauth2_provider.contrib.rest_framework import TokenHasReadWriteScope, TokenHasScope # first we define the serializers class UserSerializer(serializers.ModelSerializer): class Meta: model = User fields = ('username', 'email', "first_name", "last_name") class GroupSerializer(serializers.ModelSerializer): class Meta: model = Group fields = ("name", ) # Create the API views class UserList(generics.ListCreateAPIView): permission_classes = [permissions.IsAuthenticated, TokenHasReadWriteScope] queryset = User.objects.all() serializer_class = UserSerializer class UserDetails(generics.RetrieveAPIView): permission_classes = [permissions.IsAuthenticated, TokenHasReadWriteScope] queryset = User.objects.all() serializer_class = UserSerializer class GroupList(generics.ListAPIView): permission_classes = [permissions.IsAuthenticated, TokenHasScope] required_scopes = ['groups'] queryset = Group.objects.all() serializer_class = GroupSerializer # Setup the URLs and include login URLs for the browsable API. urlpatterns = [ path('admin/', admin.site.urls), path('o/', include('oauth2_provider.urls', namespace='oauth2_provider')), path('users/', UserList.as_view()), path('users//', UserDetails.as_view()), path('groups/', GroupList.as_view()), # ... ] Also add the following to your `settings.py` module: .. code-block:: python OAUTH2_PROVIDER = { # this is the list of available scopes 'SCOPES': {'read': 'Read scope', 'write': 'Write scope', 'groups': 'Access to your groups'} } REST_FRAMEWORK = { # ... 'DEFAULT_PERMISSION_CLASSES': ( 'rest_framework.permissions.IsAuthenticated', ) } `OAUTH2_PROVIDER.SCOPES` setting parameter contains the scopes that the application will be aware of, so we can use them for permission check. Now run the following commands: :: python manage.py migrate python manage.py createsuperuser python manage.py runserver The first command creates the tables, the second creates the admin user account and the last one runs the application. Next thing you should do is to login in the admin at :: http://localhost:8000/admin and create some users and groups that will be queried later through our API. Step 3: Register an application ------------------------------- To obtain a valid access_token first we must register an application. DOT has a set of customizable views you can use to CRUD application instances, just point your browser at: :: http://localhost:8000/o/applications/ Click on the link to create a new application and fill the form with the following data: * Name: *just a name of your choice* * Client Type: *confidential* * Authorization Grant Type: *Resource owner password-based* Save your app! Step 4: Get your token and use your API --------------------------------------- At this point we're ready to request an access_token. Open your shell :: curl -X POST -d "grant_type=password&username=&password=" -u":" http://localhost:8000/o/token/ The *user_name* and *password* are the credential of the users registered in your :term:`Authorization Server`, like any user created in Step 2. Response should be something like: .. code-block:: javascript { "access_token": "", "token_type": "Bearer", "expires_in": 36000, "refresh_token": "", "scope": "read write groups" } Grab your access_token and start using your new OAuth2 API: :: # Retrieve users curl -H "Authorization: Bearer " http://localhost:8000/users/ curl -H "Authorization: Bearer " http://localhost:8000/users/1/ # Retrieve groups curl -H "Authorization: Bearer " http://localhost:8000/groups/ # Insert a new user curl -H "Authorization: Bearer " -X POST -d"username=foo&password=bar" http://localhost:8000/users/ Some time has passed and your access token is about to expire, you can get renew the access token issued using the `refresh token`: :: curl -X POST -d "grant_type=refresh_token&refresh_token=&client_id=&client_secret=" http://localhost:8000/o/token/ Your response should be similar to your first access_token request, containing a new access_token and refresh_token: .. code-block:: javascript { "access_token": "", "token_type": "Bearer", "expires_in": 36000, "refresh_token": "", "scope": "read write groups" } Step 5: Testing Restricted Access --------------------------------- Let's try to access resources using a token with a restricted scope adding a `scope` parameter to the token request :: curl -X POST -d "grant_type=password&username=&password=&scope=read" -u":" http://localhost:8000/o/token/ As you can see the only scope provided is `read`: .. code-block:: javascript { "access_token": "", "token_type": "Bearer", "expires_in": 36000, "refresh_token": "", "scope": "read" } We now try to access our resources: :: # Retrieve users curl -H "Authorization: Bearer " http://localhost:8000/users/ curl -H "Authorization: Bearer " http://localhost:8000/users/1/ Ok, this one works since users read only requires `read` scope. :: # 'groups' scope needed curl -H "Authorization: Bearer " http://localhost:8000/groups/ # 'write' scope needed curl -H "Authorization: Bearer " -X POST -d"username=foo&password=bar" http://localhost:8000/users/ You'll get a `"You do not have permission to perform this action"` error because your access_token does not provide the required scopes `groups` and `write`. django-oauth-toolkit-2.3.0/docs/rest-framework/openapi.yaml000066400000000000000000000021631443573112200240020ustar00rootroot00000000000000openapi: "3.0.0" info: title: songs version: v1 components: securitySchemes: song_auth: type: oauth2 flows: implicit: authorizationUrl: http://localhost:8000/o/authorize scopes: read: read about a song create: create a new song update: update an existing song delete: delete a song post: create a new song widget: widget scope scope2: scope too scope3: another scope paths: /songs: get: security: - song_auth: [read] responses: '200': description: A list of songs. post: security: - song_auth: [create] - song_auth: [post, widget] responses: '201': description: new song added put: security: - song_auth: [update] - song_auth: [put, widget] responses: '204': description: song updated delete: security: - song_auth: [delete] - song_auth: [scope2, scope3] responses: '200': description: song deleted django-oauth-toolkit-2.3.0/docs/rest-framework/permissions.rst000066400000000000000000000120341443573112200245660ustar00rootroot00000000000000Permissions =========== Django OAuth Toolkit provides a few utility classes to use along with other permissions in Django REST Framework, so you can easily add scoped-based permission checks to your API views. More details on how to add custom permissions to your API Endpoints can be found at the official `Django REST Framework documentation `_ TokenHasScope ------------- The `TokenHasScope` permission class allows access only when the current access token has been authorized for **all** the scopes listed in the `required_scopes` field of the view. For example: .. code-block:: python class SongView(views.APIView): authentication_classes = [OAuth2Authentication] permission_classes = [TokenHasScope] required_scopes = ['music'] The `required_scopes` attribute is mandatory. TokenHasReadWriteScope ---------------------- The `TokenHasReadWriteScope` permission class allows access based on the `READ_SCOPE` and `WRITE_SCOPE` configured in the settings. When the current request's method is one of the "safe" methods `GET`, `HEAD`, `OPTIONS` the access is allowed only if the access token has been authorized for the `READ_SCOPE` scope. When the request's method is one of `POST`, `PUT`, `PATCH`, `DELETE` the access is allowed if the access token has been authorized for the `WRITE_SCOPE`. The `required_scopes` attribute is optional and can be used by other scopes needed in the view. For example: .. code-block:: python class SongView(views.APIView): authentication_classes = [OAuth2Authentication] permission_classes = [TokenHasReadWriteScope] required_scopes = ['music'] When a request is performed both the `READ_SCOPE` \\ `WRITE_SCOPE` and 'music' scopes are required to be authorized for the current access token. TokenHasResourceScope ---------------------- The `TokenHasResourceScope` permission class allows access only when the current access token has been authorized for **all** the scopes listed in the `required_scopes` field of the view but according of request's method. When the current request's method is one of the "safe" methods, the access is allowed only if the access token has been authorized for the `scope:read` scope (for example `music:read`). When the request's method is one of "non safe" methods, the access is allowed only if the access token has been authorized for the `scope:write` scope (for example `music:write`). .. code-block:: python class SongView(views.APIView): authentication_classes = [OAuth2Authentication] permission_classes = [TokenHasResourceScope] required_scopes = ['music'] The `required_scopes` attribute is mandatory (you just need inform the resource scope). IsAuthenticatedOrTokenHasScope ------------------------------ The `IsAuthenticatedOrTokenHasScope` permission class allows access only when the current access token has been authorized for **all** the scopes listed in the `required_scopes` field of the view but according to the request's method. It also allows access to Authenticated users who are authenticated in django, but were not authenticated through the OAuth2Authentication class. This allows for protection of the API using scopes, but still let's users browse the full browsable API. To restrict users to only browse the parts of the browsable API they should be allowed to see, you can combine this with the DjangoModelPermission or the DjangoObjectPermission. For example: .. code-block:: python class SongView(views.APIView): permission_classes = [IsAuthenticatedOrTokenHasScope, DjangoModelPermission] required_scopes = ['music'] The `required_scopes` attribute is mandatory. TokenMatchesOASRequirements ------------------------------ The `TokenMatchesOASRequirements` permission class allows the access based on a per-method basis and with alternative lists of required scopes. This permission provides full functionality required by REST API specifications like the `OpenAPI Specification (OAS) security requirement object `_. The `required_alternate_scopes` attribute is a required map keyed by HTTP method name where each value is a list of alternative lists of required scopes. In the follow example GET requires "read" scope, POST requires either "create" scope **OR** "post" and "widget" scopes, etc. .. code-block:: python class SongView(views.APIView): authentication_classes = [OAuth2Authentication] permission_classes = [TokenMatchesOASRequirements] required_alternate_scopes = { "GET": [["read"]], "POST": [["create"], ["post", "widget"]], "PUT": [["update"], ["put", "widget"]], "DELETE": [["delete"], ["scope2", "scope3"]], } The following is a minimal OAS declaration that shows the same required alternate scopes. It is complete enough to try it in the `swagger editor `_. .. literalinclude:: openapi.yaml :language: YAML django-oauth-toolkit-2.3.0/docs/rest-framework/rest-framework.rst000066400000000000000000000001601443573112200251600ustar00rootroot00000000000000Django Rest Framework --------------------- .. toctree:: :maxdepth: 2 getting_started permissions django-oauth-toolkit-2.3.0/docs/rfc.py000066400000000000000000000021071443573112200176350ustar00rootroot00000000000000""" Custom Sphinx documentation module to link to parts of the OAuth2 RFC. """ from docutils import nodes base_url = "http://tools.ietf.org/html/rfc6749" def rfclink(name, rawtext, text, lineno, inliner, options={}, content=[]): """Link to the OAuth2 draft. Returns 2 part tuple containing list of nodes to insert into the document and a list of system messages. Both are allowed to be empty. :param name: The role name used in the document. :param rawtext: The entire markup snippet, with role. :param text: The text marked with the role. :param lineno: The line number where rawtext appears in the input. :param inliner: The inliner instance that called us. :param options: Directive options for customization. :param content: The directive content for customization. """ node = nodes.reference(rawtext, "RFC6749 Section " + text, refuri="%s#section-%s" % (base_url, text)) return [node], [] def setup(app): """ Install the plugin. :param app: Sphinx application context. """ app.add_role("rfc", rfclink) django-oauth-toolkit-2.3.0/docs/settings.rst000066400000000000000000000356071443573112200211160ustar00rootroot00000000000000Settings ======== Our configurations are all namespaced under the `OAUTH2_PROVIDER` settings with the exception of `OAUTH2_PROVIDER_APPLICATION_MODEL, OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL, OAUTH2_PROVIDER_GRANT_MODEL, OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL`: this is because of the way Django currently implements swappable models. See issue #90 (https://github.com/jazzband/django-oauth-toolkit/issues/90) for details. For example: .. code-block:: python OAUTH2_PROVIDER = { 'SCOPES': { 'read': 'Read scope', 'write': 'Write scope', }, 'CLIENT_ID_GENERATOR_CLASS': 'oauth2_provider.generators.ClientIdGenerator', } A big *thank you* to the guys from Django REST Framework for inspiring this. List of available settings -------------------------- ACCESS_TOKEN_EXPIRE_SECONDS ~~~~~~~~~~~~~~~~~~~~~~~~~~~ Default: ``36000`` The number of seconds an access token remains valid. Requesting a protected resource after this duration will fail. Keep this value high enough so clients can cache the token for a reasonable amount of time. ACCESS_TOKEN_MODEL ~~~~~~~~~~~~~~~~~~ The import string of the class (model) representing your access tokens. Overwrite this value if you wrote your own implementation (subclass of ``oauth2_provider.models.AccessToken``). ACCESS_TOKEN_GENERATOR ~~~~~~~~~~~~~~~~~~~~~~ Import path of a callable used to generate access tokens. oauthlib.oauth2.rfc6749.tokens.random_token_generator is (normally) used if not provided. ALLOWED_REDIRECT_URI_SCHEMES ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 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. APPLICATION_MODEL ~~~~~~~~~~~~~~~~~ The import string of the class (model) representing your applications. Overwrite this value if you wrote your own implementation (subclass of ``oauth2_provider.models.Application``). AUTHORIZATION_CODE_EXPIRE_SECONDS ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Default: ``60`` The number of seconds an authorization code remains valid. Requesting an access token after this duration will fail. :rfc:`4.1.2` recommends expire after a short lifetime, with 10 minutes (600 seconds) being the maximum acceptable. CLIENT_ID_GENERATOR_CLASS ~~~~~~~~~~~~~~~~~~~~~~~~~ The import string of the class responsible for generating client identifiers. These are usually random strings. CLIENT_SECRET_GENERATOR_CLASS ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The import string of the class responsible for generating client secrets. These are usually random strings. CLIENT_SECRET_GENERATOR_LENGTH ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The length of the generated secrets, in characters. If this value is too low, secrets may become subject to bruteforce guessing. EXTRA_SERVER_KWARGS ~~~~~~~~~~~~~~~~~~~ A dictionary to be passed to oauthlib's Server class. Three options are natively supported: token_expires_in, token_generator, refresh_token_generator. There's no extra processing so callables (every one of those three can be a callable) must be passed here directly and classes must be instantiated (callables should accept request as their only argument). GRANT_MODEL ~~~~~~~~~~~ The import string of the class (model) representing your grants. Overwrite this value if you wrote your own implementation (subclass of ``oauth2_provider.models.Grant``). APPLICATION_ADMIN_CLASS ~~~~~~~~~~~~~~~~~~~~~~~ The import string of the class (model) representing your application admin class. Overwrite this value if you wrote your own implementation (subclass of ``oauth2_provider.admin.ApplicationAdmin``). ACCESS_TOKEN_ADMIN_CLASS ~~~~~~~~~~~~~~~~~~~~~~~~ The import string of the class (model) representing your access token admin class. Overwrite this value if you wrote your own implementation (subclass of ``oauth2_provider.admin.AccessTokenAdmin``). GRANT_ADMIN_CLASS ~~~~~~~~~~~~~~~~~ The import string of the class (model) representing your grant admin class. Overwrite this value if you wrote your own implementation (subclass of ``oauth2_provider.admin.GrantAdmin``). REFRESH_TOKEN_ADMIN_CLASS ~~~~~~~~~~~~~~~~~~~~~~~~~ The import string of the class (model) representing your refresh token admin class. Overwrite this value if you wrote your own implementation (subclass of ``oauth2_provider.admin.RefreshTokenAdmin``). OAUTH2_SERVER_CLASS ~~~~~~~~~~~~~~~~~~~ The import string for the ``server_class`` (or ``oauthlib.oauth2.Server`` subclass) used in the ``OAuthLibMixin`` that implements OAuth2 grant types. It defaults to ``oauthlib.oauth2.Server``, except when OIDC support is enabled, when the default is ``oauthlib.openid.Server``. OAUTH2_VALIDATOR_CLASS ~~~~~~~~~~~~~~~~~~~~~~ The import string of the ``oauthlib.oauth2.RequestValidator`` subclass that validates every step of the OAuth2 process. OAUTH2_BACKEND_CLASS ~~~~~~~~~~~~~~~~~~~~ The import string for the ``oauthlib_backend_class`` used in the ``OAuthLibMixin``, to get a ``Server`` instance. 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. REFRESH_TOKEN_GRACE_PERIOD_SECONDS ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The number of seconds between when a refresh token is first used when it is expired. The most common case of this for this is native mobile applications that run into issues of network connectivity during the refresh cycle and are unable to complete the full request/response life cycle. Without a grace period the application, the app then has only a consumed refresh token and the only recourse is to have the user re-authenticate. A suggested value, if this is enabled, is 2 minutes. REFRESH_TOKEN_MODEL ~~~~~~~~~~~~~~~~~~~ The import string of the class (model) representing your refresh tokens. Overwrite this value if you wrote your own implementation (subclass of ``oauth2_provider.models.RefreshToken``). ROTATE_REFRESH_TOKEN ~~~~~~~~~~~~~~~~~~~~ When is set to `True` (default) a new refresh token is issued to the client when the client refreshes an access token. If `False`, it will reuse the same refresh token and only update the access token with a new token value. See also: validator's rotate_refresh_token method can be overridden to make this variable (could be usable with expiring refresh tokens, in particular, so that they are rotated when close to expiration, theoretically). REFRESH_TOKEN_GENERATOR ~~~~~~~~~~~~~~~~~~~~~~~ See `ACCESS_TOKEN_GENERATOR`. This is the same but for refresh tokens. Defaults to access token generator if not provided. REQUEST_APPROVAL_PROMPT ~~~~~~~~~~~~~~~~~~~~~~~ Can be ``'force'`` or ``'auto'``. The strategy used to display the authorization form. Refer to :ref:`skip-auth-form`. SCOPES_BACKEND_CLASS ~~~~~~~~~~~~~~~~~~~~ **New in 0.12.0**. The import string for the scopes backend class. Defaults to ``oauth2_provider.scopes.SettingsScopes``, which reads scopes through the settings defined below. SCOPES ~~~~~~ .. note:: (0.12.0+) Only used if `SCOPES_BACKEND_CLASS` is set to the SettingsScopes default. A dictionary mapping each scope name to its human description. .. _settings_default_scopes: DEFAULT_SCOPES ~~~~~~~~~~~~~~ .. note:: (0.12.0+) Only used if `SCOPES_BACKEND_CLASS` is set to the SettingsScopes default. A list of scopes that should be returned by default. This is a subset of the keys of the SCOPES setting. By default this is set to '__all__' meaning that the whole set of SCOPES will be returned. .. code-block:: python DEFAULT_SCOPES = ['read', 'write'] READ_SCOPE ~~~~~~~~~~ .. note:: (0.12.0+) Only used if `SCOPES_BACKEND_CLASS` is set to the SettingsScopes default. The name of the *read* scope. WRITE_SCOPE ~~~~~~~~~~~ .. note:: (0.12.0+) Only used if `SCOPES_BACKEND_CLASS` is set to the SettingsScopes default. The name of the *write* scope. ERROR_RESPONSE_WITH_SCOPES ~~~~~~~~~~~~~~~~~~~~~~~~~~ When authorization fails due to insufficient scopes include the required scopes in the response. Only applicable when used with `Django REST Framework `_ RESOURCE_SERVER_INTROSPECTION_URL ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The introspection endpoint for validating token remotely (RFC7662). This URL requires either an authorization token (RESOURCE_SERVER_AUTH_TOKEN) or HTTP Basic Auth client credentials (RESOURCE_SERVER_INTROSPECTION_CREDENTIALS): RESOURCE_SERVER_AUTH_TOKEN ~~~~~~~~~~~~~~~~~~~~~~~~~~ The bearer token to authenticate the introspection request towards the introspection endpoint (RFC7662). RESOURCE_SERVER_INTROSPECTION_CREDENTIALS ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The HTTP Basic Auth Client_ID and Client_Secret to authenticate the introspection request towards the introspect endpoint (RFC7662) as a tuple: (client_id,client_secret). RESOURCE_SERVER_TOKEN_CACHING_SECONDS ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The number of seconds an authorization token received from the introspection endpoint remains valid. If the expire time of the received token is less than ``RESOURCE_SERVER_TOKEN_CACHING_SECONDS`` the expire time will be used. PKCE_REQUIRED ~~~~~~~~~~~~~ Default: ``True`` Can be either a bool or a callable that takes a client id and returns a bool. Whether or not `Proof Key for Code Exchange `_ is required. According to `OAuth 2.0 Security Best Current Practice `_ related to the `Authorization Code Grant `_ - Public clients MUST use PKCE `RFC7636 `_ - For confidential clients, the use of PKCE `RFC7636 `_ is RECOMMENDED. OIDC_RSA_PRIVATE_KEY ~~~~~~~~~~~~~~~~~~~~ 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 ~~~~~~~~~~~~~~~~~~~~~~ Default: ``""`` The url of the userinfo endpoint. Used to advertise the location of the endpoint in the OIDC discovery metadata. Changing this does not change the URL that ``django-oauth-toolkit`` adds for the userinfo endpoint, so if you change this you must also provide the service at that endpoint. If unset, the default location is used, eg if ``django-oauth-toolkit`` is mounted at ``/o/``, it will be ``/o/userinfo/``. OIDC_RP_INITIATED_LOGOUT_ENABLED ~~~~~~~~~~~~~~~~~~~~~~~~ Default: ``False`` When is set to `False` (default) the `OpenID Connect RP-Initiated Logout `_ endpoint is not enabled. OpenID Connect RP-Initiated Logout enables an :term:`Client` (Relying Party) to request that a :term:`Resource Owner` (End User) is logged out at the :term:`Authorization Server` (OpenID Provider). OIDC_RP_INITIATED_LOGOUT_ALWAYS_PROMPT ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Default: ``True`` Whether to always prompt the :term:`Resource Owner` (End User) to confirm a logout requested by a :term:`Client` (Relying Party). If it is disabled the :term:`Resource Owner` (End User) will only be prompted if required by the standard. OIDC_RP_INITIATED_LOGOUT_STRICT_REDIRECT_URIS ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Default: ``False`` Enable this setting to require `https` in post logout redirect URIs. `http` is only allowed when a :term:`Client` is `confidential`. OIDC_RP_INITIATED_LOGOUT_ACCEPT_EXPIRED_TOKENS ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Default: ``True`` Whether expired ID tokens are accepted for RP-Initiated Logout. The Tokens must still be signed by the OP and otherwise valid. OIDC_RP_INITIATED_LOGOUT_DELETE_TOKENS ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Default: ``True`` Whether to delete the access, refresh and ID tokens of the user that is being logged out. The types of applications for which tokens are deleted can be customized with `RPInitiatedLogoutView.token_types_to_delete`. The default is to delete the tokens of all applications if this flag is enabled. OIDC_ISS_ENDPOINT ~~~~~~~~~~~~~~~~~ Default: ``""`` The URL of the issuer that is used in the ID token JWT and advertised in the OIDC discovery metadata. Clients use this location to retrieve the OIDC discovery metadata from ``OIDC_ISS_ENDPOINT`` + ``/.well-known/openid-configuration/``. If unset, the default location is used, eg if ``django-oauth-toolkit`` is mounted at ``/o``, it will be ``/o``. OIDC_RESPONSE_TYPES_SUPPORTED ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Default:: [ "code", "token", "id_token", "id_token token", "code token", "code id_token", "code id_token token", ] The response types that are advertised to be supported by this server. OIDC_SUBJECT_TYPES_SUPPORTED ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Default: ``["public"]`` The subject types that are advertised to be supported by this server. 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. django-oauth-toolkit-2.3.0/docs/signals.rst000066400000000000000000000012241443573112200207020ustar00rootroot00000000000000Signals ======= Django-oauth-toolkit sends messages to various signals, depending on the action that has been triggered. You can easily import signals from `oauth2_provider.signals` and attach your own listeners. For example: .. code-block:: python from oauth2_provider.signals import app_authorized def handle_app_authorized(sender, request, token, **kwargs): print('App {} was authorized'.format(token.application.name)) app_authorized.connect(handle_app_authorized) Currently supported signals are: * `oauth2_provider.signals.app_authorized` - fired once an oauth code has been authorized and an access token has been granted django-oauth-toolkit-2.3.0/docs/templates.rst000066400000000000000000000215431443573112200212460ustar00rootroot00000000000000Templates ========= A set of templates is provided. These templates range from Django Admin Site alternatives to manage the Apps that use your App as a provider, to Error and Authorization Templates. You can override default templates located in ``templates/oauth2_provider`` folder and provide a custom layout. To override these templates you just need to create a folder named ``oauth2_provider`` inside your templates folder and, inside this folder, add a file that matches the name of the template you're trying to override. .. important: In ``INSTALLED_APPS`` on ``settings.py``, ``'django.contrib.staticfiles'``, must be before ``'oauth2_provider'``. .. note: Every view provides access only to data belonging to the logged in user who performs the request. The templates available are: - `base.html`_ - `authorize.html`_ - `Management`_: - `Application`_: - `application_list.html`_ - `application_form.html`_ - `application_registration_form.html`_ - `application_detail.html`_ - `application_confirm_delete.html`_ - `Token`_: - `authorized-tokens.html`_ - `authorized-token-delete.html`_ base.html --------- If you just want a different look and feel you may only override this template. To inherit this template just add ``{% extends "oauth2_provider/base.html" %}`` in the first line of the other templates. This is what is done with the default templates. The blocks defined in it are: - ``title`` inside the HTML title tag; - ``css`` inside the head; - ``content`` in the body. .. note: See ` Django docs on template inheritance `_ for more information on the use of blocks. authorize.html -------------- Authorize is rendered in :class:`~oauth2_provider.views.base.AuthorizationView` (``authorize/``). This template gets passed the following context variables: - ``scopes`` - :obj:`list` with the scopes requested by the application; .. caution:: See :ref:`settings_default_scopes` to understand what is returned if no scopes are requested. - ``scopes_descriptions`` - :obj:`list` with the descriptions for the scopes requested; - ``application`` - An :class:`~oauth2_provider.models.Application` object .. note:: If you haven't created your own Application Model (see how in :ref:`extend_app_model`), you will get an :class:`~oauth2_provider.models.AbstractApplication` object. - ``client_id`` - Passed in the URI, already validated. - ``redirect_uri`` - Passed in the URI (optional), already validated. .. note:: If it wasn't provided on the request, the default one has been set (see :meth:`~oauth2_provider.models.AbstractApplication.default_redirect_uri`). - ``response_type`` - Passed in the URI, already validated. - ``state`` - Passed in the URI (optional). - ``form`` - An :class:`~oauth2_provider.forms.AllowForm` with all the hidden fields already filled with the values above. .. important:: One extra variable, named ``error`` will also be available if an Oauth2 exception occurs. This variable is a :obj:`dict` with ``error`` and ``description`` Example (this is the default page you may find on ``templates/oauth2_provider/authorize.html``): :: {% extends "oauth2_provider/base.html" %} {% load i18n %} {% block content %}
{% if not error %}

{% trans "Authorize" %} {{ application.name }}?

{% csrf_token %} {% for field in form %} {% if field.is_hidden %} {{ field }} {% endif %} {% endfor %}

{% trans "Application requires the following permissions" %}

    {% for scope in scopes_descriptions %}
  • {{ scope }}
  • {% endfor %}
{{ form.errors }} {{ form.non_field_errors }}
{% else %}

Error: {{ error.error }}

{{ error.description }}

{% endif %}
{% endblock %} Management ---------- The management templates are Django Admin Site alternatives to manage the Apps. Application ``````````` All templates receive :class:`~oauth2_provider.models.Application` objects. .. note:: If you haven't created your own Application Model (see how in :ref:`extend_app_model`), you will get an :class:`~oauth2_provider.models.AbstractApplication` object. application_list.html ~~~~~~~~~~~~~~~~~~~~~ Rendered in :class:`~oauth2_provider.views.base.ApplicationList` (``applications/``). This class inherits :class:`django.views.generic.edit.ListView`. This template gets passed the following template context variable: - ``applications`` - a :obj:`list` with all the applications, may be ``None``. application_form.html ~~~~~~~~~~~~~~~~~~~~~ Rendered in :class:`~oauth2_provider.views.base.ApplicationUpdate` (``applications//update/``). This class inherits :class:`django.views.generic.edit.UpdateView`. This template gets passed the following template context variables: - ``application`` - the :class:`~oauth2_provider.models.Application` object. - ``form`` - a :obj:`~django.forms.Form` with the following fields: - ``name`` - ``client_id`` - ``client_secret`` - ``client_type`` - ``authorization_grant_type`` - ``redirect_uris`` .. caution:: In the default implementation this template in extended by `application_registration_form.html`_. Be sure to provide the same blocks if you are only overriding this template. application_registration_form.html ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Rendered in :class:`~oauth2_provider.views.base.ApplicationRegistration` (``applications/register/``). This class inherits :class:`django.views.generic.edit.CreateView`. This template gets passed the following template context variable: - ``form`` - a :obj:`~django.forms.Form` with the following fields: - ``name`` - ``client_id`` - ``client_secret`` - ``client_type`` - ``authorization_grant_type`` - ``redirect_uris`` .. note:: In the default implementation this template extends `application_form.html`_. application_detail.html ~~~~~~~~~~~~~~~~~~~~~~~ Rendered in :class:`~oauth2_provider.views.base.ApplicationDetail` (``applications//``). This class inherits :class:`django.views.generic.edit.DetailView`. This template gets passed the following template context variable: - ``application`` - the :class:`~oauth2_provider.models.Application` object. application_confirm_delete.html ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Rendered in :class:`~oauth2_provider.views.base.ApplicationDelete` (``applications//delete/``). This class inherits :class:`django.views.generic.edit.DeleteView`. This template gets passed the following template context variable: - ``application`` - the :class:`~oauth2_provider.models.Application` object. .. important:: To override successfully this template you should provide a form that posts to the same URL, example: ``
`` Token ````` All templates receive :class:`~oauth2_provider.models.AccessToken` objects. authorized-tokens.html ~~~~~~~~~~~~~~~~~~~~~~ Rendered in :class:`~oauth2_provider.views.base.AuthorizedTokensListView` (``authorized_tokens/``). This class inherits :class:`django.views.generic.edit.ListView`. This template gets passed the following template context variable: - ``authorized_tokens`` - a :obj:`list` with all the tokens that belong to applications that the user owns, may be ``None``. .. important:: To override successfully this template you should provide links to revoke the token, example: ``revoke`` authorized-token-delete.html ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Rendered in :class:`~oauth2_provider.views.base.AuthorizedTokenDeleteView` (``authorized_tokens//delete/``). This class inherits :class:`django.views.generic.edit.DeleteView`. This template gets passed the following template context variable: - ``authorized_token`` - the :class:`~oauth2_provider.models.AccessToken` object. .. important:: To override successfully this template you should provide a form that posts to the same URL, example: ```` django-oauth-toolkit-2.3.0/docs/tutorial/000077500000000000000000000000001443573112200203545ustar00rootroot00000000000000django-oauth-toolkit-2.3.0/docs/tutorial/admin+celery.png000066400000000000000000002030201443573112200234260ustar00rootroot00000000000000PNG  IHDR* >iCCPICC Profile(c``I,(aa``+) rwRR` \\TQk .,'_RJ!}m}S= JI-N8!1V./)l") v:NՄ9l $Ć lFƦJ*(I(E% IUKQ0202b`5D0d@ !X9Y 1 ۢ `Rflasu5^.7\EVeXIfMM*iDASCIIScreenshot݇~iTXtXML:com.adobe.xmp 697 668 Screenshot 0@IDATxxTU?R % %!BcDł]{]ڻkPbAKg{{Nn&3ɤ:UuټCLi]E(ޕ~+7 7YѠ5yj*O*|N9ayvp{,u>A .r 9JiS*iBf)$+'P=)Q"J 뫎p5ޭRn*-;!?Z(PyuʼB>8~k!ꕜu߸YrAv83X1aӐ֓;eұ!{e*NB00ݛ*}YsebrI+h+\KF~\Vgl՛S.(u:*a!%l&6AJ>Wc/dξ1$9穗|Җ l.Yvi"齝r_Y>#o-.-25#M]dM[o/ټlPzCeٜ9vjtrnCl }VofWkU|oHHH |` Ii!Ftl![42:$ݞIc@3`~XĥKۨD=3:;9ڟtn%)AڦRjjD4n//I&G,Q#;4bfKfo(Tuh-S.d/**]$;eᒣmRiy-ǥ;hD_4cROvs5u=GJgu+^w6@*C̸X|'k&x&yqn 0>EZ7ɕgC+@a}d8g\fR_:!P6 )!X=ȧuH1AUUh > 6qN9M=fʛZ,%MLiRe ox}ݩ)*3+~#s4dHMŅ;![q׺an#1Żo母~6b RS+Ƶh$@$@$P[|NpFKtHFnR0,#~z:Cdrzh. 67l "]Fb靓Ǎ͜n>y-h)ӗDx0?j&"hwkZ,O]ɞ-B}*VMW`y|#i>\c 9=7u%`&ڝ-T~K5  a>i]G_-tIގHHH*T횇; D7a:^{̐jHXfW{ܲb usիtxƶ)SGًx43$ f[RDMUA.8;( Q#K𩺜^9%jsgs]NchXt÷6"d溦esK39y%<˞u,k{3VTlt7vj-"TYS,'  "Mrl&~TdH댝&9*T,7, (V譵*<)8[MK<6`{F4ǘuf'W Ir״Re* U>=Gb#rM|jRhDcybLQCʑ=GCLl"-֡y=P,TB\KU: qۦ8MI5K5HEU0lt<a[lU^;#  $PC>2Ʊ v!r}E}ywĭM'nH$fOk˧GU*۠[5Z{=cy>F$@$@J' s-@ɩZE';{9ք,~ݓv'iUX\NzJ~ac{Zm]_:{5K]NwO=:d-6wgmJ6_ZbJl9fBzŻwO4\X J+?قmS?Z\-ou 9Yb3+W3xawT{wlnWl2;{(k86dإUyfxmRd Xx>mʳ-.lT:wT5o/3DmTd9Q{uRWCT%/SxCqMg}Pesm0֏$oXSQH?Au:@cTi$@$@$<3Bgm1LEl؈|#PGv*}JM4k#M yw ༹Kl3w-'Sk ;zdzGgwI?|# GA2ݔ!oڛ=\a;nucuݯ< }=M< lm{[(ICHAq!ŵ+̖MUqx4% 9[0_Y ['!᪣*E0Ky> oVu.~> /]^y=BVW<.GM.[+iCnt?]tXJ#  _&PNa? ѭ |Y|& (Cg~q ̣,dU'S>~Ҳ#XHwVx́DX)Þ8v@=[fʥQ w\ MztF'H6YUG#dn!Y9V)"vBlǀ^1.ΆWzj}Њ (G`>fga<9@_QENc7!fu-X"\YuK k?*3ި~ NHg'N,!j=Rݏ"wSbڮs1M='<^;[fݛilEԨ(Ӗ57浶Wp>ߥ:  a{O<<3A \ K؊hnHReexf/7xw{6cC ,wTjn뻋Uw 1]׍ѝlZ99ڣUpuw{3{v6?k[@h@tỸN]ܭh$@$@$/|I`'XNG᯶ A=S.:wY R2n!Vt)t;m%炌(*Ԫ\@/WŬ^Uڲ ʮ)݋کqpvzqt=]cu[a-ng $   &׸Ln5-FH h}VyfY&p>fiVHxM   U>-8KG3ڻڀfhV'B <%%qYI$@$@$@~O瓆Hzvp+bVB 5u   o>ڃi%{'jM_ HHH MҐ/CHHHH<̆gHHHHjg @d$@$@$@$@ Ppzf3$@$@$@$@5@     (8=     @Y g @ HHHH3z<    z8ǏIHHH* @Y &    ck    PpVIHHHGzؚHHHHi    ?&   gxHHHHz(8ǏIHHH* @Y &    ck    PpVIHHHGzؚHHHHi    ?&   gxHHHHz(8ǏIHHH* @Y &    ck    PpVIHHHGzؚHHHHi    ?g7ѣGy@$@$@$@$ ju 2{l駟dٲe*aaaҸqRo"K.}ͭ*5믿.[[_r|Wҷo_ սJb   :!T'W)'|"(˗Kvv\xr9%??TٯjΟ|zAFF̛7Orssh.ZH~w iӦjaYz\tEjme|z%]vu6:; O3 yiɹ+M41 ՚>}|g$Ǐ7O>;v,N(3<<\^z2RPysu_,rʚ}guHHH&PgSN5^+)6qXws/8yԯ_,; NE,W*b*ת6չߚ!  YugAG?tPoXXXhbaҽ&Jʹ[zHvaO:bR;k. vW_]?#Gq³6lh*s_ʒ%Kd޽F\c_MVwu;Յ'x߾}r DuLHBvj*?}ǝeXƽ"~633\cǎreILL`~ڴiz0fYǽ3F>Ss|駛?X;joOxIIIx2пS]e)b̸vDD`̸_xE-[bOq \СC 3ϴHHH YPRRׄiժU[p7n3믿6Եh)h6mL?iiir=a1p@ILL4<۷/s=`͚5KAA5Jn`saÆYUe׈'{n/B{Τ{O#$$$P,#ƶnj#!!pH`:|uQ3֮]+seA) CAc!Tׁ=z1͙3Ljl(ȃX;;udBp}x"ѧO#!cz8R!~{s/~@0#zg~f͚eeN0 ׯ_/ ={yx _IHH@y8'M$w/ۄio!3iʔ)FD}gy4B3x}{gc8z猨|`@VV=D?okl^+[o9CJA?0`r 7Ě'C:De?F"F֝A0#"W b͕5w믿y$s n͚53aDy ^V@Sx1f̥%qmeY@2HDm$@$@$@'P.^)x!vB<Ol曝bӪ`XAxb9:, AeM! 9x`VWx|]k^^Ow֯_?3zcTbn8Ħu~d`V#b{g^\xvA#  {uĭ5Aby%sö" aN "6vjݥKec'S,G "^{5?i$@$@$@MNb8O=7Oȁ2x 6en."{cyꫯPI q#ldY۶mx,/%]Сav3;o4CxeynoE뚎zE9Ė޵:oo2p6{cZp 혳3gnI IXbQ9r=B#p!Na2}52kRwpoqWfW   %PO7a^L:BX^yZo&>VG|bE7Ƃ1a o;q'f1-Ğ|1'o o&/+W|?'m1UJs'HHHj&8kx    _ PzF1 W(8͐ 9HHHH"@y\M'oHHH| GD$@$@$@ j:y3$@$@$@${(8}oN8"   8PpWɛ!   #@{s qEN  NߛHHH+tfHHHHPpޜpD$@$@$@$p\<7C$@$@$@G#"   q5   =7' W(8͐ 9HHHH"@y\M'oHHH| GD$@$@$@ j:y3$@$@$@${(8}oN8"   8ouq^HHHNS&{qN'   ڠ>IHHH(8(x@$@$@$@$P(8k*$   pt @m HHHHIӉ$@$@$@$@A6O    ' N'  :yPe~iPz|dKf3H>ZIHu3a|jɅ:8_-+6u)Vf0e y;d2Qo4-:vi8Ln<8gD6p OlSL@9o`)iY}T1 ZDVA䞳T9![/{v՟8lTN/Mח݇3 G3Ar䗍Mm.Y ɧ:nO%. ?ԬJ绿 Z@XZc&oxX՝yuÛ~BS,e݋y+ xCouoz:Yy*edxE=؁,tjζr;6W=]Bj[1 'QB9`-_x>oQ1pkin/<{зT%Gߺ_wrOwTsq uh$@$@$ p;v`:fuZhV a<չx)Tw%6״EeMg%)-,uc5h,k:C Lϑ*T̓pNeeaAvu;"gRݳw=Ʋ:k& %~7j8X4֥uu͉nW28ti,ci 5VQo^y$@$@$Jgg6udz6`xuU}?D1eH]w5:꽳k5Aŕ>n֨(핃Z.޾8T==E+k1*`Nv( -[렉pPGM =m#[4>F1*@]yLNz{Kh^m%[=˼Z}5V& 9]&byl.2hQ6kĤLYkwۛ:]ejRx;v VfHHl|fIofxB׻.?n z+T|$l@Z'Sc+Go %],Vr TT.!DKzuD|9I]ňk;i*a5s17xG$\k"nysǑIИO[1iյ#ɩ:eugѽefkfZA7e/,U!1 @kzv^Z``;x~M"Ru(ŮVS?*3ΛHH,HV:x'/8dfW@3{{*\A*YfmId]r MrtgEVuɞi읣IXU$Yv&<1isI Ep>S ?HbݻծWd|k?}T-A聴,{9/4K 8g|6 oh;/,&MBv=[Ȁ?KLyUx_&`1.6:vZuTzkgz[y;]I|꫇r[q*/i33Wɰc>^|6a_ޱc̼YmJ$@$@JԎ:|?gr&6C/آ̩ٹ2} ~$kLe'u1" jBKeiqi7[+ua9JMysZibzLF .cʯke@Z&U{J'X="6;u)Ͳzv41-53d^-#2l4\CfvEnÌ؅`&FrW JuOӐ#I}blv,IoIJ1%lq`= f]?6 Mπv-WZ`}:_\=V4vW|O$@$@_.Gկҹ[>J5X֭p9b Zb`^-xzVғڵ^u#u=ϻɃvAT|mIGg?"` g(}۶   ϙ;rtIl*bɕ]ѱ")((܂|uT?rw.1CHwV#Rk|"eǁڷϗ%%+[ꖀOx8 ?1bc\ y/>]̇ ZN#YWҲKGG} ey(wWe$@$@$kf,Xm7_/\vhXYԝwtw,cdsVעTI_|q<\#6y}c7 k.(S`jF2@@k(9W3 n+`~"Ox8;䜀n$rJ*"%;'_R2r~JLT$I'a7g43wGמ#M׾[ȿ/=S,MQ, ׎;II5jK̖yoixDW+CCetnO>?g  ?%|^=o͐ջW`Yߋ0|E瘢a%;S^!6 SIA" r\/$$fK/\M:Jf|@zraʷ[{_\u^E6j ^6VƇU/ri .]2Fw~Z*SU !ݸarȾ*ꟛ9cEE2aH/F3)ٜҭJK͖+7__z 7fթWKl4DXq7%N^.3BIVmKHD8!^r QMUl4bk'ʌ5FC_%C$Ȼ# U;0N=䨸}'Ȣ;4t'n0ǯwNq!2,ziCzQcuQ5D u|n~@wz+%ת{}93ub%辝k$f Va;;!,7:2 P>g,ۺW+CrPk$}Kz#6׌*N%o5ѥi,sFCjn_5q!u4>LzH˳. '> ^e|]I}_j,ϨzÒ:KOU.ǵ9ߓ bc+Q=ۗvkCOIG/&5g>5;oNkMa~l}V^cw)\n3Wm=[v][^nK{l;ffe3מ&XW2鑷u4գ ZO?D^YlVJv5k `$G//831 E<&  :'0η%@IDAToGWi_j@uz^~HHHHN('tfIHHH Pp=s^HHHN('tfIHHH Pp=s^HHHN('tfIHHH Pp=s^HHHN(u!Ɵ'], 8ybbZBnIHHH|}}8>   s~>> : N_!HHHO O$@$@$@Ng#   ?'@ HHHH Ppr$@$@$@$(8}}8>   s>!8$g v9qf昲o, ~ddH~A&)^@J~VQK#  wKKPP|jaVn ]ktWV_i.\ZT*+.s{o7_$4Q{*zg2f)CA޻FP`tٙA{@k :OD 7oO'S$ Hh)\rW]|'IG.1MJ^~\4Wc"m7_OO/7:$@$@~C`ŎDI̖:V}@zn?Yټo$YMj)kV_CgfQz):D.:į$nd.#Fc3Z=鈜߷%7/_"5{/:MmiZO~1W~]]O 12ur mX:jg I{|t87s fNJdrÙβ;Vv8lF$-h5h(W%%#S^Ժ9'/$ "X͛^^^LEU&2sksvy.pd?yHOuXKاC^«G2+|f8)NܵwH@Ii :PhJV.J>N^Ѱ)?!ozGr^~||_ayss@K Vq J>s^njg]̻NrT ?%wi~g'x8r#X2dT   Beka KO8G>-˷s$%Lh)):~oqv_tw ;"qdT|Dz~T,j/kmX1 wfPf]9zs+_ԗf:0^UgO N-[Vh34y%O^5^K6wq6{+t",4ԈMݷ,ڴ|/^.;Nz-HHH$]?NE8j1$ Yf/G=w6{V\}lMLVgLy#w]I&tZv[ =Mpzlrz 2U f_ te})ob3%/4.'$ᬂ^ٸבjt͢jjd>!8[D:~C׊m%&V!%X=hڝ^(9/x1_ar4iH܅Fܱ,/\qlݫk 凥A]Sliy$@$@>J`h$eXF־X_^Vbx3k]VNcF{쫙9&]2iAx` 41Q٬Hlbއͻp>ˏT;L g7+ 9fӕvѩh'jӬqs #dzOT ٽUs2S]G*r6֧ @y ;!,!âSU"0f*4vH׶rC+__%H,:E@pY , ݺn ދΐSzsΪmgsLH]N{u?2gZg4qCeY`2]~JވM"ٷ-zH>ӽ8%FYE8,**M6wnsfeAbIY\ZD4ҸHA&`=gP7{7ONē<}yT^'  \=p}#q.~bVGoӺChJM7g +Y#xN\_|%bxL$@$@~Ik{y 'uM(5~̧ד7CfUs3^[!};u)ϐS$4ɚ_*#6qcTdf;?.tw8NI.;ٱ":lK<Є!z]Z]pֲR}h'̾r0# ZK=v8rߛͣdxs|SO7;) KzZv4 wpiPPD#H@oq`Xa$@$@$K&}*s b6Kc6vqCwߛ<%^3H@niP *7f>_fUƏ` ^foĠv fyGr3ƙlvkH TjZU}_߷5]*t#   ?eQ)[] {/7 Yu9ŷ$@$@$@$i/X}Gd摜ѻI<tKoIHHHIpzPM(lN$@$@$@$pb<1wM$@$@$@uFPB$@$@$@$pb<1wM$@$@$@uFPB$@$@$@$pb<1wM$@$@$@uFPB$@$@$@$pb<1wM$@$@$@uF .tẸ A$@$@$@' 8:y@IHHHjI\RHHHH NOdXN$@$@$@$P#(8k#;!   D  NHHHH<D$@$@$@$@5BF0    O(8=a9 @HHHH NOdXN$@$@$@$P#(8k#;!   D  NHHHH<D$@$@$@$@5BF0    O(8=a9 @HHHH NOdXN$@$@$@$P#(8k#;!   D  NHHHH<D$@$@$@$@5BF0    O(8=a9 @HHHH NOdXN$@$@$@$P#(8k#;!   D  NHHHH<t&o} O`~ÀN*HHHGM$@$@$@~Co%   $@Q @IHHH? Ppq$@$@$@$7(8f8P   O9o5  N*HHH@ZQ `:Õe`6wK憛u-VzrۧUS~q]<*!m0TtʃF$@$@$J+?yLs7퓍w߽U_5*S^ 9Dxt~bG>OJ7+e4s|N;[Kv1"?9=8'"_!bExmA夎2c6ϛ\ wt95k{w+*C:Igf.7s# W^#+%ɧ6: rFo<nMhTSoeedgu2| [HHHf |m;%@r7vzx f%vZgtk%ZfRtSl<3bt9=)5)6QA=x t)pFsl)>-slhV?(4X-zPC Z*u_7'gz[SLgYE#:*ݔ(m[Qu'  a>)8k[{ɬh h\F2OEμMqr.;I~aޮq`IQ( b;aR)Vqo(\V9^c ;9V,Eü#qο[S.L&QjOĉmRqnXa" hoY9X'HHH| Ύ-ްiYB<]T RdX8͗:,(4T]B[fn&ׄP)%}49fT _PT&eC8"2tK{S d9ĭcw2!o+Y7-ˑ`%UBRpw<9rWIFǮr$ Y9L L>m$,"! yr ə[˸mLBKA6Uܦ)rvbO:ڔtĀƪz 5nT$UQsd`m , Ҷ?}xYlp{ U?{cpwe:Į!qH[ P)lsX>aV_q%8j4:;6;Sex^Q&#xH!LZ.gNlF-t{NYXn!]=!ƫ6@,y'?jtzQey`dc#wpkמč5KEZoƾUxFx /Ф#$G\%kl&s^FTYe}!h6KU=ŷIJ.~?w1}6iOh$@$@$@I S\6lͺƤHVn+,lՌ<]Ev'O cvfP_6U&S{'1HnXȈ{_U/*9ܸL[ُ -;d_;߹@FL?ZIBlѤqODs PZ,iUO[\@zWHa>6YO%8FyCK׹ }t?#aF7ո-[/<$  i ֿ!$j RdqX}fyƝWPxG5VԾ|]W Pw @qy"~*#=sʈMR+Y7%qVa8% {>~,%   g<{;  N?$HHH?N$@$@$@~@&C$   &@ϳDZ IIHHH Ppq$@$@$@$(8`8D   guhKı TіpV[ Tg%`* @ PpV[ Tg%`* @ PpV[ Tg%`* @ PpV[ Tg%`* @ PpV[ T vVʶ# :w唻'S3c% OUyj$kehFNM)YrZ~Zz<20kM2Qe;.Sy;y<AusRXtLwRPP :)MTK?r J`@=yV8T{+,*.+ E`ᖣdL Vq_%S\,9r=/:Vdr IS>Ldo !mY>[ PfHND?(cˎos7/KJV4bZ)ܵo-~ʑ,Ҳs%#'9sgZqnWHU6+zV5eڡNQq)9_H!kq+ Ȍ%((H^-<ԝ;@JSu^lfhkQA/8rSL޾b鱛c\  jc{vfކD4p~`a%3;GY^2)Y2wfu(rRIw1CvXO"׿vkkϑ ]I̖А`ݿ-FG7+Gst7|NEw_Eu3`J9q>׋m _$}Hhp'>W<=UT5a2skYm<ϲQWv%<,HH"|^o͐ջW',+YpE2|dwؔjTKo 6 SIA" rJ/$$f+.\M: +w:&&,y٨b'$$zKDPC=<.J(CZBH ?g6wlHwlsg|g\});EsE%˶FeJ\3=K(7kS+:K{z? "䆶e%6%퓑(ǝkVN3H":nҼ7ktmEPrlV'  I@b5Ǯ0΅ԘѾ^{Bvhv*tiIMIyY3qLYEb/lZjb)rҢ)dՉN!4CfAxDε0JNTz4gg[u5N*1Tb. R{D39FTL}ǚB^l,33dƎtJF=sic6sCP.5ي,ݘb(.!5SZtN=ڲODu)B"u㭔m2g[usBԘ/t^g6Ѐ8ER586*9;+߃sZslԗy3YdgEǶhgb)jy먶%p#j|NU vWϭպԳyAۏ/輨}-ެS;i{^~46-Tv{_Fmu}ʾ?*xhō?*/X&/@o?tgV<\ De׵|*ǔ, ٽ!ߥ.qٰ|-\Hj'}`t&*-|it6EWFKZӊlu10b44׾d 6k&?VlsML5f[v#*ޞ,9!#a^w*٤z-vX_>L͚ k(*>gu4'^nElJW+2lJlJDGp|ILgqhު>O3JlpbaVSY 5O1 bhY6 YNbI?/Pfui=ub?8uQyqUiؗsTLWc1"OsTH0~݅ġٖ RUx` CEѾLoڤIs֨ŴsW~ XDܨ ElzG:x6R-Wtwdq{ vĦ֬xd:Ӳ>yRvMșZdXBNFŪlsj9{HKފ 7ꙉEFd&%SA>4yfj[/ibŻʐx1nH<} 1Y#-YtSNĦ;Yy'"=b!Y.bϳ}>ٳA*hZΉؔ';5% +eL˔${L%-Zt :Rrը?CRS{Og{&o@b! JC_q haU>5v.'OoW. D4{@!=np   PN,f']ԨFwf)3iwZ<1ϭhy'0^>jëŹR0rR(ȥBUM)Ыy]5f 4ZIftV |Eਜ਼"3VAd5a{2=qrܒj,hX/5tUjXG_N7V>tّ)K4CzLfnqʽlkNvܐddFKU*VV4G+^͑sEnNP`%Xpy8ȗfB=ӹ'WbSUf>[QOvT[K|4;U;FhC,8W g@! li0e{SԦNU=:uJOtlFB%ж_K8{Bc>chb1rҢ)؛d94M)Y6u:ׂC~yqTJB"$bv?rZOFQ ۺ}f4]l: V=f's(/ E19?;f#8:P7Hm!޽IEM%^D P EzK鼢{N^ҟ39Jղ>}ᯰ'Q8&sH4e׻ }U>vvԫ.N͙r>C6I8//륞2wCÚt$vvF.$"^xbt? , K -p8Vq-.>hh$abbێ8F{۽%{\idׂXHYl#>Q;YrYq^Adž;inÇarfP ڡQ :~I%z4S𣩖';bl!dt3Z7TRX3`a~, uu@}i׃`6S뼙WXd~Fw3/iv)e[ N 7b٨{%?pխM͏ P{M>bZ^[$/6/&e\~'{ݜtmtZ"Ųsr)۝Д<%]<-0rBBUH[Y,a3eC*̑H:G&9]i(c$ڔL88)1!39ȫԵIM}>9T=_8Ne;vb7yqK,Q+p[ gqmd}kB p<8ddLxa/FMKNE?"v{]y aQ߭)&_:5R+I5b$ײwUa꜅CruQq agUT!1~)-*CVCQG@MU'Vڞ=e-KIoVwhLӓė@@@v\KT3>ٍ:7/!}`z'eGvy>zE}A}U|,d^3w[:qE-g;8ȩA84DZ][W6?q|83dd,%{ńxS\, |RWF5oUjs:}x1z7G|3O t0OOE~Z0tKp˺X&h U?Y7>E :sY}ɞjqZc)_Q${FS5H|7Jzj>EłJ{i4Q̿~ JP.-j@Y9/Ԯ>?v@@@$ z^Pl"2e[P-Ѣi7GGG^߁d{׶j]EY ʺ0SM)zȔzj7 `6^K~q ջ;5mC? +5+T0Wl>{TtDo6Yi>dvBK@"==k ]t+8(WmϜH/H=kME彗^dGlo:.4m+8SV2 ΖwlMǛi  (>=$fpn)iot1 IhXY͵dm˯4H6JټUX*,ˏӼuY^Tb,gKOGVkeQqt0|PA ePŔ(NJ=༏qi( 8]FA@@@>༏qi( 8]FA@@@>༏qi( 8]FA@@@>༏qi(_/,G(3evPKCwH,cJ];    罠:A@@@ 8(    p/@p @@@@@OS;    罠:A@@@ 8(    p/@p @@@@@OS;    Y ԌLr4~Yܼ{g   E`݅˽Iwb7Rh#Z/*J/]5u|>~t乲LL O kRN/++4uc!Og*pz1vʆ@[ӕs1i\c>@Mos22\ur  _˞J=&,JK73htŖ˥l2'q?-TJvXI6ZiR1SsWb7ON_8H i}-pfTbVLoҤl""O+%TJ,QȓxG MG,!%bR>6&sQ};o=m ;x:m6 {<EDR4ltz{2 C!|=i Nc[ mppJX2wSfNb8e!ǺT2./&{M>~Qn*ʊ=ڎ~ZSZp6:NrsvfIyӦ#'UY2qHjG36/Iʋ+wr 7=F?*MjD]'66G"6O\?6畝kשEj{:VvTy ,O1,w\ݜ^ŸI_/H)酽*#9vm2%t.FU=} r˩)vҥ%5q$5f+7ejV*b/nZjb)rҢ)dՉN!4#3+kپAps/3_BtzBU>ՃLA)9pR/ e;uIAGv-(jS7fr0nVjRb6μ6u P4/9~cLpjnk|bO]r݋eL'>ٕl(ǝ29$7綣g4_Ȼ3ҧuq4`bO:_е/ @@Ԭۺlq2h' WNoFՍxzi<{lC}Yl#0VCⶒs9& uw1y^KGTSZqhzԳY Y,)ۏъVlLG]OR^gfK>\t, Ќ`#y=8PUpv1k˯-"R'O=x:S,;'Gm?v66jשY&{6C;#ԗ} ެz.Vڄ-D1j賜KK>SG?Dٛ)Sr)ߺ>b0*Јh04mJT{VؔĦɕg.ŎQ3LdnUTv{[}]U+{PJZVE=K:"B^WIKw e-Ofi7V^нimq!Iidauj;#-F|5Tb$XpD=Ԡ:цH?j{豶 N@e]&2"!if2ل6 bKv!YhoϞ͔;BlR=nӍRI6 ~Z4}mE'Q2=#bSb4ZgVbS"!=w<vܸfqXݪ>_bZ[UQb^?,Zb9ٜ?")Ыp+{;mI0L\Vu<O%E~kZ;|&YI0!~N dˏE̍HPY,r~ _vĦ^myhOtB |kBED86Sc pqQdm[ֳu1X?in$V& ya?![M_, aE&ddzng&c@@̚s߫E$G.N6Ԍ ѹyrrc)Kqfz1T{9.Rg{Z-"kע}ͣ^J1רoe{CKͫSo'{L[ p6!_L΅FDJSRhM)cS;$_Lh&u$$L'I"zM=)JT   p wlmmi&"35eYٹtCs̰vmnv"eni߭ܩoMI7a?бKWaU}sdZysiuONҔ𳊎(=Dl&ȶd Ɋ^J6]M6iߩT;xUR筭htV=zQDuN$7g'zGi?`-a\z35[Ѽ-݅._nhpF:9,eԾ0ͥ^-=[QBJ::xu^4GC{Yz87QbSD_;]Oy*(7h@FJʾFA>޼JlVtZ>Iⳅ%޲Kk.ǵ`!Gb/ήxc-TSSN^s1I\"=_m¢  jcXazI1*ݩMY Ws3OtlF㧿x~`/a+{ GwoUl=ĦcESƱ7!rhכR2)6u:עFTb.Bמ";ZFdJl~2͟0*V%}7VМM9aӪ]R=@1 =~_E,n~it zAWyЏ/czQ3~Nʎ<,1?G9+TQ b&Y ɾҲ2Y J_vĦyD3UF~gw̒̃annb,V{@+[ǿrf`KÕdQѾӗʍdzwZZo(OfhVFթN b?DTʯp^]T8Fǒ4Q4L*i ԫYmzS3u|eJʢצjR*_DNGSkȲ~:KZf~8>#v@@@ ,woJhC"2Ѳl f=ZԣE;Өo?]>iy{:v!~!gJ0dPօ)ljҝM)Ѓb8<z櫹4?Ҥ6 X7K+a0mwwI>: 'e5+T0Wl>{T\lKgU HW%7ޮLkq Niw; tpqPRj/Dŏ#@@ŧGlN- YF?dOnu'k[v4U$V%lު]ZxݭOMG;;ˏ4>o,;Ȫs\0о{Z~Y@$N6Gy[ 9 Y λ@@@-qr l6ʃjz"H&/ywpޫCp5TFp   O@…9<:W uk\C{9N#F@p   wIgY%RTznrLTznrLTznrLTzn%#ԉR ՀQ&@CA@@@J4)RAJ@@@@#Y RJ@@@@#Y RJ@@@@#Y RJ@@@@#Y Jp    `>Bp&GOۦGMmUWoP*LU  %Xwgr`؍45&X,.)UIᗮ7Y&ϕebjxJX_rx\>9Cl9D\E]?N(KV]N?,Z$[UoTa氳v_8RZU O~VneggS[s22\ueDԝ{˼  P ,:pF(za,JK73htŖUcRFvև@)i/yPc}/Dlys)Uə򴻈 Ӗ'ӐM}!_<=L}{8J8ЪyC k$=҇UbI* sWYZs@/SؔOXSq}H۝YDJȁι[tq5Z3][Q*~te8NlA@̇j][G1lazI1 q} rVBSQהҥ%5q$56pWn-Y3z'ĦS ESWΫ97BN!?i8xV}BZiCS.cO9F= ?tEl~9f-{ U@?(i=1&-(jS7fr0Cefc'5vP4/9~cLuN.UA9cVS>JcSO"$tjBgIDML5f[v#j́=;\r7S5,gpa}cH/f[HU@@"pZ]WLovun4iNʇ9Q+vR6 XDۨ aWwg#UVϽ G)y nGljyhOtm>H5! A-֓gEs'tN߹1uy6S>ggL4,2G3.ɋ7yZX.@Jdj {؄$%6%Ib=ߊ{EL˪vkkz_$+?}Z)i?U/F~lEo=zQI}-('Or+)+Ń*\"Ov3,}b%ө/Zw;ȔuTϋznȋe׿GT%7*QvHbxo9Y)~fM)Ыy]߯#;РI+~[LitVzCW\D M_6xqVI:0ZLcqގRtDk=[+vYR'%:r8 I&L% d_*=w}3&`(8Řq4$i !i9 -$i !i9 -$i !i9 -$i !`[M*    PnXL_o[̭BCA@@@r_{@ Bt@@@̛y@@@,Bt@@@̛y@@@,Bt@@@̛y@@@,Bt@@@̛y@@@,Bt@@@̛y@@@,Bt@@@̛y@@@,Bt@@@̛y@@@,Bt@@@̛y@@@,Bt@@@̛y@@@,Bt@@@̛y@@@,Bt@@@̛y@@@,Bt@@@̛y@@@,Bt@@@̛y@@@,Bt@@@̛y@@@,mY92Svi1 $i !i1 $i !i1 $i !i1 $i !i1 $P&ЭTt{6gKNϤgЁǩ経OFюSQ4EMjࡒ3s)r8EiY*m )z n R `oM'$Вg)#+GZ7 Vt":m=I7y_3Su FMy5퉈-'#ujr;-/Sv6ffE:x>Pv?Ե~FIj(J2 ]*Ѭ'T 6ry&}J5Ik?s-)rߣ @@@pڐsMNIDжuX6U}6>/ys%A'\^&R|5h6e-zq]MI7Xxz+q#ͥ {\ c͌|]]ըek Jо]~loLW픺m8F>kzUőzdǭMݜj Ŭ15}U O >-ȧREZz^Kc:6P>p*y0Xz8;   M@Av<u]|=x: kifF-VmezKqZa~\ JO+ѶlJϪVIX}w:oGη gԩ5ޮy:QlR>ՄT}~nYT\Gw򥸔t%|Yd޸N7 "+~C,$kѻ)Urt/LܜuNn}:꧃Q꾴6V[(5f4՛,Nmyjw֟Mܼ6YhiJ<&L;kruSS-߫Daٗeq8Ivq&"rMYzfSz7&mkY; \=a/!i\2=1CZR;[p$ubvmy.!Qy 5{j!¡w(    `@9g$uT~|?MYvq)$>N^~Y&Aj,x>7W+mV=(5H9T3M˫p|ʃ!=FcqԖxD@@@ @Qp~0GA%Ro_O-ke\s^LM%ؒx6Q{ 'UEj8N͌U߯toL5tw.2ʔ/Ǒ P(&~7s@2.Kr(u!OR X(}xz΂]4uQz+譨ecf .&@@@@X2NUWlӎ9;@轢u!DnUY HfOnjyw&ou-0/Sv4&= ,ƍ)"R|Y|fhq     8@@@@T @p*NT    `LӘA@@@JgDe     8@@@@T @p*NT    `LӘA@@@JgDe    䟶4(A@@@l%<F@@@@ }; 8-@@@@@&`_@@@;~ x @p>N4@@@lE@@@@0+IWŻQ2k6(U-鎷Tu|׆;.kX`[yU;3W&c@@ u~/&މHIۏPbjzRKc|>~t:djxJX_rxܼ<:r M]r"epz1vʆm\W9K㾝Oy-J?8WK*eggSfNed:RR3hYzJ*Z0v--YCS&cxAMYVN.]MH6uRUt2Pms)yS&1yV%6g5ST+E"e7eɩNd9g$!3q044WaΣc(#3h޴[<2L:E_/ڤqԤI{58sDMZF5iS :7_N!U-G#賿ˮt#k++iڊT(T@@@iecmH5_}3eZyáף>]v ug+PZnI =Xwjgd۽PQ@?ZM%>vʝЂqpsvY?4yӦ#'UY2qHjG36+]hHǦ*~hfyԷMԷ=%?/ 1q|ȾJ_r,<.^QU-!Tr@>~6|2ܝ ݊ckhlRҚ'I@ߚ\kP͟+Ӧ'i= TͲss՟*Yߛ>|'o~_fA$_!.u.]KPbS3~rQ  wI@b5ǮVg^RLaTۃ "/7ZȱƸ!]Z_GRcjLƶ)K8b/ ibMk@IïW'rn:B~,GRε|}B>ՃLAskJ!b1hcbquNHh_ǐߣ k#*&$ P40 =M]BXP4/9~cLT,t嶸o>zVܤ}@rt?udya   pt|䣙Lk6nlԎMm}XPxʳa)J/A@^%z' !$w>~^gLxd0|M} 2#bSb4]Z޳y+)?x;G5eͳ?kNJl0J1y}bҚsXxL<厇-]R{e@-VH;"BOi TLo X+nar1Ilf a7n͂Eu5R~*c`f<Ѳv@OO{D2ۼWdWJW8eatc=^TC €"D<;?ۛ5e'h?Ԛs0Y_Sv,dn)d K;>viiͅT!J5|M- |}Z֣C<4>>߮ס島`Op&e!ςxUM^Lb2H~djyd#[|q")XJ PJi.Mgz4ÁH =mٔb\˳ed2ӓ(ɫ"UrAA4_Gu^4iMm7G#~FVU,F74sseR}]*w,19tk --#gJ??[/u@rk>S ӗљ+Tݿ,J?EKͫW8nVoDHx8o?TbI@8+5ST|Ģs $JK#xK]A'#.ӡeyy#ծѶr^" @@ @#L>fvԚgdeoWpfeSꁡVժJlғݸY\w4~M9m__h zO0/V$FlT p|o}b+{TuC:6!GϦ)$ubT*AxbRMjӫ2_T}EpT.o +o 9_۪h2S(!_&b3+e]C5^#Hz2r*j?O_)uFhE?e-8up/ڣdWjZ"`M9\ؙW9t*W)!?V4}Cu j/nfeQilVBo\E/Ǫ󭂪ARbEv!J3t^d Zᴊ`Ows{ps Jl6~ѳK"8,Y•*-~tm;H˕v lfFElMK ,v5ZaG(2\tѣ \'.@@nj}z~1wQ_:o)Z+\̉K1Jl>ѱ~)jXxa/8^‘[G ͙ iGI Gȭr;-O-d3G5UuEh I$Br+CɉHL4͏S⧵{9!4]Wf(zD`fzOYvA=hϱcz\nq)SXRdRJxEԨMч&=S/b.C_B5;v2U䷸^̓TtjJUic2j!_/S=vh.K~xe}Hv%5^ӛe{k`ld? h.;   Pə9`e/!;a[=uyuiS~Y@ݽ%{ixXHl#>QUvk?-lӋ͌g]F΁#4Sx]R^GAلlftdB2 q%,oKYsaH c/r<-j,MnoFĩcp1refǀ݇8?zq!x-3#[PJVdA*vʕ'#xA ٭%Ndj   p'|KPRZ^& iO|l])՗<N"c8<-E}Z,V-o7-2 !iqlKlJ k6 bN,,2ukRӜO-Ycl7'>#_kTb4lep~vV `,\Wy[$g퀲3*^SЏFDӛ?P2niBLk&*5-u{ܸ6y>Lgss]bQoqTliBH?<M+9h?f2+MvmnW:L>ޞb*Ky3xe;rDi֔gr^SUѮVĦiIzĦAB"{>jo\dz9OVbs?S38tVք?$Nc]SCAy-t6 U[H`lZZfi6qkho״̚縂i[o"x=)Kupu6yIEf1]ֶ͞nINSlTy# 0.^ ' >4M^^IّEɺ׏CgWRy5/b*O׻-?sI͉0g;29ȣ>JNZaZДi?_Ϧ-8grP䎳WXL6FHT,!~ݸs`%vTL[ۿ)A+AN,q')v<頛W6PkwJ}LCWj;B^l{TaL^MCv}PUu|8=Ǐ~:%%p@e׿ϒg_,2OFf)FMu_Y.!Ϛ׊5|Z   `;~ؔMPݖbe'5vv}Mrt%\JSVlo}B.q?N.5#=9IH`*eOK=%ywXENZRfM57:})jT(Gg//5)F2yp+\Eif"_i3Kʢc/QHGmZz&E'(ˑĢ)==\=,˹滽b  EL+l xAYzf]~(XҾgT5*=Y{ժM]Y9o ZYtȓR3hٯ&5 y䜦-kdg&BDhEi Frt:2ZnSMޗNʜG|}Jћ3WkdI4yd?j\tո'j<5U@vkEcz_7ͧ)`:z!Rݣ_izu@gjSo0{JLn.Խy=zkilA@@&\QOoBGQ*:ɰGձz[+.ZOMNMa܃&:^\BU6V۞-ҷ+)oB=Z֧Ș8l:9Fj:ӁYpB{O^g>}z~1Rr>ԾnU"vhv<OuiI;Pf╓Y4cz5 +&6=)39L.~ȳ4hm*Hg t_-׹,(ү&$;Cz܉ȕ=mQ=GQnm$c#MPUcDńW ԬffzO+?,3^& Ki0[v|Gh {*ԭb"Mh6˳YBvP":E_Ӳkڏ^++o>bԠ7!4ᱎԤzJr-pCZf:o07ϴqOP`۔:C7 t|vmʑ$!cbT}8S+=ۨt6"^j˨% kG/6ӢLCwzfr2j5M1e[ԩB]P^Nks&h˺<?Q(կ7VF4etԿ=ޮ2,rlf|YRJmWS)Ow*Wڛo䐺٤K,y+ -? Ouu-&4,.կIVTomB`Uļh}HmG?6W˱K;&H5J72_:.ԼO|l!d'؛)_Sfqvԣ/Q5?kRșHцfljĦpz6k<-vz4c'̠tpepu6&ĨQЕ,F}(W̕ Qj_m?9xUˬq2cz4_%$'I 5o0"8wWz*۔EknĬQ H^jUdeӰ.Y  MC$4at&Y.~|=9˕L͔%BQE[ҼV Xs"b([6A|wDlJ_+{6o%6)=d䳓flCy~civ%b3Qe͕֜?$./w[oW/74מ.4,ԹaMu}RV0Kش\ ":>>Yi9E%^Ȫ|} pJ祀&H>o\t 5)8D1zv 55OO=y)F)aje29LCt3-|˂M- %~ƟY`,O&Y4bz{'8EDLe#S&Jc&^e1d$~v;Ϻ%Qż@q2늬 ][;dbM˞Jws敄=f{sNtf!h8WW2k]wPoF9.d8B_G >ֽ'C.v;9:"WOrmo歞NhSr✪B^ܒͤ^xU1WS&9y#RRr<š   Ex*_ \6Pkz,OpojyoTbJ@9xm'"EH5*   MD    P@p@e@@@@#)@@@¨MDDDabC 0Z(0@PQ}y4 @p    `_@@@@,@pZ!} @pڗ'J i    %i_( @@@@ؗ}y4 NfHaFFFeF''hԥX9:8{R,]Zi_E]K%\]^rT8 b{ubl)OY^qF  P l#8]ψ꥚גi?.p~2>d7A9W iG_#oOrphY7oR˴z1:q|=M:yu_F9lM3_xaTѕt22:{II;)o\/li>Y5ӄv-h!k{#5 j3Tj\aWtfeSꁡVժJlғݸY\w4~v>YwbrNg&}z #̋eWHdp;з=s%pY=ZMLQWq5?%(E b>Pf'sP틏Qrj:=:r͕¶e|t6*x\Lp]4iS*R2B6.v >qA[̾Π/Wl{|"tlBC:4&.~ ;.}=.xfO\ -Q:Q:T/ҿ5\\_FH_2^9aO}21{|\V=FӖmVig%~ͬ#um9x\"ȩ\7Us<J~y ٣f;upʣΓ]i$%iSitS935Os6P2^t5%?Ҋo\Qj/nfeQilVBo\E/B[qqGa(RNx=˔tWo)"2s_05d/ Ao_7%ŧ6lCN2t5IJ$)YJ ERgr]=h/ܳCgؔAS%򃗘tJ|bӛ,mi@J~S=S ]O}bv  & 1/mjl}ew!qaEv,f*N {،f5Z|Nr߰I@#3h|w4@ɑz ryL!׺]P2:&Rs-j7J,ҥ!9!DKvVPba}h$tv,Ԫv%^uN}UPF03's`Ja"#=݃;O!LN0sNpʗ{?bSQ-M/$-XGOEG1((,m;zޜ." Oz;8hxsܛR56=cSgA]cNq%V~;^̓hǑT'=ө)]`l|!&''ҮѤS34 ⏆=JNiڭkY F_~LB=աK|P@ $gZ,V>y гw˺U/_FNSRljPr>yɋ>g|M-^.i}qhVLc@@Hh?f2+MvmnW:Ky*" +xX]g#vj,ۑ#b4))䞽&Er?+}6GB*Fӧ%=R-d^OA\ӹ)yޜfm'J\*:6O1=3-OYbvi.VFha$R?'j$/4 1ۺV234 ^"Ak{ڕ.\O *e-sZ45>Cy@=[Q;;f\9^c-fy<Ļt{{]3-43<޿m*Bsԥ Pq"h@@'p!&{GsddnGN6}zE>sʫ 1Sy8^pKd`ޑAQ̷*2ւp?=';a\B PLKYYtbH6Ϟl:a&EL4L>u~$3[z|sTMx4kR-Sx, ~G2v9 k>wB*WP?S$w&=vvc@ Vg;6eQX[MP)o2ϝc:VL"_R JR,nc)&޵9[U D20W;=/dH; 4n~d#}tw[9$e*sE{v  P7 #{gGhQo;@#\&rǝ˼s4E;;8$vCfzFn*ݎؔ dOҳSѿMjЇ?5} j6 3F8[W2{[^XOʺ"<6ޜ֙S8 oQ& are T7vJ$Tc/F+w5-Qi@@ ,>k!ԣnJ#im󺬛Y/MH'q?-|fB>a9jJY7iԴhQK߯J_8ZaDѐIP$<#C&7W:v<$JK̕f \MLuJx(ӫ7(9KG%uOcɫA&$A5WLꮙTd 3bsKꕘe@@L+vz4/5K[6+#wZK7+9͡4!qT+<ye4~v z^rN3rVt0/V$*W"UH7hJ>,953E^̌]4Q.rn;:r꼣y}j9@>ݸOeOiOD$q?鯃ԵݧzPjh憿i=TǛ.D_UB~tvTǦ*_,ޠͬ,ۦ۞Rht.*FoSMޗnŊeJeh?0 &?:pM[fT#{M_}c}z6ӣe}i6()֧G廏VierFJIˤ rp:r%Kc)lXFq=9<ݨr9㟷isU)+Fr(V&bStsꢍ*>@@t.4"LFEl>ե%pjC͚WNf+l5 $KcM煞չ'.|W󷷇g)v%jY5UY BݚԢo Ԗ/+wRy  P|HO(ʗN1ӷmCUl7 7+ ]V24g.2F@`eh@3g(L2y1ɭhHNOWe[hjF5SZtI=ڲ.rz.Hx%2r1=ےi/z]#%*eX^&= ײ=c!r#ٙT`ө;+m`"1.?f}-~]:pOg]е˩PVmEHZZ8{49+X5@2(qrYڹX+fגNM՛n2/֧U} ! 1Ea׎35/d:!'^\ﲧS'NMUT@@xst\NO|l+:LqHLo]mY3цiĦpz6k<-vI JHL?]FM[@W%-s2y/8U!mK+[[o ?SSd9M4wҥqt]3\٢>[v:5ZQ 5{Z9W[G=ɷsDmJ@J8%R@⋄ki 4&(HOٱae&RTs29LCkVZxZOztoG5!F-ۃ=t,ؚeQRk8G`eTu2;4f dL>˒}h*й<,_\Hp֯\^%o%2QFĦLޣHC#1CyҎx,;5MMK8I`vRHEh;ȔѸ%;Q ~Իu,kc 2^fO^NN jÁ9~"2,7fp,3z:9'Dbyy%j۔"'8xC@&?IDATl7@@@ C0M1 @pPk0 N4*    $ivCA@@@0 8 T(1 4LS    `Ll7@@@ C0M1 @pPk0 N4*    $ivCA@@@0 8 T(1 4LS    `Ll7@@@ C0M1 @pPk0 N4*    $ivCA@@@0 8 T(1 4LS    `Ll7@@@ C0M1 @pPk0 1X|;IENDB`django-oauth-toolkit-2.3.0/docs/tutorial/celery+add.png000066400000000000000000002162551443573112200231040ustar00rootroot00000000000000PNG  IHDRRoB>iCCPICC Profile(c``I,(aa``+) rwRR` \\TQk .,'_RJ!}m}S= JI-N8!1V./)l") v:NՄ9l $Ć lFƦJ*(I(E% IUKQ0202b`5D0d@ !X9Y 1 ۢ `Rflasu5^.7\EVeXIfMM*iDӠRASCIIScreenshot ѝiTXtXML:com.adobe.xmp 850 723 Screenshot +ٯ@IDATxe׆ҋ *bGEb﨨 (a(v,ذbwEAE*|g$,\W[<3o$d" " " " " YX) " " " " "$u @ HLĴ(!S61 " " " " %$ 1]Bp&" " " " :D@D@D@D@D$KND@D@D@D@D@bZǀt )HL.!8ei" " " " "PB%l" " " " " 1c@D@D@D@D@JH@bMD@D@D@D@$u @ p1h";v-Y.d*A|?y(" " " #P&bog}Lĉ믷_~Ȭ٤-2L/˰/|>/˼\8w\_uR" " " "PnjEMYfٚki;v,"+U-]8k/-آLzwΝ;Iy*DD@D@D@Rم4|*)of+((qӰaC߿eVbKLR$" " "P:cȑVvm޽Λ7 _Wԩcu-~7nتW^.uJ^l}ᇶ;g󩧞~;mèQ^ӧpM7m6e I-1e駟n|}X&Ml]w?ܗ7{>?Zhaݺu+e6lM4c,wՎ9#;3ϸq\jʎ>hkӦMH,n/Bk yGW_}l5j\;0UV?l͞=WUW] [!޲9sX5e˖vQGYvEٔ)SGs}`իWP8u]ZϞ=]D@D@D@*4R? 6C=R#Vq=z.e6HM7N:Y]"{l2[߈2uH}=j<<Nj۷mӦMjժ%d2ymFwwN3,R`ki?{^{5}[m%YfK/yQ0`}'>a`1uTgx5u]g[|8rWmlWAo.MU?y+UVY^D@D@D@D(V[=y]v{&LP³GK/M+Dy,!OO=3lp4>4s1"EF/\u45^縱!G7h DE@D@D@D(q4~t$G5 m/I'MM[(c 8fa^`/gD AH;]F( ݨQǹƲa-+,=vaJꪫډ'\lB=RKFr{Hw,S 8}J& x;A9bmۆO^Z6i߸A}xr1b10`_dkb1b7~GσGc/ab7px5>8 .$F/f ^Rl℺;Éı+^:JS" " " qh=BPAC=܋uӮBxE*IT{`#G Fce b:8lܴiS_;%&&;[#.rA4p<w`zg{|<η7%\.ބ)" " " eI k1M|~j[o@ /GD,{-Io,X/)a 3;jTeDӤ[fVK%d!h4E2=K]y駗"GӰLfs'!㢛Fx0`\ܴM&OldN<\0cgbbiw~maj<ʠ_Ν;uYhNBxp/-lB=Kg_#oȿz+u8ffATc)gnæ\È; *ŗ`sWÑ4." " " DttS" " " " U@긅C=b.6*%.̃1CDpwMD@D@D@D@ʎ@^ãD@D@D@D@D =yg-" " " " "tF<(" " " "  HLg-" " " " "tF<(" " " "  HLg-" " " " "tF<(" " " "  HLg-" " " " "tF<(" " " "  HLg-" " " " "tF<(" " " "  HLg-" " " " "tF<(" " " "  HLg-" " " " "tF<(" " " "  HLg-" " " " "tF<(" " " "  HLg-" " " " "tF<(" " " "  HLg-" " " " "tF<(" " " "  HLg-" " " " "tF<(" " " "  HLg-" " " " "tF<(" " " "  HLg-" " " " "tF<(" " " "  HLg-" " " " "tF<(" " " "  HLg-" " " " "@[QD`Xx͘1f͚e .\QD@*6uZ&MyVvXN@A?KqYƍiӦV^:%"Pv,X`3gδ9sXv$J$SbJ&O iժUhZ!"PiQivZ (f@5? ځGZ&" 9D&"[ӹETVhG*TY;Ex9.GتJD@D@D@D HLToD@D@D@D@ʑt9VU" " " " E@b:z#N`ԨQ֥KJA;s=7mmĈ9CTMUsyN`ѢE+)W^ynbQ?χ[>۾be>}*)%Ȗ@4?3/jBED;˥K.WfϞr=+w[drY*sٲeG3|AE8E%oYbmy7_5u`4lKUB9 r͛]e*F gb|UWY>} /U{'a>E@JH?=C:|ꩧRw}]v:t`O>C ^wunw?ܾk?/u}駅/O>d~u饗@ѣ 2~ϏEp ֩S'vmgϞN2ik׮_?mםs9v2o1}{O{1E`;+;찃tI.6}T9Q\He2b >᱊ocmֿ#|2h/?x`CzmҤ{6m4o'w{ 3O?Fi[o&DHn =Lk>; Xo@#P.b T/ێ;Ν;5\ɸH]kۄg\w}wO߭[ |`ࢇ.S?7HJI/4b8 Cva6>=z7CT+}g.|lpYgenխ[7jժ!EسFVZd.C8qy)vFy^>PJ+dkԩSj֬">V>Ck}exz> R7oK C ?l'x7EF/сX9s_x$![nxo/ƽ¿fW_}!.7p{_}kӦ/3Ceݚ2eJB&M3wxɓ''o?/3 ֨Q#_h=Slԩ|}! @3c%\*;lDx#7n\ZBC rg3#[jLB3!#"aBc=\s^۷x}r.W^ye;/۷xqq3τ kw*?>y&&[|u&sVs/H޽@>[{4@Sw@HU3 Lc} 3 &0mb`7DDb!yp1nvXc {W2~o֬ zBI\sMO;fXGyկ_~1o۶+9~xnce*<#dZRʸXp62a$NYwұܢSxxh /7۸=`3*SʊV0\QFҨKuN_Mn9]^ԈUVY%NNKMY|v݄QGi[_v}MN/@DtbBGuf~m7vܺ[Q_f (D$r>c< EO&HUQ7*< ¦R3}ˋ8.8$M rLB;H#&V&"[ӹE@D@D@D@勮; Z[nO? uD@D@ʎtٱTI"PƏoO=]z饶:븈HUPPժUKcOv KH/Si(wֲeK1bzv%"qx[Hj}Xw7/Oma[(ELᄊhѢ?BF.ؑGVYӕIyX'~=CIKYi,笳β𖪝:t{;w.]BL/ҭ/Y_D@D@JE߫FQf\@0"X[hdv! B_{3ٳgtʔ)SO5o̙3mv_ 'k>ԬYӚ5k^S#>w\6Ȏ9[k_6TѳgO/6;mO=9i$_^;}ַo_0aM8˧`.A?{ҥK]va.2QTf`Hwmd/Rgk׮iQ8=DZmqk>apׂOC dv9Dg=svזFED@0JU.nj뮻q"ࠃrL[`ˈc,FBƍ{q.۱%K ^OOmmq!nͽȃ Mu㏻;ou1_B:os O9s\#qgM6֭!XΛ7Ͻxtv}]ߜL+ŵT0ړ]q~l=~I# `֩S~dq{\pI*Z.9;r!@lgXDB)Έ7ܦO?~FL-6qx.3Sgg l,t/W_}e 4papG0_uUf0 A HUVV[N>wQ<%|o┙-~!ht܍xCvmȑ'"0>gUF#f#7O[zHxP믿ral - b֬Y@{xpXfb7`#|ζ~{cJ71|A;v/t4kժe ,Hc!ʘK.P]Zvm,Τ$L(S" " ' tY)0ָ,1ҔQt: Au3[ Jڏ?g.LUұK>Ośm%9fB(>yJ)E@rLxiBxQ&" " teKj@$<*nQD@D@D@D@*ʰF I@bB5JD@D@D@D2 {Im$+nQD@D@D@D@*ʰF I@bB5JD@D@D@D2 {Im$+nQD@D@D@D@*6rڴifQzK;ܭ@yPGyPV" " " " yI@b:/w:%" " " "P$˃Ky[) 1]U@^ݪN:D@D@D@D@t^VuJD@D@D@D<HLe!" " " ";񼤠N@&xb1c͚5.\X[@ԭ[ך4ib͛7ڵk/R@%;F Ǎg7MZzF*= ̙3mΜ9֮]; JG$W+8ɓ'{ [jU[@t|gL9D@*LW}@xe"89e" "P HLW潧=bڑvc[Tݯ@Λ]7&D@D@D@D@tJuDD@D@D@D HL7q'" " " "7ZL/]>YEu䣏>d." " " "PFr&jBQ{׬wʺdk>(V&;vMtA=짟~{n&[lYU"/w}W$;="ӕ&o#F(Mi5ʺtvpW_"E@Dșף>jO-ZTDO>}}7ˠ/+v뭷ڔպӧl5\y/#31}iOi&MlmM~{スi/Rl;wnF2I/_~e;mW2B:.?S n `9OwqvWIyQOz]L=X]Gټ(g!K9Ѵa#EN: %oXy8~>S?>g]a}8~w{7!O*z| z!+.Rq(e*l<}>~{?S=^[!! uEǬ'=D3aHKye2}qܨ8(R/C4~2e6jV=:t_֭kzn]CvZ 5 _l͚5pVw\ ofmy&L`_~j.:,k۶p 6eOjժyč7.e0@zwǛn_||ln7A'l;{Ӷ5jo۷뮻/\l#JK HЧg}f;v}E,J)?o믿v /kNZli5A:[nvm7O5Xý^~m1@y}SN9W]t A2oaÆ^V7“+D۹NWΌ3.31p|qW[Oau]o/I|'O-k獫~ ƻw\gy^'y-tz7{ V\rmcٝw3G0׮]PT>wۇy)j֝tI~sη~k\pAקO;Ò{[b{osOX|a V9#)S&" B_Z=BASN.pdޭ[7k 23'~N|/:ԯ_4hPW\|H+qq"z-wK@0" C".1j/ªb07n2x`l[3S9.t  maoQϺ#ANq܁"LQc>mȑ[{IC?H q9|ԪU+N~֬YRC4燇~1h[ #ŸzꩶV[9##4#M^ΛGða_uv84!c'p:.ϴWl#LD@ \4!G"nQω>3s1_zu8ᡊ^;/ڵ äI\ M\Щk/lr!DO3m KOx)eGoFbluV_}i)O=x>М'|`U-pFtFc|+.~d@H` ~x_3^~L吞Η_~5b 3wLxvC^#HS=GA3~_xOb9 ΄y%Sι`]w5 F#"JSY|"H#{ǟ! x!8>C&_n& i? #(0#y?!/bK)10lCcy,̎C"GCޜCNևuH afs=>Pg@@_;s" cq5qR."/V[ۥVnݞwyΔ Dz6--nr{ Ŝ Ʌ^o͛7O FׇUVYxnL)qv7eiqNƭ,SZ.E&6mUWx:Kpt;Xq%~h"o#Ԥ.Tf+$#Y< dyX=n91 :.\H-&2n\dgi bG!3Z[G(qw۠\q!^"s{ ln sK;9Sm6.||F >ƌ"U I_bCE4/>L{mf*(n q|6~oAxa1g "Ãw1DYJD{WS"OycѲ-G!b;b G qw\1<\s) 'ʙ4sM8- 9rNdø!@%^eD?ݡMKriSaЖM>DZTǴs+.Q 'bS  jI˅9ۨbn2.=7&)#\Wx`'x1L}^ 03dc{P,-1±L%P=^)?z}A{A9s ?P6!!so>^F;Y6xn}T1Z!"PrͣSʅ3c 3C&x Me/"$oG" " " " D@aZՈۧ@9.'ЪFD@D@D@D HL>UD@D@D@D@ʉt9V5" " " " G@b:z$" " " "PN$ ?O#r" 1]NU@ο}rjD@D@D@D@@l4mڴl(%j ˞S" " " " "c 1`/" " " "$wߪg" " " " 9& 1c*^D@D@D@D  HLUD@D@D@D@rL@b:ǀU@}䘀tx% 1V=1V" " " " K _TL/3fجYl…jU%Pn]kҤ5oj׮]a۩@e%{Nƍƍ[ӦM^zUdX`͜9̙cڵ.;*ID@Ĵ&OkժUnVX {Im3]\eځGZ&%q$([eS@ FZeq*q!\-" " " "${w" " " " 9$ 1C*ZD@D@D@D HLUD@D@D@D@rH ҥK?!`*" " " "Prٳgl:rc1 iɒ%v*b;v,2}EIG<,CD@D@D@D ?&MUN6jԨgˊDbiϟ˯>|Q矿2XSPP`]wz%K&ԬY."ӧmֺutI^D@D@D@D`ș>Ӓ]ݻmvꩧ&ױ ?I&ևmͳF-- ?¼za/tI+_(J+lٲBe=oz۶mkժU"#Ч ILsj}O\FhD@D@D@D@*颺ꫯСC] ׭[=ᄏg{7D񪫮j{uСP3gδ/ؚ5kf\sM!1hl͒y 쯿o쳏=o6;֮z뮻/}̓'9^z?dL{D ֘<4rZJE<#k'bxݻ'&=3}`A[m wygcB<=\g#Gz)R(ְad5jn… m)mɒ%)gZI-+DLYw~Ń|[׮]K?Xj5{l[eDv,X֭kРլY3tիUl23g5iҤz}o" 阳 mc6-wx(f]w͝;_x5jdz 7f͚e})Sxmڴ .3+.]jgy[n+n{~;o?_~뭷lȐ!\~}y]D@D@D@@bC630d֎zoˀ͆}R9zhE&W>}x. xW{̅r~oɓ'ȑ#ҥfmgŋ'm۶vm{mÆ s!N8?l۷wn)r>&F3dӆf6,lxaluї/VZi%9sM:V[m53fL\vm;蠃<o|o_}ocǎN:0llĈ7X:u|qgZO'jAD@D@D@D \L~Ĥ[JkvKk_6עA"o:?[$)N/h9lgZ4,KGh~ T9Uz*" " " "PU 4oȂtTD@D@D@D@$4," " " " Y@tE@D@D@D@D YRRвdA@b: XJ*" " " " Q"-[-ϝ;FB۵pB{7 bI>h_2+J[ϻk3A2^D@D@D@r*_x;3cg$>f6mri>`Vr)v1z͟??e_~z衔۲Y[`A6Yi{W\a//,~C.+EC2+J[R;Y2)" " " LL#z);l6_o=Æ ѣG'g&ŋw^7jР v$ ,6[fΜg62:,u]fLLYėLTy2!ԙ*۲Yte3}&orMD@D@D.ZVZi%kѢծ]9_~ɒ%v7ڔ)Sl6r1 &ĉ !ܭ[7oرc=-NpQ:gk֬^O/5k4^(|~G? s޼yַo_oGӦMO6Ҏouz=餓|/ /V_}u曽;SCꢯ7p& 0v[ڵ{äcǎֳgO_:֧Od, J.ϜGQ]^{m@?`d֬Y/jժy۞}YQ^v4dG[9kԨo8 ϗM=e`p6ydoҥK `Εxtb͛7 P " " " "Py7tS[wu](!W;蠃 aym4h!Xd^zi;찤m! [sKk=)Bw}g?M4o#F,IFa<ÇB =b?NɠaW_} ~Yƍ=ҳOiԉ`| Owa~~ΈБO>$런駟\P?QK,pqy<\uUQCH3(M:uԝwwԯg˦-|nV;^n0` YBED@D@D@2șg: ~..2e]G.7`C.*^g<XDT :xL=^r1`xP_{5x)I9u!;wd?l\r5lo?{gv6B }. `$~0v⬉mgS'q@; 4C!$~f$;ݠ8 RED@D@D@D NZ;\uey 7#4AX2vǽLAB&CFxU3/^tXE-S[2m X)/7d]~tL>ZfQ i," " " \LTD@D@D@D@D ,RD@D@D@D@DHLdU!" " " "$sW" " " " @@b $ 1U(YU'ܯ@9.ȪBD@D@D@D ? HL~UD@D@D@D@ʁt9@V" " " " I@b:?z%" " " "Pjd[ǴiӲ͢" " " " "$,/{N@ (#ǀU@}䘀tx% 1V=1V" " " " K@b:z&" " " "c9E@D@D@D@t[LD@D@D@D $s Xŋ/N<Qg"P1 ,^f̘afͲ VFU" " " ԭ[ך4ib͛7ڵkkKw[@vƍƍ[ӦM^z" " "P,XfΜis̱vڕXy L<ߪU \D@D@ʉn*fv LD@D@Dh\3vIL'm%Y FZYBSr*Kkfy?_$1]e7u\D@D@D@D$KKPE@D@D@D@,*q@ TE@D^gF.]T.wߕo.+믿唤b.2vmo,Ieg„ ;گZ&Bg^xaZ&jk4N-ݵXU&hѢr,x?%KSsw?r[|y׊6|p줓N*& ?o:.]jsεL0|={veۓ)CښNmW_}xk+v%~)=l2{g"ufer&Pi7֥6!nT0I~WPP\w~㷅іx=÷26U}!VԿDR槟~j}!i2mR1 2$78Ql:tm+ 3 FYCf7?>Hxas?ge=m3 mQ.öhB}n D> 'mKC>}T]9ӏ>{V^u"9zr+_|QdZ$$ /X@V{tSOl^}vehx1DMD;l~}׾^d=vuWof뭷vɎ87x1'& \)駟{:wlݺu4[mqvA_~{޽6dFq:vCᓾ7tP.y'Rl"nb ^Qڀg߇ ڏt|23;# BOx8 x>.TgTZ$ OIZ3(/[.A| Huca[r= h{w\F{xE-˺?#ɗӵZ!1 [ׄW !, ._~'(dM XˤNtЇq\&>c7NPЫW/$!}[B|W 뿕'|G W{$ ;W oy/+H T ."9o ^_o||~ϙKtoKNX)S qKxN 0,u6! K3[pxy 31 ;Ӿ)UWr‷ nӦ{^D 1 3P< LDŽ7ʈN[F^< ضZk>P!DP?xcNൊ<(pڵǃq:0B=5͛{l/S!J1q< chى+'  dĈ;N錻`xNW-/{0F4.#)n:@`izY=8ON`)o|q'"w81dc~fs}}8GG#EE\La=aѶu]yxH"6!_߾}]h3kt_SD g ^^n'cxb83i8I2 bظr^s5 fDˍ~(B<=, ہLHO(/7/ Lmڴif(bz@ x7zSŰO;":P7CB4^.Nx1n3P/>!,z-xi3p<@0(FpsbiGA`v-a6tSˈ~KNL! auF?ˤKܑRe Y4;.B]t!Ηh{`G [cd}aovc?0cVDx}&" a93fH7 @JAbT Oי ?]8$Nt'IX]NIx<-u4=ˌE(7I퉸Ƃ LA3r^?OH ΓQ&%!P'%c(Kߔ$)Uۙ!QC@l-S}#i/HfHJG{"к|I4S^f _Y=,B-xg|IՆLj0U|FKL3$:&-^;˪59L1ŭUû"2Kݻwo߆o O7ha /C:PԣB~?^'$ܝVL?gKr~9#,yNhV9F}|Nx5BWՈ& n}*0O]c=V"oe=ymO:9_'_sWKuk,S]هӶjs4i6 H@$ o!MCu %  H@ J!aA:%P'&,|u6$  H41\3vv)uInG1%  H@KHHc\;ziк$ |7_} O?}|lJ H@@IFH6,,aI&鲞+ I}ԃqꫯW$  H@ML,<1 be]v ov<ꫯK/^xam|G7\s!P|+I'Tԇwᮻ |bXww*9d3?\uUX2|}w}eg믿au  t;n7$6+ E\;p#GQXeQ:ݕi8kfi}OyTYFk̛4m-QF/?3qs9'h0쳏)sQ__vc9{G,?>K,,N|/"#s5W8#¤N𢒇_ׯ_8L3GŒK. jl׿u,2+În!S"D+<:k,'#1qw>e~2k£>`6 뮻nvw:thoK?D>W _|c oF7cx1,67`6D}#?xaL=c)2˄y'{a _c v Z\nK.:3<贃~2eEd",~_(=bϞ=[?B bVT/zkk\ga.K@MB@t \LEAp@4m&Hbj;cB -I. . 6EYれG!-vD5lD|)D](MQAvHXBY {QL\xQSsh\b1G,RѳD̲ @`~mCt_~QP! @S&A{'MO,JN?(>a1XncODэ`yÿ}Kl7NBya;}W"mx)Y\c5)io~چFM!FcGvWƛgNwIH63̛oy,,~X9;G, ,p|h?XfB!X/p•;E 9sE1FӇMұH8 7:8^4'\EB:h>zoLQ@!#fpa@Pbo[xx+bH*'10qg뭷nx>Wb-#B(@=S[}գhFp-ѻ7~5ouߠA^!"'x"]G>I":&ni+{u1/h-qzGx؜s_;c>1W𠲠`Ƚ{ofˍ6(3 o`8)9c} ^q,xȯfIOצJ9b9eĢg-ځ3v=Ƽ8ewwJX _O&L slu'@(<=[ ařN;85VnOsVd.”"=+#G/Bt:=BH1bxzU37[^N8! \d.ڄN1.ļ&x1Bg\TѢN7@IDAT2ĉCQr-C؇׫v>e)* [mUfQ/"u#er;q{N?1Y[eU?x:.=!rIcI 0^wK$b.]b}1!B3]+yM?ԴeVʰ sPXg'K(/+^4VChf,j}[MM8,Az 3YbGڔ-!4xy U}X03\); ¹ i@PL7ڳ6Fx}S$j9M^,.w\b4IЄ )G"lm 蹸PqxZy_Lڅhy#Ń6YK sz%|~IfbCa&!B>D4W_p+8Z*:8o).<6OyLo 8Q4TO~< 7a=ki1XXU4(]m|ɾʲ{Q}n&  cl$ c$  H@bzl~*o~H!.( H@(5tK@$  4Bg^ H@$ RPLz$  H@@#Ӎ3$  H@@ (K=v^$  HFW$  Hӥ~;/ H@$t#+ H@$PjR$  H@hbz敀$  H@(5^mۚ$  H@hJxS$  H@ yte됀$  H@hJV;% H@$]A:$  H@b)NI@$  ttWP H@$ $naS$  H@]A@1C$  H) (rX$  H@@WPLwe됀$  H@hJV;% H@$]A:$  H@b)NI@$  ttWP H@$ $naS$  H@]A@1C$  H) (rX$  H@@WPLwe됀$  H@hJV;% H@$]A:$  H@b)NI@$  ttWP H@$ $naS$  H@]A@1C$  H) (rX$  H@@WPLwe됀$  H@hJV;% H@$]A:$  H@b)NI@$  ttWP H@$ $Ы{ۚ$  H@hJzrX$  H@@WPLwe됀$  H@hJV;% H@$]A:$  H@b)NI@$  ttWP H@$ $naS$  H@]A@1C$  H) (rX$  H@@W41=T-/l3/oT8`%J[-N~jI⾾SLUbP3 $  H@$PI?'^Y@}^ K/wkӴx v_ɣ?픓I{"y-?|NYg]$  H@&hGγNxAs7/A~N@]$  H@* tgz'ÿ k.8k˅1 pC/V[hְT~E<a߄][B.ypOѣƄu~4Gܷ\ê-B?'DæKa|˳o?dAWAVRa& p㯇<,VM$  H@y:\LS 7<xЧӻS[%,9gXR-qW<{ /]Ϋ»^<,pD1c‹ 3L5yxig1yA}yx൱=y6EKXwi[5 H@$  0*!,Ƨތ|S6vYiCq͟x}7{O 9jTHj_K 5Xi7yy3Oibg&l)'Mo}$  H@X:\L׼\KHQ>n~!D=-bGN>Vo3-A;Sx7o1afi-喰<U4a~Ժ/惡_į[mYr- /]\v6%  H@$v0zq'_6_fްj_@!^E{ߨicʷ~@Mn^xe@AϾ6oFWg_E M|6&w]φ_Xi`sM H@$  1BbK;9!or,|Жohra^>߆Ig 滥OٚG`!LE$  H@@Lg+k\dxqQ_.Q-_W&?ٚ$  H@jZ_$  H@B@1,#i?$  H@bˑ[$  H@@PL7H H@$ .'rV( H@$,2C$  H (J@$  4 t$  H@r.Gn$  H@B?',$  H@hF _$  Hӥz;. H@$(t/ H@$PZ$  H@hbQ旀$  H@(-tiގK@$  4J@1(AK@$  bCo%  H@%n%  H@JKWi{n%0Cz7;7'Ԏ w^a~=l4QeH@@;H~ٛkuV;8b±5J@h^[{}bXB>#lT04gv-:"peoGH`Ri%  H3I`-VAbNy5);X (Ɵyy1v&K@h:R;L^y؝͗e_I`ſFhg1f$ $@%&0>yv4 ~->o%  4%M9vJ$  H+(uH@$  4%tS$  H@ 鮠l$  H@MIS[ouKpvi3,,>`s=k&>|xe]/\3 $0> _{7s9k _~7o뮻O~nB& H@]ES^}3fL8cT1"|7m;s/pw[o57 W^yen?<|gMzO۾꫚yk.8?m6pI'w7y1̈́6i|WO=~ Ã?pU$P6*0G^dlK/>5MOQFq?WZڟG w}wON=pg[/zвB?T8>fw?6lX믳lPٿ #ʴ~nNK1=s/Lv?+-2dH\|\yqoyzC=KK. vXvmi*0+Mi;Stl(`>XoqWVv皼 ۸Y6bU 0|-|Χ/^5 H@e#Ы:|qŋ.F\dN!N9 B$|?ӻ6ۄ6(k&L6d/믿~ A>8zag'|rxwcs=w8C”SNY'{\{͆nbVW\?8av -X,wމ#xSZh/M|ڹ">!M7tp/30Cܾ;=#,䒭xx8ZK_Wa᠃\_~s<#8"lfaWs`뭷f;;/xcmVak>qqyUW/'5ɖZjpWǏ^zi_x+W_hܾkbQD //< F8Բ.k n<ƕL>Sa!-/09(9Vwa 10zW3g; s2JuYZk1%K~'8/˸%YAnE7b;""Z8M('TW^baC cx,L+%'fQ@(ǯx\⮓ڒ$KsBZ}Ӯ7$<~PFg/,ܹc5lP{5!c)ȏjmިaԺ͈_{s' H@.fib? Uֈ4:f̭j׍\xxqCL# $IPy.˕u'g]+zzq!pSO5KhG RcSiM!yjyXHVjBcˋw-FFx @z|E$1xaQ9o oxs  b wxl\w6:'|(9Jm@-![YAM{:#LEHӶ>,h)k;4 H@Nx)/*q[zg0&<5\PǦxDqQo2.Nx(۴%JwÍWь%.i0"QO:ȃGx\ 65h@{ymw6g&NFQqyF giY<&n,9 q}7`%6bb )I:Nj#(V[ջ.oB/1c*˃]As7 O1;FXs}x |;Xt@\SqX&$sBzށ6s , 7mL lyeߊ>'AB {*4*iR .zr_?$  4; {(3nrbDl3w/;#1'-qq̳ċ'xR+稚Iݺ~B:q [>6A.#0K.zMr,7zxH #_pFu &+2 Z-OZfC(ix0>cD`O=C}|%P&D'O7i+66#T,1d,H(Yٲ)%A6rh˃+\>oiGey@ە?|xqKzgEFć](HMF7mܴ'/_^SxlsD doa*׮%c1c}\|MXEl-|ڍ[iӺσn^tU# H@l#NWpo=$ zxhga87co/8X$  TV3]6?J@-~7ñW "#9n9! H@݄b ͐@5ϽU8= }MH`& +o8lâMք=K&\ wl$  H@x&`x$  H@p ('ܱ$  H@㙀bz<K@$  Lr H@$ L@1=%0><+zԨQq)O oVv8W_;ohK{6dȐ7{wU&9<8fjy;kFÄccPΥXw݅SgC1ل-_Ç;S൚w}/}W]uU 8[_o;W^y%{ .bo)?Uoчw}7 E;ug}|XG#\崵zKۚ]z_:Γ=3~Ui_eNTύjVt1+?m2O}|͞9^}$#<8bmMZbInuOcƌ Gq>G2MYw(o*W_~_;/,r9YQ9m^6Q핶dۓMEn멯VT9眺.jSG3NC*6ToQS_u?,\xNI"l9EjIlZhTO[%=ܲRV[:/GʗZԦUTUoqQc1iFWwk@Yp袋BϞ=uzhk"#<2~^p_㏇~.,q_߾}Ë/V_}'_W1vq0TS/O? :kw}kcDDMfi0$-2L1x?> >a=mag tP|C|g|+RO6dᬳ /ss {W\qEM7ЫWzM7ݴuW^s!cהψo&,JztM_70 r)o#G 6 l6 ?cݻ{l ,[ _~9w#Λ+xjzkk±faC›x KbmE}l-7pmOs'Gqo8?+[^8!ǼSkFߌWz+~+/3.b\ODaD8Xhl6QiE3<F/R\vmau։$b[l0<_3  qB⇈愾"R9?vs=Qp" 7d- m+B!o~Å,yje Qye ~؏l7m|x!\zl~nmg}bK6 a⎨N:#OcǾzT+hҔO}N:i 2nEr!c i0\ 7ܰ՛^>X3X[k'],'RK5Xfe|cjyKHl4ofDDa,>NHG⚹ӧ!go'*{+Bx's\sNAqX?獼s})j! <" cN-{e!ߢ2R}c|,_{(,81Ǥe͝W^9 c1ʞ9GrcaݝOdljQY>ļS%6\D2,c*Oh!9 ?ۋ88sb;4DaB O?=;Q r^:16zɸ0⋋[o=V)A !O/=i Ze{jmHixM}n+zҦJ.E^4~/E v! d.xL}ϳrw,hobP7wWEg˫wB!x}2Z[NuTDwh BeヹG9YL+➗6mCf1R;ceX/8';bxgy(:S}Zml9. .:6RQ\#BP}ȞP,<ޜOzOyes [K@5pkq;4/)M.ZlV!v eˆÈQ[' :yߘj^~. oOK,2\;{#CVaV=r y:B |%TtA̻EjQ8aa,ĸbEeĝ- 3|p3}-dUHarɖUYNXj.>.\AmѫLqA3,,OK kP8U+Wk7cK+~_{Λ2 } ,wêq|PJ,1HY-^dpM,c@w8OR#`E F1 xN w;0>Ƶ~e_޼5{ui,>ϩ,"FxgJW~S6?m"Ďl:eshYWSJ@ k:a[B%I'TDV=)])VvZ^mGPAEmx굢Փ1KZZud)jTne˯߅whm8n⌉;!6ZSZ*Yg"*Sr㿈{4G Xc|"ʺ B}Xþ2]6QeQ8{ܧyy̳O*-{>ɾ2nϾoϜLk?>}v<c;%JCTjjԑDjm#O=mE)f[4ZVYQZud'[^qʦ{-?ov[Ql/z3NZSv)w|Z^ERE~^z=HB:vr۲eEmWHSNQqTEn6/k M[K/Վʴ~nZ$  t1necD&M0ų(|W%V-E`& O?}߿ݻwjJC/P B^ } 30C@Ol$} (2 H@$ nN@1I@$  t_;6L$  HPLwy$  H@ݗb-$  H@:]L'^zi;ǷzaW^yexg:ve%i~ako$  H@:@8y!8o]vY_&|w^}հ{,Pk#<<0TS)2\{=0і: i1jԨ~ }g/$PO_ Y!oŻxExeai +βUqBH@3w\8sȑ#ѣۆ _|*x’m~駭(7=\׺ R/u.h"w۞t}ǂJ>|㔓 ʻE-*{^|ziuZs|vą/O cFkW]wuAx$:3>oytI#E[lEx7g6i89/p{}GoDMvqǰkk{k?6làA⶝w9~Qt|Ɂȇ2o'|?N*W^d>L34qW\nᆸ(N;[,clMXo>@~RYdO8q,w/r+a1}g1K_WGEX~cW H@ׂ݅??{ .SO=ul y8OpBxQ M?l0꫖kۡ}w,LhNw &l8mgy&VZ! +)'dC {l8#P|GV_}зopK.$ eN'xbxwߞ<Գr˅:|7yO=T8蠃$Y ,ypuŸ03S!x̗Yf_<><_|qoyf[oFm/ -l'tRHO?tS#k#w=5\Q4s:#MGBz÷*)Igj,KhB?\;s_|E,q馫Z2UEN2$avykCEe 9sx( {d'/p{/zrL=mY,gv r#~pu>v( Q΍7M[m]u ه 'd%cŋ;#묳N qe#W^yg s-'?'ghe+1?Xj{ MFHD_xN5{Kv?N$C[:]&1N HtY֚sgc[iFAN F,&ód^NoxOx}Yf%)#nc;0J1M2$~a @X&|# {O >gw#j. HFsuYvRvw Ӻ7MEce>c3+?W+h_W tJAM.[:L6gǚ#FwjVh H41ȊiB.T^hL10O&*xÎ裏)H,&D/6_7uxe-/{0?ӦWy欹暕ܤxuFh:@VPSGz3Bi֧w0[y/cm'& 4X/<+R3&[<\u뭷έfB8a%#Q8};'a>&LE< ;/Lu1Q<8~4?ng?묳z`1}%ž~އ~8Ѵ7g /8[n%Gup"8tbO5Zb5 H@ݝ@ԝ!yp-w80\aEOnrn'& 4NL {1<ɲNUW]b){=VGeM{`|m(P0(Z*1i-ʶX;y(ģ>aqA V[ne|A#zx:?Бe]6袋Ýxk;uG-1r胭E#ݚ7@i hc: Uy)NV}4 r[O*gQel~#Ռs;)LZ?Sgޏ۰v=M%" L@`߿6lXxwPoq x96fq- HstɏtN-U 0bĈ+u@IDAT!s{YB;n(+L% HqZ$  H@@I Qҁ$  H@PL7$  H@JJ@1]ҁ$  H@PL7$  H@JJ@1]ҁQK#^~_=VoRf^w_Ovm{ wC ?p7hO+);/τ4'1ǧwz iۓO>{x.ϗbzB5+E/Zwf|A8®vipYgѣGf-;?0p$~ Wx窖W#?g~Wå^ڮ^x]y]yۚ_ϼ+U򩚰ζΉuF 1>ٹ9)oB|gRIJ>W)4u9rd~fpi:(xm\QMV;4CSkF<6q~/LJ<&txa9hm' tm-P&B)b.yAP02XfeZay"vذaQ@d4rM6Bbv[Xgu…^ gQ0寰 q帧My礢do߾naKHZ0Jtl{crSc&ceϙq.Gne&I0+jXZa) $.rn*N@ֲرD;@^{(HYXؖypヱn jNF{`?αM,tfE,sr9pǛ(yt%鸧=EjN!XΑmQ<<ՍZ+bo4aKܬ^i>p@2X1\al_X caϳ,,Z0 Ky8V;Ϥtc7{~)%t%mK`&$MosW)$qx7If\axRh"1MέA7&Cd!")x-+6b4*\oC&jRf)<" 6xXxTqڤA>"Gҷ#0: =HVzN(E"  Q-rspbӅ:+rV*qa#f1 K<'3cJ^<D4> ^ufqɦEqwvn93|U!/ƏE,Xv hEH֞JSim-0k+N%,8vsgEt0.8B:=h⁻Goq^ȳc93yB:MD^%+*#[_vn=`J `pFrNMw[WT.EܢұHH~ .-oijK1:qSZ~絉mE(ڞ-Me,w@gZ*qŋLJ-kS^+#>+-}rSy.MmOk=m'M*/ZOS2N|YR!nc&@6@/&eMM@1])$  H@$K'rwQ$  H@F@1]$  H@$Pb wI@$  Ht5:$  H@@*p% H@$ jO$  H@U(q$  H@PLW> H@$  T!]$  H@FWym$  H@(NtCn%  H@:aEr$  H@JG@1]!$  H@E@1Q$-G$  Htӥr;, H@$QEr$  H@JG@1]!$  H@E@1Q$-G$  Htӥr;, H@$QEr$  H@JG?'^:BvX#G}ܚ~& O?}߿ݻwUlM$ 6ˤj^z)70 Yc0dȐ0lذ {&ECF\P1KPf&/k9^E`sDL PE" E$t.;;3Y[_>cժU˗" " YD@b::KU?3f̰kZӦMxn)m" "3*/B;H1|FcsT5 !B;zFy+@D@D@/7@9' 1];H($oߨf" " " " 圀t9 UOD@D@D@D.}s ,!CkfSNM_{饗N,aqp}ݗ,۴5N;i/^lݻwI&L" " " " @FN#GZÆ ?g}?p;#S~ɒ%lٲR%(n.]j+WL}]:?sZH/Ջٚ峭rݬB*hԬ6yXkUub塝D@D@r@<3gtB㏷~Y_~0^zud|g_/A+J-Zܵ2(\~L"uv#ݚ5k Ƃ/ S"?\@iu̱BOտM_߷+SE2&1t*Uʕ.sVf̈́{yd+Vh'xȷ~kwqoFv:,GV^='jg;SD&lb'tm.~SNq^ 7=\zݶ7|ӞyWwx饗Z6mݻ!䱍6}?oÇx0axvwv%,qM7?'v%؜9sgϞvyY6'D!=`{mƱ!uf?^|%ڬ ZjMl՚jh-Gy5jdmX" Ǎguqa/Ҟz)oo߾N saB˶b {϶>ȦLHFxk׮u,Ø1cW^#8‰M7 wOwC0:m?~=E [0Am޼;ψ蠐fiJkV-UK&$5W޸$Q^" " yHoqeو#`E0㕾 lm?تUf'p+:zhjѢEyVR%#dӈ-ҥ6ʒ>ڵk[.]ܮx_Dm߾}."ni駟vO>q=å= :c=m4;\5udBhed"&裏)oV Uj>s&8eɓ6׼yb^zb۞{iۏxЖD OA] &je4ۼ:U^lB*(m+~jUu[#qGy׬b4 " A bB ?Rc|ЅApXƓLtxoܸ:qǔg0k,k֬Yb׺u&=+"}3B*{sMXxr}|aÆ;3jىe-@㕉7DZr{|2 j˸.xۚ?Xה+֤0tR" " YC cb[o97 /.Ȅ-_3썇m۶'WMHp: zxdmܹ`ZԘW"{1@:x~ RU'ߠfzBiVj#kRg+Xן/X%nnsw5#X'{.x}2A#>UFTuԩ} !~< Iy!+1N畀<~Acǎ-,*s^-MRVb{uSfh@Șg:*(YF(#bIW\!M978:OВ՝7pYzt@@;W>aqbCssm9.zZ!" "WJELQ5VD Tm|DFU" "U2bVQPeE@D@D@D@D$M$5D@D@D@D@D$ N@itީ^u)2-[fQU}T HLihY7hO9VV_~q/BUI@b:MPJ&eAYpM>^M HT~<6\ul!؜9sOSmQ?-z6_ND@D@D@D AZD@D@D@D HLvu" " " " $ 1AZD@D@D@D HLvu" " " " $ 1AZXzu͉mSN'w!H3l0K'mT='MfÇϺY_.x$6o<㏓%)mH3gδ?<)* ~1)/<e.mnq˴=`:.*Jp[N0u2kEIW~|uK~ B+7>˗۝wc=fgy 4ȦMX^qJPo[﷧GQ_Q'd>߸1_g\W\W>UQxUW/|1e3{ Tު"}-+Vƍ?DrGZ͚5r-W,A.9i$뮻lѢE۹>/r[}ַo_kذ/Yyx(Mک؏?m&ֳgO~:uN:?е [la/vu{ᇭjժ6x`wIDx {rvA^u.dL8qlvv:~'.ˆ 7 p>]|m믿PV-޽BrSRY1`ׯ|ծ]o;o+ ӧE {{uc^qnl[4KZq~QGw^^zN^3e'l:tO>ĝ8/YgFƝ`O~?}fyT ۖ[niww}6{lN\{u9қ[o#?*UoJU"z6m\sۥKW?!F_S ɂϟODO?.h]wkIhvanD̚5FH"ƱksX"׉u5.jot\*7YNׇi']w[ї]vY}뭷ھӦnj>/U,P?L\d1j*%]>~=;sm۶n̸jٲeRQO-{CDaL>N㏻I3N:rDž|/vƍ"s\sn@衇(5%ncq,!<2!&T1պuk{]ni7._dԱ+&L晈N2ݤAq΄LֶzkW4N2j>xy=ww I8=D" 1]k."ÛsW;"C=]ph .sM>݉qA+\>hnV[m7&+fBZo\vOɀqF]g"4/#]:AE`Ei#"! ?.5W_}L0Tḟ>e\rl sf+jޞOx3.^i%q|pL3#X#@"8q !fҥF<4c/LY:xֱcG'?#7FȌ #`G9Nc6l3OoiE_?(3$}c"P1bKآ%0;< @N[˅/kHK>؉KfC$aGHpq Dx%D}{^.^^ 闣ғ旓ӷ1.n٧a.q> at '.xL~LHUVQ<{B1(O }->~ a*Dq!D@"nuQZCueH(_lVƭϏOB,"xiX$_w&:m-#C0byRa >Xp~h>9z~ ? {/!NnnA_<ƿ {Y|B6RȤw qF~0=}gyp^&\)lQ';?N*q8Z0˖@*ۺtvDi /d4x(PG=zkQ֑R< TFLe\h/|G |ҵ?\vk\&de^6<Ǎe˖EC`x lWp}q3&XSO\Sl3Qa $}qDx[o\?˱1Jt.;f)?2._}<(tl=apcD>qc?XFqƤ?U}:oggl<%;JH#f{qN"ӱQ+ٗE' 93Y+JYk=S0]xW>`[Q}Ndu,Nީ'ݩuQŭy }ܡ iy++X_q*.+N i×Q>7viR_-~4,';&it 3]U@ nec=WųxE̼P#@fHLgrjdtF*S| 16dtF*S| 16dtF*S| 16dtF*S| 16d@:k֬" " " " "s9٭j@iPGiPV" " " " 9I@b:'U( ӥAYe$V5JD@D@D@D4HLe!" " " "$s[( 1]U@NnUD@D@D@D@JtiPV" " " " 9I?'((VXas̱_~~r\Ӓl`o5jȪVZ+7(!9lD 'NuZ /a޼ypBkݺUV-_vd,,U5̘1֮]kM6Ϳӧ|f@Ptt#F." "P HLQDP| 5jUx($oߨf" " " " 圀t9 UOD@D@D@D.}s弃T=K@bj&" " " "P d\L/_>3{ꩧlwo=xk>z- opGy\Uhyڴi;~Ƙ/gu92fkS4f;SW}d@FNw^ve l 2Ğ~i]odɒj"[tzef׆ٚ4ibg϶%\b;:+38yn+r\ykyGx^<,mf lIqԬ6yXkUuwTJ#QM7duԱ[޽+W:QG ,~Z8vc~-nՅ D-?`~Ҵ?o-B6NSE'/n[Th_&2k4p@Yׯ_X3VOտM_߷+SE2&14ڵkW^7#qDg'Yf+o\=zڂtˈ۪Ucǎ믿nb)v<0j7b>hX&~XV-7bb[nM2%R&\9 !wyp>eۘhri = A줓NJjr!6פֿˣ(뽐J5 :fʅZmieEt\HuN_nNЂi2 AC>UM 7Zj.GyW_9Îj ӧaÆ7p/̙3>t '㎳ѣG;o4[oy#>4ůsn_cu/jO/P&HlB ;221=,mQ j͸.xۚ?Xה+,KK+3% ӛm_oB ïCvxs샨A@k L.#$çýt?هm/盷wON z<{znݶm[E'm6vEڞnbX2~Beǖ>=z$m$wDJ:tP,L2ib"[IXt6cb| 'LD@D *vyFxeB+"@G}:/3q%IH.,o7|PgL\4:=ؠA{饗/t j<~WoqOeh0! &QFYƍ]۷wfݬY\62BȗVH8(x <'}C5/3!yȰbA)asv}Any[/[:y7 cua A#s9Fqxh>KO>Pvm.x۵kǢ ;73l5믻 IClA( oDƷme_p{I;=^&" " 呀tyI"ШQ#>ӧO7X?2i$86sLcwg81i f Tu u$]\ _(}qdӘXqGYOisc$?-Zd˲,ٗd%/l)3g̝;׮*֭s9'6` Ċ4{={wL]8_m/B^}U;]=>ルb,YbFݞ Ჟz)U|P:鏢Tnza'tf͚,|b(c"2d}A?礸9h֬Ynȑ#b?;Oi// ʪeE^%+Wu]gճozizHL&J֮]6è|?.s=gg}=#־}{ ʓ e}:Ӹorƍ`>+>37}p|)Wpt8ޗ=n}QʉKNިSIDATWtM,.勤uQQyPF\ǭOV۸qIrK,$*v--e[J"?N;4p AvQGn NwuW"Cc9mdoXJl=t@#'\D ku7<6:uri1)>OmN 5n`ʑGi5k4֓-bx$6mjz5le~6d7Q|6c fm\6|:\|v'N;d&LpNXQ" (r MBkK/5_[<3mwt̸kk9+i{ܱ?kW\a*TpYEBtO:r!=ug`z/ s=}64oWJ/M2M.~^=CALc/s`18 &M-^0N_|E\tA֪Uu&TFQkKߗn!i wSwZ^yk /Ѝƭ/{Ǝmxc֯_?oN:}nEqhI~?O1wG_|ojժeݻwvMnOݱи .G=zh7fw_;ܘرɸK2wy+>UUТ˭oSNu'Nty8&ȜS9?] Ň~؍9Γwn6>ˀ̜tpGꫯ0ݶ#F$/8w}de9AҥS1c\BN3.a~m-;9{lC,pA( 8i }]wuIG W IpG\aqu@xU .EFëN~\PgM7g} CעE wQ|D7._xsQBʟp~=䈧 a}_o[o[v8ÝիW/ϱs}K?֭[׍Ν;'<+Vpm̝cu#Ҿۮ;S#$26!ӂ۷MN?$8hD\:yPc}2J` ?&'1Aҟ=Mp8SY19nڴiv= 7`C q9&>>r4vQ{_9).>=q9F׮].>VQL8^NHdw,OlƹKkOl>%ˆJS(/ /<-iŒF`9׮]ۉqO8! pAexc/"hCL0⋋G䓆=!>v[N,XĄ sK;TQpK:kig5tP{'|ܵr~ 3 dM%1!u-p8'{ NpQ5\jUwgO4'H;SuFPNf͚9tԟqh^bs%Gܩ1n|߆Eܩ`\'k1'GDqVIj :V1"=w{eDPG $p7o{ 6 /ÿD9o\ }xXX3 '&bl? u!?̷1e~{x2|ݫˆg {?]x<>ț6ܩ ܉pzp`G|ly>fVBB۶mG42a}7Pg2  V񟯃LO%X:u YY!ԽI5%-oZbΛ=xlw7 hA\Dx^".tVqT DB[Dž+ e@" ֥"z 傎 `^Uxռ7x”ϭ{B8XX ?wܒ|+8'ߍcS.쳏 Q`q<XN[0`Z!.xh3G!;GijMR)2 U8z#䵗L9Oqslpa`EMP+e>rW:1P'^F*y'Y#{jxyxЋjĵ˧H[i7>Aμe #d7TQׅfןuq\ B3b p ?8ݽ1"F##pqfm5l`z}Lւeұ!E>ZE<"( `RǗ$҇z3Bx!y;U0B46“x6?TVrJcxcD}0ڇט_88yB|_D0cLP40ebEyo3jlytI6^_<ꟻ Lj4>=``8U+7Dp_ D;יԧgPZ Jr"C=vD4Ek4xYc䃠 s L}-*pwS_1a[\(/mOfꀧ DDԧb,/U`mQ\.ܼ Y~ n=ٗenl3JOxar8 6}}Te$;n}}㧽E@D@ʘ1x BYx; e/tcHs'.#|uYD@r!/jZƛ!,x9yԇe"*@tw." " " "PbW" " " " YL@b:;OU([e_d1,k֬" " " " "s9٭j@iPGiPV" " " " 9I@b:'U( ӥAYe$V5JD@D@D@D4HLe!" " " "$s[( 1]U@NnUD@D@D@D@JtiPV" " " " 9I@b:'U( ӥAYe$V5JD@D@D@D4HLe!" " " "$s[( 1]U@NRD@Jas̱^*eu lFYFZj&22YI&Yݺum75jl[{Ö-[f-\Zn-A];L)9՝jӧš6mZz'Ihdb3U@ ?@x lѶzjnYfҺ1cec 6mƪW^*e;[neBD,#Ў _&x>""  PD}P"rСf'l ݻ{`8ޭTs=?C{mʕۦln 0͛g^<{VR%kӦM8@1c5i$gړ+ QJO" B c3gȑ#㏷:x'__'fZje*Tpi{2=6[k6Egv.ȭS}w[z\>w6pC|WX7:u긺믝7h}+-ܒhYj*[xq]h"𖯋 ׯȇuLD` (2&TZXEtj֬iƍ|msygo=SN4򪧣: ~ze 4p.]+ɐvy DR6kv'x;0 1s^}Ն(b$l׮͞=y?'/2CkqM#fуvbGq1ٝ:urb;|3Ux/b'7x6~xA׮]]-[:lD嗉@8sNZplҌ&:!I+Doqjx1b"y0aU2”p7Nhz! ݻw72w_R׸qc=u&Ob?SWVn&LpuABȐ!C'zvr'{ xX۶mr1BQ(NB˗u.+qмyscEL^z,XҮUU{>0:ayիc; vi.Ixot_/X}IKLW_}^E8H7^>sbk\sDŽao[ FƴiӜ"0s?P>^qY ACHn"\dg7Ju"!N][7Ky^6'#hm4m {X(+[N"tINL &$hx߈\01={ &Ye+x{ ߉[JE"ƃx3e3w\{2URL.O8'}UW]e[ouEL;'ky&b}/-L{o\YǶ nӲ&i!Q^^@mFD P`<H?{]vA9:Cif-Zp駟lҥVV-_q#OYHÂz]{6uT?a!#B裏)SX~1&`_ 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-headers`: :: pip install django-oauth-toolkit django-cors-headers Start a Django project, add `oauth2_provider` and `corsheaders` to the installed apps, and enable admin: .. code-block:: python INSTALLED_APPS = { 'django.contrib.admin', # ... 'oauth2_provider', 'corsheaders', } Include the Django OAuth Toolkit urls in your `urls.py`, choosing the urlspace you prefer. For example: .. code-block:: python from django.urls import path, include urlpatterns = [ path("admin", admin.site.urls), path("o/", include('oauth2_provider.urls', namespace='oauth2_provider')), # ... ] Include the CORS middleware in your `settings.py`: CorsMiddleware should be placed as high as possible, especially before any middleware that can generate responses such as Django's CommonMiddleware or Whitenoise's WhiteNoiseMiddleware. If it is not before, it will not be able to add the CORS headers to these responses. .. code-block:: python MIDDLEWARE = ( # ... 'corsheaders.middleware.CorsMiddleware', # ... ) Allow CORS requests from all domains (just for the scope of this tutorial): .. code-block:: python CORS_ORIGIN_ALLOW_ALL = True .. _loginTemplate: Include the required hidden input in your login template, `registration/login.html`. The ``{{ next }}`` template context variable will be populated with the correct redirect value. See the `Django documentation `_ for details on using login templates. .. code-block:: html As a final step, execute the migrate command, start the internal server, and login with your credentials. Create an OAuth2 Client Application ----------------------------------- Before your :term:`Application` can use the :term:`Authorization Server` for user login, you must first register the app (also known as the :term:`Client`.) Once registered, your app will be granted access to 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 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: * `User`: the owner of the Application (e.g. a developer, or the currently logged in user.) * `Redirect uris`: Applications must register at least one redirection endpoint before using the authorization endpoint. The :term:`Authorization Server` will deliver the access token to the client only if the client specifies one of the verified redirection uris. For this tutorial, paste verbatim the value `https://www.getpostman.com/oauth2/callback` * `Client type`: this value affects the security level at which some communications between the client application and the authorization server are performed. For this tutorial choose *Confidential*. * `Authorization grant type`: choose *Authorization code* * `Name`: this is the name of the client application on the server, and will be displayed on the authorization request page, where users can allow/deny access to their data. Take note of the `Client id` and the `Client Secret` then logout (this is needed only for testing the authorization process we'll explain shortly) Test Your Authorization Server ------------------------------ Your authorization server is ready and can begin issuing access tokens. To test the process you need an OAuth2 consumer; if you are familiar enough with OAuth2, you can use curl, requests, or anything that speaks http. For this tutorial, we suggest using [Postman](https://www.postman.com/downloads/) : Open up the Authorization tab under a request and, for this tutorial, set the fields as follows: * Grant type: `Authorization code (With PKCE)` * Callback URL: `https://www.getpostman.com/oauth2/callback` <- need to be in your added application * Authorize using browser: leave unchecked * Auth URL: `http://localhost:8000/o/authorize/` * Access Token URL: `http://localhost:8000/o/token/` * Client ID: `random string for this app, as generated` * Client Secret: `random string for this app, as generated` <- must be before hashing, should not begin with 'pbkdf2_sha256' or similar The rest can be left to their (mostly empty) default values. Build an Authorization Link for Your Users ++++++++++++++++++++++++++++++++++++++++++ Authorizing an application to access OAuth2 protected data in an :term:`Authorization Code` flow is always initiated by the user. Your application can prompt users to click a special link to start the process. Here, we click "Get New Access Token" in postman, which should open your browser and show django's login. Authorize the Application +++++++++++++++++++++++++ When a user clicks the link, she is redirected to your (possibly local) :term:`Authorization Server`. If you're not logged in, you will be prompted for username and password. This is because the authorization page is login protected by django-oauth-toolkit. Login, then you should see the (not so cute) form a user can use to give her authorization to the client application. Flag the *Allow* checkbox and click *Authorize*, you will be redirected again to the consumer service. Possible errors: * loginTemplate: If you are not redirected to the correct page after logging in successfully, you probably need to `setup your login template correctly`__. * invalid client: client id and client secret needs to be correct. Secret cannot be copied from Django admin after creation. (but you can reset it by pasting the same random string into Django admin and into Postman, to avoid recreating the app) * invalid callback url: Add the postman link into your app in Django admin. * invalid_request: Use "Authorization Code (With PCKE)" from postman or disable PKCE in Django Exchange the token ++++++++++++++++++ At this point your authorization server redirected the user to a special page on the consumer passing in an :term:`Authorization Code`, a special token the consumer will use to obtain the final access token. If everything is ok, you will be routed to another page showing your access token, the token type, its lifetime and the :term:`Refresh Token`. Refresh the token +++++++++++++++++ The page showing the access token retrieved from the :term:`Authorization Server` also let you make a POST request to the server itself to swap the refresh token for another, brand new access token. Just fill in the missing form fields and click the Refresh button: if everything goes smoothly you will see the access and refresh token change their values, otherwise you will likely see an error message. When you have finished playing with your authorization server, take note of both the access and refresh tokens, we will use them for the next part of the tutorial. So let's make an API and protect it with your OAuth2 tokens in the :doc:`part 2 of the tutorial `. django-oauth-toolkit-2.3.0/docs/tutorial/tutorial_02.rst000066400000000000000000000107441443573112200232600ustar00rootroot00000000000000Part 2 - protect your APIs ========================== Scenario -------- It's very common for an :term:`Authorization Server` to also be the :term:`Resource Server`, usually exposing an API to let others access its own resources. Django OAuth Toolkit implements an easy way to protect the views of a Django application with OAuth2, in this tutorial we will see how to do it. Make your API ------------- We start where we left the :doc:`part 1 of the tutorial `: you have an authorization server and we want it to provide an API to access some kind of resources. We don't need an actual resource, so we will simply expose an endpoint protected with OAuth2: let's do it in a *class based view* fashion! Django OAuth Toolkit provides a set of generic class based view you can use to add OAuth behaviour to your views. Open your `views.py` module and import the view: .. code-block:: python from oauth2_provider.views.generic import ProtectedResourceView from django.http import HttpResponse Then create the view which will respond to the API endpoint: .. code-block:: python class ApiEndpoint(ProtectedResourceView): def get(self, request, *args, **kwargs): return HttpResponse('Hello, OAuth2!') That's it, our API will expose only one method, responding to `GET` requests. Now open your `urls.py` and specify the URL this view will respond to: .. code-block:: python from django.urls import path, include import oauth2_provider.views as oauth2_views from django.conf import settings from .views import ApiEndpoint # OAuth2 provider endpoints oauth2_endpoint_views = [ path('authorize/', oauth2_views.AuthorizationView.as_view(), name="authorize"), path('token/', oauth2_views.TokenView.as_view(), name="token"), path('revoke-token/', oauth2_views.RevokeTokenView.as_view(), name="revoke-token"), ] if settings.DEBUG: # OAuth2 Application Management endpoints oauth2_endpoint_views += [ path('applications/', oauth2_views.ApplicationList.as_view(), name="list"), path('applications/register/', oauth2_views.ApplicationRegistration.as_view(), name="register"), path('applications//', oauth2_views.ApplicationDetail.as_view(), name="detail"), path('applications//delete/', oauth2_views.ApplicationDelete.as_view(), name="delete"), path('applications//update/', oauth2_views.ApplicationUpdate.as_view(), name="update"), ] # OAuth2 Token Management endpoints oauth2_endpoint_views += [ path('authorized-tokens/', oauth2_views.AuthorizedTokensListView.as_view(), name="authorized-token-list"), path('authorized-tokens//delete/', oauth2_views.AuthorizedTokenDeleteView.as_view(), name="authorized-token-delete"), ] urlpatterns = [ # OAuth 2 endpoints: # need to pass in a tuple of the endpoints as well as the app's name # because the app_name attribute is not set in the included module path('o/', include((oauth2_endpoint_views, 'oauth2_provider'), namespace="oauth2_provider")), path('api/hello', ApiEndpoint.as_view()), # an example resource endpoint ] You will probably want to write your own application views to deal with permissions and access control but the ones packaged with the library can get you started when developing the app. Since we inherit from `ProtectedResourceView`, we're done and our API is OAuth2 protected - for the sake of the lazy programmer. Testing your API ---------------- Time to make requests to your API. For a quick test, try accessing your app at the url `/api/hello` with your browser and verify that it responds with a `403` (in fact no `HTTP_AUTHORIZATION` header was provided). You can test your API with anything that can perform HTTP requests, but for this tutorial you can use the online `consumer client `_. Just fill the form with the URL of the API endpoint (i.e. http://localhost:8000/api/hello if you're on localhost) and the access token coming from the :doc:`part 1 of the tutorial `. Going in the Django admin and get the token from there is not considered cheating, so it's an option. Try performing a request and check that your :term:`Resource Server` aka :term:`Authorization Server` correctly responds with an HTTP 200. :doc:`Part 3 of the tutorial ` will show how to use an access token to authenticate users. django-oauth-toolkit-2.3.0/docs/tutorial/tutorial_03.rst000066400000000000000000000111731443573112200232560ustar00rootroot00000000000000Part 3 - OAuth2 token authentication ==================================== Scenario -------- You want to use an :term:`Access Token` to authenticate users against Django's authentication system. Setup a provider ---------------- You need a fully-functional OAuth2 provider which is able to release access tokens: just follow the steps in :doc:`the part 1 of the tutorial `. To enable OAuth2 token authentication you need a middleware that checks for tokens inside requests and a custom authentication backend which takes care of token verification. In your settings.py: .. code-block:: python AUTHENTICATION_BACKENDS = [ 'oauth2_provider.backends.OAuth2Backend', # Uncomment following if you want to access the admin #'django.contrib.auth.backends.ModelBackend', '...', ] MIDDLEWARE = [ '...', # If you use AuthenticationMiddleware, be sure it appears before OAuth2TokenMiddleware. # AuthenticationMiddleware is NOT required for using django-oauth-toolkit. 'django.contrib.auth.middleware.AuthenticationMiddleware', '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 Django processes authentication backends. If you put the OAuth2 backend *after* the AuthenticationMiddleware and `request.user` is valid, the backend will do nothing; if `request.user` is the Anonymous user it will try to authenticate the user using the OAuth2 access token. If you put the OAuth2 backend *before* AuthenticationMiddleware, or AuthenticationMiddleware is not used at all, it will try to authenticate user with the OAuth2 access token and set `request.user` and `request._cached_user` fields so that AuthenticationMiddleware (when active) will not try to get user from the session. If you use AuthenticationMiddleware, be sure it appears before OAuth2TokenMiddleware. However AuthenticationMiddleware is NOT required for using django-oauth-toolkit. Protect your view ----------------- The authentication backend will run smoothly with, for example, `login_required` decorators, so that you can have a view like this in your `views.py` module: .. code-block:: python from django.contrib.auth.decorators import login_required from django.http.response import HttpResponse @login_required() def secret_page(request, *args, **kwargs): return HttpResponse('Secret contents!', status=200) To check everything works properly, mount the view above to some url: .. code-block:: python urlpatterns = [ path('secret', 'my.views.secret_page', name='secret'), '...', ] You should have an :term:`Application` registered at this point, if you don't, follow the steps in the previous tutorials to create one. Obtain an :term:`Access Token`, either following the OAuth2 flow of your application or manually creating in the Django admin. Now supposing your access token value is `123456` you can try to access your authenticated view: :: curl -H "Authorization: Bearer 123456" -X GET http://localhost:8000/secret Working with Rest_framework generic class based views ----------------------------------------------------- If you have completed the `Django REST framework tutorial `_, you will be familiar with the 'Snippet' example, in particular the SnippetList and SnippetDetail classes. It would be nice to reuse those views **and** support token handling. Instead of reworking those classes to be ProtectedResourceView based, the solution is much simpler than that. Assume you have already modified the settings as was already shown. The key is setting a class attribute to override the default *permissions_classes* with something that will use our :term:`Access Token` properly. .. code-block:: python from oauth2_provider.contrib.rest_framework import TokenHasReadWriteScope class SnippetList(generics.ListCreateAPIView): ... permission_classes = [TokenHasReadWriteScope] class SnippetDetail(generics.ListCreateAPIView): ... permission_classes = [TokenHasReadWriteScope] Note that this example overrides the Django default permission class setting. There are several other ways this can be solved. Overriding the class function *get_permission_classes* is another way to solve the problem. A detailed dive into the `Django REST framework permissions is here. `_ django-oauth-toolkit-2.3.0/docs/tutorial/tutorial_04.rst000066400000000000000000000044001443573112200232520ustar00rootroot00000000000000Part 4 - Revoking an OAuth2 Token ================================= Scenario -------- You've granted a user an :term:`Access Token`, following :doc:`part 1 ` and now you would like to revoke that token, probably in response to a client request (to logout). Revoking a Token ---------------- Be sure that you've granted a valid token. If you've hooked in `oauth-toolkit` into your `urls.py` as specified in :doc:`part 1 `, you'll have a URL at `/o/revoke_token`. By submitting the appropriate request to that URL, you can revoke a user's :term:`Access Token`. `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'. Note that these revocation-specific parameters are in addition to the authentication parameters already specified by your particular client type. Setup a Request --------------- Depending on the client type you're using, the token revocation request you may submit to the authentication server may vary. A `Public` client, for example, will not have access to your `Client Secret`. A revoke request from a public client would omit that secret, and take the form: :: POST /o/revoke_token/ HTTP/1.1 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: :: POST /o/revoke_token/ HTTP/1.1 Content-Type: application/x-www-form-urlencoded 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. :: curl --data "token=XXXX&client_id=XXXX&client_secret=XXXX" http://localhost:8000/o/revoke_token/ django-oauth-toolkit-2.3.0/docs/tutorial/tutorial_05.rst000066400000000000000000000123551443573112200232630ustar00rootroot00000000000000Part 5 - Using Celery to Automate Maintenance Chores ==================================================== Scenario -------- In :doc:`Part 1 ` you created your own :term:`Authorization Server` and it's running along just fine. However, the database is getting cluttered with expired tokens. You can periodically run the :doc:`cleartokens management command <../management_commands>`, but why not automate this with `Celery `_? Set up RabbitMQ --------------- Celery components communicate via a message queue. We'll use `RabbitMQ `_. Install RabbitMQ on MacOS ~~~~~~~~~~~~~~~~~~~~~~~~~~ If you are using MacOS it's likely you are already using `Homebrew `_. If not, now's the time to install this fantastic package manager. :: brew install rabbitmq brew service start rabbitmq Install RabbitMQ with Docker ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ This will start up a docker image that just works: :: docker run -it --rm --name rabbitmq -p 5672:5672 -p 15672:15672 rabbitmq:3.9-management Install RabbitMQ on Windows ~~~~~~~~~~~~~~~~~~~~~~~~~~~ See the `RabbitMQ Installing on Windows `_ instructions. Add Celery ---------- Make sure you virtualenv is active and install `celery` and `django-celery-beat `_. :: pip install celery django-celery-beat Update your list of installed apps to include both your :term:`Authorization Server` app -- we'll call it ``tutorial``, and ``django_celery_beat`` which extends your Django project to store your periodic task schedule in the database and adds a Django Admin interface for configuring them. .. code-block:: python INSTALLED_APPS = { # ... "tutorial", "django_celery_beat", } Now add a new file to your app to add Celery: ``tutorial/celery.py``: .. code-block:: python import os from celery import Celery # Set the default Django settings module for the 'celery' program. os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tutorial.settings') app = Celery('tutorial', broker="pyamqp://guest@localhost//") app.config_from_object('django.conf:settings', namespace='CELERY') # Load task modules from all registered Django apps. app.autodiscover_tasks() This will autodiscover any ``tasks.py`` files in the list of installed apps. We'll add ours now in ``tutorial/tasks.py``: .. code-block:: python from celery import shared_task @shared_task def clear_tokens(): from oauth2_provider.models import clear_expired clear_expired() Finally, update ``tutorial/__init__.py`` to make sure Celery gets loaded when the app starts up: .. code-block:: python from .celery import app as celery_app __all__ = ('celery_app',) Run Celery Beat and the Worker ------------------------------ RabbitMQ should already be running; it's the "glue" between Beat and the Worker. It's best to run each of these in its own terminal window so you can see the log messages. Start Celery Beat ~~~~~~~~~~~~~~~~~ :: celery -A tutorial beat -l INFO --scheduler django_celery_beat.schedulers:DatabaseScheduler Start Celery Worker ~~~~~~~~~~~~~~~~~~~ :: celery -A tutorial worker -l INFO Configure the ``clear_tokens`` task ----------------------------------- Go into `Django Admin `_ and you'll see a new section for periodic tasks: .. image:: admin+celery.png :width: 500 :alt: Django Admin interface screenshot Now let's define a fairly short (10 second) interval. Go to: http://127.0.0.1:8000/admin/django_celery_beat/intervalschedule/ and select Add Interval, set number of intervals to 10 and interval period to seconds and Save. Then go to http://127.0.0.1:8000/admin/django_celery_beat/periodictask/ to add a new periodic task by selecting `Add Periodic Task `_ and select ``tutorial.tasks.clear_tokens``, choose the ``every 10 seconds`` interval schedule, and "Save." .. image:: celery+add.png :width: 500 :alt: Django Admin interface screenshot Now your Celery Beat and Celery Workers should start running the task every 10 seconds. The Beat console will look like this: :: [2022-03-19 22:06:35,605: INFO/MainProcess] Scheduler: Sending due task clear stale tokens (tutorial.tasks.clear_tokens) And the Workers console like this: :: [2022-03-19 22:06:35,614: INFO/MainProcess] Task tutorial.tasks.clear_tokens[5ec25fb8-5ce3-4d15-b9ad-750b80fc07e0] received [2022-03-19 22:06:35,616: INFO/ForkPoolWorker-8] refresh_expire_at is None. No refresh tokens deleted. [2022-03-19 22:06:35,629: INFO/ForkPoolWorker-8] 0 Expired access tokens deleted [2022-03-19 22:06:35,631: INFO/ForkPoolWorker-8] 0 Expired grant tokens deleted [2022-03-19 22:06:35,632: INFO/ForkPoolWorker-8] Task tutorial.tasks.clear_tokens[5ec25fb8-5ce3-4d15-b9ad-750b80fc07e0] succeeded in 0.016124433999999965s: None References ---------- The preceding is based on these references: https://docs.celeryq.dev/en/stable/django/first-steps-with-django.html https://docs.celeryq.dev/en/stable/userguide/periodic-tasks.html#beat-custom-schedulers https://django-celery-beat.readthedocs.io/en/latest/index.html django-oauth-toolkit-2.3.0/docs/views/000077500000000000000000000000001443573112200176465ustar00rootroot00000000000000django-oauth-toolkit-2.3.0/docs/views/application.rst000066400000000000000000000010341443573112200227010ustar00rootroot00000000000000Application Views ================= A set of views is provided to let users handle application instances without accessing Django Admin Site. Application views are listed at the url `applications/` and you can register a new one at the url `applications/register`. You can override default templates located in `templates/oauth2_provider` folder and provide a custom layout. Every view provides access only to data belonging to the logged in user who performs the request. .. automodule:: oauth2_provider.views.application :members: django-oauth-toolkit-2.3.0/docs/views/class_based.rst000066400000000000000000000045461443573112200226540ustar00rootroot00000000000000Class-based Views ================= Django OAuth Toolkit provides generic classes useful to implement OAuth2 protected endpoints using the *Class Based View* approach. .. class:: ProtectedResourceView(ProtectedResourceMixin, View): A view that provides OAuth2 authentication out of the box. To implement a protected endpoint, just define your CBV as:: class MyEndpoint(ProtectedResourceView): """ A GET endpoint that needs OAuth2 authentication """ def get(self, request, *args, **kwargs): return HttpResponse('Hello, World!') **Please notice**: ``OPTION`` method is not OAuth2 protected to allow preflight requests. .. class:: ScopedProtectedResourceView(ScopedResourceMixin, ProtectedResourceView): A view that provides OAuth2 authentication and scopes handling out of the box. To implement a protected endpoint, just define your CBV specifying the ``required_scopes`` field:: class MyScopedEndpoint(ScopedProtectedResourceView): required_scopes = ['can_make_it can_break_it'] """ A GET endpoint that needs OAuth2 authentication and a set of scopes: 'can_make_it' and 'can_break_it' """ def get(self, request, *args, **kwargs): return HttpResponse('Hello, World!') .. class:: ReadWriteScopedResourceView(ReadWriteScopedResourceMixin, ProtectedResourceView): A view that provides OAuth2 authentication and read/write default scopes. ``GET``, ``HEAD``, ``OPTIONS`` http methods require ``read`` scope, others methods need the ``write`` scope. If you need, you can always specify an additional list of scopes in the ``required_scopes`` field:: class MyRWEndpoint(ReadWriteScopedResourceView): required_scopes = ['has_additional_powers'] # optional """ A GET endpoint that needs OAuth2 authentication and the 'read' scope. If required_scopes was specified, clients also need those scopes. """ def get(self, request, *args, **kwargs): return HttpResponse('Hello, World!') Generic views in DOT are obtained composing a set of mixins you can find in the :doc:`views.mixins ` module: feel free to use those mixins directly if you want to provide your own class based views. django-oauth-toolkit-2.3.0/docs/views/details.rst000066400000000000000000000012201443573112200220200ustar00rootroot00000000000000Views code and details ====================== Generic ------- Generic views are intended to use in a "batteries included" fashion to protect own views with OAuth2 authentication and Scopes handling. .. automodule:: oauth2_provider.views.generic :members: Mixins ------ These views are mainly for internal use, but advanced users may use them as basic components to customize OAuth2 logic inside their Django applications. .. automodule:: oauth2_provider.views.mixins :members: Base ---- Views needed to implement the main OAuth2 authorization flows supported by Django OAuth Toolkit. .. automodule:: oauth2_provider.views.base :members: django-oauth-toolkit-2.3.0/docs/views/function_based.rst000066400000000000000000000044521443573112200233700ustar00rootroot00000000000000Function-based views ==================== Django OAuth Toolkit provides decorators to help you in protecting your function-based views. .. function:: protected_resource(scopes=None, validator_cls=OAuth2Validator, server_cls=Server) Decorator to protect views by providing OAuth2 authentication out of the box, optionally with scope handling. Basic usage, without using scopes:: from oauth2_provider.decorators import protected_resource @protected_resource() def my_view(request): # An access token is required to get here... # ... pass If you want to check scopes as well when accessing a view you can pass them along as decorator's parameter:: from oauth2_provider.decorators import protected_resource @protected_resource(scopes=['can_make_it can_break_it']) def my_view(request): # An access token AND the right scopes are required to get here... # ... pass The decorator also accept server and validator classes if you want or need to use your own OAuth2 logic:: from oauth2_provider.decorators import protected_resource from myapp.oauth2_validators import MyValidator @protected_resource(validator_cls=MyValidator) def my_view(request): # You have to leverage your own logic to get here... # ... pass .. function:: rw_protected_resource(scopes=None, validator_cls=OAuth2Validator, server_cls=Server) Decorator to protect views by providing OAuth2 authentication and read/write scopes out of the box. GET, HEAD, OPTIONS http methods require "read" scope. Otherwise "write" scope is required:: from oauth2_provider.decorators import rw_protected_resource @rw_protected_resource() def my_view(request): # If this is a POST, you have to provide 'write' scope to get here... # ... pass If you need, you can ask for other scopes over "read" and "write":: from oauth2_provider.decorators import rw_protected_resource @rw_protected_resource(scopes=['exotic_scope']) def my_view(request): # If this is a POST, you have to provide 'exotic_scope write' scopes to get here... # ... pass django-oauth-toolkit-2.3.0/docs/views/mixins.rst000066400000000000000000000001651443573112200217110ustar00rootroot00000000000000Mixins for Class Based Views ============================ .. automodule:: oauth2_provider.views.mixins :members: django-oauth-toolkit-2.3.0/docs/views/token.rst000066400000000000000000000012601443573112200215170ustar00rootroot00000000000000Granted Tokens Views ==================== A set of views is provided to let users handle tokens that have been granted to them, without needing to accessing Django Admin Site. Every view provides access only to the tokens that have been granted to the user performing the request. 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. .. automodule:: oauth2_provider.views.token :members: django-oauth-toolkit-2.3.0/docs/views/views.rst000066400000000000000000000003241443573112200215340ustar00rootroot00000000000000Using the views =============== Django OAuth Toolkit provides a set of pre-defined views for different purposes: .. toctree:: :maxdepth: 2 function_based class_based application token mixins django-oauth-toolkit-2.3.0/oauth2_provider/000077500000000000000000000000001443573112200206755ustar00rootroot00000000000000django-oauth-toolkit-2.3.0/oauth2_provider/__init__.py000066400000000000000000000001751443573112200230110ustar00rootroot00000000000000import django __version__ = "2.3.0" if django.VERSION < (3, 2): default_app_config = "oauth2_provider.apps.DOTConfig" django-oauth-toolkit-2.3.0/oauth2_provider/admin.py000066400000000000000000000052441443573112200223440ustar00rootroot00000000000000from django.contrib import admin from django.contrib.auth import get_user_model from oauth2_provider.models import ( get_access_token_admin_class, get_access_token_model, get_application_admin_class, get_application_model, get_grant_admin_class, get_grant_model, get_id_token_admin_class, get_id_token_model, get_refresh_token_admin_class, get_refresh_token_model, ) 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") radio_fields = { "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",) list_select_related = ("application", "user") 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() access_token_model = get_access_token_model() grant_model = get_grant_model() id_token_model = get_id_token_model() refresh_token_model = get_refresh_token_model() application_admin_class = get_application_admin_class() access_token_admin_class = get_access_token_admin_class() grant_admin_class = get_grant_admin_class() id_token_admin_class = get_id_token_admin_class() refresh_token_admin_class = get_refresh_token_admin_class() admin.site.register(application_model, application_admin_class) admin.site.register(access_token_model, access_token_admin_class) admin.site.register(grant_model, grant_admin_class) admin.site.register(id_token_model, id_token_admin_class) admin.site.register(refresh_token_model, refresh_token_admin_class) django-oauth-toolkit-2.3.0/oauth2_provider/apps.py000066400000000000000000000002071443573112200222110ustar00rootroot00000000000000from django.apps import AppConfig class DOTConfig(AppConfig): name = "oauth2_provider" verbose_name = "Django OAuth Toolkit" django-oauth-toolkit-2.3.0/oauth2_provider/backends.py000066400000000000000000000017161443573112200230260ustar00rootroot00000000000000from django.contrib.auth import get_user_model from django.core.exceptions import SuspiciousOperation from .oauth2_backends import get_oauthlib_core UserModel = get_user_model() OAuthLibCore = get_oauthlib_core() class OAuth2Backend: """ Authenticate against an OAuth2 access token """ def authenticate(self, request=None, **credentials): if request is not None: 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): try: return UserModel.objects.get(pk=user_id) except UserModel.DoesNotExist: return None django-oauth-toolkit-2.3.0/oauth2_provider/compat.py000066400000000000000000000001631443573112200225320ustar00rootroot00000000000000""" The `compat` module provides support for backwards compatibility with older versions of Django and Python. """ django-oauth-toolkit-2.3.0/oauth2_provider/contrib/000077500000000000000000000000001443573112200223355ustar00rootroot00000000000000django-oauth-toolkit-2.3.0/oauth2_provider/contrib/__init__.py000066400000000000000000000000001443573112200244340ustar00rootroot00000000000000django-oauth-toolkit-2.3.0/oauth2_provider/contrib/rest_framework/000077500000000000000000000000001443573112200253675ustar00rootroot00000000000000django-oauth-toolkit-2.3.0/oauth2_provider/contrib/rest_framework/__init__.py000066400000000000000000000003541443573112200275020ustar00rootroot00000000000000# flake8: noqa from .authentication import OAuth2Authentication from .permissions import ( IsAuthenticatedOrTokenHasScope, TokenHasReadWriteScope, TokenHasResourceScope, TokenHasScope, TokenMatchesOASRequirements, ) django-oauth-toolkit-2.3.0/oauth2_provider/contrib/rest_framework/authentication.py000066400000000000000000000026761443573112200307730ustar00rootroot00000000000000from collections import OrderedDict from rest_framework.authentication import BaseAuthentication from ...oauth2_backends import get_oauthlib_core class OAuth2Authentication(BaseAuthentication): """ OAuth 2 authentication backend using `django-oauth-toolkit` """ www_authenticate_realm = "api" def _dict_to_string(self, my_dict): """ Return a string of comma-separated key-value pairs (e.g. k="v",k2="v2"). """ return ",".join(['{k}="{v}"'.format(k=k, v=v) for k, v in my_dict.items()]) def authenticate(self, request): """ Returns two-tuple of (user, token) if authentication succeeds, or None otherwise. """ oauthlib_core = get_oauthlib_core() valid, r = oauthlib_core.verify_request(request, scopes=[]) if valid: return r.user, r.access_token request.oauth2_error = getattr(r, "oauth2_error", {}) return None def authenticate_header(self, request): """ Bearer is the only finalized type currently """ www_authenticate_attributes = OrderedDict( [ ("realm", self.www_authenticate_realm), ] ) oauth2_error = getattr(request, "oauth2_error", {}) www_authenticate_attributes.update(oauth2_error) return "Bearer {attributes}".format( attributes=self._dict_to_string(www_authenticate_attributes), ) django-oauth-toolkit-2.3.0/oauth2_provider/contrib/rest_framework/permissions.py000066400000000000000000000146731443573112200303270ustar00rootroot00000000000000import logging from django.core.exceptions import ImproperlyConfigured from rest_framework.exceptions import PermissionDenied from rest_framework.permissions import SAFE_METHODS, BasePermission, IsAuthenticated from ...settings import oauth2_settings from .authentication import OAuth2Authentication log = logging.getLogger("oauth2_provider") class TokenHasScope(BasePermission): """ The request is authenticated as a user and the token used has the right scope """ def has_permission(self, request, view): token = request.auth if not token: return False if hasattr(token, "scope"): # OAuth 2 required_scopes = self.get_scopes(request, view) log.debug("Required scopes to access resource: {0}".format(required_scopes)) if token.is_valid(required_scopes): return True # Provide information about required scope? include_required_scope = ( oauth2_settings.ERROR_RESPONSE_WITH_SCOPES and required_scopes and not token.is_expired() and not token.allow_scopes(required_scopes) ) if include_required_scope: self.message = { "detail": PermissionDenied.default_detail, "required_scopes": list(required_scopes), } return False assert False, ( "TokenHasScope requires the" "`oauth2_provider.rest_framework.OAuth2Authentication` authentication " "class to be used." ) def get_scopes(self, request, view): try: return getattr(view, "required_scopes") except AttributeError: raise ImproperlyConfigured( "TokenHasScope requires the view to define the required_scopes attribute" ) class TokenHasReadWriteScope(TokenHasScope): """ The request is authenticated as a user and the token used has the right scope """ def get_scopes(self, request, view): try: required_scopes = super().get_scopes(request, view) except ImproperlyConfigured: required_scopes = [] # TODO: code duplication!! see dispatch in ReadWriteScopedResourceMixin if request.method.upper() in SAFE_METHODS: read_write_scope = oauth2_settings.READ_SCOPE else: read_write_scope = oauth2_settings.WRITE_SCOPE return required_scopes + [read_write_scope] class TokenHasResourceScope(TokenHasScope): """ The request is authenticated as a user and the token used has the right scope """ def get_scopes(self, request, view): try: view_scopes = super().get_scopes(request, view) except ImproperlyConfigured: view_scopes = [] if request.method.upper() in SAFE_METHODS: scope_type = oauth2_settings.READ_SCOPE else: scope_type = oauth2_settings.WRITE_SCOPE required_scopes = ["{}:{}".format(scope, scope_type) for scope in view_scopes] return required_scopes class IsAuthenticatedOrTokenHasScope(BasePermission): """ The user is authenticated using some backend or the token has the right scope This only returns True if the user is authenticated, but not using a token or using a token, and the token has the correct scope. This is usefull when combined with the DjangoModelPermissions to allow people browse the browsable api's if they log in using the a non token bassed middleware, and let them access the api's using a rest client with a token """ def has_permission(self, request, view): is_authenticated = IsAuthenticated().has_permission(request, view) oauth2authenticated = False if is_authenticated: oauth2authenticated = isinstance(request.successful_authenticator, OAuth2Authentication) token_has_scope = TokenHasScope() return (is_authenticated and not oauth2authenticated) or token_has_scope.has_permission(request, view) class TokenMatchesOASRequirements(BasePermission): """ :attr:alternate_required_scopes: dict keyed by HTTP method name with value: iterable alternate scope lists This fulfills the [Open API Specification (OAS; formerly Swagger)](https://www.openapis.org/) list of alternative Security Requirements Objects for oauth2 or openIdConnect: When a list of Security Requirement Objects is defined on the Open API object or Operation Object, only one of Security Requirement Objects in the list needs to be satisfied to authorize the request. [1](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#securityRequirementObject) For each method, a list of lists of allowed scopes is tried in order and the first to match succeeds. @example required_alternate_scopes = { 'GET': [['read']], 'POST': [['create1','scope2'], ['alt-scope3'], ['alt-scope4','alt-scope5']], } TODO: DRY: subclass TokenHasScope and iterate over values of required_scope? """ def has_permission(self, request, view): token = request.auth if not token: return False if hasattr(token, "scope"): # OAuth 2 required_alternate_scopes = self.get_required_alternate_scopes(request, view) m = request.method.upper() if m in required_alternate_scopes: log.debug( "Required scopes alternatives to access resource: {0}".format( required_alternate_scopes[m] ) ) for alt in required_alternate_scopes[m]: if token.is_valid(alt): return True return False else: log.warning("no scope alternates defined for method {0}".format(m)) return False assert False, ( "TokenMatchesOASRequirements requires the" "`oauth2_provider.rest_framework.OAuth2Authentication` authentication " "class to be used." ) def get_required_alternate_scopes(self, request, view): try: return getattr(view, "required_alternate_scopes") except AttributeError: raise ImproperlyConfigured( "TokenMatchesOASRequirements requires the view to" " define the required_alternate_scopes attribute" ) django-oauth-toolkit-2.3.0/oauth2_provider/decorators.py000066400000000000000000000060301443573112200234130ustar00rootroot00000000000000from functools import wraps from django.core.exceptions import ImproperlyConfigured from django.http import HttpResponseForbidden from oauthlib.oauth2 import Server from .oauth2_backends import OAuthLibCore from .oauth2_validators import OAuth2Validator from .scopes import get_scopes_backend from .settings import oauth2_settings def protected_resource(scopes=None, validator_cls=OAuth2Validator, server_cls=Server): """ Decorator to protect views by providing OAuth2 authentication out of the box, optionally with scope handling. @protected_resource() def my_view(request): # An access token is required to get here... # ... pass """ _scopes = scopes or [] def decorator(view_func): @wraps(view_func) def _validate(request, *args, **kwargs): validator = validator_cls() core = OAuthLibCore(server_cls(validator)) valid, oauthlib_req = core.verify_request(request, scopes=_scopes) if valid: request.resource_owner = oauthlib_req.user return view_func(request, *args, **kwargs) return HttpResponseForbidden() return _validate return decorator def rw_protected_resource(scopes=None, validator_cls=OAuth2Validator, server_cls=Server): """ Decorator to protect views by providing OAuth2 authentication and read/write scopes out of the box. GET, HEAD, OPTIONS http methods require "read" scope. Otherwise "write" scope is required. @rw_protected_resource() def my_view(request): # If this is a POST, you have to provide 'write' scope to get here... # ... pass """ _scopes = scopes or [] def decorator(view_func): @wraps(view_func) def _validate(request, *args, **kwargs): # Check if provided scopes are acceptable provided_scopes = get_scopes_backend().get_all_scopes() read_write_scopes = [oauth2_settings.READ_SCOPE, oauth2_settings.WRITE_SCOPE] if not set(read_write_scopes).issubset(set(provided_scopes)): raise ImproperlyConfigured( "rw_protected_resource decorator requires following scopes {0}" " to be in OAUTH2_PROVIDER['SCOPES'] list in settings".format(read_write_scopes) ) # Check if method is safe if request.method.upper() in ["GET", "HEAD", "OPTIONS"]: _scopes.append(oauth2_settings.READ_SCOPE) else: _scopes.append(oauth2_settings.WRITE_SCOPE) # proceed with validation validator = validator_cls() core = OAuthLibCore(server_cls(validator)) valid, oauthlib_req = core.verify_request(request, scopes=_scopes) if valid: request.resource_owner = oauthlib_req.user return view_func(request, *args, **kwargs) return HttpResponseForbidden() return _validate return decorator django-oauth-toolkit-2.3.0/oauth2_provider/exceptions.py000066400000000000000000000032141443573112200234300ustar00rootroot00000000000000class OAuthToolkitError(Exception): """ Base class for exceptions """ def __init__(self, error=None, redirect_uri=None, *args, **kwargs): super().__init__(*args, **kwargs) self.oauthlib_error = error if redirect_uri: self.oauthlib_error.redirect_uri = redirect_uri class FatalClientError(OAuthToolkitError): """ Class for critical errors """ pass class OIDCError(Exception): """ General class to derive from for all OIDC related errors. """ status_code = 400 error = None def __init__(self, description=None): if description is not None: self.description = description message = "({}) {}".format(self.error, self.description) super().__init__(message) class InvalidRequestFatalError(OIDCError): """ For fatal errors. These are requests with invalid parameter values, missing parameters or otherwise incorrect requests. """ error = "invalid_request" class ClientIdMissmatch(InvalidRequestFatalError): description = "Mismatch between the Client ID of the ID Token and the Client ID that was provided." class InvalidOIDCClientError(InvalidRequestFatalError): description = "The client is unknown or no client has been included." class InvalidOIDCRedirectURIError(InvalidRequestFatalError): description = "Invalid post logout redirect URI." class InvalidIDTokenError(InvalidRequestFatalError): description = "The ID Token is expired, revoked, malformed, or otherwise invalid." class LogoutDenied(OIDCError): error = "logout_denied" description = "Logout has been refused by the user." django-oauth-toolkit-2.3.0/oauth2_provider/forms.py000066400000000000000000000026521443573112200224020ustar00rootroot00000000000000from django import forms class AllowForm(forms.Form): allow = forms.BooleanField(required=False) redirect_uri = forms.CharField(widget=forms.HiddenInput()) scope = forms.CharField(widget=forms.HiddenInput()) nonce = forms.CharField(required=False, widget=forms.HiddenInput()) client_id = forms.CharField(widget=forms.HiddenInput()) state = forms.CharField(required=False, widget=forms.HiddenInput()) response_type = forms.CharField(widget=forms.HiddenInput()) code_challenge = forms.CharField(required=False, widget=forms.HiddenInput()) code_challenge_method = forms.CharField(required=False, widget=forms.HiddenInput()) claims = forms.CharField(required=False, widget=forms.HiddenInput()) class ConfirmLogoutForm(forms.Form): allow = forms.BooleanField(required=False) id_token_hint = forms.CharField(required=False, widget=forms.HiddenInput()) logout_hint = forms.CharField(required=False, widget=forms.HiddenInput()) client_id = forms.CharField(required=False, widget=forms.HiddenInput()) post_logout_redirect_uri = forms.CharField(required=False, widget=forms.HiddenInput()) state = forms.CharField(required=False, widget=forms.HiddenInput()) ui_locales = forms.CharField(required=False, widget=forms.HiddenInput()) def __init__(self, *args, **kwargs): self.request = kwargs.pop("request", None) super(ConfirmLogoutForm, self).__init__(*args, **kwargs) django-oauth-toolkit-2.3.0/oauth2_provider/generators.py000066400000000000000000000024311443573112200234200ustar00rootroot00000000000000from oauthlib.common import UNICODE_ASCII_CHARACTER_SET from oauthlib.common import generate_client_id as oauthlib_generate_client_id from .settings import oauth2_settings class BaseHashGenerator: """ All generators should extend this class overriding `.hash()` method. """ def hash(self): raise NotImplementedError() class ClientIdGenerator(BaseHashGenerator): def hash(self): """ Generate a client_id for Basic Authentication scheme without colon char as in http://tools.ietf.org/html/rfc2617#section-2 """ return oauthlib_generate_client_id(length=40, chars=UNICODE_ASCII_CHARACTER_SET) class ClientSecretGenerator(BaseHashGenerator): def hash(self): length = oauth2_settings.CLIENT_SECRET_GENERATOR_LENGTH chars = UNICODE_ASCII_CHARACTER_SET return oauthlib_generate_client_id(length=length, chars=chars) def generate_client_id(): """ Generate a suitable client id """ client_id_generator = oauth2_settings.CLIENT_ID_GENERATOR_CLASS() return client_id_generator.hash() def generate_client_secret(): """ Generate a suitable client secret """ client_secret_generator = oauth2_settings.CLIENT_SECRET_GENERATOR_CLASS() return client_secret_generator.hash() django-oauth-toolkit-2.3.0/oauth2_provider/http.py000066400000000000000000000021541443573112200222300ustar00rootroot00000000000000from urllib.parse import urlparse from django.core.exceptions import DisallowedRedirect from django.http import HttpResponse from django.utils.encoding import iri_to_uri class OAuth2ResponseRedirect(HttpResponse): """ An HTTP 302 redirect with an explicit list of allowed schemes. Works like django.http.HttpResponseRedirect but we customize it to give us more flexibility on allowed scheme validation. """ status_code = 302 def __init__(self, redirect_to, allowed_schemes, *args, **kwargs): super().__init__(*args, **kwargs) self["Location"] = iri_to_uri(redirect_to) self.allowed_schemes = allowed_schemes self.validate_redirect(redirect_to) @property def url(self): return self["Location"] def validate_redirect(self, redirect_to): parsed = urlparse(str(redirect_to)) if not parsed.scheme: raise DisallowedRedirect("OAuth2 redirects require a URI scheme.") if parsed.scheme not in self.allowed_schemes: raise DisallowedRedirect("Redirect to scheme {!r} is not permitted".format(parsed.scheme)) django-oauth-toolkit-2.3.0/oauth2_provider/locale/000077500000000000000000000000001443573112200221345ustar00rootroot00000000000000django-oauth-toolkit-2.3.0/oauth2_provider/locale/es/000077500000000000000000000000001443573112200225435ustar00rootroot00000000000000django-oauth-toolkit-2.3.0/oauth2_provider/locale/es/LC_MESSAGES/000077500000000000000000000000001443573112200243305ustar00rootroot00000000000000django-oauth-toolkit-2.3.0/oauth2_provider/locale/es/LC_MESSAGES/django.po000066400000000000000000000144371443573112200261430ustar00rootroot00000000000000# 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: 2022-05-29 19:04-0500\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Jordi Neil Sánchez A\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" #: oauth2_provider/models.py:66 msgid "Confidential" msgstr "Confidencial" #: oauth2_provider/models.py:67 msgid "Public" msgstr "Público" #: oauth2_provider/models.py:76 msgid "Authorization code" msgstr "Código de autorización" #: oauth2_provider/models.py:77 msgid "Implicit" msgstr "Implícito" #: oauth2_provider/models.py:78 msgid "Resource owner password-based" msgstr "Propiedario del recurso basado en contraseña" #: oauth2_provider/models.py:79 msgid "Client credentials" msgstr "Credenciales de cliente" #: oauth2_provider/models.py:80 msgid "OpenID connect hybrid" msgstr "OpenID connect híbrido" #: oauth2_provider/models.py:87 msgid "No OIDC support" msgstr "Sin soporte para OIDC" #: oauth2_provider/models.py:88 msgid "RSA with SHA-2 256" msgstr "RSA con SHA-2 256" #: oauth2_provider/models.py:89 msgid "HMAC with SHA-2 256" msgstr "HMAC con SHA-2 256" #: oauth2_provider/models.py:104 msgid "Allowed URIs list, space separated" msgstr "Lista de URIs permitidas, separadas por espacio" #: oauth2_provider/models.py:113 msgid "Hashed on Save. Copy it now if this is a new secret." msgstr "Encriptadas al guardar. Copiar ahora si este es un nuevo secreto." #: oauth2_provider/models.py:175 #, python-brace-format msgid "Unauthorized redirect scheme: {scheme}" msgstr "Esquema de redirección no autorizado: {scheme}" #: oauth2_provider/models.py:179 #, python-brace-format msgid "redirect_uris cannot be empty with grant_type {grant_type}" msgstr "redirect_uris no pueden estar vacías para el tipo de autorización {grant_type}" #: oauth2_provider/models.py:185 msgid "You must set OIDC_RSA_PRIVATE_KEY to use RSA algorithm" msgstr "Debes seleccionar OIDC_RSA_PRIVATE_KEY para usar el algoritmo RSA" #: oauth2_provider/models.py:194 msgid "You cannot use HS256 with public grants or clients" msgstr "No es posible usar HS256 con autorizaciones o clientes públicos" #: oauth2_provider/oauth2_validators.py:211 msgid "The access token is invalid." msgstr "El token de acceso es inválido." #: oauth2_provider/oauth2_validators.py:218 msgid "The access token has expired." msgstr "El token de acceso ha expirado." #: oauth2_provider/oauth2_validators.py:225 msgid "The access token is valid but does not have enough scope." msgstr "El token de acceso es válido pero no tiene suficiente alcance." #: oauth2_provider/templates/oauth2_provider/application_confirm_delete.html:6 msgid "Are you sure to delete the application" msgstr "¿Está seguro de eliminar la aplicación" #: oauth2_provider/templates/oauth2_provider/application_confirm_delete.html:12 #: oauth2_provider/templates/oauth2_provider/authorize.html:29 msgid "Cancel" msgstr "Cancelar" #: oauth2_provider/templates/oauth2_provider/application_confirm_delete.html:13 #: oauth2_provider/templates/oauth2_provider/application_detail.html:38 #: oauth2_provider/templates/oauth2_provider/authorized-token-delete.html:7 msgid "Delete" msgstr "Eliminar" #: oauth2_provider/templates/oauth2_provider/application_detail.html:10 msgid "Client id" msgstr "Identificador de cliente" #: oauth2_provider/templates/oauth2_provider/application_detail.html:15 msgid "Client secret" msgstr "Secreto de cliente" #: oauth2_provider/templates/oauth2_provider/application_detail.html:20 msgid "Client type" msgstr "Tipo de cliente" #: oauth2_provider/templates/oauth2_provider/application_detail.html:25 msgid "Authorization Grant Type" msgstr "Tipo de acceso de autorización" #: oauth2_provider/templates/oauth2_provider/application_detail.html:30 msgid "Redirect Uris" msgstr "Uris de redirección" #: oauth2_provider/templates/oauth2_provider/application_detail.html:36 #: oauth2_provider/templates/oauth2_provider/application_form.html:35 msgid "Go Back" msgstr "Volver" #: oauth2_provider/templates/oauth2_provider/application_detail.html:37 msgid "Edit" msgstr "Editar" #: oauth2_provider/templates/oauth2_provider/application_form.html:9 msgid "Edit application" msgstr "Editar aplicación" #: oauth2_provider/templates/oauth2_provider/application_form.html:37 msgid "Save" msgstr "Guardar" #: oauth2_provider/templates/oauth2_provider/application_list.html:6 msgid "Your applications" msgstr "Tus aplicaciones" #: oauth2_provider/templates/oauth2_provider/application_list.html:14 msgid "New Application" msgstr "Nueva aplicación" #: oauth2_provider/templates/oauth2_provider/application_list.html:17 msgid "No applications defined" msgstr "No hay aplicaciones definidas" #: oauth2_provider/templates/oauth2_provider/application_list.html:17 msgid "Click here" msgstr "Click aquí" #: oauth2_provider/templates/oauth2_provider/application_list.html:17 msgid "if you want to register a new one" msgstr "si quiere regitrar una nueva" #: oauth2_provider/templates/oauth2_provider/application_registration_form.html:5 msgid "Register a new application" msgstr "Registrar una nueva aplicación" #: oauth2_provider/templates/oauth2_provider/authorize.html:8 #: oauth2_provider/templates/oauth2_provider/authorize.html:30 msgid "Authorize" msgstr "Autorizar" #: oauth2_provider/templates/oauth2_provider/authorize.html:17 msgid "Application requires the following permissions" msgstr "La aplicación requiere los siguientes permisos" #: oauth2_provider/templates/oauth2_provider/authorized-token-delete.html:6 msgid "Are you sure you want to delete this token?" msgstr "¿Está seguro de que quiere eliminar este token?" #: oauth2_provider/templates/oauth2_provider/authorized-tokens.html:6 msgid "Tokens" msgstr "Tokens" #: oauth2_provider/templates/oauth2_provider/authorized-tokens.html:11 msgid "revoke" msgstr "Anular" #: oauth2_provider/templates/oauth2_provider/authorized-tokens.html:19 msgid "There are no authorized tokens yet." msgstr "No hay tokens autorizados aún." django-oauth-toolkit-2.3.0/oauth2_provider/locale/fa/000077500000000000000000000000001443573112200225225ustar00rootroot00000000000000django-oauth-toolkit-2.3.0/oauth2_provider/locale/fa/LC_MESSAGES/000077500000000000000000000000001443573112200243075ustar00rootroot00000000000000django-oauth-toolkit-2.3.0/oauth2_provider/locale/fa/LC_MESSAGES/django.po000066400000000000000000000137731443573112200261240ustar00rootroot00000000000000# 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 "هنوز هیچ توکن مجازی وجود ندارد." django-oauth-toolkit-2.3.0/oauth2_provider/locale/fr/000077500000000000000000000000001443573112200225435ustar00rootroot00000000000000django-oauth-toolkit-2.3.0/oauth2_provider/locale/fr/LC_MESSAGES/000077500000000000000000000000001443573112200243305ustar00rootroot00000000000000django-oauth-toolkit-2.3.0/oauth2_provider/locale/fr/LC_MESSAGES/django.po000066400000000000000000000126731443573112200261430ustar00rootroot00000000000000#, fuzzy msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2022-05-19 15:36+0200\n" "PO-Revision-Date: 2022-05-19 15:56+0200\n" "Last-Translator: Alejandro Mantecon Guillen \n" "Language-Team: LANGUAGE \n" "Language: fr-FR\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:66 msgid "Confidential" msgstr "Confidential" #: models.py:67 msgid "Public" msgstr "Public" #: models.py:76 msgid "Authorization code" msgstr "Code d'autorisation" #: models.py:77 msgid "Implicit" msgstr "Implicite" #: models.py:78 msgid "Resource owner password-based" msgstr "Propriétaire de la resource, basé mot-de-passe" #: models.py:79 msgid "Client credentials" msgstr "Données d'identification du client" #: models.py:80 msgid "OpenID connect hybrid" msgstr "OpenID connection hybride" #: models.py:87 msgid "No OIDC support" msgstr "Pas de support OIDC" #: models.py:88 msgid "RSA with SHA-2 256" msgstr "RSA avec SHA-2 256" #: models.py:89 msgid "HMAC with SHA-2 256" msgstr "HMAC avec SHA-2 256" #: models.py:104 msgid "Allowed URIs list, space separated" msgstr "Liste des URIs autorisés, séparés par un espace" #: models.py:113 msgid "Hashed on Save. Copy it now if this is a new secret." msgstr "Hachage en sauvegarde. Copiez-le maintenant s'il s'agit d'un nouveau secret." #: models.py:175 #, python-brace-format msgid "Unauthorized redirect scheme: {scheme}" msgstr "Schéma de redirection non autorisé : {scheme}" #: models.py:179 #, python-brace-format msgid "redirect_uris cannot be empty with grant_type {grant_type}" msgstr "redirect_uris ne peut pas être vide avec un grant_type {grant_type}" #: models.py:185 msgid "You must set OIDC_RSA_PRIVATE_KEY to use RSA algorithm" msgstr "Vous devez renseigner OIDC_RSA_PRIVATE_KEY pour l'utilisation de l'algorithme RSA" #: models.py:194 msgid "You cannot use HS256 with public grants or clients" msgstr "Vous ne pouvez pas utiliser HS256 avec des cession publiques ou clients" #: oauth2_validators.py:211 msgid "The access token is invalid." msgstr "Le token d'accès n'est pas valide." #: oauth2_validators.py:218 msgid "The access token has expired." msgstr "Le token d'accès a expiré." #: oauth2_validators.py:225 msgid "The access token is valid but does not have enough scope." msgstr "Le token d'accès est valide, mais sa portée n'est pas suffisante." #: templates/oauth2_provider/application_confirm_delete.html:6 msgid "Are you sure to delete the application" msgstr "Êtes-vous sûr de vouloir supprimer l'application" #: templates/oauth2_provider/application_confirm_delete.html:12 #: templates/oauth2_provider/authorize.html:29 msgid "Cancel" msgstr "Annuler" #: 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 "Supprimer" #: templates/oauth2_provider/application_detail.html:10 msgid "Client id" msgstr "ID du client" #: templates/oauth2_provider/application_detail.html:15 msgid "Client secret" msgstr "Secret client" #: templates/oauth2_provider/application_detail.html:20 msgid "Client type" msgstr "Type de client" #: templates/oauth2_provider/application_detail.html:25 msgid "Authorization Grant Type" msgstr "Type de flux d'autorisation" #: templates/oauth2_provider/application_detail.html:30 msgid "Redirect Uris" msgstr "URIs de redirection" #: templates/oauth2_provider/application_detail.html:36 #: templates/oauth2_provider/application_form.html:35 msgid "Go Back" msgstr "Revenir en arrière" #: templates/oauth2_provider/application_detail.html:37 msgid "Edit" msgstr "Modifier" #: templates/oauth2_provider/application_form.html:9 msgid "Edit application" msgstr "Modifier l'application" #: templates/oauth2_provider/application_form.html:37 msgid "Save" msgstr "Sauvegarder" #: templates/oauth2_provider/application_list.html:6 msgid "Your applications" msgstr "Vos applications" #: templates/oauth2_provider/application_list.html:14 msgid "New Application" msgstr "Nouvelle application" #: templates/oauth2_provider/application_list.html:17 msgid "No applications defined" msgstr "Pas d'applications définies" #: templates/oauth2_provider/application_list.html:17 msgid "Click here" msgstr "Cliquez ici" #: templates/oauth2_provider/application_list.html:17 msgid "if you want to register a new one" msgstr "si vous voulez en enregistrer une nouvelle" #: templates/oauth2_provider/application_registration_form.html:5 msgid "Register a new application" msgstr "Enregistrer une application" #: templates/oauth2_provider/authorize.html:8 #: templates/oauth2_provider/authorize.html:30 msgid "Authorize" msgstr "Autoriser" #: templates/oauth2_provider/authorize.html:17 msgid "Application requires the following permissions" msgstr "L'application nécessite les permissions suivantes" #: templates/oauth2_provider/authorized-token-delete.html:6 msgid "Are you sure you want to delete this token?" msgstr "Êtes-vous sûr de vouloir supprimer ce jeton ?" #: templates/oauth2_provider/authorized-tokens.html:6 msgid "Tokens" msgstr "Jetons" #: templates/oauth2_provider/authorized-tokens.html:11 msgid "revoke" msgstr "révoquer" #: templates/oauth2_provider/authorized-tokens.html:19 msgid "There are no authorized tokens yet." msgstr "Il n'y a pas encore de jetons." django-oauth-toolkit-2.3.0/oauth2_provider/locale/ja/000077500000000000000000000000001443573112200225265ustar00rootroot00000000000000django-oauth-toolkit-2.3.0/oauth2_provider/locale/ja/LC_MESSAGES/000077500000000000000000000000001443573112200243135ustar00rootroot00000000000000django-oauth-toolkit-2.3.0/oauth2_provider/locale/ja/LC_MESSAGES/django.po000066400000000000000000000136661443573112200261310ustar00rootroot00000000000000# 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: 2022-11-28 09:45+0900\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Sora Yanai \n" "Language-Team: LANGUAGE \n" "Language: ja-JP\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" #: models.py:66 msgid "Confidential" msgstr "プライベート" #: models.py:67 msgid "Public" msgstr "公開" #: models.py:76 msgid "Authorization code" msgstr "認証コード" #: models.py:77 msgid "Implicit" msgstr "Implicit Flow" #: models.py:78 msgid "Resource owner password-based" msgstr "リソース所有者のパスワードに基づく" #: models.py:79 msgid "Client credentials" msgstr "ユーザ証明書" #: models.py:80 msgid "OpenID connect hybrid" msgstr "OpenID Connect ハイブリットフロー" #: models.py:87 msgid "No OIDC support" msgstr "OIDCをサポートしない" #: models.py:88 msgid "RSA with SHA-2 256" msgstr "RSA with SHA-2 256" #: models.py:89 msgid "HMAC with SHA-2 256" msgstr "HMAC with SHA-2 256" #: models.py:104 msgid "Allowed URIs list, space separated" msgstr "許可されるURLのリスト(半角スペース区切り)" #: models.py:113 msgid "Hashed on Save. Copy it now if this is a new secret." msgstr "保存時にハッシュ化されます。新しいシークレットであれば、今すぐコピーしてください。" #: models.py:175 #, python-brace-format msgid "Unauthorized redirect scheme: {scheme}" msgstr "{scheme} は許可されないリダイレクトスキームです" #: models.py:179 #, python-brace-format msgid "redirect_uris cannot be empty with grant_type {grant_type}" msgstr "{grant_type} 認証タイプではリダイレクトURLを空欄にすることはできません" #: models.py:185 msgid "You must set OIDC_RSA_PRIVATE_KEY to use RSA algorithm" msgstr "RSAアルゴリズムを使用する場合はOIDC_RSA_PRIVATE_KEYを設定する必要があります" #: models.py:194 msgid "You cannot use HS256 with public grants or clients" msgstr "HS256を公開認証やユーザに使用することはできません" #: oauth2_validators.py:211 msgid "The access token is invalid." msgstr "アクセストークンが無効です。" #: oauth2_validators.py:218 msgid "The access token has expired." msgstr "アクセストークンの有効期限が切れています。" #: oauth2_validators.py:225 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 "ユーザID" #: 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 "リダイレクトURL" #: 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-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 "認証されたトークンはありません" django-oauth-toolkit-2.3.0/oauth2_provider/locale/pt/000077500000000000000000000000001443573112200225575ustar00rootroot00000000000000django-oauth-toolkit-2.3.0/oauth2_provider/locale/pt/LC_MESSAGES/000077500000000000000000000000001443573112200243445ustar00rootroot00000000000000django-oauth-toolkit-2.3.0/oauth2_provider/locale/pt/LC_MESSAGES/django.po000066400000000000000000000125101443573112200261450ustar00rootroot00000000000000#, fuzzy msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2019-01-25 11:45+0000\n" "PO-Revision-Date: 2019-01-25 11:45+0000\n" "Last-Translator: Sandro Rodrigues \n" "Language-Team: LANGUAGE \n" "Language: pt-PT\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" #: docs/_build/html/_sources/templates.rst.txt:94 #: oauth2_provider/templates/oauth2_provider/authorize.html:8 #: oauth2_provider/templates/oauth2_provider/authorize.html:30 msgid "Authorize" msgstr "Autorizar" #: docs/_build/html/_sources/templates.rst.txt:103 #: oauth2_provider/templates/oauth2_provider/authorize.html:17 msgid "Application requires the following permissions" msgstr "A aplicação requer as seguintes permissões" #: oauth2_provider/models.py:41 msgid "Confidential" msgstr "Confidencial" #: oauth2_provider/models.py:42 msgid "Public" msgstr "Público" #: oauth2_provider/models.py:50 msgid "Authorization code" msgstr "Código de autorização" #: oauth2_provider/models.py:51 msgid "Implicit" msgstr "Implícito" #: oauth2_provider/models.py:52 msgid "Resource owner password-based" msgstr "Palavra-passe do proprietário de dados" #: oauth2_provider/models.py:53 msgid "Client credentials" msgstr "Credenciais do cliente" #: oauth2_provider/models.py:67 msgid "Allowed URIs list, space separated" msgstr "Lista de URIs permitidos, separados por espaço" #: oauth2_provider/models.py:143 #, python-brace-format msgid "Unauthorized redirect scheme: {scheme}" msgstr "Esquema de redirecionamento não autorizado: {scheme}" #: oauth2_provider/models.py:148 #, python-brace-format msgid "redirect_uris cannot be empty with grant_type {grant_type}" msgstr "redirect_uris não pode estar vazio com o grant_type {grant_type}" #: oauth2_provider/oauth2_validators.py:166 msgid "The access token is invalid." msgstr "O token de acesso é inválido." #: oauth2_provider/oauth2_validators.py:171 msgid "The access token has expired." msgstr "O token de acesso expirou." #: oauth2_provider/oauth2_validators.py:176 msgid "The access token is valid but does not have enough scope." msgstr "O token de acesso é válido, mas não tem permissões suficientes." #: oauth2_provider/templates/oauth2_provider/application_confirm_delete.html:6 msgid "Are you sure to delete the application" msgstr "Tem a certeza que pretende apagar a aplicação" #: oauth2_provider/templates/oauth2_provider/application_confirm_delete.html:12 #: oauth2_provider/templates/oauth2_provider/authorize.html:29 msgid "Cancel" msgstr "Cancelar" #: oauth2_provider/templates/oauth2_provider/application_confirm_delete.html:13 #: oauth2_provider/templates/oauth2_provider/application_detail.html:38 #: oauth2_provider/templates/oauth2_provider/authorized-token-delete.html:7 msgid "Delete" msgstr "Apagar" #: oauth2_provider/templates/oauth2_provider/application_detail.html:10 msgid "Client id" msgstr "ID do Cliente" #: oauth2_provider/templates/oauth2_provider/application_detail.html:15 msgid "Client secret" msgstr "Segredo do cliente" #: oauth2_provider/templates/oauth2_provider/application_detail.html:20 msgid "Client type" msgstr "Tipo de cliente" #: oauth2_provider/templates/oauth2_provider/application_detail.html:25 msgid "Authorization Grant Type" msgstr "Tipo de concessão de autorização" #: oauth2_provider/templates/oauth2_provider/application_detail.html:30 msgid "Redirect Uris" msgstr "URI's de redirecionamento" #: oauth2_provider/templates/oauth2_provider/application_detail.html:36 #: oauth2_provider/templates/oauth2_provider/application_form.html:35 msgid "Go Back" msgstr "Voltar" #: oauth2_provider/templates/oauth2_provider/application_detail.html:37 msgid "Edit" msgstr "Editar" #: oauth2_provider/templates/oauth2_provider/application_form.html:9 msgid "Edit application" msgstr "Editar aplicação" #: oauth2_provider/templates/oauth2_provider/application_form.html:37 msgid "Save" msgstr "Guardar" #: oauth2_provider/templates/oauth2_provider/application_list.html:6 msgid "Your applications" msgstr "As tuas aplicações" #: oauth2_provider/templates/oauth2_provider/application_list.html:14 msgid "New Application" msgstr "Nova Aplicação" #: oauth2_provider/templates/oauth2_provider/application_list.html:17 msgid "No applications defined" msgstr "Sem aplicações definidas" #: oauth2_provider/templates/oauth2_provider/application_list.html:17 msgid "Click here" msgstr "Clica aqui" #: oauth2_provider/templates/oauth2_provider/application_list.html:17 msgid "if you want to register a new one" msgstr "se pretender registar uma nova" #: oauth2_provider/templates/oauth2_provider/application_registration_form.html:5 msgid "Register a new application" msgstr "Registar nova aplicação" #: oauth2_provider/templates/oauth2_provider/authorized-token-delete.html:6 msgid "Are you sure you want to delete this token?" msgstr "Tem a certeza que pretende apagar o token?" #: oauth2_provider/templates/oauth2_provider/authorized-tokens.html:6 msgid "Tokens" msgstr "Tokens" #: oauth2_provider/templates/oauth2_provider/authorized-tokens.html:11 msgid "revoke" msgstr "revogar" #: oauth2_provider/templates/oauth2_provider/authorized-tokens.html:19 msgid "There are no authorized tokens yet." msgstr "De momento, não tem tokens autorizados." django-oauth-toolkit-2.3.0/oauth2_provider/locale/pt_BR/000077500000000000000000000000001443573112200231425ustar00rootroot00000000000000django-oauth-toolkit-2.3.0/oauth2_provider/locale/pt_BR/LC_MESSAGES/000077500000000000000000000000001443573112200247275ustar00rootroot00000000000000django-oauth-toolkit-2.3.0/oauth2_provider/locale/pt_BR/LC_MESSAGES/django.po000066400000000000000000000132751443573112200265410ustar00rootroot00000000000000# 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." django-oauth-toolkit-2.3.0/oauth2_provider/management/000077500000000000000000000000001443573112200230115ustar00rootroot00000000000000django-oauth-toolkit-2.3.0/oauth2_provider/management/__init__.py000066400000000000000000000000001443573112200251100ustar00rootroot00000000000000django-oauth-toolkit-2.3.0/oauth2_provider/management/commands/000077500000000000000000000000001443573112200246125ustar00rootroot00000000000000django-oauth-toolkit-2.3.0/oauth2_provider/management/commands/__init__.py000066400000000000000000000000001443573112200267110ustar00rootroot00000000000000django-oauth-toolkit-2.3.0/oauth2_provider/management/commands/cleartokens.py000066400000000000000000000004311443573112200274740ustar00rootroot00000000000000from django.core.management.base import BaseCommand from ...models import clear_expired 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): clear_expired() django-oauth-toolkit-2.3.0/oauth2_provider/management/commands/createapplication.py000066400000000000000000000077721443573112200306700ustar00rootroot00000000000000from django.core.exceptions import ValidationError from django.core.management.base import BaseCommand from oauth2_provider.models import get_application_model Application = get_application_model() class Command(BaseCommand): help = "Shortcut to create a new application in a programmatic way" def add_arguments(self, parser): parser.add_argument( "client_type", type=str, help="The client type, one of: %s" % ", ".join([ctype[0] for ctype in Application.CLIENT_TYPES]), ) parser.add_argument( "authorization_grant_type", type=str, help="The type of authorization grant to be used, one of: %s" % ", ".join([gtype[0] for gtype in Application.GRANT_TYPES]), ) parser.add_argument( "--client-id", type=str, help="The ID of the new application", ) parser.add_argument( "--user", type=str, help="The user the application belongs to", ) parser.add_argument( "--redirect-uris", type=str, help="The redirect URIs, this must be a space separated string e.g 'URI1 URI2'", ) parser.add_argument( "--post-logout-redirect-uris", type=str, help="The post logout redirect URIs, this must be a space separated string e.g 'URI1 URI2'", default="", ) parser.add_argument( "--client-secret", type=str, help="The secret for this application", ) parser.add_argument( "--name", type=str, help="The name this application", ) parser.add_argument( "--skip-authorization", action="store_true", help="If set, completely bypass the authorization form, even on the first use of the application", ) parser.add_argument( "--algorithm", type=str, help="The OIDC token signing algorithm for this application, one of: %s" % ", ".join([atype[0] for atype in Application.ALGORITHM_TYPES if atype[0]]), ) def handle(self, *args, **options): # Extract all fields related to the application, this will work now and in the future # and also with custom application models. application_fields = [field.name for field in Application._meta.fields] application_data = {} for key, value in options.items(): # Data in options must be cleaned because there are unneeded key-value like # verbosity and others. Also do not pass any None to the Application # instance so default values will be generated for those fields if key in application_fields and value: if key == "user": application_data.update({"user_id": value}) else: application_data.update({key: value}) new_application = Application(**application_data) try: new_application.full_clean() except ValidationError as exc: errors = "\n ".join( ["- " + err_key + ": " + str(err_value) for err_key, err_value in exc.message_dict.items()] ) self.stdout.write(self.style.ERROR("Please correct the following errors:\n %s" % errors)) else: cleartext_secret = new_application.client_secret new_application.save() # Display the newly-created client_name or id. client_name_or_id = application_data.get("name", new_application.client_id) self.stdout.write( self.style.SUCCESS("New application %s created successfully." % client_name_or_id) ) # Print out the cleartext client_secret if it was autogenerated. if "client_secret" not in application_data: self.stdout.write(self.style.SUCCESS("client_secret: %s" % cleartext_secret)) django-oauth-toolkit-2.3.0/oauth2_provider/middleware.py000066400000000000000000000031401443573112200233620ustar00rootroot00000000000000from django.contrib.auth import authenticate from django.utils.cache import patch_vary_headers class OAuth2TokenMiddleware: """ Middleware for OAuth2 user authentication This middleware is able to work along with AuthenticationMiddleware and its behaviour depends on the order it's processed with. If it comes *after* AuthenticationMiddleware and request.user is valid, leave it as is and does not proceed with token validation. If request.user is the Anonymous user proceeds and try to authenticate the user using the OAuth2 access token. If it comes *before* AuthenticationMiddleware, or AuthenticationMiddleware is not used at all, tries to authenticate user with the OAuth2 access token and set request.user field. Setting also request._cached_user field makes AuthenticationMiddleware use that instead of the one from the session. It also adds "Authorization" to the "Vary" header, so that django's cache middleware or a reverse proxy can create proper cache keys. """ def __init__(self, get_response): self.get_response = get_response def __call__(self, request): # do something only if request contains a Bearer token if request.META.get("HTTP_AUTHORIZATION", "").startswith("Bearer"): if not hasattr(request, "user") or request.user.is_anonymous: user = authenticate(request=request) if user: request.user = request._cached_user = user response = self.get_response(request) patch_vary_headers(response, ("Authorization",)) return response django-oauth-toolkit-2.3.0/oauth2_provider/migrations/000077500000000000000000000000001443573112200230515ustar00rootroot00000000000000django-oauth-toolkit-2.3.0/oauth2_provider/migrations/0001_initial.py000066400000000000000000000133601443573112200255170ustar00rootroot00000000000000from django.conf import settings import django.db.models.deletion from django.db import migrations, models import oauth2_provider.generators import oauth2_provider.validators from oauth2_provider.settings import oauth2_settings class Migration(migrations.Migration): """ The following migrations are squashed here: - 0001_initial.py - 0002_08_updates.py - 0003_auto_20160316_1503.py - 0004_auto_20160525_1623.py - 0005_auto_20170514_1141.py - 0006_auto_20171214_2232.py """ dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL) ] operations = [ migrations.CreateModel( name='Application', fields=[ ('id', models.BigAutoField(serialize=False, primary_key=True)), ('client_id', models.CharField(default=oauth2_provider.generators.generate_client_id, unique=True, max_length=100, db_index=True)), ('redirect_uris', models.TextField(help_text='Allowed URIs list, space separated', blank=True)), ('client_type', models.CharField(max_length=32, choices=[('confidential', 'Confidential'), ('public', 'Public')])), ('authorization_grant_type', models.CharField(max_length=32, choices=[('authorization-code', 'Authorization code'), ('implicit', 'Implicit'), ('password', 'Resource owner password-based'), ('client-credentials', 'Client credentials')])), ('client_secret', models.CharField(default=oauth2_provider.generators.generate_client_secret, max_length=255, db_index=True, blank=True)), ('name', models.CharField(max_length=255, blank=True)), ('user', models.ForeignKey(related_name="oauth2_provider_application", blank=True, to=settings.AUTH_USER_MODEL, null=True, on_delete=models.CASCADE)), ('skip_authorization', models.BooleanField(default=False)), ('created', models.DateTimeField(auto_now_add=True)), ('updated', models.DateTimeField(auto_now=True)), ], options={ 'abstract': False, 'swappable': 'OAUTH2_PROVIDER_APPLICATION_MODEL', }, ), migrations.CreateModel( name='AccessToken', fields=[ ('id', models.BigAutoField(serialize=False, primary_key=True)), ('token', models.CharField(unique=True, max_length=255)), ('expires', models.DateTimeField()), ('scope', models.TextField(blank=True)), ('application', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=oauth2_settings.APPLICATION_MODEL)), ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='oauth2_provider_accesstoken', to=settings.AUTH_USER_MODEL)), ('created', models.DateTimeField(auto_now_add=True)), ('updated', models.DateTimeField(auto_now=True)), # Circular reference. Can't add it here. #('source_refresh_token', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=oauth2_settings.REFRESH_TOKEN_MODEL, related_name="refreshed_access_token")), ], options={ 'abstract': False, 'swappable': 'OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL', }, ), migrations.CreateModel( name='Grant', fields=[ ('id', models.BigAutoField(serialize=False, primary_key=True)), ('code', models.CharField(unique=True, max_length=255)), ('expires', models.DateTimeField()), ('redirect_uri', models.CharField(max_length=255)), ('scope', models.TextField(blank=True)), ('application', models.ForeignKey(to=oauth2_settings.APPLICATION_MODEL, on_delete=models.CASCADE)), ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='oauth2_provider_grant', to=settings.AUTH_USER_MODEL)), ('created', models.DateTimeField(auto_now_add=True)), ('updated', models.DateTimeField(auto_now=True)), ], options={ 'abstract': False, 'swappable': 'OAUTH2_PROVIDER_GRANT_MODEL', }, ), migrations.CreateModel( name='RefreshToken', fields=[ ('id', models.BigAutoField(serialize=False, primary_key=True)), ('token', models.CharField(max_length=255)), ('access_token', models.OneToOneField(blank=True, null=True, related_name="refresh_token", to=oauth2_settings.ACCESS_TOKEN_MODEL, on_delete=models.SET_NULL)), ('application', models.ForeignKey(to=oauth2_settings.APPLICATION_MODEL, on_delete=models.CASCADE)), ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='oauth2_provider_refreshtoken', to=settings.AUTH_USER_MODEL)), ('created', models.DateTimeField(auto_now_add=True)), ('updated', models.DateTimeField(auto_now=True)), ('revoked', models.DateTimeField(null=True)), ], options={ 'abstract': False, 'swappable': 'OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL', 'unique_together': set([("token", "revoked")]), }, ), migrations.AddField( model_name='AccessToken', name='source_refresh_token', field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=oauth2_settings.REFRESH_TOKEN_MODEL, related_name="refreshed_access_token"), ), ] django-oauth-toolkit-2.3.0/oauth2_provider/migrations/0002_auto_20190406_1805.py000066400000000000000000000012101443573112200264700ustar00rootroot00000000000000# Generated by Django 2.2 on 2019-04-06 18:05 from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ ('oauth2_provider', '0001_initial'), ] operations = [ migrations.AddField( model_name='grant', name='code_challenge', field=models.CharField(blank=True, default='', max_length=128), ), migrations.AddField( model_name='grant', name='code_challenge_method', field=models.CharField(blank=True, choices=[('plain', 'plain'), ('S256', 'S256')], default='', max_length=10), ), ] django-oauth-toolkit-2.3.0/oauth2_provider/migrations/0003_auto_20201211_1314.py000066400000000000000000000006021443573112200264530ustar00rootroot00000000000000# Generated by Django 3.1.4 on 2020-12-11 13:14 from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ ('oauth2_provider', '0002_auto_20190406_1805'), ] operations = [ migrations.AlterField( model_name='grant', name='redirect_uri', field=models.TextField(), ), ] django-oauth-toolkit-2.3.0/oauth2_provider/migrations/0004_auto_20200902_2022.py000066400000000000000000000052141443573112200264630ustar00rootroot00000000000000import uuid from django.conf import settings from django.db import migrations, models import django.db.models.deletion from oauth2_provider.settings import oauth2_settings class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), ('oauth2_provider', '0003_auto_20201211_1314'), ] operations = [ migrations.AddField( model_name='application', name='algorithm', field=models.CharField(blank=True, choices=[("", "No OIDC support"), ('RS256', 'RSA with SHA-2 256'), ('HS256', 'HMAC with SHA-2 256')], default='', max_length=5), ), migrations.AlterField( model_name='application', name='authorization_grant_type', field=models.CharField(choices=[('authorization-code', 'Authorization code'), ('implicit', 'Implicit'), ('password', 'Resource owner password-based'), ('client-credentials', 'Client credentials'), ('openid-hybrid', 'OpenID connect hybrid')], max_length=32), ), migrations.CreateModel( name='IDToken', fields=[ ('id', models.BigAutoField(primary_key=True, serialize=False)), ("jti", models.UUIDField(unique=True, default=uuid.uuid4, editable=False, verbose_name="JWT Token ID")), ('expires', models.DateTimeField()), ('scope', models.TextField(blank=True)), ('created', models.DateTimeField(auto_now_add=True)), ('updated', models.DateTimeField(auto_now=True)), ('application', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=oauth2_settings.APPLICATION_MODEL)), ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='oauth2_provider_idtoken', to=settings.AUTH_USER_MODEL)), ], options={ 'abstract': False, 'swappable': 'OAUTH2_PROVIDER_ID_TOKEN_MODEL', }, ), migrations.AddField( model_name='accesstoken', name='id_token', field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='access_token', to=oauth2_settings.ID_TOKEN_MODEL), ), migrations.AddField( model_name="grant", name="nonce", field=models.CharField(blank=True, max_length=255, default=""), ), migrations.AddField( model_name="grant", name="claims", field=models.TextField(blank=True), ), ] django-oauth-toolkit-2.3.0/oauth2_provider/migrations/0005_auto_20211222_2352.py000066400000000000000000000032011443573112200264610ustar00rootroot00000000000000import 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), ), ] django-oauth-toolkit-2.3.0/oauth2_provider/migrations/0006_alter_application_client_secret.py000066400000000000000000000020441443573112200324650ustar00rootroot00000000000000from django.db import migrations from oauth2_provider import settings import oauth2_provider.generators import oauth2_provider.models def forwards_func(apps, schema_editor): """ Forward migration touches every application.client_secret which will cause it to be hashed if not already the case. """ Application = apps.get_model(settings.APPLICATION_MODEL) applications = Application._default_manager.all() for application in applications: application.save(update_fields=['client_secret']) class Migration(migrations.Migration): dependencies = [ ('oauth2_provider', '0005_auto_20211222_2352'), ] operations = [ migrations.AlterField( model_name='application', name='client_secret', field=oauth2_provider.models.ClientSecretField(blank=True, db_index=True, default=oauth2_provider.generators.generate_client_secret, help_text='Hashed on Save. Copy it now if this is a new secret.', max_length=255), ), migrations.RunPython(forwards_func), ] django-oauth-toolkit-2.3.0/oauth2_provider/migrations/0007_application_post_logout_redirect_uris.py000066400000000000000000000007461443573112200337640ustar00rootroot00000000000000# Generated by Django 4.1.5 on 2023-01-14 12:32 from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ ("oauth2_provider", "0006_alter_application_client_secret"), ] operations = [ migrations.AddField( model_name="application", name="post_logout_redirect_uris", field=models.TextField(blank=True, help_text="Allowed Post Logout URIs list, space separated"), ), ] django-oauth-toolkit-2.3.0/oauth2_provider/migrations/__init__.py000066400000000000000000000000001443573112200251500ustar00rootroot00000000000000django-oauth-toolkit-2.3.0/oauth2_provider/models.py000066400000000000000000000650421443573112200225410ustar00rootroot00000000000000import logging import time import uuid from datetime import timedelta from urllib.parse import parse_qsl, urlparse from django.apps import apps from django.conf import settings from django.contrib.auth.hashers import identify_hasher, make_password from django.core.exceptions import ImproperlyConfigured from django.db import models, transaction from django.urls import reverse from django.utils import timezone 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 from .settings import oauth2_settings from .validators import RedirectURIValidator, WildcardSet logger = logging.getLogger(__name__) class ClientSecretField(models.CharField): def pre_save(self, model_instance, add): secret = getattr(model_instance, self.attname) try: hasher = identify_hasher(secret) logger.debug(f"{model_instance}: {self.attname} is already hashed with {hasher}.") except ValueError: logger.debug(f"{model_instance}: {self.attname} is not hashed; hashing it now.") hashed_secret = make_password(secret) setattr(model_instance, self.attname, hashed_secret) return hashed_secret return super().pre_save(model_instance, add) class AbstractApplication(models.Model): """ An Application instance represents a Client on the Authorization server. Usually an Application is created manually by client's developers after logging in on an Authorization Server. Fields: * :attr:`client_id` The client identifier issued to the client during the registration process as described in :rfc:`2.2` * :attr:`user` ref to a Django user * :attr:`redirect_uris` The list of allowed redirect uri. The string consists of valid URLs separated by space * :attr:`post_logout_redirect_uris` The list of allowed redirect uris after an RP initiated logout. The string consists of valid URLs separated by space * :attr:`client_type` Client type as described in :rfc:`2.1` * :attr:`authorization_grant_type` Authorization flows available to the Application * :attr:`client_secret` Confidential secret issued to the client during the registration process as described in :rfc:`2.2` * :attr:`name` Friendly name for the Application """ CLIENT_CONFIDENTIAL = "confidential" CLIENT_PUBLIC = "public" CLIENT_TYPES = ( (CLIENT_CONFIDENTIAL, _("Confidential")), (CLIENT_PUBLIC, _("Public")), ) GRANT_AUTHORIZATION_CODE = "authorization-code" GRANT_IMPLICIT = "implicit" GRANT_PASSWORD = "password" GRANT_CLIENT_CREDENTIALS = "client-credentials" GRANT_OPENID_HYBRID = "openid-hybrid" GRANT_TYPES = ( (GRANT_AUTHORIZATION_CODE, _("Authorization code")), (GRANT_IMPLICIT, _("Implicit")), (GRANT_PASSWORD, _("Resource owner password-based")), (GRANT_CLIENT_CREDENTIALS, _("Client credentials")), (GRANT_OPENID_HYBRID, _("OpenID connect hybrid")), ) NO_ALGORITHM = "" RS256_ALGORITHM = "RS256" HS256_ALGORITHM = "HS256" ALGORITHM_TYPES = ( (NO_ALGORITHM, _("No OIDC support")), (RS256_ALGORITHM, _("RSA with SHA-2 256")), (HS256_ALGORITHM, _("HMAC with SHA-2 256")), ) id = models.BigAutoField(primary_key=True) client_id = models.CharField(max_length=100, unique=True, default=generate_client_id, db_index=True) user = models.ForeignKey( settings.AUTH_USER_MODEL, related_name="%(app_label)s_%(class)s", null=True, blank=True, on_delete=models.CASCADE, ) redirect_uris = models.TextField( blank=True, help_text=_("Allowed URIs list, space separated"), ) post_logout_redirect_uris = models.TextField( blank=True, help_text=_("Allowed Post Logout URIs list, space separated"), ) client_type = models.CharField(max_length=32, choices=CLIENT_TYPES) authorization_grant_type = models.CharField(max_length=32, choices=GRANT_TYPES) client_secret = ClientSecretField( max_length=255, blank=True, default=generate_client_secret, db_index=True, help_text=_("Hashed on Save. Copy it now if this is a new secret."), ) name = models.CharField(max_length=255, blank=True) skip_authorization = models.BooleanField(default=False) created = models.DateTimeField(auto_now_add=True) updated = models.DateTimeField(auto_now=True) algorithm = models.CharField(max_length=5, choices=ALGORITHM_TYPES, default=NO_ALGORITHM, blank=True) class Meta: abstract = True def __str__(self): return self.name or self.client_id @property def default_redirect_uri(self): """ Returns the default redirect_uri, *if* only one is registered. """ if self.redirect_uris: 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 " "or all-in-one grant_type, you must define " "redirect_uris field in your Application model" ) def redirect_uri_allowed(self, uri): """ Checks if given url is one of the items in :attr:`redirect_uris` string :param uri: Url to check """ return redirect_to_uri_allowed(uri, self.redirect_uris.split()) def post_logout_redirect_uri_allowed(self, uri): """ Checks if given URI is one of the items in :attr:`post_logout_redirect_uris` string :param uri: URI to check """ return redirect_to_uri_allowed(uri, self.post_logout_redirect_uris.split()) def clean(self): from django.core.exceptions import ValidationError grant_types = ( AbstractApplication.GRANT_AUTHORIZATION_CODE, AbstractApplication.GRANT_IMPLICIT, AbstractApplication.GRANT_OPENID_HYBRID, ) hs_forbidden_grant_types = ( AbstractApplication.GRANT_IMPLICIT, AbstractApplication.GRANT_OPENID_HYBRID, ) redirect_uris = self.redirect_uris.strip().split() allowed_schemes = set(s.lower() for s in self.get_allowed_schemes()) if redirect_uris: validator = RedirectURIValidator(WildcardSet()) for uri in redirect_uris: validator(uri) scheme = urlparse(uri).scheme if scheme not in allowed_schemes: raise ValidationError(_("Unauthorized redirect scheme: {scheme}").format(scheme=scheme)) elif self.authorization_grant_type in grant_types: raise ValidationError( _("redirect_uris cannot be empty with grant_type {grant_type}").format( grant_type=self.authorization_grant_type ) ) if self.algorithm == AbstractApplication.RS256_ALGORITHM: if not oauth2_settings.OIDC_RSA_PRIVATE_KEY: raise ValidationError(_("You must set OIDC_RSA_PRIVATE_KEY to use RSA algorithm")) if self.algorithm == AbstractApplication.HS256_ALGORITHM: if any( ( self.authorization_grant_type in hs_forbidden_grant_types, self.client_type == Application.CLIENT_PUBLIC, ) ): raise ValidationError(_("You cannot use HS256 with public grants or clients")) def get_absolute_url(self): return reverse("oauth2_provider:detail", args=[str(self.id)]) def get_allowed_schemes(self): """ Returns the list of redirect schemes allowed by the Application. By default, returns `ALLOWED_REDIRECT_URI_SCHEMES`. """ return oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES def allows_grant_type(self, *grant_types): return self.authorization_grant_type in grant_types def is_usable(self, request): """ Determines whether the application can be used. :param request: The oauthlib.common.Request being processed. """ return True @property def jwk_key(self): if self.algorithm == AbstractApplication.RS256_ALGORITHM: if not oauth2_settings.OIDC_RSA_PRIVATE_KEY: raise ImproperlyConfigured("You must set OIDC_RSA_PRIVATE_KEY to use RSA algorithm") return jwk.JWK.from_pem(oauth2_settings.OIDC_RSA_PRIVATE_KEY.encode("utf8")) elif self.algorithm == AbstractApplication.HS256_ALGORITHM: return jwk.JWK(kty="oct", k=base64url_encode(self.client_secret)) raise ImproperlyConfigured("This application does not support signed tokens") class ApplicationManager(models.Manager): def get_by_natural_key(self, client_id): return self.get(client_id=client_id) class Application(AbstractApplication): objects = ApplicationManager() class Meta(AbstractApplication.Meta): swappable = "OAUTH2_PROVIDER_APPLICATION_MODEL" def natural_key(self): return (self.client_id,) class AbstractGrant(models.Model): """ A Grant instance represents a token with a short lifetime that can be swapped for an access token, as described in :rfc:`4.1.2` Fields: * :attr:`user` The Django user who requested the grant * :attr:`code` The authorization code generated by the authorization server * :attr:`application` Application instance this grant was asked for * :attr:`expires` Expire time in seconds, defaults to :data:`settings.AUTHORIZATION_CODE_EXPIRE_SECONDS` * :attr:`redirect_uri` Self explained * :attr:`scope` Required scopes, optional * :attr:`code_challenge` PKCE code challenge * :attr:`code_challenge_method` PKCE code challenge transform algorithm """ CODE_CHALLENGE_PLAIN = "plain" CODE_CHALLENGE_S256 = "S256" CODE_CHALLENGE_METHODS = ((CODE_CHALLENGE_PLAIN, "plain"), (CODE_CHALLENGE_S256, "S256")) id = models.BigAutoField(primary_key=True) user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="%(app_label)s_%(class)s" ) code = models.CharField(max_length=255, unique=True) # code comes from oauthlib application = models.ForeignKey(oauth2_settings.APPLICATION_MODEL, on_delete=models.CASCADE) expires = models.DateTimeField() redirect_uri = models.TextField() scope = models.TextField(blank=True) created = models.DateTimeField(auto_now_add=True) updated = models.DateTimeField(auto_now=True) code_challenge = models.CharField(max_length=128, blank=True, default="") code_challenge_method = models.CharField( max_length=10, blank=True, default="", choices=CODE_CHALLENGE_METHODS ) nonce = models.CharField(max_length=255, blank=True, default="") claims = models.TextField(blank=True) def is_expired(self): """ Check token expiration with timezone awareness """ if not self.expires: return True return timezone.now() >= self.expires def redirect_uri_allowed(self, uri): return uri == self.redirect_uri def __str__(self): return self.code class Meta: abstract = True class Grant(AbstractGrant): class Meta(AbstractGrant.Meta): swappable = "OAUTH2_PROVIDER_GRANT_MODEL" class AbstractAccessToken(models.Model): """ An AccessToken instance represents the actual access token to access user's resources, as in :rfc:`5`. Fields: * :attr:`user` The Django user representing resources" owner * :attr:`source_refresh_token` If from a refresh, the consumed RefeshToken * :attr:`token` Access token * :attr:`application` Application instance * :attr:`expires` Date and time of token expiration, in DateTime format * :attr:`scope` Allowed scopes """ id = models.BigAutoField(primary_key=True) user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, blank=True, null=True, related_name="%(app_label)s_%(class)s", ) source_refresh_token = models.OneToOneField( # unique=True implied by the OneToOneField oauth2_settings.REFRESH_TOKEN_MODEL, on_delete=models.SET_NULL, blank=True, null=True, related_name="refreshed_access_token", ) token = models.CharField( max_length=255, unique=True, ) id_token = models.OneToOneField( oauth2_settings.ID_TOKEN_MODEL, on_delete=models.CASCADE, blank=True, null=True, related_name="access_token", ) application = models.ForeignKey( oauth2_settings.APPLICATION_MODEL, on_delete=models.CASCADE, blank=True, null=True, ) expires = models.DateTimeField() scope = models.TextField(blank=True) created = models.DateTimeField(auto_now_add=True) updated = models.DateTimeField(auto_now=True) def is_valid(self, scopes=None): """ Checks if the access token is valid. :param scopes: An iterable containing the scopes to check or None """ return not self.is_expired() and self.allow_scopes(scopes) def is_expired(self): """ Check token expiration with timezone awareness """ if not self.expires: return True return timezone.now() >= self.expires def allow_scopes(self, scopes): """ Check if the token allows the provided scopes :param scopes: An iterable containing the scopes to check """ if not scopes: return True provided_scopes = set(self.scope.split()) resource_scopes = set(scopes) return resource_scopes.issubset(provided_scopes) def revoke(self): """ Convenience method to uniform tokens" interface, for now simply remove this token from the database in order to revoke it. """ self.delete() @property def scopes(self): """ Returns a dictionary of allowed scope names (as keys) with their descriptions (as values) """ all_scopes = get_scopes_backend().get_all_scopes() token_scopes = self.scope.split() return {name: desc for name, desc in all_scopes.items() if name in token_scopes} def __str__(self): return self.token class Meta: abstract = True class AccessToken(AbstractAccessToken): class Meta(AbstractAccessToken.Meta): swappable = "OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL" class AbstractRefreshToken(models.Model): """ A RefreshToken instance represents a token that can be swapped for a new access token when it expires. Fields: * :attr:`user` The Django user representing resources" owner * :attr:`token` Token value * :attr:`application` Application instance * :attr:`access_token` AccessToken instance this refresh token is bounded to * :attr:`revoked` Timestamp of when this refresh token was revoked """ id = models.BigAutoField(primary_key=True) user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="%(app_label)s_%(class)s" ) token = models.CharField(max_length=255) application = models.ForeignKey(oauth2_settings.APPLICATION_MODEL, on_delete=models.CASCADE) access_token = models.OneToOneField( oauth2_settings.ACCESS_TOKEN_MODEL, on_delete=models.SET_NULL, blank=True, null=True, related_name="refresh_token", ) created = models.DateTimeField(auto_now_add=True) updated = models.DateTimeField(auto_now=True) revoked = models.DateTimeField(null=True) def revoke(self): """ Mark this refresh token revoked and revoke related access token """ access_token_model = get_access_token_model() refresh_token_model = get_refresh_token_model() with transaction.atomic(): token = refresh_token_model.objects.select_for_update().filter(pk=self.pk, revoked__isnull=True) if not token: return self = list(token)[0] try: access_token_model.objects.get(id=self.access_token_id).revoke() except access_token_model.DoesNotExist: pass self.access_token = None self.revoked = timezone.now() self.save() def __str__(self): return self.token class Meta: abstract = True unique_together = ( "token", "revoked", ) class RefreshToken(AbstractRefreshToken): class Meta(AbstractRefreshToken.Meta): swappable = "OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL" class AbstractIDToken(models.Model): """ An IDToken instance represents the actual token to access user's resources, as in :openid:`2`. Fields: * :attr:`user` The Django user representing resources' owner * :attr:`jti` ID token JWT Token ID, to identify an individual token * :attr:`application` Application instance * :attr:`expires` Date and time of token expiration, in DateTime format * :attr:`scope` Allowed scopes * :attr:`created` Date and time of token creation, in DateTime format * :attr:`updated` Date and time of token update, in DateTime format """ id = models.BigAutoField(primary_key=True) user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, blank=True, null=True, related_name="%(app_label)s_%(class)s", ) jti = models.UUIDField(unique=True, default=uuid.uuid4, editable=False, verbose_name="JWT Token ID") application = models.ForeignKey( oauth2_settings.APPLICATION_MODEL, on_delete=models.CASCADE, blank=True, null=True, ) expires = models.DateTimeField() scope = models.TextField(blank=True) created = models.DateTimeField(auto_now_add=True) updated = models.DateTimeField(auto_now=True) def is_valid(self, scopes=None): """ Checks if the access token is valid. :param scopes: An iterable containing the scopes to check or None """ return not self.is_expired() and self.allow_scopes(scopes) def is_expired(self): """ Check token expiration with timezone awareness """ if not self.expires: return True return timezone.now() >= self.expires def allow_scopes(self, scopes): """ Check if the token allows the provided scopes :param scopes: An iterable containing the scopes to check """ if not scopes: return True provided_scopes = set(self.scope.split()) resource_scopes = set(scopes) return resource_scopes.issubset(provided_scopes) def revoke(self): """ Convenience method to uniform tokens' interface, for now simply remove this token from the database in order to revoke it. """ self.delete() @property def scopes(self): """ Returns a dictionary of allowed scope names (as keys) with their descriptions (as values) """ all_scopes = get_scopes_backend().get_all_scopes() token_scopes = self.scope.split() return {name: desc for name, desc in all_scopes.items() if name in token_scopes} def __str__(self): return "JTI: {self.jti} User: {self.user_id}".format(self=self) class Meta: abstract = True class IDToken(AbstractIDToken): class Meta(AbstractIDToken.Meta): swappable = "OAUTH2_PROVIDER_ID_TOKEN_MODEL" def get_application_model(): """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 apps.get_model(oauth2_settings.GRANT_MODEL) def get_access_token_model(): """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 apps.get_model(oauth2_settings.ID_TOKEN_MODEL) def get_refresh_token_model(): """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.""" 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.""" 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.""" 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.""" 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.""" 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() id_token_model = get_id_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: REFRESH_TOKEN_EXPIRE_SECONDS = timedelta(seconds=REFRESH_TOKEN_EXPIRE_SECONDS) except TypeError: e = "REFRESH_TOKEN_EXPIRE_SECONDS must be either a timedelta or seconds" raise ImproperlyConfigured(e) refresh_expire_at = now - REFRESH_TOKEN_EXPIRE_SECONDS 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) expired_query = models.Q(access_token__expires__lt=refresh_expire_at) expired = refresh_token_model.objects.filter(expired_query) 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_token_query = models.Q(refresh_token__isnull=True, expires__lt=now) access_tokens = access_token_model.objects.filter(access_token_query) access_tokens_delete_no = batch_delete(access_tokens, access_token_query) logger.info("%s Expired access tokens deleted", access_tokens_delete_no) id_token_query = models.Q(access_token__isnull=True, expires__lt=now) id_tokens = id_token_model.objects.filter(id_token_query) id_tokens_delete_no = batch_delete(id_tokens, id_token_query) logger.info("%s Expired ID tokens deleted", id_tokens_delete_no) 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 django-oauth-toolkit-2.3.0/oauth2_provider/oauth2_backends.py000066400000000000000000000223631443573112200243110ustar00rootroot00000000000000import json from urllib.parse import urlparse, urlunparse from oauthlib import oauth2 from oauthlib.common import Request as OauthlibRequest from oauthlib.common import quote, urlencode, urlencoded from oauthlib.oauth2 import OAuth2Error from .exceptions import FatalClientError, OAuthToolkitError from .settings import oauth2_settings class OAuthLibCore: """ Wrapper for oauth Server providing django-specific interfaces. Meant for things like extracting request data and converting everything to formats more palatable for oauthlib's Server. """ def __init__(self, server=None): """ :params server: An instance of oauthlib.oauth2.Server class """ validator_class = oauth2_settings.OAUTH2_VALIDATOR_CLASS validator = validator_class() server_kwargs = oauth2_settings.server_kwargs self.server = server or oauth2_settings.OAUTH2_SERVER_CLASS(validator, **server_kwargs) def _get_escaped_full_path(self, request): """ Django considers "safe" some characters that aren't so for oauthlib. We have to search for them and properly escape. """ parsed = list(urlparse(request.get_full_path())) unsafe = set(c for c in parsed[4]).difference(urlencoded) for c in unsafe: parsed[4] = parsed[4].replace(c, quote(c, safe=b"")) return urlunparse(parsed) def _get_extra_credentials(self, request): """ Produce extra credentials for token response. This dictionary will be merged with the response. See also: `oauthlib.oauth2.rfc6749.TokenEndpoint.create_token_response` :param request: The current django.http.HttpRequest object :return: dictionary of extra credentials or None (default) """ return None def _extract_params(self, request): """ Extract parameters from the Django request object. Such parameters will then be passed to OAuthLib to build its own Request object. The body should be encoded using OAuthLib urlencoded. """ uri = self._get_escaped_full_path(request) http_method = request.method headers = self.extract_headers(request) body = urlencode(self.extract_body(request)) return uri, http_method, body, headers def extract_headers(self, request): """ Extracts headers from the Django request object :param request: The current django.http.HttpRequest object :return: a dictionary with OAuthLib needed headers """ headers = request.META.copy() if "wsgi.input" in headers: del headers["wsgi.input"] if "wsgi.errors" in headers: del headers["wsgi.errors"] if "HTTP_AUTHORIZATION" in headers: headers["Authorization"] = headers["HTTP_AUTHORIZATION"] if request.is_secure(): headers["X_DJANGO_OAUTH_TOOLKIT_SECURE"] = "1" elif "X_DJANGO_OAUTH_TOOLKIT_SECURE" in headers: del headers["X_DJANGO_OAUTH_TOOLKIT_SECURE"] return headers def extract_body(self, request): """ Extracts the POST body from the Django request object :param request: The current django.http.HttpRequest object :return: provided POST parameters """ return request.POST.items() def validate_authorization_request(self, request): """ A wrapper method that calls validate_authorization_request on `server_class` instance. :param request: The current django.http.HttpRequest object """ try: uri, http_method, body, headers = self._extract_params(request) scopes, credentials = self.server.validate_authorization_request( uri, http_method=http_method, body=body, headers=headers ) return scopes, credentials except oauth2.FatalClientError as error: raise FatalClientError(error=error) except oauth2.OAuth2Error as error: raise OAuthToolkitError(error=error) def create_authorization_response(self, request, scopes, credentials, allow): """ A wrapper method that calls create_authorization_response on `server_class` instance. :param request: The current django.http.HttpRequest object :param scopes: A list of provided scopes :param credentials: Authorization credentials dictionary containing `client_id`, `state`, `redirect_uri`, `response_type` :param allow: True if the user authorize the client, otherwise False """ try: if not allow: raise oauth2.AccessDeniedError(state=credentials.get("state", None)) # add current user to credentials. this will be used by OAUTH2_VALIDATOR_CLASS credentials["user"] = request.user request_uri, http_method, _, request_headers = self._extract_params(request) headers, body, status = self.server.create_authorization_response( uri=request_uri, http_method=http_method, headers=request_headers, scopes=scopes, credentials=credentials, ) uri = headers.get("Location", None) return uri, headers, body, status except oauth2.FatalClientError as error: raise FatalClientError(error=error, redirect_uri=credentials["redirect_uri"]) except oauth2.OAuth2Error as error: raise OAuthToolkitError(error=error, redirect_uri=credentials["redirect_uri"]) def create_token_response(self, request): """ A wrapper method that calls create_token_response on `server_class` instance. :param request: The current django.http.HttpRequest object """ uri, http_method, body, headers = self._extract_params(request) extra_credentials = self._get_extra_credentials(request) try: headers, body, status = self.server.create_token_response( uri, http_method, body, headers, extra_credentials ) uri = headers.get("Location", None) return uri, headers, body, status except OAuth2Error as exc: return None, exc.headers, exc.json, exc.status_code def create_revocation_response(self, request): """ A wrapper method that calls create_revocation_response on a `server_class` instance. :param request: The current django.http.HttpRequest object """ uri, http_method, body, headers = self._extract_params(request) headers, body, status = self.server.create_revocation_response(uri, http_method, body, headers) uri = headers.get("Location", None) return uri, headers, body, status def create_userinfo_response(self, request): """ A wrapper method that calls create_userinfo_response on a `server_class` instance. :param request: The current django.http.HttpRequest object """ uri, http_method, body, headers = self._extract_params(request) try: headers, body, status = self.server.create_userinfo_response(uri, http_method, body, headers) uri = headers.get("Location", None) return uri, headers, body, status except OAuth2Error as exc: return None, exc.headers, exc.json, exc.status_code def verify_request(self, request, scopes): """ A wrapper method that calls verify_request on `server_class` instance. :param request: The current django.http.HttpRequest object :param scopes: A list of scopes required to verify so that request is verified """ uri, http_method, body, headers = self._extract_params(request) valid, r = self.server.verify_request(uri, http_method, body, headers, scopes=scopes) return valid, r def authenticate_client(self, request): """Wrapper to call `authenticate_client` on `server_class` instance. :param request: The current django.http.HttpRequest object """ uri, http_method, body, headers = self._extract_params(request) oauth_request = OauthlibRequest(uri, http_method, body, headers) return self.server.request_validator.authenticate_client(oauth_request) class JSONOAuthLibCore(OAuthLibCore): """ Extends the default OAuthLibCore to parse correctly application/json requests """ def extract_body(self, request): """ Extracts the JSON body from the Django request object :param request: The current django.http.HttpRequest object :return: provided POST parameters "urlencodable" """ try: body = json.loads(request.body.decode("utf-8")).items() except AttributeError: body = "" except ValueError: body = "" return body def get_oauthlib_core(): """ Utility function that returns an instance of `oauth2_provider.backends.OAuthLibCore` """ validator_class = oauth2_settings.OAUTH2_VALIDATOR_CLASS validator = validator_class() server_kwargs = oauth2_settings.server_kwargs server = oauth2_settings.OAUTH2_SERVER_CLASS(validator, **server_kwargs) return oauth2_settings.OAUTH2_BACKEND_CLASS(server) django-oauth-toolkit-2.3.0/oauth2_provider/oauth2_validators.py000066400000000000000000001126131443573112200247050ustar00rootroot00000000000000import base64 import binascii import http.client import inspect import json import logging import uuid from collections import OrderedDict from datetime import datetime, timedelta from urllib.parse import unquote_plus import requests from django.conf import settings from django.contrib.auth import authenticate, get_user_model from django.contrib.auth.hashers import check_password from django.core.exceptions import ObjectDoesNotExist from django.db import transaction from django.db.models import Q 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 _ from jwcrypto import jws, jwt from jwcrypto.common import JWException from jwcrypto.jwt import JWTExpired from oauthlib.oauth2.rfc6749 import utils from oauthlib.openid import RequestValidator from .exceptions import FatalClientError from .models import ( AbstractApplication, get_access_token_model, get_application_model, get_grant_model, get_id_token_model, get_refresh_token_model, ) from .scopes import get_scopes_backend from .settings import oauth2_settings log = logging.getLogger("oauth2_provider") GRANT_TYPE_MAPPING = { "authorization_code": ( AbstractApplication.GRANT_AUTHORIZATION_CODE, AbstractApplication.GRANT_OPENID_HYBRID, ), "password": (AbstractApplication.GRANT_PASSWORD,), "client_credentials": (AbstractApplication.GRANT_CLIENT_CREDENTIALS,), "refresh_token": ( AbstractApplication.GRANT_AUTHORIZATION_CODE, AbstractApplication.GRANT_PASSWORD, AbstractApplication.GRANT_CLIENT_CREDENTIALS, AbstractApplication.GRANT_OPENID_HYBRID, ), } Application = get_application_model() AccessToken = get_access_token_model() IDToken = get_id_token_model() Grant = get_grant_model() RefreshToken = get_refresh_token_model() UserModel = get_user_model() class OAuth2Validator(RequestValidator): # Return the given claim only if the given scope is present. # Extended as needed for non-standard OIDC claims/scopes. # Override by setting to None to ignore scopes. # see https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims # For example, for the "nickname" claim, you need the "profile" scope. oidc_claim_scope = { "sub": "openid", "name": "profile", "family_name": "profile", "given_name": "profile", "middle_name": "profile", "nickname": "profile", "preferred_username": "profile", "profile": "profile", "picture": "profile", "website": "profile", "gender": "profile", "birthdate": "profile", "zoneinfo": "profile", "locale": "profile", "updated_at": "profile", "email": "email", "email_verified": "email", "address": "address", "phone_number": "phone", "phone_number_verified": "phone", } def _extract_basic_auth(self, request): """ Return authentication string if request contains basic auth credentials, otherwise return None """ auth = request.headers.get("HTTP_AUTHORIZATION", None) if not auth: return None splitted = auth.split(" ", 1) if len(splitted) != 2: return None auth_type, auth_string = splitted if auth_type != "Basic": return None return auth_string def _authenticate_basic_auth(self, request): """ Authenticates with HTTP Basic Auth. Note: as stated in rfc:`2.3.1`, client_id and client_secret must be encoded with "application/x-www-form-urlencoded" encoding algorithm. """ auth_string = self._extract_basic_auth(request) if not auth_string: return False try: encoding = request.encoding or settings.DEFAULT_CHARSET or "utf-8" except AttributeError: encoding = "utf-8" try: b64_decoded = base64.b64decode(auth_string) except (TypeError, binascii.Error): log.debug("Failed basic auth: %r can't be decoded as base64", auth_string) return False try: auth_string_decoded = b64_decoded.decode(encoding) except UnicodeDecodeError: log.debug("Failed basic auth: %r can't be decoded as unicode by %r", auth_string, encoding) return False try: client_id, client_secret = map(unquote_plus, auth_string_decoded.split(":", 1)) except ValueError: log.debug("Failed basic auth, Invalid base64 encoding.") return False if self._load_application(client_id, request) is None: log.debug("Failed basic auth: Application %s does not exist" % client_id) return False elif request.client.client_id != client_id: log.debug("Failed basic auth: wrong client id %s" % client_id) return False elif not check_password(client_secret, request.client.client_secret): log.debug("Failed basic auth: wrong client secret %s" % client_secret) return False else: return True def _authenticate_request_body(self, request): """ Try to authenticate the client using client_id and client_secret parameters included in body. Remember that this method is NOT RECOMMENDED and SHOULD be limited to clients unable to directly utilize the HTTP Basic authentication scheme. See rfc:`2.3.1` for more details. """ # TODO: check if oauthlib has already unquoted client_id and client_secret try: client_id = request.client_id client_secret = getattr(request, "client_secret", "") except AttributeError: return False if self._load_application(client_id, request) is None: log.debug("Failed body auth: Application %s does not exists" % client_id) return False elif not check_password(client_secret, request.client.client_secret): log.debug("Failed body auth: wrong client secret %s" % client_secret) return False else: return True def _load_application(self, client_id, request): """ If request.client was not set, load application instance for given client_id and store it in request.client """ # we want to be sure that request has the client attribute! assert hasattr(request, "client"), '"request" instance has no "client" attribute' try: request.client = request.client or Application.objects.get(client_id=client_id) # Check that the application can be used (defaults to always True) if not request.client.is_usable(request): log.debug("Failed body authentication: Application %r is disabled" % (client_id)) return None return request.client except Application.DoesNotExist: log.debug("Failed body authentication: Application %r does not exist" % (client_id)) return None def _set_oauth2_error_on_request(self, request, access_token, scopes): if access_token is None: error = OrderedDict( [ ("error", "invalid_token"), ("error_description", _("The access token is invalid.")), ] ) elif access_token.is_expired(): error = OrderedDict( [ ("error", "invalid_token"), ("error_description", _("The access token has expired.")), ] ) elif not access_token.allow_scopes(scopes): error = OrderedDict( [ ("error", "insufficient_scope"), ("error_description", _("The access token is valid but does not have enough scope.")), ] ) else: log.warning("OAuth2 access token is invalid for an unknown reason.") error = OrderedDict( [ ("error", "invalid_token"), ] ) request.oauth2_error = error return request def client_authentication_required(self, request, *args, **kwargs): """ Determine if the client has to be authenticated This method is called only for grant types that supports client authentication: * Authorization code grant * Resource owner password grant * Refresh token grant If the request contains authorization headers, always authenticate the client no matter the grant type. If the request does not contain authorization headers, proceed with authentication only if the client is of type `Confidential`. If something goes wrong, call oauthlib implementation of the method. """ if self._extract_basic_auth(request): return True try: if request.client_id and request.client_secret: return True except AttributeError: log.debug("Client ID or client secret not provided...") pass self._load_application(request.client_id, request) if request.client: return request.client.client_type == AbstractApplication.CLIENT_CONFIDENTIAL return super().client_authentication_required(request, *args, **kwargs) def authenticate_client(self, request, *args, **kwargs): """ Check if client exists and is authenticating itself as in rfc:`3.2.1` First we try to authenticate with HTTP Basic Auth, and that is the PREFERRED authentication method. Whether this fails we support including the client credentials in the request-body, but this method is NOT RECOMMENDED and SHOULD be limited to clients unable to directly utilize the HTTP Basic authentication scheme. See rfc:`2.3.1` for more details """ authenticated = self._authenticate_basic_auth(request) if not authenticated: authenticated = self._authenticate_request_body(request) return authenticated def authenticate_client_id(self, client_id, request, *args, **kwargs): """ If we are here, the client did not authenticate itself as in rfc:`3.2.1` and we can proceed only if the client exists and is not of type "Confidential". """ if self._load_application(client_id, request) is not None: log.debug("Application %r has type %r" % (client_id, request.client.client_type)) return request.client.client_type != AbstractApplication.CLIENT_CONFIDENTIAL return False def confirm_redirect_uri(self, client_id, code, redirect_uri, client, *args, **kwargs): """ Ensure the redirect_uri is listed in the Application instance redirect_uris field """ grant = Grant.objects.get(code=code, application=client) return grant.redirect_uri_allowed(redirect_uri) def invalidate_authorization_code(self, client_id, code, request, *args, **kwargs): """ Remove the temporary grant used to swap the authorization token """ grant = Grant.objects.get(code=code, application=request.client) grant.delete() def validate_client_id(self, client_id, request, *args, **kwargs): """ Ensure an Application exists with given client_id. If it exists, it's assigned to request.client. """ return self._load_application(client_id, request) is not None def get_default_redirect_uri(self, client_id, request, *args, **kwargs): return request.client.default_redirect_uri def _get_token_from_authentication_server( self, token, introspection_url, introspection_token, introspection_credentials ): """Use external introspection endpoint to "crack open" the token. :param introspection_url: introspection endpoint URL :param introspection_token: Bearer token :param introspection_credentials: Basic Auth credentials (id,secret) :return: :class:`models.AccessToken` Some RFC 7662 implementations (including this one) use a Bearer token while others use Basic Auth. Depending on the external AS's implementation, provide either the introspection_token or the introspection_credentials. If the resulting access_token identifies a username (e.g. Authorization Code grant), add that user to the UserModel. Also cache the access_token up until its expiry time or a configured maximum time. """ headers = None if introspection_token: headers = {"Authorization": "Bearer {}".format(introspection_token)} elif introspection_credentials: client_id = introspection_credentials[0].encode("utf-8") client_secret = introspection_credentials[1].encode("utf-8") basic_auth = base64.b64encode(client_id + b":" + client_secret) headers = {"Authorization": "Basic {}".format(basic_auth.decode("utf-8"))} try: response = requests.post(introspection_url, data={"token": token}, headers=headers) except requests.exceptions.RequestException: log.exception("Introspection: Failed POST to %r in token lookup", introspection_url) return None # Log an exception when response from auth server is not successful if response.status_code != http.client.OK: log.exception( "Introspection: Failed to get a valid response " "from authentication server. Status code: {}, " "Reason: {}.".format(response.status_code, response.reason) ) return None try: content = response.json() except ValueError: log.exception("Introspection: Failed to parse response as json") return None if "active" in content and content["active"] is True: if "username" in content: user, _created = UserModel.objects.get_or_create( **{UserModel.USERNAME_FIELD: content["username"]} ) else: user = None max_caching_time = datetime.now() + timedelta( seconds=oauth2_settings.RESOURCE_SERVER_TOKEN_CACHING_SECONDS ) if "exp" in content: expires = datetime.utcfromtimestamp(content["exp"]) if expires > max_caching_time: expires = max_caching_time else: expires = max_caching_time scope = content.get("scope", "") expires = make_aware(expires) if settings.USE_TZ else expires access_token, _created = AccessToken.objects.update_or_create( token=token, defaults={ "user": user, "application": None, "scope": scope, "expires": expires, }, ) return access_token def validate_bearer_token(self, token, scopes, request): """ When users try to access resources, check that provided token is valid """ if not token: return False introspection_url = oauth2_settings.RESOURCE_SERVER_INTROSPECTION_URL introspection_token = oauth2_settings.RESOURCE_SERVER_AUTH_TOKEN introspection_credentials = oauth2_settings.RESOURCE_SERVER_INTROSPECTION_CREDENTIALS access_token = self._load_access_token(token) # if there is no token or it's invalid then introspect the token if there's an external OAuth server if not access_token or not access_token.is_valid(scopes): if introspection_url and (introspection_token or introspection_credentials): access_token = self._get_token_from_authentication_server( token, introspection_url, introspection_token, introspection_credentials ) if access_token and access_token.is_valid(scopes): request.client = access_token.application request.user = access_token.user request.scopes = list(access_token.scopes) # this is needed by django rest framework request.access_token = access_token return True else: self._set_oauth2_error_on_request(request, access_token, scopes) return False def _load_access_token(self, token): return AccessToken.objects.select_related("application", "user").filter(token=token).first() def validate_code(self, client_id, code, client, request, *args, **kwargs): try: grant = Grant.objects.get(code=code, application=client) if not grant.is_expired(): request.scopes = grant.scope.split(" ") request.user = grant.user if grant.nonce: request.nonce = grant.nonce if grant.claims: request.claims = json.loads(grant.claims) return True return False except Grant.DoesNotExist: return False def validate_grant_type(self, client_id, grant_type, client, request, *args, **kwargs): """ Validate both grant_type is a valid string and grant_type is allowed for current workflow """ assert grant_type in GRANT_TYPE_MAPPING # mapping misconfiguration return request.client.allows_grant_type(*GRANT_TYPE_MAPPING[grant_type]) def validate_response_type(self, client_id, response_type, client, request, *args, **kwargs): """ We currently do not support the Authorization Endpoint Response Types registry as in rfc:`8.4`, so validate the response_type only if it matches "code" or "token" """ if response_type == "code": return client.allows_grant_type(AbstractApplication.GRANT_AUTHORIZATION_CODE) elif response_type == "token": return client.allows_grant_type(AbstractApplication.GRANT_IMPLICIT) elif response_type == "id_token": return client.allows_grant_type(AbstractApplication.GRANT_IMPLICIT) elif response_type == "id_token token": return client.allows_grant_type(AbstractApplication.GRANT_IMPLICIT) elif response_type == "code id_token": return client.allows_grant_type(AbstractApplication.GRANT_OPENID_HYBRID) elif response_type == "code token": return client.allows_grant_type(AbstractApplication.GRANT_OPENID_HYBRID) elif response_type == "code id_token token": return client.allows_grant_type(AbstractApplication.GRANT_OPENID_HYBRID) else: return False def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs): """ Ensure required scopes are permitted (as specified in the settings file) """ available_scopes = get_scopes_backend().get_available_scopes(application=client, request=request) return set(scopes).issubset(set(available_scopes)) def get_default_scopes(self, client_id, request, *args, **kwargs): default_scopes = get_scopes_backend().get_default_scopes(application=request.client, request=request) return default_scopes def validate_redirect_uri(self, client_id, redirect_uri, request, *args, **kwargs): return request.client.redirect_uri_allowed(redirect_uri) def is_pkce_required(self, client_id, request): """ Enables or disables PKCE verification. Uses the setting PKCE_REQUIRED, which can be either a bool or a callable that receives the client id and returns a bool. """ if callable(oauth2_settings.PKCE_REQUIRED): return oauth2_settings.PKCE_REQUIRED(client_id) return oauth2_settings.PKCE_REQUIRED def get_code_challenge(self, code, request): grant = Grant.objects.get(code=code, application=request.client) return grant.code_challenge or None def get_code_challenge_method(self, code, request): grant = Grant.objects.get(code=code, application=request.client) return grant.code_challenge_method or None def save_authorization_code(self, client_id, code, request, *args, **kwargs): self._create_authorization_code(request, code) def get_authorization_code_scopes(self, client_id, code, redirect_uri, request): scopes = Grant.objects.filter(code=code).values_list("scope", flat=True).first() if scopes: return utils.scope_to_list(scopes) return [] def rotate_refresh_token(self, request): """ Checks if rotate refresh token is enabled """ return oauth2_settings.ROTATE_REFRESH_TOKEN @transaction.atomic def save_bearer_token(self, token, request, *args, **kwargs): """ Save access and refresh token, If refresh token is issued, remove or reuse old refresh token as in rfc:`6` @see: https://tools.ietf.org/html/draft-ietf-oauth-v2-31#page-43 """ if "scope" not in token: raise FatalClientError("Failed to renew access token: missing scope") # expires_in is passed to Server on initialization # custom server class can have logic to override this expires = timezone.now() + timedelta( seconds=token.get( "expires_in", oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS, ) ) if request.grant_type == "client_credentials": request.user = None # This comes from OAuthLib: # https://github.com/idan/oauthlib/blob/1.0.3/oauthlib/oauth2/rfc6749/tokens.py#L267 # Its value is either a new random code; or if we are reusing # refresh tokens, then it is the same value that the request passed in # (stored in `request.refresh_token`) refresh_token_code = token.get("refresh_token", None) if refresh_token_code: # an instance of `RefreshToken` that matches the old refresh code. # Set on the request in `validate_refresh_token` refresh_token_instance = getattr(request, "refresh_token_instance", None) # If we are to reuse tokens, and we can: do so if ( not self.rotate_refresh_token(request) and isinstance(refresh_token_instance, RefreshToken) and refresh_token_instance.access_token ): access_token = AccessToken.objects.select_for_update().get( pk=refresh_token_instance.access_token.pk ) access_token.user = request.user access_token.scope = token["scope"] access_token.expires = expires access_token.token = token["access_token"] access_token.application = request.client access_token.save() # else create fresh with access & refresh tokens else: # revoke existing tokens if possible to allow reuse of grant if isinstance(refresh_token_instance, RefreshToken): # First, to ensure we don't have concurrency issues, we refresh the refresh token # from the db while acquiring a lock on it # We also put it in the "request cache" refresh_token_instance = RefreshToken.objects.select_for_update().get( id=refresh_token_instance.id ) request.refresh_token_instance = refresh_token_instance previous_access_token = AccessToken.objects.filter( source_refresh_token=refresh_token_instance ).first() try: refresh_token_instance.revoke() except (AccessToken.DoesNotExist, RefreshToken.DoesNotExist): pass else: setattr(request, "refresh_token_instance", None) else: previous_access_token = None # If the refresh token has already been used to create an # access token (ie it's within the grace period), return that # access token if not previous_access_token: access_token = self._create_access_token( expires, request, token, source_refresh_token=refresh_token_instance, ) self._create_refresh_token(request, refresh_token_code, access_token) else: # make sure that the token data we're returning matches # the existing token token["access_token"] = previous_access_token.token token["refresh_token"] = ( RefreshToken.objects.filter(access_token=previous_access_token).first().token ) token["scope"] = previous_access_token.scope # No refresh token should be created, just access token else: self._create_access_token(expires, request, token) def _create_access_token(self, expires, request, token, source_refresh_token=None): id_token = token.get("id_token", None) if id_token: id_token = self._load_id_token(id_token) return AccessToken.objects.create( user=request.user, scope=token["scope"], expires=expires, token=token["access_token"], id_token=id_token, application=request.client, source_refresh_token=source_refresh_token, ) def _create_authorization_code(self, request, code, expires=None): if not expires: expires = timezone.now() + timedelta(seconds=oauth2_settings.AUTHORIZATION_CODE_EXPIRE_SECONDS) return Grant.objects.create( application=request.client, user=request.user, code=code["code"], expires=expires, redirect_uri=request.redirect_uri, scope=" ".join(request.scopes), code_challenge=request.code_challenge or "", code_challenge_method=request.code_challenge_method or "", nonce=request.nonce or "", claims=json.dumps(request.claims or {}), ) def _create_refresh_token(self, request, refresh_token_code, access_token): return RefreshToken.objects.create( user=request.user, token=refresh_token_code, application=request.client, access_token=access_token ) def revoke_token(self, token, token_type_hint, request, *args, **kwargs): """ Revoke an access or refresh token. :param token: The token string. :param token_type_hint: access_token or refresh_token. :param request: The HTTP Request (oauthlib.common.Request) """ if token_type_hint not in ["access_token", "refresh_token"]: token_type_hint = None token_types = { "access_token": AccessToken, "refresh_token": RefreshToken, } token_type = token_types.get(token_type_hint, AccessToken) try: token_type.objects.get(token=token).revoke() except ObjectDoesNotExist: for other_type in [_t for _t in token_types.values() if _t != token_type]: # slightly inefficient on Python2, but the queryset contains only one instance list(map(lambda t: t.revoke(), other_type.objects.filter(token=token))) def validate_user(self, username, password, client, request, *args, **kwargs): """ Check username and password correspond to a valid and active User """ # 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 return False def get_original_scopes(self, refresh_token, request, *args, **kwargs): # Avoid second query for RefreshToken since this method is invoked *after* # validate_refresh_token. rt = request.refresh_token_instance if not rt.access_token_id: return AccessToken.objects.get(source_refresh_token_id=rt.id).scope return rt.access_token.scope def validate_refresh_token(self, refresh_token, client, request, *args, **kwargs): """ Check refresh_token exists and refers to the right client. Also attach User instance to the request object """ null_or_recent = Q(revoked__isnull=True) | Q( revoked__gt=timezone.now() - timedelta(seconds=oauth2_settings.REFRESH_TOKEN_GRACE_PERIOD_SECONDS) ) rt = ( RefreshToken.objects.filter(null_or_recent, token=refresh_token) .select_related("access_token") .first() ) if not rt: return False request.user = rt.user request.refresh_token = rt.token # Temporary store RefreshToken instance to be reused by get_original_scopes and save_bearer_token. request.refresh_token_instance = rt return rt.application == client @transaction.atomic def _save_id_token(self, jti, request, expires, *args, **kwargs): scopes = request.scope or " ".join(request.scopes) id_token = IDToken.objects.create( user=request.user, scope=scopes, expires=expires, jti=jti, application=request.client, ) 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_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 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 = {} # TODO if request.claims then return only the claims requested, but limited by granted scopes. for k, v in data.items(): if not self.oidc_claim_scope or self.oidc_claim_scope.get(k) in request.scopes: claims[k] = v(request) if callable(v) else v return claims def get_id_token_dictionary(self, token, token_handler, request): """ Get the claims to put in the ID Token. These claims are in addition to the claims automatically added by ``oauthlib`` - aud, iat, nonce, at_hash, c_hash. This function adds in iss, exp and auth_time, plus any claims added from calling ``get_oidc_claims()`` """ claims = self.get_oidc_claims(token, token_handler, request) expiration_time = timezone.now() + timedelta(seconds=oauth2_settings.ID_TOKEN_EXPIRE_SECONDS) # Required ID Token claims claims.update( **{ "iss": self.get_oidc_issuer_endpoint(request), "exp": int(dateformat.format(expiration_time, "U")), "auth_time": int(dateformat.format(request.user.last_login, "U")), "jti": str(uuid.uuid4()), } ) return claims, expiration_time def get_oidc_issuer_endpoint(self, request): return oauth2_settings.oidc_issuer(request) def finalize_id_token(self, id_token, token, token_handler, request): claims, expiration_time = self.get_id_token_dictionary(token, token_handler, request) id_token.update(**claims) # Workaround for oauthlib bug #746 # https://github.com/oauthlib/oauthlib/issues/746 if "nonce" not in id_token and request.nonce: id_token["nonce"] = request.nonce header = { "typ": "JWT", "alg": request.client.algorithm, } # RS256 consumers expect a kid in the header for verifying the token if request.client.algorithm == AbstractApplication.RS256_ALGORITHM: header["kid"] = request.client.jwk_key.thumbprint() jwt_token = jwt.JWT( header=json.dumps(header, default=str), claims=json.dumps(id_token, default=str), ) jwt_token.make_signed_token(request.client.jwk_key) id_token = self._save_id_token(id_token["jti"], request, expiration_time) # this is needed by django rest framework request.access_token = id_token request.id_token = id_token return jwt_token.serialize() def validate_jwt_bearer_token(self, token, scopes, request): return self.validate_id_token(token, scopes, request) def validate_id_token(self, token, scopes, request): """ When users try to access resources, check that provided id_token is valid """ if not token: return False id_token = self._load_id_token(token) if not id_token: return False if not id_token.allow_scopes(scopes): return False request.client = id_token.application request.user = id_token.user request.scopes = scopes # this is needed by django rest framework request.access_token = id_token return True def _load_id_token(self, token): key = self._get_key_for_token(token) if not key: return None try: jwt_token = jwt.JWT(key=key, jwt=token) claims = json.loads(jwt_token.claims) return IDToken.objects.get(jti=claims["jti"]) except (JWException, JWTExpired, IDToken.DoesNotExist): return None def _get_key_for_token(self, token): """ Peek at the unvalidated token to discover who it was issued for and then use that to load that application and its key. """ unverified_token = jws.JWS() unverified_token.deserialize(token) claims = json.loads(unverified_token.objects["payload"].decode("utf-8")) if "aud" not in claims: return None application = self._get_client_by_audience(claims["aud"]) if application: return application.jwk_key def _get_client_by_audience(self, audience): """ Load a client by the aud claim in a JWT. aud may be multi-valued, if your provider makes it so. This function is separate to allow further customization. """ if isinstance(audience, str): audience = [audience] return Application.objects.filter(client_id__in=audience).first() def validate_user_match(self, id_token_hint, scopes, claims, request): # TODO: Fix to validate when necessary acording # https://github.com/idan/oauthlib/blob/master/oauthlib/oauth2/rfc6749/request_validator.py#L556 # http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest id_token_hint section return True def get_authorization_code_nonce(self, client_id, code, redirect_uri, request): """Extracts nonce from saved authorization code. If present in the Authentication Request, Authorization Servers MUST include a nonce Claim in the ID Token with the Claim Value being the nonce value sent in the Authentication Request. Authorization Servers SHOULD perform no other processing on nonce values used. The nonce value is a case-sensitive string. Only code param should be sufficient to retrieve grant code from any storage you are using. However, `client_id` and `redirect_uri` have been validated and can be used also. :param client_id: Unicode client identifier :param code: Unicode authorization code grant :param redirect_uri: Unicode absolute URI :return: Unicode nonce Method is used by: - Authorization Token Grant Dispatcher """ nonce = Grant.objects.filter(code=code).values_list("nonce", flat=True).first() if nonce: return nonce def get_userinfo_claims(self, request): """ Generates and saves a new JWT for this request, and returns it as the current user's claims. """ return self.get_oidc_claims(request.access_token, None, request) def get_additional_claims(self, request): return {} django-oauth-toolkit-2.3.0/oauth2_provider/scopes.py000066400000000000000000000030401443573112200225400ustar00rootroot00000000000000from .settings import oauth2_settings class BaseScopes: def get_all_scopes(self): """ Return a dict-like object with all the scopes available in the system. The key should be the scope name and the value should be the description. ex: {"read": "A read scope", "write": "A write scope"} """ raise NotImplementedError("") def get_available_scopes(self, application=None, request=None, *args, **kwargs): """ Return a list of scopes available for the current application/request. TODO: add info on where and why this method is called. ex: ["read", "write"] """ raise NotImplementedError("") def get_default_scopes(self, application=None, request=None, *args, **kwargs): """ Return a list of the default scopes for the current application/request. This MUST be a subset of the scopes returned by `get_available_scopes`. TODO: add info on where and why this method is called. ex: ["read"] """ raise NotImplementedError("") class SettingsScopes(BaseScopes): def get_all_scopes(self): return oauth2_settings.SCOPES def get_available_scopes(self, application=None, request=None, *args, **kwargs): return oauth2_settings._SCOPES def get_default_scopes(self, application=None, request=None, *args, **kwargs): return oauth2_settings._DEFAULT_SCOPES def get_scopes_backend(): scopes_class = oauth2_settings.SCOPES_BACKEND_CLASS return scopes_class() django-oauth-toolkit-2.3.0/oauth2_provider/settings.py000066400000000000000000000266401443573112200231170ustar00rootroot00000000000000""" This module is largely inspired by django-rest-framework settings. Settings for the OAuth2 Provider are all namespaced in the OAUTH2_PROVIDER setting. For example your project's `settings.py` file might look like this: OAUTH2_PROVIDER = { "CLIENT_ID_GENERATOR_CLASS": "oauth2_provider.generators.ClientIdGenerator", "CLIENT_SECRET_GENERATOR_CLASS": "oauth2_provider.generators.ClientSecretGenerator", } This module provides the `oauth2_settings` object, that is used to access OAuth2 Provider settings, checking for user settings first, then falling back to the defaults. """ from django.conf import settings from django.core.exceptions import ImproperlyConfigured from django.http import HttpRequest from django.test.signals import setting_changed from django.urls import reverse from django.utils.module_loading import import_string from oauthlib.common import Request USER_SETTINGS = getattr(settings, "OAUTH2_PROVIDER", None) APPLICATION_MODEL = getattr(settings, "OAUTH2_PROVIDER_APPLICATION_MODEL", "oauth2_provider.Application") ACCESS_TOKEN_MODEL = getattr(settings, "OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL", "oauth2_provider.AccessToken") ID_TOKEN_MODEL = getattr(settings, "OAUTH2_PROVIDER_ID_TOKEN_MODEL", "oauth2_provider.IDToken") GRANT_MODEL = getattr(settings, "OAUTH2_PROVIDER_GRANT_MODEL", "oauth2_provider.Grant") REFRESH_TOKEN_MODEL = getattr(settings, "OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL", "oauth2_provider.RefreshToken") DEFAULTS = { "CLIENT_ID_GENERATOR_CLASS": "oauth2_provider.generators.ClientIdGenerator", "CLIENT_SECRET_GENERATOR_CLASS": "oauth2_provider.generators.ClientSecretGenerator", "CLIENT_SECRET_GENERATOR_LENGTH": 128, "ACCESS_TOKEN_GENERATOR": None, "REFRESH_TOKEN_GENERATOR": None, "EXTRA_SERVER_KWARGS": {}, "OAUTH2_SERVER_CLASS": "oauthlib.oauth2.Server", "OIDC_SERVER_CLASS": "oauthlib.openid.Server", "OAUTH2_VALIDATOR_CLASS": "oauth2_provider.oauth2_validators.OAuth2Validator", "OAUTH2_BACKEND_CLASS": "oauth2_provider.oauth2_backends.OAuthLibCore", "SCOPES": {"read": "Reading scope", "write": "Writing scope"}, "DEFAULT_SCOPES": ["__all__"], "SCOPES_BACKEND_CLASS": "oauth2_provider.scopes.SettingsScopes", "READ_SCOPE": "read", "WRITE_SCOPE": "write", "AUTHORIZATION_CODE_EXPIRE_SECONDS": 60, "ACCESS_TOKEN_EXPIRE_SECONDS": 36000, "ID_TOKEN_EXPIRE_SECONDS": 36000, "REFRESH_TOKEN_EXPIRE_SECONDS": None, "REFRESH_TOKEN_GRACE_PERIOD_SECONDS": 0, "ROTATE_REFRESH_TOKEN": True, "ERROR_RESPONSE_WITH_SCOPES": False, "APPLICATION_MODEL": APPLICATION_MODEL, "ACCESS_TOKEN_MODEL": ACCESS_TOKEN_MODEL, "ID_TOKEN_MODEL": ID_TOKEN_MODEL, "GRANT_MODEL": GRANT_MODEL, "REFRESH_TOKEN_MODEL": REFRESH_TOKEN_MODEL, "APPLICATION_ADMIN_CLASS": "oauth2_provider.admin.ApplicationAdmin", "ACCESS_TOKEN_ADMIN_CLASS": "oauth2_provider.admin.AccessTokenAdmin", "GRANT_ADMIN_CLASS": "oauth2_provider.admin.GrantAdmin", "ID_TOKEN_ADMIN_CLASS": "oauth2_provider.admin.IDTokenAdmin", "REFRESH_TOKEN_ADMIN_CLASS": "oauth2_provider.admin.RefreshTokenAdmin", "REQUEST_APPROVAL_PROMPT": "force", "ALLOWED_REDIRECT_URI_SCHEMES": ["http", "https"], "OIDC_ENABLED": False, "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", "id_token", "id_token token", "code token", "code id_token", "code id_token token", ], "OIDC_SUBJECT_TYPES_SUPPORTED": ["public"], "OIDC_TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED": [ "client_secret_post", "client_secret_basic", ], "OIDC_RP_INITIATED_LOGOUT_ENABLED": False, "OIDC_RP_INITIATED_LOGOUT_ALWAYS_PROMPT": True, "OIDC_RP_INITIATED_LOGOUT_STRICT_REDIRECT_URIS": False, "OIDC_RP_INITIATED_LOGOUT_ACCEPT_EXPIRED_TOKENS": True, "OIDC_RP_INITIATED_LOGOUT_DELETE_TOKENS": True, # Special settings that will be evaluated at runtime "_SCOPES": [], "_DEFAULT_SCOPES": [], # Resource Server with Token Introspection "RESOURCE_SERVER_INTROSPECTION_URL": None, "RESOURCE_SERVER_AUTH_TOKEN": None, "RESOURCE_SERVER_INTROSPECTION_CREDENTIALS": None, "RESOURCE_SERVER_TOKEN_CACHING_SECONDS": 36000, # Whether or not PKCE is required "PKCE_REQUIRED": True, # 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 MANDATORY = ( "CLIENT_ID_GENERATOR_CLASS", "CLIENT_SECRET_GENERATOR_CLASS", "OAUTH2_SERVER_CLASS", "OAUTH2_VALIDATOR_CLASS", "OAUTH2_BACKEND_CLASS", "SCOPES", "ALLOWED_REDIRECT_URI_SCHEMES", "OIDC_RESPONSE_TYPES_SUPPORTED", "OIDC_SUBJECT_TYPES_SUPPORTED", "OIDC_TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED", ) # List of settings that may be in string import notation. IMPORT_STRINGS = ( "CLIENT_ID_GENERATOR_CLASS", "CLIENT_SECRET_GENERATOR_CLASS", "ACCESS_TOKEN_GENERATOR", "REFRESH_TOKEN_GENERATOR", "OAUTH2_SERVER_CLASS", "OAUTH2_VALIDATOR_CLASS", "OAUTH2_BACKEND_CLASS", "SCOPES_BACKEND_CLASS", "APPLICATION_ADMIN_CLASS", "ACCESS_TOKEN_ADMIN_CLASS", "GRANT_ADMIN_CLASS", "ID_TOKEN_ADMIN_CLASS", "REFRESH_TOKEN_ADMIN_CLASS", ) def perform_import(val, setting_name): """ If the given setting is a string import notation, then perform the necessary import or imports. """ if val is None: return None elif isinstance(val, str): return import_from_string(val, setting_name) elif isinstance(val, (list, tuple)): return [import_from_string(item, setting_name) for item in val] return val def import_from_string(val, setting_name): """ Attempt to import a class from a string representation. """ try: return import_string(val) except ImportError as e: msg = "Could not import %r for setting %r. %s: %s." % (val, setting_name, e.__class__.__name__, e) raise ImportError(msg) class _PhonyHttpRequest(HttpRequest): _scheme = "http" def _get_scheme(self): return self._scheme class OAuth2ProviderSettings: """ A settings object, that allows OAuth2 Provider settings to be accessed as properties. Any setting with string import paths will be automatically resolved and return the class, rather than the string literal. """ def __init__(self, user_settings=None, defaults=None, import_strings=None, mandatory=None): self._user_settings = user_settings or {} self.defaults = defaults or DEFAULTS self.import_strings = import_strings or IMPORT_STRINGS self.mandatory = mandatory or () self._cached_attrs = set() @property def user_settings(self): if not hasattr(self, "_user_settings"): self._user_settings = getattr(settings, "OAUTH2_PROVIDER", {}) return self._user_settings def __getattr__(self, attr): if attr not in self.defaults: raise AttributeError("Invalid OAuth2Provider setting: %s" % attr) try: # Check if present in user settings val = self.user_settings[attr] except KeyError: # Fall back to defaults # Special case OAUTH2_SERVER_CLASS - if not specified, and OIDC is # enabled, use the OIDC_SERVER_CLASS setting instead if attr == "OAUTH2_SERVER_CLASS" and self.OIDC_ENABLED: val = self.defaults["OIDC_SERVER_CLASS"] else: val = self.defaults[attr] # Coerce import strings into classes if val and attr in self.import_strings: val = perform_import(val, attr) # Overriding special settings if attr == "_SCOPES": val = list(self.SCOPES.keys()) if attr == "_DEFAULT_SCOPES": if "__all__" in self.DEFAULT_SCOPES: # If DEFAULT_SCOPES is set to ["__all__"] the whole set of scopes is returned val = list(self._SCOPES) else: # Otherwise we return a subset (that can be void) of SCOPES val = [] for scope in self.DEFAULT_SCOPES: if scope in self._SCOPES: val.append(scope) else: raise ImproperlyConfigured("Defined DEFAULT_SCOPES not present in SCOPES") self.validate_setting(attr, val) # Cache the result self._cached_attrs.add(attr) setattr(self, attr, val) return val def validate_setting(self, attr, val): if not val and attr in self.mandatory: raise AttributeError("OAuth2Provider setting: %s is mandatory" % attr) @property def server_kwargs(self): """ This is used to communicate settings to oauth server. Takes relevant settings and format them accordingly. There's also EXTRA_SERVER_KWARGS that can override every value and is more flexible regarding keys and acceptable values but doesn't have import string magic or any additional processing, callables have to be assigned directly. For the likes of signed_token_generator it means something like {"token_generator": signed_token_generator(privkey, **kwargs)} """ kwargs = { key: getattr(self, value) for key, value in [ ("token_expires_in", "ACCESS_TOKEN_EXPIRE_SECONDS"), ("refresh_token_expires_in", "REFRESH_TOKEN_EXPIRE_SECONDS"), ("token_generator", "ACCESS_TOKEN_GENERATOR"), ("refresh_token_generator", "REFRESH_TOKEN_GENERATOR"), ] } kwargs.update(self.EXTRA_SERVER_KWARGS) return kwargs def reload(self): for attr in self._cached_attrs: delattr(self, attr) self._cached_attrs.clear() if hasattr(self, "_user_settings"): delattr(self, "_user_settings") def oidc_issuer(self, request): """ Helper function to get the OIDC issuer URL, either from the settings or constructing it from the passed request. If only an oauthlib request is available, a dummy django request is built from that and used to generate the URL. """ if self.OIDC_ISS_ENDPOINT: return self.OIDC_ISS_ENDPOINT if isinstance(request, HttpRequest): django_request = request elif isinstance(request, Request): django_request = _PhonyHttpRequest() django_request.META = request.headers if request.headers.get("X_DJANGO_OAUTH_TOOLKIT_SECURE", False): django_request._scheme = "https" else: raise TypeError("request must be a django or oauthlib request: got %r" % request) abs_url = django_request.build_absolute_uri(reverse("oauth2_provider:oidc-connect-discovery-info")) return abs_url[: -len("/.well-known/openid-configuration/")] oauth2_settings = OAuth2ProviderSettings(USER_SETTINGS, DEFAULTS, IMPORT_STRINGS, MANDATORY) def reload_oauth2_settings(*args, **kwargs): setting = kwargs["setting"] if setting == "OAUTH2_PROVIDER": oauth2_settings.reload() setting_changed.connect(reload_oauth2_settings) django-oauth-toolkit-2.3.0/oauth2_provider/signals.py000066400000000000000000000001461443573112200227100ustar00rootroot00000000000000from django.dispatch import Signal app_authorized = Signal() # providing_args=["request", "token"] django-oauth-toolkit-2.3.0/oauth2_provider/templates/000077500000000000000000000000001443573112200226735ustar00rootroot00000000000000django-oauth-toolkit-2.3.0/oauth2_provider/templates/oauth2_provider/000077500000000000000000000000001443573112200260075ustar00rootroot00000000000000django-oauth-toolkit-2.3.0/oauth2_provider/templates/oauth2_provider/application_confirm_delete.html000066400000000000000000000013561443573112200342440ustar00rootroot00000000000000{% extends "oauth2_provider/base.html" %} {% load i18n %} {% block content %}

{% trans "Are you sure to delete the application" %} {{ application.name }}?

{% csrf_token %}
{% endblock content %} django-oauth-toolkit-2.3.0/oauth2_provider/templates/oauth2_provider/application_detail.html000066400000000000000000000030231443573112200325200ustar00rootroot00000000000000{% extends "oauth2_provider/base.html" %} {% load i18n %} {% block content %}

{{ application.name }}

  • {% trans "Client id" %}

  • {% trans "Client secret" %}

  • {% trans "Client type" %}

    {{ application.client_type }}

  • {% trans "Authorization Grant Type" %}

    {{ application.authorization_grant_type }}

  • {% trans "Redirect Uris" %}

{% endblock content %} django-oauth-toolkit-2.3.0/oauth2_provider/templates/oauth2_provider/application_form.html000066400000000000000000000034451443573112200322310ustar00rootroot00000000000000{% extends "oauth2_provider/base.html" %} {% load i18n %} {% block content %}

{% block app-form-title %} {% trans "Edit application" %} {{ application.name }} {% endblock app-form-title %}

{% csrf_token %} {% for field in form %}
{{ field }} {% for error in field.errors %} {{ error }} {% endfor %}
{% endfor %}
{% for error in form.non_field_errors %} {{ error }} {% endfor %}
{% trans "Go Back" %}
{% endblock %} django-oauth-toolkit-2.3.0/oauth2_provider/templates/oauth2_provider/application_list.html000066400000000000000000000014531443573112200322360ustar00rootroot00000000000000{% extends "oauth2_provider/base.html" %} {% load i18n %} {% block content %}

{% trans "Your applications" %}

{% if applications %} {% trans "New Application" %} {% else %}

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

{% endif %}
{% endblock content %} application_registration_form.html000066400000000000000000000005571443573112200347450ustar00rootroot00000000000000django-oauth-toolkit-2.3.0/oauth2_provider/templates/oauth2_provider{% extends "oauth2_provider/application_form.html" %} {% load i18n %} {% block app-form-title %}{% trans "Register a new application" %}{% endblock app-form-title %} {% block app-form-action-url %}{% url 'oauth2_provider:register' %}{% endblock app-form-action-url %} {% block app-form-back-url %}{% url "oauth2_provider:list" %}"{% endblock app-form-back-url %} django-oauth-toolkit-2.3.0/oauth2_provider/templates/oauth2_provider/authorize.html000066400000000000000000000025601443573112200307120ustar00rootroot00000000000000{% extends "oauth2_provider/base.html" %} {% load i18n %} {% block content %}
{% if not error %}

{% trans "Authorize" %} {{ application.name }}?

{% csrf_token %} {% for field in form %} {% if field.is_hidden %} {{ field }} {% endif %} {% endfor %}

{% trans "Application requires the following permissions" %}

    {% for scope in scopes_descriptions %}
  • {{ scope }}
  • {% endfor %}
{{ form.errors }} {{ form.non_field_errors }}
{% else %}

Error: {{ error.error }}

{{ error.description }}

{% endif %}
{% endblock %} django-oauth-toolkit-2.3.0/oauth2_provider/templates/oauth2_provider/authorized-token-delete.html000066400000000000000000000004431443573112200334320ustar00rootroot00000000000000{% extends "oauth2_provider/base.html" %} {% load i18n %} {% block content %}
{% csrf_token %}

{% trans "Are you sure you want to delete this token?" %}

{% endblock %} django-oauth-toolkit-2.3.0/oauth2_provider/templates/oauth2_provider/authorized-tokens.html000066400000000000000000000014241443573112200323550ustar00rootroot00000000000000{% extends "oauth2_provider/base.html" %} {% load i18n %} {% block content %}

{% trans "Tokens" %}

    {% for authorized_token in authorized_tokens %}
  • {{ authorized_token.application }} ({% trans "revoke" %})
    • {% for scope_name, scope_description in authorized_token.scopes.items %}
    • {{ scope_name }}: {{ scope_description }}
    • {% endfor %}
    {% empty %}
  • {% trans "There are no authorized tokens yet." %}
  • {% endfor %}
{% endblock %} django-oauth-toolkit-2.3.0/oauth2_provider/templates/oauth2_provider/base.html000066400000000000000000000024161443573112200276120ustar00rootroot00000000000000 {% block title %}{% endblock title %} {% block css %} {% endblock css %}
{% block content %} {% endblock content %}
django-oauth-toolkit-2.3.0/oauth2_provider/templates/oauth2_provider/logout_confirm.html000066400000000000000000000023641443573112200317300ustar00rootroot00000000000000{% extends "oauth2_provider/base.html" %} {% load i18n %} {% block content %}
{% if not error %}
{% if application %}

Confirm Logout requested by {{ application.name }}

{% else %}

Confirm Logout

{% endif %} {% csrf_token %} {% for field in form %} {% if field.is_hidden %} {{ field }} {% endif %} {% endfor %} {{ form.errors }} {{ form.non_field_errors }}
{% else %}

Error: {{ error.error }}

{{ error.description }}

{% endif %}
{% endblock %} django-oauth-toolkit-2.3.0/oauth2_provider/urls.py000066400000000000000000000034301443573112200222340ustar00rootroot00000000000000from django.urls import re_path from . import views app_name = "oauth2_provider" base_urlpatterns = [ re_path(r"^authorize/$", views.AuthorizationView.as_view(), name="authorize"), re_path(r"^token/$", views.TokenView.as_view(), name="token"), re_path(r"^revoke_token/$", views.RevokeTokenView.as_view(), name="revoke-token"), re_path(r"^introspect/$", views.IntrospectTokenView.as_view(), name="introspect"), ] management_urlpatterns = [ # Application management views re_path(r"^applications/$", views.ApplicationList.as_view(), name="list"), re_path(r"^applications/register/$", views.ApplicationRegistration.as_view(), name="register"), re_path(r"^applications/(?P[\w-]+)/$", views.ApplicationDetail.as_view(), name="detail"), re_path(r"^applications/(?P[\w-]+)/delete/$", views.ApplicationDelete.as_view(), name="delete"), re_path(r"^applications/(?P[\w-]+)/update/$", views.ApplicationUpdate.as_view(), name="update"), # Token management views re_path(r"^authorized_tokens/$", views.AuthorizedTokensListView.as_view(), name="authorized-token-list"), re_path( r"^authorized_tokens/(?P[\w-]+)/delete/$", views.AuthorizedTokenDeleteView.as_view(), name="authorized-token-delete", ), ] oidc_urlpatterns = [ re_path( r"^\.well-known/openid-configuration/$", views.ConnectDiscoveryInfoView.as_view(), name="oidc-connect-discovery-info", ), re_path(r"^\.well-known/jwks.json$", views.JwksInfoView.as_view(), name="jwks-info"), re_path(r"^userinfo/$", views.UserInfoView.as_view(), name="user-info"), re_path(r"^logout/$", views.RPInitiatedLogoutView.as_view(), name="rp-initiated-logout"), ] urlpatterns = base_urlpatterns + management_urlpatterns + oidc_urlpatterns django-oauth-toolkit-2.3.0/oauth2_provider/validators.py000066400000000000000000000026531443573112200234250ustar00rootroot00000000000000import re from urllib.parse import urlsplit from django.core.exceptions import ValidationError from django.core.validators import URLValidator from django.utils.encoding import force_str class URIValidator(URLValidator): scheme_re = r"^(?:[a-z][a-z0-9\.\-\+]*)://" dotless_domain_re = r"(?!-)[A-Z\d-]{1,63}(? assume an in-house applications # are already approved. if application.skip_authorization: uri, headers, body, status = self.create_authorization_response( request=self.request, scopes=" ".join(scopes), credentials=credentials, allow=True ) return self.redirect(uri, application) elif require_approval == "auto": tokens = ( get_access_token_model() .objects.filter( user=request.user, application=kwargs["application"], expires__gt=timezone.now() ) .all() ) # check past authorizations regarded the same scopes as the current one for token in tokens: if token.allow_scopes(scopes): uri, headers, body, status = self.create_authorization_response( request=self.request, scopes=" ".join(scopes), credentials=credentials, allow=True, ) return self.redirect(uri, application) except OAuthToolkitError as error: return self.error_response(error, application) return self.render_to_response(self.get_context_data(**kwargs)) def handle_prompt_login(self): path = self.request.build_absolute_uri() resolved_login_url = resolve_url(self.get_login_url()) # If the login url is the same scheme and net location then use the # path as the "next" url. login_scheme, login_netloc = urlparse(resolved_login_url)[:2] current_scheme, current_netloc = urlparse(path)[:2] if (not login_scheme or login_scheme == current_scheme) and ( not login_netloc or login_netloc == current_netloc ): path = self.request.get_full_path() parsed = urlparse(path) parsed_query = dict(parse_qsl(parsed.query)) parsed_query.pop("prompt") parsed = parsed._replace(query=urlencode(parsed_query)) return redirect_to_login( parsed.geturl(), resolved_login_url, self.get_redirect_field_name(), ) @method_decorator(csrf_exempt, name="dispatch") class TokenView(OAuthLibMixin, View): """ Implements an endpoint to provide access tokens The endpoint is used in the following flows: * Authorization code * Password * Client credentials """ @method_decorator(sensitive_post_parameters("password")) def post(self, request, *args, **kwargs): url, headers, body, status = self.create_token_response(request) if status == 200: access_token = json.loads(body).get("access_token") if access_token is not None: token = get_access_token_model().objects.get(token=access_token) app_authorized.send(sender=self, request=request, token=token) response = HttpResponse(content=body, status=status) for k, v in headers.items(): response[k] = v return response @method_decorator(csrf_exempt, name="dispatch") class RevokeTokenView(OAuthLibMixin, View): """ Implements an endpoint to revoke access or refresh tokens """ def post(self, request, *args, **kwargs): url, headers, body, status = self.create_revocation_response(request) response = HttpResponse(content=body or "", status=status) for k, v in headers.items(): response[k] = v return response django-oauth-toolkit-2.3.0/oauth2_provider/views/generic.py000066400000000000000000000025301443573112200240200ustar00rootroot00000000000000from django.views.generic import View from .mixins import ( ClientProtectedResourceMixin, OAuthLibMixin, ProtectedResourceMixin, ReadWriteScopedResourceMixin, ScopedResourceMixin, ) class ProtectedResourceView(ProtectedResourceMixin, OAuthLibMixin, View): """ Generic view protecting resources by providing OAuth2 authentication out of the box """ pass class ScopedProtectedResourceView(ScopedResourceMixin, ProtectedResourceView): """ Generic view protecting resources by providing OAuth2 authentication and Scopes handling out of the box """ pass class ReadWriteScopedResourceView(ReadWriteScopedResourceMixin, ProtectedResourceView): """ Generic view protecting resources with OAuth2 authentication and read/write scopes. GET, HEAD, OPTIONS http methods require "read" scope. Otherwise "write" scope is required. """ pass class ClientProtectedResourceView(ClientProtectedResourceMixin, OAuthLibMixin, View): """View for protecting a resource with client-credentials method. This involves allowing access tokens, Basic Auth and plain credentials in request body. """ pass class ClientProtectedScopedResourceView(ScopedResourceMixin, ClientProtectedResourceView): """Impose scope restrictions if client protection fallsback to access token.""" pass django-oauth-toolkit-2.3.0/oauth2_provider/views/introspect.py000066400000000000000000000044031443573112200245770ustar00rootroot00000000000000import calendar from django.core.exceptions import ObjectDoesNotExist from django.http import JsonResponse from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt from oauth2_provider.models import get_access_token_model from oauth2_provider.views.generic import ClientProtectedScopedResourceView @method_decorator(csrf_exempt, name="dispatch") class IntrospectTokenView(ClientProtectedScopedResourceView): """ Implements an endpoint for token introspection based on RFC 7662 https://tools.ietf.org/html/rfc7662 To access this view the request must pass a OAuth2 Bearer Token which is allowed to access the scope `introspection`. """ required_scopes = ["introspection"] @staticmethod def get_token_response(token_value=None): try: token = ( get_access_token_model().objects.select_related("user", "application").get(token=token_value) ) except ObjectDoesNotExist: return JsonResponse({"active": False}, status=200) else: if token.is_valid(): data = { "active": True, "scope": token.scope, "exp": int(calendar.timegm(token.expires.timetuple())), } if token.application: data["client_id"] = token.application.client_id if token.user: data["username"] = token.user.get_username() return JsonResponse(data) else: return JsonResponse({"active": False}, status=200) def get(self, request, *args, **kwargs): """ Get the token from the URL parameters. URL: https://example.com/introspect?token=mF_9.B5f-4.1JqM :param request: :param args: :param kwargs: :return: """ return self.get_token_response(request.GET.get("token", None)) def post(self, request, *args, **kwargs): """ Get the token from the body form parameters. Body: token=mF_9.B5f-4.1JqM :param request: :param args: :param kwargs: :return: """ return self.get_token_response(request.POST.get("token", None)) django-oauth-toolkit-2.3.0/oauth2_provider/views/mixins.py000066400000000000000000000277321443573112200237260ustar00rootroot00000000000000import logging from django.conf import settings from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation from django.http import HttpResponseForbidden, HttpResponseNotFound from ..exceptions import FatalClientError from ..scopes import get_scopes_backend from ..settings import oauth2_settings log = logging.getLogger("oauth2_provider") SAFE_HTTP_METHODS = ["GET", "HEAD", "OPTIONS"] class OAuthLibMixin: """ This mixin decouples Django OAuth Toolkit from OAuthLib. Users can configure the Server, Validator and OAuthlibCore classes used by this mixin by setting the following class variables: * server_class * validator_class * oauthlib_backend_class If these class variables are not set, it will fall back to using the classes specified in oauth2_settings (OAUTH2_SERVER_CLASS, OAUTH2_VALIDATOR_CLASS and OAUTH2_BACKEND_CLASS). """ server_class = None validator_class = None oauthlib_backend_class = None @classmethod def get_server_class(cls): """ Return the OAuthlib server class to use """ if cls.server_class is None: return oauth2_settings.OAUTH2_SERVER_CLASS else: return cls.server_class @classmethod def get_validator_class(cls): """ Return the RequestValidator implementation class to use """ if cls.validator_class is None: return oauth2_settings.OAUTH2_VALIDATOR_CLASS else: return cls.validator_class @classmethod def get_oauthlib_backend_class(cls): """ Return the OAuthLibCore implementation class to use """ if cls.oauthlib_backend_class is None: return oauth2_settings.OAUTH2_BACKEND_CLASS else: return cls.oauthlib_backend_class @classmethod def get_server(cls): """ Return an instance of `server_class` initialized with a `validator_class` object """ server_class = cls.get_server_class() validator_class = cls.get_validator_class() server_kwargs = oauth2_settings.server_kwargs return server_class(validator_class(), **server_kwargs) @classmethod def get_oauthlib_core(cls): """ Cache and return `OAuthlibCore` instance so it will be created only on first request unless ALWAYS_RELOAD_OAUTHLIB_CORE is True. """ if not hasattr(cls, "_oauthlib_core") or oauth2_settings.ALWAYS_RELOAD_OAUTHLIB_CORE: server = cls.get_server() core_class = cls.get_oauthlib_backend_class() cls._oauthlib_core = core_class(server) return cls._oauthlib_core def validate_authorization_request(self, request): """ A wrapper method that calls validate_authorization_request on `server_class` instance. :param request: The current django.http.HttpRequest object """ core = self.get_oauthlib_core() return core.validate_authorization_request(request) def create_authorization_response(self, request, scopes, credentials, allow): """ A wrapper method that calls create_authorization_response on `server_class` instance. :param request: The current django.http.HttpRequest object :param scopes: A space-separated string of provided scopes :param credentials: Authorization credentials dictionary containing `client_id`, `state`, `redirect_uri` and `response_type` :param allow: True if the user authorize the client, otherwise False """ # TODO: move this scopes conversion from and to string into a utils function scopes = scopes.split(" ") if scopes else [] core = self.get_oauthlib_core() return core.create_authorization_response(request, scopes, credentials, allow) def create_token_response(self, request): """ A wrapper method that calls create_token_response on `server_class` instance. :param request: The current django.http.HttpRequest object """ core = self.get_oauthlib_core() return core.create_token_response(request) def create_revocation_response(self, request): """ A wrapper method that calls create_revocation_response on the `server_class` instance. :param request: The current django.http.HttpRequest object """ core = self.get_oauthlib_core() return core.create_revocation_response(request) def create_userinfo_response(self, request): """ A wrapper method that calls create_userinfo_response on the `server_class` instance. :param request: The current django.http.HttpRequest object """ core = self.get_oauthlib_core() return core.create_userinfo_response(request) def verify_request(self, request): """ A wrapper method that calls verify_request on `server_class` instance. :param request: The current django.http.HttpRequest object """ core = self.get_oauthlib_core() 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): """ This should return the list of scopes required to access the resources. By default it returns an empty list. """ return [] def error_response(self, error, **kwargs): """ Return an error to be displayed to the resource owner if anything goes awry. :param error: :attr:`OAuthToolkitError` """ oauthlib_error = error.oauthlib_error redirect_uri = oauthlib_error.redirect_uri or "" separator = "&" if "?" in redirect_uri else "?" error_response = { "error": oauthlib_error, "url": redirect_uri + separator + oauthlib_error.urlencoded, } error_response.update(kwargs) # If we got a malicious redirect_uri or client_id, we will *not* redirect back to the URL. if isinstance(error, FatalClientError): redirect = False else: redirect = True return redirect, error_response def authenticate_client(self, request): """Returns a boolean representing if client is authenticated with client credentials method. Returns `True` if authenticated. :param request: The current django.http.HttpRequest object """ core = self.get_oauthlib_core() return core.authenticate_client(request) class ScopedResourceMixin: """ Helper mixin that implements "scopes handling" behaviour """ required_scopes = None def get_scopes(self, *args, **kwargs): """ Return the scopes needed to access the resource :param args: Support scopes injections from the outside (not yet implemented) """ if self.required_scopes is None: raise ImproperlyConfigured( "ProtectedResourceMixin requires either a definition of 'required_scopes'" " or an implementation of 'get_scopes()'" ) else: return self.required_scopes class ProtectedResourceMixin(OAuthLibMixin): """ Helper mixin that implements OAuth2 protection on request dispatch, specially useful for Django Generic Views """ def dispatch(self, request, *args, **kwargs): # let preflight OPTIONS requests pass if request.method.upper() == "OPTIONS": return super().dispatch(request, *args, **kwargs) # check if the request is valid and the protected resource may be accessed valid, r = self.verify_request(request) if valid: request.resource_owner = r.user return super().dispatch(request, *args, **kwargs) else: return HttpResponseForbidden() class ReadWriteScopedResourceMixin(ScopedResourceMixin, OAuthLibMixin): """ Helper mixin that implements "read and write scopes" behavior """ required_scopes = [] read_write_scope = None def __new__(cls, *args, **kwargs): provided_scopes = get_scopes_backend().get_all_scopes() read_write_scopes = [oauth2_settings.READ_SCOPE, oauth2_settings.WRITE_SCOPE] if not set(read_write_scopes).issubset(set(provided_scopes)): raise ImproperlyConfigured( "ReadWriteScopedResourceMixin requires following scopes {}" ' to be in OAUTH2_PROVIDER["SCOPES"] list in settings'.format(read_write_scopes) ) return super().__new__(cls, *args, **kwargs) def dispatch(self, request, *args, **kwargs): if request.method.upper() in SAFE_HTTP_METHODS: self.read_write_scope = oauth2_settings.READ_SCOPE else: self.read_write_scope = oauth2_settings.WRITE_SCOPE return super().dispatch(request, *args, **kwargs) def get_scopes(self, *args, **kwargs): scopes = super().get_scopes(*args, **kwargs) # this returns a copy so that self.required_scopes is not modified return scopes + [self.read_write_scope] class ClientProtectedResourceMixin(OAuthLibMixin): """Mixin for protecting resources with client authentication as mentioned in rfc:`3.2.1` This involves authenticating with any of: HTTP Basic Auth, Client Credentials and Access token in that order. Breaks off after first validation. """ def dispatch(self, request, *args, **kwargs): # let preflight OPTIONS requests pass if request.method.upper() == "OPTIONS": return super().dispatch(request, *args, **kwargs) # Validate either with HTTP basic or client creds in request body. # TODO: Restrict to POST. valid = self.authenticate_client(request) if not valid: # Alternatively allow access tokens # check if the request is valid and the protected resource may be accessed valid, r = self.verify_request(request) if valid: request.resource_owner = r.user return super().dispatch(request, *args, **kwargs) return HttpResponseForbidden() else: return super().dispatch(request, *args, **kwargs) class OIDCOnlyMixin: """ Mixin for views that should only be accessible when OIDC is enabled. If OIDC is not enabled: * if DEBUG is True, raises an ImproperlyConfigured exception explaining why * otherwise, returns a 404 response, logging the same warning """ debug_error_message = ( "django-oauth-toolkit OIDC views are not enabled unless you " "have configured OIDC_ENABLED in the settings" ) def dispatch(self, *args, **kwargs): if not oauth2_settings.OIDC_ENABLED: if settings.DEBUG: raise ImproperlyConfigured(self.debug_error_message) log.warning(self.debug_error_message) return HttpResponseNotFound() return super().dispatch(*args, **kwargs) class OIDCLogoutOnlyMixin(OIDCOnlyMixin): """ Mixin for views that should only be accessible when OIDC and OIDC RP-Initiated Logout are enabled. If either is not enabled: * if DEBUG is True, raises an ImproperlyConfigured exception explaining why * otherwise, returns a 404 response, logging the same warning """ debug_error_message = ( "The django-oauth-toolkit OIDC RP-Initiated Logout view is not enabled unless you " "have configured OIDC_RP_INITIATED_LOGOUT_ENABLED in the settings" ) def dispatch(self, *args, **kwargs): if not oauth2_settings.OIDC_RP_INITIATED_LOGOUT_ENABLED: if settings.DEBUG: raise ImproperlyConfigured(self.debug_error_message) log.warning(self.debug_error_message) return HttpResponseNotFound() return super().dispatch(*args, **kwargs) django-oauth-toolkit-2.3.0/oauth2_provider/views/oidc.py000066400000000000000000000413641443573112200233320ustar00rootroot00000000000000import json from urllib.parse import urlparse from django.contrib.auth import logout from django.http import HttpResponse, JsonResponse from django.urls import reverse from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt from django.views.generic import FormView, View from jwcrypto import jwk, jwt from jwcrypto.common import JWException from jwcrypto.jws import InvalidJWSObject from jwcrypto.jwt import JWTExpired from oauthlib.common import add_params_to_uri from ..exceptions import ( ClientIdMissmatch, InvalidIDTokenError, InvalidOIDCClientError, InvalidOIDCRedirectURIError, LogoutDenied, OIDCError, ) from ..forms import ConfirmLogoutForm from ..http import OAuth2ResponseRedirect from ..models import ( get_access_token_model, get_application_model, get_id_token_model, get_refresh_token_model, ) from ..settings import oauth2_settings from .mixins import OAuthLibMixin, OIDCLogoutOnlyMixin, OIDCOnlyMixin Application = get_application_model() class ConnectDiscoveryInfoView(OIDCOnlyMixin, View): """ View used to show oidc provider configuration information per `OpenID Provider Metadata `_ """ def get(self, request, *args, **kwargs): issuer_url = oauth2_settings.OIDC_ISS_ENDPOINT if not issuer_url: issuer_url = oauth2_settings.oidc_issuer(request) authorization_endpoint = request.build_absolute_uri(reverse("oauth2_provider:authorize")) token_endpoint = request.build_absolute_uri(reverse("oauth2_provider:token")) userinfo_endpoint = oauth2_settings.OIDC_USERINFO_ENDPOINT or request.build_absolute_uri( reverse("oauth2_provider:user-info") ) jwks_uri = request.build_absolute_uri(reverse("oauth2_provider:jwks-info")) if oauth2_settings.OIDC_RP_INITIATED_LOGOUT_ENABLED: end_session_endpoint = request.build_absolute_uri( reverse("oauth2_provider:rp-initiated-logout") ) else: 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( host, reverse("oauth2_provider:user-info") ) jwks_uri = "{}{}".format(host, reverse("oauth2_provider:jwks-info")) if oauth2_settings.OIDC_RP_INITIATED_LOGOUT_ENABLED: end_session_endpoint = "{}{}".format(host, reverse("oauth2_provider:rp-initiated-logout")) 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))) scopes_class = oauth2_settings.SCOPES_BACKEND_CLASS scopes = scopes_class() scopes_supported = [scope for scope in scopes.get_available_scopes()] data = { "issuer": issuer_url, "authorization_endpoint": authorization_endpoint, "token_endpoint": token_endpoint, "userinfo_endpoint": userinfo_endpoint, "jwks_uri": jwks_uri, "scopes_supported": scopes_supported, "response_types_supported": oauth2_settings.OIDC_RESPONSE_TYPES_SUPPORTED, "subject_types_supported": oauth2_settings.OIDC_SUBJECT_TYPES_SUPPORTED, "id_token_signing_alg_values_supported": signing_algorithms, "token_endpoint_auth_methods_supported": ( oauth2_settings.OIDC_TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED ), "claims_supported": oidc_claims, } if oauth2_settings.OIDC_RP_INITIATED_LOGOUT_ENABLED: data["end_session_endpoint"] = end_session_endpoint response = JsonResponse(data) response["Access-Control-Allow-Origin"] = "*" return response class JwksInfoView(OIDCOnlyMixin, View): """ View used to show oidc json web key set document """ def get(self, request, *args, **kwargs): keys = [] if oauth2_settings.OIDC_RSA_PRIVATE_KEY: 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 @method_decorator(csrf_exempt, name="dispatch") class UserInfoView(OIDCOnlyMixin, OAuthLibMixin, View): """ View used to show Claims about the authenticated End-User """ def get(self, request, *args, **kwargs): return self._create_userinfo_response(request) def post(self, request, *args, **kwargs): return self._create_userinfo_response(request) def _create_userinfo_response(self, request): url, headers, body, status = self.create_userinfo_response(request) response = HttpResponse(content=body or "", status=status) for k, v in headers.items(): response[k] = v return response def _load_id_token(token): """ Loads an IDToken given its string representation for use with RP-Initiated Logout. A tuple (IDToken, claims) is returned. Depending on the configuration expired tokens may be loaded. If loading failed (None, None) is returned. """ IDToken = get_id_token_model() validator = oauth2_settings.OAUTH2_VALIDATOR_CLASS() try: key = validator._get_key_for_token(token) except InvalidJWSObject: # Failed to deserialize the key. return None, None # Could not identify key from the ID Token. if not key: return None, None try: if oauth2_settings.OIDC_RP_INITIATED_LOGOUT_ACCEPT_EXPIRED_TOKENS: # Only check the following while loading the JWT # - claims are dict # - the Claims defined in RFC7519 if present have the correct type (string, integer, etc.) # The claim contents are not validated. `exp` and `nbf` in particular are not validated. check_claims = {} else: # Also validate the `exp` (expiration time) and `nbf` (not before) claims. check_claims = None jwt_token = jwt.JWT(key=key, jwt=token, check_claims=check_claims) claims = json.loads(jwt_token.claims) # Assumption: the `sub` claim and `user` property of the corresponding IDToken Object point to the # same user. # To verify that the IDToken was intended for the user it is therefore sufficient to check the `user` # attribute on the IDToken Object later on. return IDToken.objects.get(jti=claims["jti"]), claims except (JWException, JWTExpired, IDToken.DoesNotExist): return None, None def _validate_claims(request, claims): """ Validates the claims of an IDToken for use with OIDC RP-Initiated Logout. """ validator = oauth2_settings.OAUTH2_VALIDATOR_CLASS() # Verification of `iss` claim is mandated by OIDC RP-Initiated Logout specs. if "iss" not in claims or claims["iss"] != validator.get_oidc_issuer_endpoint(request): # IDToken was not issued by this OP, or it can not be verified. return False return True def validate_logout_request(request, id_token_hint, client_id, post_logout_redirect_uri): """ Validate an OIDC RP-Initiated Logout Request. `(prompt_logout, (post_logout_redirect_uri, application), token_user)` is returned. `prompt_logout` indicates whether the logout has to be confirmed by the user. This happens if the specifications force a confirmation, or it is enabled by `OIDC_RP_INITIATED_LOGOUT_ALWAYS_PROMPT`. `post_logout_redirect_uri` is the validated URI where the User should be redirected to after the logout. Can be None. None will redirect to "/" of this app. If it is set `application` will also be set to the Application that is requesting the logout. `token_user` is the id_token user, which will used to revoke the tokens if found. The `id_token_hint` will be validated if given. If both `client_id` and `id_token_hint` are given they will be validated against each other. """ id_token = None must_prompt_logout = True token_user = None if id_token_hint: # Only basic validation has been done on the IDToken at this point. id_token, claims = _load_id_token(id_token_hint) if not id_token or not _validate_claims(request, claims): raise InvalidIDTokenError() token_user = id_token.user if id_token.user == request.user: # A logout without user interaction (i.e. no prompt) is only allowed # if an ID Token is provided that matches the current user. must_prompt_logout = False # If both id_token_hint and client_id are given it must be verified that they match. if client_id: if id_token.application.client_id != client_id: raise ClientIdMissmatch() # The standard states that a prompt should always be shown. # This behaviour can be configured with OIDC_RP_INITIATED_LOGOUT_ALWAYS_PROMPT. prompt_logout = must_prompt_logout or oauth2_settings.OIDC_RP_INITIATED_LOGOUT_ALWAYS_PROMPT application = None # Determine the application that is requesting the logout. if client_id: application = get_application_model().objects.get(client_id=client_id) elif id_token: application = id_token.application # Validate `post_logout_redirect_uri` if post_logout_redirect_uri: if not application: raise InvalidOIDCClientError() scheme = urlparse(post_logout_redirect_uri)[0] if not scheme: raise InvalidOIDCRedirectURIError("A Scheme is required for the redirect URI.") if oauth2_settings.OIDC_RP_INITIATED_LOGOUT_STRICT_REDIRECT_URIS and ( scheme == "http" and application.client_type != "confidential" ): raise InvalidOIDCRedirectURIError("http is only allowed with confidential clients.") if scheme not in application.get_allowed_schemes(): raise InvalidOIDCRedirectURIError(f'Redirect to scheme "{scheme}" is not permitted.') if not application.post_logout_redirect_uri_allowed(post_logout_redirect_uri): raise InvalidOIDCRedirectURIError("This client does not have this redirect uri registered.") return prompt_logout, (post_logout_redirect_uri, application), token_user class RPInitiatedLogoutView(OIDCLogoutOnlyMixin, FormView): template_name = "oauth2_provider/logout_confirm.html" form_class = ConfirmLogoutForm # Only delete tokens for Application whose client type and authorization # grant type are in the respective lists. token_deletion_client_types = [ Application.CLIENT_PUBLIC, Application.CLIENT_CONFIDENTIAL, ] token_deletion_grant_types = [ Application.GRANT_AUTHORIZATION_CODE, Application.GRANT_IMPLICIT, Application.GRANT_PASSWORD, Application.GRANT_CLIENT_CREDENTIALS, Application.GRANT_OPENID_HYBRID, ] def get_initial(self): return { "id_token_hint": self.oidc_data.get("id_token_hint", None), "logout_hint": self.oidc_data.get("logout_hint", None), "client_id": self.oidc_data.get("client_id", None), "post_logout_redirect_uri": self.oidc_data.get("post_logout_redirect_uri", None), "state": self.oidc_data.get("state", None), "ui_locales": self.oidc_data.get("ui_locales", None), } def dispatch(self, request, *args, **kwargs): self.oidc_data = {} return super().dispatch(request, *args, **kwargs) def get(self, request, *args, **kwargs): id_token_hint = request.GET.get("id_token_hint") client_id = request.GET.get("client_id") post_logout_redirect_uri = request.GET.get("post_logout_redirect_uri") state = request.GET.get("state") try: prompt, (redirect_uri, application), token_user = validate_logout_request( request=request, id_token_hint=id_token_hint, client_id=client_id, post_logout_redirect_uri=post_logout_redirect_uri, ) except OIDCError as error: return self.error_response(error) if not prompt: return self.do_logout(application, redirect_uri, state, token_user) self.oidc_data = { "id_token_hint": id_token_hint, "client_id": client_id, "post_logout_redirect_uri": post_logout_redirect_uri, "state": state, } form = self.get_form(self.get_form_class()) kwargs["form"] = form if application: kwargs["application"] = application return self.render_to_response(self.get_context_data(**kwargs)) def form_valid(self, form): id_token_hint = form.cleaned_data.get("id_token_hint") client_id = form.cleaned_data.get("client_id") post_logout_redirect_uri = form.cleaned_data.get("post_logout_redirect_uri") state = form.cleaned_data.get("state") try: prompt, (redirect_uri, application), token_user = validate_logout_request( request=self.request, id_token_hint=id_token_hint, client_id=client_id, post_logout_redirect_uri=post_logout_redirect_uri, ) if not prompt or form.cleaned_data.get("allow"): return self.do_logout(application, redirect_uri, state, token_user) else: raise LogoutDenied() except OIDCError as error: return self.error_response(error) def do_logout(self, application=None, post_logout_redirect_uri=None, state=None, token_user=None): # Delete Access Tokens if oauth2_settings.OIDC_RP_INITIATED_LOGOUT_DELETE_TOKENS: AccessToken = get_access_token_model() RefreshToken = get_refresh_token_model() access_tokens_to_delete = AccessToken.objects.filter( user=token_user or self.request.user, application__client_type__in=self.token_deletion_client_types, application__authorization_grant_type__in=self.token_deletion_grant_types, ) # This queryset has to be evaluated eagerly. The queryset would be empty with lazy evaluation # because `access_tokens_to_delete` represents an empty queryset once `refresh_tokens_to_delete` # is evaluated as all AccessTokens have been deleted. refresh_tokens_to_delete = list( RefreshToken.objects.filter(access_token__in=access_tokens_to_delete) ) for token in access_tokens_to_delete: # Delete the token and its corresponding refresh and IDTokens. if token.id_token: token.id_token.revoke() token.revoke() for refresh_token in refresh_tokens_to_delete: refresh_token.revoke() # Logout in Django logout(self.request) # Redirect if post_logout_redirect_uri: if state: return OAuth2ResponseRedirect( add_params_to_uri(post_logout_redirect_uri, [("state", state)]), application.get_allowed_schemes(), ) else: return OAuth2ResponseRedirect(post_logout_redirect_uri, application.get_allowed_schemes()) else: return OAuth2ResponseRedirect( self.request.build_absolute_uri("/"), oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES, ) def error_response(self, error): error_response = {"error": error} return self.render_to_response(error_response, status=error.status_code) django-oauth-toolkit-2.3.0/oauth2_provider/views/token.py000066400000000000000000000021041443573112200235210ustar00rootroot00000000000000from django.contrib.auth.mixins import LoginRequiredMixin from django.urls import reverse_lazy from django.views.generic import DeleteView, ListView from ..models import get_access_token_model class AuthorizedTokensListView(LoginRequiredMixin, ListView): """ Show a page where the current logged-in user can see his tokens so they can revoke them """ context_object_name = "authorized_tokens" template_name = "oauth2_provider/authorized-tokens.html" model = get_access_token_model() def get_queryset(self): """ Show only user"s tokens """ return super().get_queryset().select_related("application").filter(user=self.request.user) class AuthorizedTokenDeleteView(LoginRequiredMixin, DeleteView): """ View for revoking a specific token """ template_name = "oauth2_provider/authorized-token-delete.html" success_url = reverse_lazy("oauth2_provider:authorized-token-list") model = get_access_token_model() def get_queryset(self): return super().get_queryset().filter(user=self.request.user) django-oauth-toolkit-2.3.0/pyproject.toml000066400000000000000000000002341443573112200204740ustar00rootroot00000000000000[tool.black] line-length = 110 target-version = ['py38'] exclude = ''' ^/( oauth2_provider/migrations/ | tests/migrations/ | .tox ) ''' django-oauth-toolkit-2.3.0/setup.cfg000066400000000000000000000025541443573112200174100ustar00rootroot00000000000000[metadata] name = django-oauth-toolkit version = attr: oauth2_provider.__version__ description = OAuth2 Provider for Django long_description = file: README.rst long_description_content_type = text/x-rst author = Federico Frenguelli, Massimiliano Pippi author_email = synasius@gmail.com url = https://github.com/jazzband/django-oauth-toolkit keywords = django, oauth, oauth2, oauthlib classifiers = Development Status :: 5 - Production/Stable Environment :: Web Environment Framework :: Django Framework :: Django :: 2.2 Framework :: Django :: 3.2 Framework :: Django :: 4.0 Framework :: Django :: 4.1 Framework :: Django :: 4.2 Intended Audience :: Developers License :: OSI Approved :: BSD License Operating System :: OS Independent Programming Language :: Python :: 3 Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 Programming Language :: Python :: 3.11 Topic :: Internet :: WWW/HTTP [options] packages = find: include_package_data = True 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, != 4.0.0 requests >= 2.13.0 oauthlib >= 3.1.0 jwcrypto >= 0.8.0 [options.packages.find] exclude = tests tests.* django-oauth-toolkit-2.3.0/setup.py000077500000000000000000000000761443573112200173010ustar00rootroot00000000000000#!/usr/bin/env python from setuptools import setup setup() django-oauth-toolkit-2.3.0/tests/000077500000000000000000000000001443573112200167235ustar00rootroot00000000000000django-oauth-toolkit-2.3.0/tests/__init__.py000066400000000000000000000000001443573112200210220ustar00rootroot00000000000000django-oauth-toolkit-2.3.0/tests/admin.py000066400000000000000000000006311443573112200203650ustar00rootroot00000000000000from django.contrib import admin class CustomApplicationAdmin(admin.ModelAdmin): list_display = ("id",) class CustomAccessTokenAdmin(admin.ModelAdmin): list_display = ("id",) class CustomGrantAdmin(admin.ModelAdmin): list_display = ("id",) class CustomIDTokenAdmin(admin.ModelAdmin): list_display = ("id",) class CustomRefreshTokenAdmin(admin.ModelAdmin): list_display = ("id",) django-oauth-toolkit-2.3.0/tests/conftest.py000066400000000000000000000216121443573112200211240ustar00rootroot00000000000000import uuid from datetime import timedelta from types import SimpleNamespace from urllib.parse import parse_qs, urlparse import pytest from django.conf import settings as test_settings from django.contrib.auth import get_user_model from django.urls import reverse from django.utils import dateformat, timezone from jwcrypto import jwk, jwt from oauth2_provider.models import get_application_model, get_id_token_model from oauth2_provider.settings import oauth2_settings as _oauth2_settings from . import presets Application = get_application_model() UserModel = get_user_model() CLEARTEXT_SECRET = "1234567890abcdefghijklmnopqrstuvwxyz" class OAuthSettingsWrapper: """ A wrapper around oauth2_settings to ensure that when an overridden value is set, it also records it in _cached_attrs, so that the settings can be reset. """ def __init__(self, settings, user_settings): self.settings = settings if not user_settings: user_settings = {} self.update(user_settings) def update(self, user_settings): self.settings.OAUTH2_PROVIDER = user_settings _oauth2_settings.reload() # Reload OAuthlibCore for every view request during tests self.ALWAYS_RELOAD_OAUTHLIB_CORE = True def __setattr__(self, attr, value): if attr == "settings": super().__setattr__(attr, value) else: setattr(_oauth2_settings, attr, value) _oauth2_settings._cached_attrs.add(attr) def __delattr__(self, attr): delattr(_oauth2_settings, attr) if attr in _oauth2_settings._cached_attrs: _oauth2_settings._cached_attrs.remove(attr) def __getattr__(self, attr): return getattr(_oauth2_settings, attr) def finalize(self): self.settings.finalize() _oauth2_settings.reload() @pytest.fixture def oauth2_settings(request, settings): """ A fixture that provides a simple way to override OAUTH2_PROVIDER settings. It can be used two ways - either setting things on the fly, or by reading configuration data from the pytest marker oauth2_settings. If used on a standard pytest function, you can use argument dependency injection to get the wrapper. If used on a unittest.TestCase, the wrapper is made available on the class instance, as `oauth2_settings`. Anything overridden will be restored at the end of the test case, ensuring that there is no configuration leakage between test cases. """ marker = request.node.get_closest_marker("oauth2_settings") user_settings = {} if marker is not None: user_settings = marker.args[0] wrapper = OAuthSettingsWrapper(settings, user_settings) if request.instance is not None: request.instance.oauth2_settings = wrapper yield wrapper wrapper.finalize() @pytest.fixture(scope="session") def oidc_key_(): return jwk.JWK.from_pem(test_settings.OIDC_RSA_PRIVATE_KEY.encode("utf8")) @pytest.fixture def oidc_key(request, oidc_key_): if request.instance is not None: request.instance.key = oidc_key_ return oidc_key_ @pytest.fixture def application(): return Application.objects.create( name="Test Application", redirect_uris="http://example.org", post_logout_redirect_uris="http://example.org", client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, algorithm=Application.RS256_ALGORITHM, client_secret=CLEARTEXT_SECRET, ) @pytest.fixture def public_application(): return Application.objects.create( name="Other Application", redirect_uris="http://other.org", post_logout_redirect_uris="http://other.org", client_type=Application.CLIENT_PUBLIC, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, algorithm=Application.RS256_ALGORITHM, client_secret=CLEARTEXT_SECRET, ) @pytest.fixture def logged_in_client(test_user): from django.test.client import Client client = Client() client.force_login(test_user) return client @pytest.fixture def hybrid_application(application): application.authorization_grant_type = application.GRANT_OPENID_HYBRID application.client_secret = CLEARTEXT_SECRET application.save() return application @pytest.fixture def test_user(): return UserModel.objects.create_user("test_user", "test@example.com", "123456") @pytest.fixture def other_user(): return UserModel.objects.create_user("other_user", "other@example.com", "123456") @pytest.fixture def rp_settings(oauth2_settings): oauth2_settings.update(presets.OIDC_SETTINGS_RP_LOGOUT) return oauth2_settings def generate_access_token(oauth2_settings, application, test_user, client, settings, scope, redirect_uri): """ A helper function that generates an access_token and ID Token for a given Application and User. """ oauth2_settings.update(settings) client.force_login(test_user) auth_rsp = client.post( reverse("oauth2_provider:authorize"), data={ "client_id": application.client_id, "state": "random_state_string", "scope": scope, "redirect_uri": redirect_uri, "response_type": "code", "allow": True, }, ) assert auth_rsp.status_code == 302 code = parse_qs(urlparse(auth_rsp["Location"]).query)["code"] client.logout() token_rsp = client.post( reverse("oauth2_provider:token"), data={ "grant_type": "authorization_code", "code": code, "redirect_uri": redirect_uri, "client_id": application.client_id, "client_secret": CLEARTEXT_SECRET, "scope": scope, }, ) assert token_rsp.status_code == 200 token_data = token_rsp.json() return SimpleNamespace( user=test_user, application=application, access_token=token_data["access_token"], id_token=token_data["id_token"], oauth2_settings=oauth2_settings, ) @pytest.fixture def expired_id_token(oauth2_settings, oidc_key, test_user, application): payload = generate_id_token_payload(oauth2_settings, application, oidc_key) return generate_id_token(test_user, payload, oidc_key, application) @pytest.fixture def id_token_wrong_aud(oauth2_settings, oidc_key, test_user, application): payload = generate_id_token_payload(oauth2_settings, application, oidc_key) payload[1]["aud"] = "" return generate_id_token(test_user, payload, oidc_key, application) @pytest.fixture def id_token_wrong_iss(oauth2_settings, oidc_key, test_user, application): payload = generate_id_token_payload(oauth2_settings, application, oidc_key) payload[1]["iss"] = "" return generate_id_token(test_user, payload, oidc_key, application) def generate_id_token_payload(oauth2_settings, application, oidc_key): # Default leeway of JWT in jwcrypto is 60 seconds. This means that tokens that expired up to 60 seconds # ago are still accepted. expiration_time = timezone.now() - timedelta(seconds=61) # Calculate values for the IDToken exp = int(dateformat.format(expiration_time, "U")) jti = str(uuid.uuid4()) aud = application.client_id iss = oauth2_settings.OIDC_ISS_ENDPOINT # Construct and sign the IDToken header = {"typ": "JWT", "alg": "RS256", "kid": oidc_key.thumbprint()} id_token = {"exp": exp, "jti": jti, "aud": aud, "iss": iss} return header, id_token, jti, expiration_time def generate_id_token(user, payload, oidc_key, application): header, id_token, jti, expiration_time = payload jwt_token = jwt.JWT(header=header, claims=id_token) jwt_token.make_signed_token(oidc_key) # Save the IDToken in the DB. Required for later lookups from e.g. RP-Initiated Logout. IDToken = get_id_token_model() IDToken.objects.create(user=user, scope="", expires=expiration_time, jti=jti, application=application) # Return the token as a string. return jwt_token.token.serialize(compact=True) @pytest.fixture def oidc_tokens(oauth2_settings, application, test_user, client): return generate_access_token( oauth2_settings, application, test_user, client, presets.OIDC_SETTINGS_RW, "openid", "http://example.org", ) @pytest.fixture def oidc_email_scope_tokens(oauth2_settings, application, test_user, client): return generate_access_token( oauth2_settings, application, test_user, client, presets.OIDC_SETTINGS_EMAIL_SCOPE, "openid email", "http://example.org", ) @pytest.fixture def oidc_non_confidential_tokens(oauth2_settings, public_application, test_user, client): return generate_access_token( oauth2_settings, public_application, test_user, client, presets.OIDC_SETTINGS_EMAIL_SCOPE, "openid", "http://other.org", ) django-oauth-toolkit-2.3.0/tests/mig_settings.py000066400000000000000000000063071443573112200217770ustar00rootroot00000000000000""" 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" django-oauth-toolkit-2.3.0/tests/migrations/000077500000000000000000000000001443573112200210775ustar00rootroot00000000000000django-oauth-toolkit-2.3.0/tests/migrations/0001_initial.py000066400000000000000000000026501443573112200235450ustar00rootroot00000000000000# Generated by Django 4.0.4 on 2022-05-27 21:07 from django.db import migrations, models class Migration(migrations.Migration): initial = True dependencies = [ ] run_before = [ ('oauth2_provider', '0001_initial'), ] operations = [ migrations.CreateModel( name='BaseTestApplication', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ], ), migrations.CreateModel( name='SampleAccessToken', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ], ), migrations.CreateModel( name='SampleApplication', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ], ), migrations.CreateModel( name='SampleGrant', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ], ), migrations.CreateModel( name='SampleRefreshToken', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ], ), ] django-oauth-toolkit-2.3.0/tests/migrations/0002_swapped_models.py000066400000000000000000000337771443573112200251410ustar00rootroot00000000000000# Generated by Django 4.0.4 on 2022-05-27 21:12 from django.conf import settings from django.db import migrations, models import django.db.models.deletion import oauth2_provider.generators import oauth2_provider.models class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), migrations.swappable_dependency(settings.OAUTH2_PROVIDER_ID_TOKEN_MODEL), ('tests', '0001_initial'), ] operations = [ migrations.AddField( model_name='basetestapplication', name='algorithm', field=models.CharField(blank=True, choices=[('', 'No OIDC support'), ('RS256', 'RSA with SHA-2 256'), ('HS256', 'HMAC with SHA-2 256')], default='', max_length=5), ), migrations.AddField( model_name='basetestapplication', name='allowed_schemes', field=models.TextField(blank=True), ), migrations.AddField( model_name='basetestapplication', name='authorization_grant_type', field=models.CharField(choices=[('authorization-code', 'Authorization code'), ('implicit', 'Implicit'), ('password', 'Resource owner password-based'), ('client-credentials', 'Client credentials'), ('openid-hybrid', 'OpenID connect hybrid')], max_length=32), preserve_default=False, ), migrations.AddField( model_name='basetestapplication', name='client_id', field=models.CharField(db_index=True, default=oauth2_provider.generators.generate_client_id, max_length=100, unique=True), ), migrations.AddField( model_name='basetestapplication', name='client_secret', field=oauth2_provider.models.ClientSecretField(blank=True, db_index=True, default=oauth2_provider.generators.generate_client_secret, help_text='Hashed on Save. Copy it now if this is a new secret.', max_length=255), ), migrations.AddField( model_name='basetestapplication', name='client_type', field=models.CharField(choices=[('confidential', 'Confidential'), ('public', 'Public')], max_length=32), preserve_default=False, ), migrations.AddField( model_name='basetestapplication', name='created', field=models.DateTimeField(auto_now_add=True), preserve_default=False, ), migrations.AddField( model_name='basetestapplication', name='name', field=models.CharField(blank=True, max_length=255), ), migrations.AddField( model_name='basetestapplication', name='redirect_uris', field=models.TextField(blank=True, help_text='Allowed URIs list, space separated'), ), migrations.AddField( model_name='basetestapplication', name='skip_authorization', field=models.BooleanField(default=False), ), migrations.AddField( model_name='basetestapplication', name='updated', field=models.DateTimeField(auto_now=True), ), migrations.AddField( model_name='basetestapplication', 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.AddField( model_name='sampleaccesstoken', name='application', field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL), ), migrations.AddField( model_name='sampleaccesstoken', name='created', field=models.DateTimeField(auto_now_add=True), preserve_default=False, ), migrations.AddField( model_name='sampleaccesstoken', name='custom_field', field=models.CharField(max_length=255), preserve_default=False, ), migrations.AddField( model_name='sampleaccesstoken', name='expires', field=models.DateTimeField(), preserve_default=False, ), migrations.AddField( model_name='sampleaccesstoken', name='id_token', field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='access_token', to=settings.OAUTH2_PROVIDER_ID_TOKEN_MODEL), ), migrations.AddField( model_name='sampleaccesstoken', name='scope', field=models.TextField(blank=True), ), migrations.AddField( model_name='sampleaccesstoken', name='source_refresh_token', field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='s_refreshed_access_token', to=settings.OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL), ), migrations.AddField( model_name='sampleaccesstoken', name='token', field=models.CharField(max_length=255, unique=True), preserve_default=False, ), migrations.AddField( model_name='sampleaccesstoken', name='updated', field=models.DateTimeField(auto_now=True), ), migrations.AddField( model_name='sampleaccesstoken', 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.AddField( model_name='sampleapplication', name='algorithm', field=models.CharField(blank=True, choices=[('', 'No OIDC support'), ('RS256', 'RSA with SHA-2 256'), ('HS256', 'HMAC with SHA-2 256')], default='', max_length=5), ), migrations.AddField( model_name='sampleapplication', name='authorization_grant_type', field=models.CharField(choices=[('authorization-code', 'Authorization code'), ('implicit', 'Implicit'), ('password', 'Resource owner password-based'), ('client-credentials', 'Client credentials'), ('openid-hybrid', 'OpenID connect hybrid')], max_length=32), preserve_default=False, ), migrations.AddField( model_name='sampleapplication', name='client_id', field=models.CharField(db_index=True, default=oauth2_provider.generators.generate_client_id, max_length=100, unique=True), ), migrations.AddField( model_name='sampleapplication', name='client_secret', field=oauth2_provider.models.ClientSecretField(blank=True, db_index=True, default=oauth2_provider.generators.generate_client_secret, help_text='Hashed on Save. Copy it now if this is a new secret.', max_length=255), ), migrations.AddField( model_name='sampleapplication', name='client_type', field=models.CharField(choices=[('confidential', 'Confidential'), ('public', 'Public')], max_length=32), preserve_default=False, ), migrations.AddField( model_name='sampleapplication', name='created', field=models.DateTimeField(auto_now_add=True), preserve_default=False, ), migrations.AddField( model_name='sampleapplication', name='custom_field', field=models.CharField(max_length=255), preserve_default=False, ), migrations.AddField( model_name='sampleapplication', name='name', field=models.CharField(blank=True, max_length=255), ), migrations.AddField( model_name='sampleapplication', name='redirect_uris', field=models.TextField(blank=True, help_text='Allowed URIs list, space separated'), ), migrations.AddField( model_name='sampleapplication', name='skip_authorization', field=models.BooleanField(default=False), ), migrations.AddField( model_name='sampleapplication', name='updated', field=models.DateTimeField(auto_now=True), ), migrations.AddField( model_name='sampleapplication', 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.AddField( model_name='samplegrant', name='application', field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL), preserve_default=False, ), migrations.AddField( model_name='samplegrant', name='claims', field=models.TextField(blank=True), ), migrations.AddField( model_name='samplegrant', name='code', field=models.CharField(max_length=255, unique=True), preserve_default=False, ), migrations.AddField( model_name='samplegrant', name='code_challenge', field=models.CharField(blank=True, default='', max_length=128), ), migrations.AddField( model_name='samplegrant', name='code_challenge_method', field=models.CharField(blank=True, choices=[('plain', 'plain'), ('S256', 'S256')], default='', max_length=10), ), migrations.AddField( model_name='samplegrant', name='created', field=models.DateTimeField(auto_now_add=True), preserve_default=False, ), migrations.AddField( model_name='samplegrant', name='custom_field', field=models.CharField(max_length=255), preserve_default=False, ), migrations.AddField( model_name='samplegrant', name='expires', field=models.DateTimeField(), preserve_default=False, ), migrations.AddField( model_name='samplegrant', name='nonce', field=models.CharField(blank=True, default='', max_length=255), ), migrations.AddField( model_name='samplegrant', name='redirect_uri', field=models.TextField(), preserve_default=False, ), migrations.AddField( model_name='samplegrant', name='scope', field=models.TextField(blank=True), ), migrations.AddField( model_name='samplegrant', name='updated', field=models.DateTimeField(auto_now=True), ), migrations.AddField( model_name='samplegrant', name='user', field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL), preserve_default=False, ), migrations.AddField( model_name='samplerefreshtoken', name='access_token', field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='s_refresh_token', to=settings.OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL), ), migrations.AddField( model_name='samplerefreshtoken', name='application', field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL), preserve_default=False, ), migrations.AddField( model_name='samplerefreshtoken', name='created', field=models.DateTimeField(auto_now_add=True), preserve_default=False, ), migrations.AddField( model_name='samplerefreshtoken', name='custom_field', field=models.CharField(max_length=255), preserve_default=False, ), migrations.AddField( model_name='samplerefreshtoken', name='revoked', field=models.DateTimeField(null=True), ), migrations.AddField( model_name='samplerefreshtoken', name='token', field=models.CharField(default=1, max_length=255), preserve_default=False, ), migrations.AddField( model_name='samplerefreshtoken', name='updated', field=models.DateTimeField(auto_now=True), ), migrations.AddField( model_name='samplerefreshtoken', name='user', field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL), preserve_default=False, ), migrations.AlterField( model_name='basetestapplication', name='id', field=models.BigAutoField(primary_key=True, serialize=False), ), migrations.AlterField( model_name='sampleaccesstoken', name='id', field=models.BigAutoField(primary_key=True, serialize=False), ), migrations.AlterField( model_name='sampleapplication', name='id', field=models.BigAutoField(primary_key=True, serialize=False), ), migrations.AlterField( model_name='samplegrant', name='id', field=models.BigAutoField(primary_key=True, serialize=False), ), migrations.AlterField( model_name='samplerefreshtoken', name='id', field=models.BigAutoField(primary_key=True, serialize=False), ), migrations.AlterUniqueTogether( name='samplerefreshtoken', unique_together={('token', 'revoked')}, ), ] 0003_basetestapplication_post_logout_redirect_uris_and_more.py000066400000000000000000000015251443573112200353220ustar00rootroot00000000000000django-oauth-toolkit-2.3.0/tests/migrations# Generated by Django 4.1.5 on 2023-01-14 20:07 from django.conf import settings from django.db import migrations, models import django.db.models.deletion class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.OAUTH2_PROVIDER_ID_TOKEN_MODEL), ("tests", "0002_swapped_models"), ] operations = [ migrations.AddField( model_name="basetestapplication", name="post_logout_redirect_uris", field=models.TextField(blank=True, help_text="Allowed Post Logout URIs list, space separated"), ), migrations.AddField( model_name="sampleapplication", name="post_logout_redirect_uris", field=models.TextField(blank=True, help_text="Allowed Post Logout URIs list, space separated"), ), ] django-oauth-toolkit-2.3.0/tests/migrations/__init__.py000066400000000000000000000000001443573112200231760ustar00rootroot00000000000000django-oauth-toolkit-2.3.0/tests/models.py000066400000000000000000000030101443573112200205520ustar00rootroot00000000000000from django.db import models from oauth2_provider.models import ( AbstractAccessToken, AbstractApplication, AbstractGrant, AbstractRefreshToken, ) from oauth2_provider.settings import oauth2_settings class BaseTestApplication(AbstractApplication): allowed_schemes = models.TextField(blank=True) def get_allowed_schemes(self): if self.allowed_schemes: return self.allowed_schemes.split() return super().get_allowed_schemes() class SampleApplication(AbstractApplication): custom_field = models.CharField(max_length=255) class SampleAccessToken(AbstractAccessToken): custom_field = models.CharField(max_length=255) source_refresh_token = models.OneToOneField( # unique=True implied by the OneToOneField oauth2_settings.REFRESH_TOKEN_MODEL, on_delete=models.SET_NULL, blank=True, null=True, related_name="s_refreshed_access_token", ) id_token = models.OneToOneField( oauth2_settings.ID_TOKEN_MODEL, on_delete=models.CASCADE, blank=True, null=True, related_name="s_access_token", ) class SampleRefreshToken(AbstractRefreshToken): custom_field = models.CharField(max_length=255) access_token = models.OneToOneField( oauth2_settings.ACCESS_TOKEN_MODEL, on_delete=models.SET_NULL, blank=True, null=True, related_name="s_refresh_token", ) class SampleGrant(AbstractGrant): custom_field = models.CharField(max_length=255) django-oauth-toolkit-2.3.0/tests/presets.py000066400000000000000000000044461443573112200207720ustar00rootroot00000000000000from copy import deepcopy from django.conf import settings # A set of OAUTH2_PROVIDER settings dicts that can be used in tests DEFAULT_SCOPES_RW = {"DEFAULT_SCOPES": ["read", "write"]} DEFAULT_SCOPES_RO = {"DEFAULT_SCOPES": ["read"]} OIDC_SETTINGS_RW = { "OIDC_ENABLED": True, "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", "openid": "OpenID connect", }, "DEFAULT_SCOPES": ["read", "write"], "PKCE_REQUIRED": False, "REFRESH_TOKEN_EXPIRE_SECONDS": 3600, } OIDC_SETTINGS_RO = deepcopy(OIDC_SETTINGS_RW) OIDC_SETTINGS_RO["DEFAULT_SCOPES"] = ["read"] OIDC_SETTINGS_EMAIL_SCOPE = deepcopy(OIDC_SETTINGS_RW) OIDC_SETTINGS_EMAIL_SCOPE["SCOPES"].update({"email": "return email address"}) OIDC_SETTINGS_HS256_ONLY = deepcopy(OIDC_SETTINGS_RW) del OIDC_SETTINGS_HS256_ONLY["OIDC_RSA_PRIVATE_KEY"] OIDC_SETTINGS_RP_LOGOUT = deepcopy(OIDC_SETTINGS_RW) OIDC_SETTINGS_RP_LOGOUT["OIDC_RP_INITIATED_LOGOUT_ENABLED"] = True OIDC_SETTINGS_RP_LOGOUT["OIDC_RP_INITIATED_LOGOUT_ALWAYS_PROMPT"] = False OIDC_SETTINGS_RP_LOGOUT_STRICT_REDIRECT_URI = deepcopy(OIDC_SETTINGS_RP_LOGOUT) OIDC_SETTINGS_RP_LOGOUT_STRICT_REDIRECT_URI["OIDC_RP_INITIATED_LOGOUT_STRICT_REDIRECT_URIS"] = True OIDC_SETTINGS_RP_LOGOUT_DENY_EXPIRED = deepcopy(OIDC_SETTINGS_RP_LOGOUT) OIDC_SETTINGS_RP_LOGOUT_DENY_EXPIRED["OIDC_RP_INITIATED_LOGOUT_ACCEPT_EXPIRED_TOKENS"] = False OIDC_SETTINGS_RP_LOGOUT_KEEP_TOKENS = deepcopy(OIDC_SETTINGS_RP_LOGOUT) OIDC_SETTINGS_RP_LOGOUT_KEEP_TOKENS["OIDC_RP_INITIATED_LOGOUT_DELETE_TOKENS"] = False REST_FRAMEWORK_SCOPES = { "SCOPES": { "read": "Read scope", "write": "Write scope", "scope1": "Scope 1", "scope2": "Scope 2", "resource1": "Resource 1", }, } INTROSPECTION_SETTINGS = { "SCOPES": { "read": "Read scope", "write": "Write scope", "introspection": "Introspection scope", "dolphin": "eek eek eek scope", }, "RESOURCE_SERVER_INTROSPECTION_URL": "http://example.org/introspection", "READ_SCOPE": "read", "WRITE_SCOPE": "write", } django-oauth-toolkit-2.3.0/tests/settings.py000066400000000000000000000125671443573112200211500ustar00rootroot00000000000000import django ADMINS = () MANAGERS = ADMINS DATABASES = { "default": { "ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:", } } AUTH_USER_MODEL = "auth.User" OAUTH2_PROVIDER_APPLICATION_MODEL = "oauth2_provider.Application" OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL = "oauth2_provider.AccessToken" OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL = "oauth2_provider.RefreshToken" ALLOWED_HOSTS = [] TIME_ZONE = "UTC" LANGUAGE_CODE = "en-us" SITE_ID = 1 USE_I18N = True if django.VERSION < (4, 0): USE_L10N = True USE_TZ = True MEDIA_ROOT = "" MEDIA_URL = "" STATIC_ROOT = "" STATIC_URL = "/static/" STATICFILES_DIRS = () STATICFILES_FINDERS = ( "django.contrib.staticfiles.finders.FileSystemFinder", "django.contrib.staticfiles.finders.AppDirectoriesFinder", ) # Make this unique, and don"t share it with anybody. SECRET_KEY = "1234567890jazzband" TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", "DIRS": [], "APP_DIRS": True, "OPTIONS": { "debug": True, "context_processors": [ "django.contrib.auth.context_processors.auth", "django.template.context_processors.debug", "django.template.context_processors.i18n", "django.template.context_processors.media", "django.template.context_processors.request", "django.template.context_processors.static", "django.template.context_processors.tz", "django.contrib.messages.context_processors.messages", ], }, }, ] MIDDLEWARE = ( "django.middleware.common.CommonMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", ) ROOT_URLCONF = "tests.urls" INSTALLED_APPS = ( "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.sites", "django.contrib.staticfiles", "django.contrib.admin", "django.contrib.messages", "oauth2_provider", "tests", ) LOGGING = { "version": 1, "disable_existing_loggers": False, "formatters": { "verbose": {"format": "%(levelname)s %(asctime)s %(module)s %(process)d %(thread)d %(message)s"}, "simple": {"format": "%(levelname)s %(message)s"}, }, "filters": {"require_debug_false": {"()": "django.utils.log.RequireDebugFalse"}}, "handlers": { "mail_admins": { "level": "ERROR", "filters": ["require_debug_false"], "class": "django.utils.log.AdminEmailHandler", }, "console": {"level": "DEBUG", "class": "logging.StreamHandler", "formatter": "simple"}, "null": { "level": "DEBUG", "class": "logging.NullHandler", }, }, "loggers": { "django.request": { "handlers": ["mail_admins"], "level": "ERROR", "propagate": True, }, "oauth2_provider": { "handlers": ["null"], "level": "DEBUG", "propagate": True, }, }, } OIDC_RSA_PRIVATE_KEY = """-----BEGIN RSA PRIVATE KEY----- MIICXQIBAAKBgQCbCYh5h2NmQuBqVO6G+/CO+cHm9VBzsb0MeA6bbQfDnbhstVOT j0hcnZJzDjYc6ajBZZf6gxVP9xrdm9Uh599VI3X5PFXLbMHrmzTAMzCGIyg+/fnP 0gocYxmCX2+XKyj/Zvt1pUX8VAN2AhrJSfxNDKUHERTVEV9bRBJg4F0C3wIDAQAB AoGAP+i4nNw+Ec/8oWh8YSFm4xE6qKG0NdTtSMAOyWwy+KTB+vHuT1QPsLn1vj77 +IQrX/moogg6F1oV9YdA3vat3U7rwt1sBGsRrLhA+Spp9WEQtglguNo4+QfVo2ju YBa2rG+h75qjiA3xnU//F3rvwnAsOWv0NUVdVeguyR+u6okCQQDBUmgWeH2WHmUn 2nLNCz+9wj28rqhfOr9Ptem2gqk+ywJmuIr4Y5S1OdavOr2UZxOcEwncJ/MLVYQq MH+x4V5HAkEAzU2GMR5OdVLcxfVTjzuIC76paoHVWnLibd1cdANpPmE6SM+pf5el fVSwuH9Fmlizu8GiPCxbJUoXB/J1tGEKqQJBALhClEU+qOzpoZ6/voYi/6kdN3zc uEy0EN6n09AKb8gS9QH1STgAqh+ltjMkeMe3C2DKYK5/QU9/Pc58lWl1FkcCQG67 ZamQgxjcvJ85FvymS1aqW45KwNysIlzHjFo2jMlMf7dN6kobbPMQftDENLJvLWIT qoFyGycdsxZiPAIyZSECQQCZFn3Dl6hnJxWZH8Fsa9hj79kZ/WVkIXGmtdgt0fNr 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 PKCE_REQUIRED = False django-oauth-toolkit-2.3.0/tests/settings_swapped.py000066400000000000000000000003401443573112200226550ustar00rootroot00000000000000from .settings import * # noqa OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL = "tests.SampleAccessToken" OAUTH2_PROVIDER_APPLICATION_MODEL = "tests.SampleApplication" OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL = "tests.SampleRefreshToken" django-oauth-toolkit-2.3.0/tests/test_application_views.py000066400000000000000000000074761443573112200240720ustar00rootroot00000000000000import pytest from django.contrib.auth import get_user_model from django.test import TestCase from django.urls import reverse from oauth2_provider.models import get_application_model from oauth2_provider.views.application import ApplicationRegistration from .models import SampleApplication Application = get_application_model() UserModel = get_user_model() class BaseTest(TestCase): def setUp(self): self.foo_user = UserModel.objects.create_user("foo_user", "test@example.com", "123456") self.bar_user = UserModel.objects.create_user("bar_user", "dev@example.com", "123456") def tearDown(self): self.foo_user.delete() self.bar_user.delete() @pytest.mark.usefixtures("oauth2_settings") class TestApplicationRegistrationView(BaseTest): @pytest.mark.oauth2_settings({"APPLICATION_MODEL": "tests.SampleApplication"}) def test_get_form_class(self): """ Tests that the form class returned by the "get_form_class" method is bound to custom application model defined in the "OAUTH2_PROVIDER_APPLICATION_MODEL" setting. """ # Create a registration view and tests that the model form is bound # to the custom Application model application_form_class = ApplicationRegistration().get_form_class() self.assertEqual(SampleApplication, application_form_class._meta.model) def test_application_registration_user(self): self.client.login(username="foo_user", password="123456") form_data = { "name": "Foo app", "client_id": "client_id", "client_secret": "client_secret", "client_type": Application.CLIENT_CONFIDENTIAL, "redirect_uris": "http://example.com", "authorization_grant_type": Application.GRANT_AUTHORIZATION_CODE, "algorithm": "", } response = self.client.post(reverse("oauth2_provider:register"), form_data) self.assertEqual(response.status_code, 302) app = get_application_model().objects.get(name="Foo app") self.assertEqual(app.user.username, "foo_user") class TestApplicationViews(BaseTest): def _create_application(self, name, user): app = Application.objects.create( name=name, redirect_uris="http://example.com", client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, user=user, ) return app def setUp(self): super().setUp() self.app_foo_1 = self._create_application("app foo_user 1", self.foo_user) self.app_foo_2 = self._create_application("app foo_user 2", self.foo_user) self.app_foo_3 = self._create_application("app foo_user 3", self.foo_user) self.app_bar_1 = self._create_application("app bar_user 1", self.bar_user) self.app_bar_2 = self._create_application("app bar_user 2", self.bar_user) def tearDown(self): super().tearDown() get_application_model().objects.all().delete() def test_application_list(self): self.client.login(username="foo_user", password="123456") response = self.client.get(reverse("oauth2_provider:list")) self.assertEqual(response.status_code, 200) self.assertEqual(len(response.context["object_list"]), 3) def test_application_detail_owner(self): self.client.login(username="foo_user", password="123456") response = self.client.get(reverse("oauth2_provider:detail", args=(self.app_foo_1.pk,))) self.assertEqual(response.status_code, 200) def test_application_detail_not_owner(self): self.client.login(username="foo_user", password="123456") response = self.client.get(reverse("oauth2_provider:detail", args=(self.app_bar_1.pk,))) self.assertEqual(response.status_code, 404) django-oauth-toolkit-2.3.0/tests/test_auth_backends.py000066400000000000000000000131101443573112200231230ustar00rootroot00000000000000from 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 from django.utils.timezone import now, timedelta from oauth2_provider.backends import OAuth2Backend from oauth2_provider.middleware import OAuth2TokenMiddleware from oauth2_provider.models import get_access_token_model, get_application_model UserModel = get_user_model() ApplicationModel = get_application_model() AccessTokenModel = get_access_token_model() class BaseTest(TestCase): """ Base class for cases in this module """ def setUp(self): self.user = UserModel.objects.create_user("user", "test@example.com", "123456") self.app = ApplicationModel.objects.create( name="app", client_type=ApplicationModel.CLIENT_CONFIDENTIAL, authorization_grant_type=ApplicationModel.GRANT_CLIENT_CREDENTIALS, user=self.user, ) self.token = AccessTokenModel.objects.create( user=self.user, token="tokstr", application=self.app, expires=now() + timedelta(days=365) ) self.factory = RequestFactory() def tearDown(self): self.user.delete() self.app.delete() self.token.delete() class TestOAuth2Backend(BaseTest): def test_authenticate(self): auth_headers = { "HTTP_AUTHORIZATION": "Bearer " + "tokstr", } request = self.factory.get("/a-resource", **auth_headers) backend = OAuth2Backend() credentials = {"request": request} 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", } request = self.factory.get("/a-resource", **auth_headers) backend = OAuth2Backend() credentials = {"request": request} self.assertIsNone(backend.authenticate(**credentials)) credentials = {"username": "u", "password": "p"} self.assertIsNone(backend.authenticate(**credentials)) def test_get_user(self): backend = OAuth2Backend() self.assertEqual(self.user, backend.get_user(self.user.pk)) self.assertIsNone(backend.get_user(123456)) @override_settings( AUTHENTICATION_BACKENDS=( "oauth2_provider.backends.OAuth2Backend", "django.contrib.auth.backends.ModelBackend", ), ) @modify_settings( MIDDLEWARE={ "append": "oauth2_provider.middleware.OAuth2TokenMiddleware", } ) class TestOAuth2Middleware(BaseTest): def setUp(self): super().setUp() self.anon_user = AnonymousUser() def dummy_get_response(self, request): return HttpResponse() def test_middleware_wrong_headers(self): m = OAuth2TokenMiddleware(self.dummy_get_response) request = self.factory.get("/a-resource") m(request) self.assertFalse(hasattr(request, "user")) auth_headers = { "HTTP_AUTHORIZATION": "Beerer " + "badstring", # a Beer token for you! } request = self.factory.get("/a-resource", **auth_headers) m(request) self.assertFalse(hasattr(request, "user")) def test_middleware_user_is_set(self): m = OAuth2TokenMiddleware(self.dummy_get_response) auth_headers = { "HTTP_AUTHORIZATION": "Bearer " + "tokstr", } request = self.factory.get("/a-resource", **auth_headers) request.user = self.user m(request) self.assertIs(request.user, self.user) request.user = self.anon_user m(request) self.assertEqual(request.user.pk, self.user.pk) def test_middleware_success(self): m = OAuth2TokenMiddleware(self.dummy_get_response) auth_headers = { "HTTP_AUTHORIZATION": "Bearer " + "tokstr", } request = self.factory.get("/a-resource", **auth_headers) m(request) self.assertEqual(request.user, self.user) def test_middleware_response(self): m = OAuth2TokenMiddleware(self.dummy_get_response) auth_headers = { "HTTP_AUTHORIZATION": "Bearer " + "tokstr", } request = self.factory.get("/a-resource", **auth_headers) response = m(request) self.assertIsInstance(response, HttpResponse) def test_middleware_response_header(self): m = OAuth2TokenMiddleware(self.dummy_get_response) auth_headers = { "HTTP_AUTHORIZATION": "Bearer " + "tokstr", } request = self.factory.get("/a-resource", **auth_headers) response = m(request) self.assertIn("Vary", response) self.assertIn("Authorization", response["Vary"]) django-oauth-toolkit-2.3.0/tests/test_authorization_code.py000066400000000000000000002167411443573112200242410ustar00rootroot00000000000000import base64 import datetime import hashlib import json from urllib.parse import parse_qs, urlparse import pytest from django.conf import settings from django.contrib.auth import get_user_model from django.test import RequestFactory, TestCase from django.urls import reverse from django.utils import timezone from django.utils.crypto import get_random_string from jwcrypto import jwt from oauthlib.oauth2.rfc6749 import errors as oauthlib_errors from oauth2_provider.models import ( get_access_token_model, get_application_model, get_grant_model, get_refresh_token_model, ) from oauth2_provider.views import ProtectedResourceView from . import presets from .utils import get_basic_auth_header Application = get_application_model() AccessToken = get_access_token_model() Grant = get_grant_model() RefreshToken = get_refresh_token_model() UserModel = get_user_model() CLEARTEXT_SECRET = "1234567890abcdefghijklmnopqrstuvwxyz" # mocking a protected resource view class ResourceView(ProtectedResourceView): def get(self, request, *args, **kwargs): return "This is a protected resource" @pytest.mark.usefixtures("oauth2_settings") class BaseTest(TestCase): def setUp(self): self.factory = RequestFactory() self.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") self.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") self.oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES = ["http", "custom-scheme"] self.oauth2_settings.PKCE_REQUIRED = False self.application = Application.objects.create( name="Test Application", redirect_uris=( "http://localhost http://example.com http://example.org custom-scheme://example.com" ), user=self.dev_user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, client_secret=CLEARTEXT_SECRET, ) def tearDown(self): self.application.delete() self.test_user.delete() self.dev_user.delete() class TestRegressionIssue315(BaseTest): """ Test to avoid regression for the issue 315: request object was being reassigned when getting AuthorizationView """ def test_request_is_not_overwritten(self): self.oauth2_settings.PKCE_REQUIRED = False self.client.login(username="test_user", password="123456") response = self.client.get( reverse("oauth2_provider:authorize"), { "client_id": self.application.client_id, "response_type": "code", "state": "random_state_string", "scope": "read write", "redirect_uri": "http://example.org", }, ) self.assertEqual(response.status_code, 200) assert "request" not in response.context_data @pytest.mark.oauth2_settings(presets.DEFAULT_SCOPES_RW) class TestAuthorizationCodeView(BaseTest): def test_skip_authorization_completely(self): """ If application.skip_authorization = True, should skip the authorization page. """ self.oauth2_settings.PKCE_REQUIRED = False self.client.login(username="test_user", password="123456") self.application.skip_authorization = True self.application.save() response = self.client.get( reverse("oauth2_provider:authorize"), { "client_id": self.application.client_id, "response_type": "code", "state": "random_state_string", "scope": "read write", "redirect_uri": "http://example.org", }, ) self.assertEqual(response.status_code, 302) def test_pre_auth_invalid_client(self): """ Test error for an invalid client_id with response_type: code """ self.client.login(username="test_user", password="123456") query_data = { "client_id": "fakeclientid", "response_type": "code", } response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 400) self.assertEqual( response.context_data["url"], "?error=invalid_request&error_description=Invalid+client_id+parameter+value.", ) def test_pre_auth_valid_client(self): """ Test response for a valid client_id with response_type: code """ self.oauth2_settings.PKCE_REQUIRED = False self.client.login(username="test_user", password="123456") query_data = { "client_id": self.application.client_id, "response_type": "code", "state": "random_state_string", "scope": "read write", "redirect_uri": "http://example.org", } response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 200) # check form is in context and form params are valid self.assertIn("form", response.context) form = response.context["form"] self.assertEqual(form["redirect_uri"].value(), "http://example.org") self.assertEqual(form["state"].value(), "random_state_string") self.assertEqual(form["scope"].value(), "read write") self.assertEqual(form["client_id"].value(), self.application.client_id) def test_pre_auth_valid_client_custom_redirect_uri_scheme(self): """ Test response for a valid client_id with response_type: code using a non-standard, but allowed, redirect_uri scheme. """ self.client.login(username="test_user", password="123456") query_data = { "client_id": self.application.client_id, "response_type": "code", "state": "random_state_string", "scope": "read write", "redirect_uri": "custom-scheme://example.com", } response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 200) # check form is in context and form params are valid self.assertIn("form", response.context) form = response.context["form"] self.assertEqual(form["redirect_uri"].value(), "custom-scheme://example.com") self.assertEqual(form["state"].value(), "random_state_string") self.assertEqual(form["scope"].value(), "read write") self.assertEqual(form["client_id"].value(), self.application.client_id) def test_pre_auth_approval_prompt(self): tok = AccessToken.objects.create( user=self.test_user, token="1234567890", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), scope="read write", ) self.client.login(username="test_user", password="123456") query_data = { "client_id": self.application.client_id, "response_type": "code", "state": "random_state_string", "scope": "read write", "redirect_uri": "http://example.org", "approval_prompt": "auto", } url = reverse("oauth2_provider:authorize") response = self.client.get(url, data=query_data) self.assertEqual(response.status_code, 302) # user already authorized the application, but with different scopes: prompt them. tok.scope = "read" tok.save() response = self.client.get(url, data=query_data) self.assertEqual(response.status_code, 200) def test_pre_auth_approval_prompt_default(self): self.assertEqual(self.oauth2_settings.REQUEST_APPROVAL_PROMPT, "force") AccessToken.objects.create( user=self.test_user, token="1234567890", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), scope="read write", ) self.client.login(username="test_user", password="123456") query_data = { "client_id": self.application.client_id, "response_type": "code", "state": "random_state_string", "scope": "read write", "redirect_uri": "http://example.org", } response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 200) def test_pre_auth_approval_prompt_default_override(self): self.oauth2_settings.REQUEST_APPROVAL_PROMPT = "auto" AccessToken.objects.create( user=self.test_user, token="1234567890", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), scope="read write", ) self.client.login(username="test_user", password="123456") query_data = { "client_id": self.application.client_id, "response_type": "code", "state": "random_state_string", "scope": "read write", "redirect_uri": "http://example.org", } response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 302) 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, "response_type": "code", } response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 200) 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 """ self.client.login(username="test_user", password="123456") query_data = { "client_id": self.application.client_id, "response_type": "code", "redirect_uri": "http://forbidden.it", } response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 400) def test_pre_auth_wrong_response_type(self): """ Test error when passing a wrong response_type in query string """ self.client.login(username="test_user", password="123456") 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) self.assertEqual(response.status_code, 302) self.assertIn("error=unsupported_response_type", response["Location"]) def test_code_post_auth_allow(self): """ Test authorization code is given for an allowed request with response_type: code """ self.client.login(username="test_user", password="123456") form_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": "read write", "redirect_uri": "http://example.org", "response_type": "code", "allow": True, } response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 302) self.assertIn("http://example.org?", response["Location"]) self.assertIn("state=random_state_string", response["Location"]) self.assertIn("code=", response["Location"]) def test_code_post_auth_deny(self): """ Test error when resource owner deny access """ self.client.login(username="test_user", password="123456") form_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": "read write", "redirect_uri": "http://example.org", "response_type": "code", "allow": False, } response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 302) self.assertIn("error=access_denied", response["Location"]) self.assertIn("state=random_state_string", response["Location"]) def test_code_post_auth_deny_no_state(self): """ Test optional state when resource owner deny access """ self.client.login(username="test_user", password="123456") form_data = { "client_id": self.application.client_id, "scope": "read write", "redirect_uri": "http://example.org", "response_type": "code", "allow": False, } response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 302) self.assertIn("error=access_denied", response["Location"]) self.assertNotIn("state", response["Location"]) def test_code_post_auth_bad_responsetype(self): """ Test authorization code is given for an allowed request with a response_type not supported """ self.client.login(username="test_user", password="123456") form_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": "read write", "redirect_uri": "http://example.org", "response_type": "UNKNOWN", "allow": True, } response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 302) self.assertIn("http://example.org?error", response["Location"]) def test_code_post_auth_forbidden_redirect_uri(self): """ Test authorization code is given for an allowed request with a forbidden redirect_uri """ self.client.login(username="test_user", password="123456") form_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": "read write", "redirect_uri": "http://forbidden.it", "response_type": "code", "allow": True, } response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 400) def test_code_post_auth_malicious_redirect_uri(self): """ Test validation of a malicious redirect_uri """ self.client.login(username="test_user", password="123456") form_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": "read write", "redirect_uri": "/../", "response_type": "code", "allow": True, } response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 400) def test_code_post_auth_allow_custom_redirect_uri_scheme(self): """ Test authorization code is given for an allowed request with response_type: code using a non-standard, but allowed, redirect_uri scheme. """ self.client.login(username="test_user", password="123456") form_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": "read write", "redirect_uri": "custom-scheme://example.com", "response_type": "code", "allow": True, } response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 302) self.assertIn("custom-scheme://example.com?", response["Location"]) self.assertIn("state=random_state_string", response["Location"]) self.assertIn("code=", response["Location"]) def test_code_post_auth_deny_custom_redirect_uri_scheme(self): """ Test error when resource owner deny access using a non-standard, but allowed, redirect_uri scheme. """ self.client.login(username="test_user", password="123456") form_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": "read write", "redirect_uri": "custom-scheme://example.com", "response_type": "code", "allow": False, } response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 302) self.assertIn("custom-scheme://example.com?", response["Location"]) self.assertIn("error=access_denied", response["Location"]) self.assertIn("state=random_state_string", response["Location"]) def test_code_post_auth_redirection_uri_with_querystring(self): """ Tests that a redirection uri with query string is allowed and query string is retained on redirection. See http://tools.ietf.org/html/rfc6749#section-3.1.2 """ self.client.login(username="test_user", password="123456") form_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": "read write", "redirect_uri": "http://example.com?foo=bar", "response_type": "code", "allow": True, } response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 302) self.assertIn("http://example.com?foo=bar", response["Location"]) self.assertIn("code=", response["Location"]) self.assertIn("state=random_state_string", response["Location"]) def test_code_post_auth_failing_redirection_uri_with_querystring(self): """ Test that in case of error the querystring of the redirection uri is preserved See https://github.com/jazzband/django-oauth-toolkit/issues/238 """ self.client.login(username="test_user", password="123456") form_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": "read write", "redirect_uri": "http://example.com?foo=bar", "response_type": "code", "allow": False, } response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 302) self.assertIn("http://example.com?", response["Location"]) self.assertIn("error=access_denied", response["Location"]) self.assertIn("state=random_state_string", response["Location"]) self.assertIn("foo=bar", response["Location"]) def test_code_post_auth_fails_when_redirect_uri_path_is_invalid(self): """ Tests that a redirection uri is matched using scheme + netloc + path """ self.client.login(username="test_user", password="123456") form_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": "read write", "redirect_uri": "http://example.com/a?foo=bar", "response_type": "code", "allow": True, } response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 400) @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) class TestOIDCAuthorizationCodeView(BaseTest): def test_id_token_skip_authorization_completely(self): """ If application.skip_authorization = True, should skip the authorization page. """ self.client.login(username="test_user", password="123456") self.application.skip_authorization = True self.application.save() query_data = { "client_id": self.application.client_id, "response_type": "code", "state": "random_state_string", "scope": "openid", "redirect_uri": "http://example.org", } response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 302) def test_id_token_pre_auth_valid_client(self): """ Test response for a valid client_id with response_type: code """ self.client.login(username="test_user", password="123456") query_data = { "client_id": self.application.client_id, "response_type": "code", "state": "random_state_string", "scope": "openid", "redirect_uri": "http://example.org", } response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 200) # check form is in context and form params are valid self.assertIn("form", response.context) form = response.context["form"] self.assertEqual(form["redirect_uri"].value(), "http://example.org") self.assertEqual(form["state"].value(), "random_state_string") self.assertEqual(form["scope"].value(), "openid") self.assertEqual(form["client_id"].value(), self.application.client_id) def test_id_token_code_post_auth_allow(self): """ Test authorization code is given for an allowed request with response_type: code """ self.client.login(username="test_user", password="123456") form_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": "openid", "redirect_uri": "http://example.org", "response_type": "code", "allow": True, } response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 302) self.assertIn("http://example.org?", response["Location"]) self.assertIn("state=random_state_string", response["Location"]) self.assertIn("code=", response["Location"]) def test_prompt_login(self): """ Test response for redirect when supplied with prompt: login """ self.oauth2_settings.PKCE_REQUIRED = False self.client.login(username="test_user", password="123456") query_data = { "client_id": self.application.client_id, "response_type": "code", "state": "random_state_string", "scope": "read write", "redirect_uri": "http://example.org", "prompt": "login", } response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 302) scheme, netloc, path, params, query, fragment = urlparse(response["Location"]) self.assertEqual(path, settings.LOGIN_URL) parsed_query = parse_qs(query) next = parsed_query["next"][0] self.assertIn("redirect_uri=http%3A%2F%2Fexample.org", next) self.assertIn("state=random_state_string", next) self.assertIn("scope=read+write", next) self.assertIn(f"client_id={self.application.client_id}", next) self.assertNotIn("prompt=login", next) class BaseAuthorizationCodeTokenView(BaseTest): def get_auth(self, scope="read write"): """ Helper method to retrieve a valid authorization code """ authcode_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": scope, "redirect_uri": "http://example.org", "response_type": "code", "allow": True, } response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) query_dict = parse_qs(urlparse(response["Location"]).query) return query_dict["code"].pop() def generate_pkce_codes(self, algorithm, length=43): """ Helper method to generate pkce codes """ code_verifier = get_random_string(length) if algorithm == "S256": code_challenge = ( base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest()).decode().rstrip("=") ) else: code_challenge = code_verifier return code_verifier, code_challenge def get_pkce_auth(self, code_challenge, code_challenge_method): """ Helper method to retrieve a valid authorization code using pkce """ authcode_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": "read write", "redirect_uri": "http://example.org", "response_type": "code", "allow": True, "code_challenge": code_challenge, "code_challenge_method": code_challenge_method, } response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) query_dict = parse_qs(urlparse(response["Location"]).query) return query_dict["code"].pop() @pytest.mark.oauth2_settings(presets.DEFAULT_SCOPES_RW) class TestAuthorizationCodeTokenView(BaseAuthorizationCodeTokenView): def test_basic_auth(self): """ Request an access token using basic authentication for client authentication """ self.client.login(username="test_user", password="123456") authorization_code = self.get_auth() token_request_data = { "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "http://example.org", } auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") self.assertEqual(content["scope"], "read write") self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) def test_refresh(self): """ Request an access token using a refresh token """ self.client.login(username="test_user", password="123456") authorization_code = self.get_auth() token_request_data = { "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "http://example.org", } auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) self.assertTrue("refresh_token" in content) # make a second token request to be sure the previous refresh token remains valid, see #65 authorization_code = self.get_auth() token_request_data = { "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "http://example.org", } response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) token_request_data = { "grant_type": "refresh_token", "refresh_token": content["refresh_token"], "scope": content["scope"], } response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertTrue("access_token" in content) # check refresh token cannot be used twice response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 400) content = json.loads(response.content.decode("utf-8")) self.assertTrue("invalid_grant" in content.values()) def test_refresh_with_grace_period(self): """ Request an access token using a refresh token """ self.oauth2_settings.REFRESH_TOKEN_GRACE_PERIOD_SECONDS = 120 self.client.login(username="test_user", password="123456") authorization_code = self.get_auth() token_request_data = { "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "http://example.org", } auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) self.assertTrue("refresh_token" in content) # make a second token request to be sure the previous refresh token remains valid, see #65 authorization_code = self.get_auth() token_request_data = { "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "http://example.org", } response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) token_request_data = { "grant_type": "refresh_token", "refresh_token": content["refresh_token"], "scope": content["scope"], } response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertTrue("access_token" in content) first_access_token = content["access_token"] first_refresh_token = content["refresh_token"] # check access token returns same data if used twice, see #497 response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertTrue("access_token" in content) self.assertEqual(content["access_token"], first_access_token) # refresh token should be the same as well self.assertTrue("refresh_token" in content) self.assertEqual(content["refresh_token"], first_refresh_token) def test_refresh_invalidates_old_tokens(self): """ Ensure existing refresh tokens are cleaned up when issuing new ones """ self.client.login(username="test_user", password="123456") authorization_code = self.get_auth() token_request_data = { "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "http://example.org", } auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) rt = content["refresh_token"] at = content["access_token"] token_request_data = { "grant_type": "refresh_token", "refresh_token": rt, "scope": content["scope"], } response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) refresh_token = RefreshToken.objects.filter(token=rt).first() self.assertIsNotNone(refresh_token.revoked) self.assertFalse(AccessToken.objects.filter(token=at).exists()) def test_refresh_no_scopes(self): """ Request an access token using a refresh token without passing any scope """ self.client.login(username="test_user", password="123456") authorization_code = self.get_auth() token_request_data = { "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "http://example.org", } auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) self.assertTrue("refresh_token" in content) token_request_data = { "grant_type": "refresh_token", "refresh_token": content["refresh_token"], } response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertTrue("access_token" in content) def test_refresh_bad_scopes(self): """ Request an access token using a refresh token and wrong scopes """ self.client.login(username="test_user", password="123456") authorization_code = self.get_auth() token_request_data = { "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "http://example.org", } auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) self.assertTrue("refresh_token" in content) token_request_data = { "grant_type": "refresh_token", "refresh_token": content["refresh_token"], "scope": "read write nuke", } response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 400) def test_refresh_fail_repeating_requests(self): """ Try refreshing an access token with the same refresh token more than once """ self.client.login(username="test_user", password="123456") authorization_code = self.get_auth() token_request_data = { "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "http://example.org", } auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) self.assertTrue("refresh_token" in content) token_request_data = { "grant_type": "refresh_token", "refresh_token": content["refresh_token"], "scope": content["scope"], } response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 400) def test_refresh_repeating_requests(self): """ Trying to refresh an access token with the same refresh token more than once succeeds in the grace period and fails outside """ self.oauth2_settings.REFRESH_TOKEN_GRACE_PERIOD_SECONDS = 120 self.client.login(username="test_user", password="123456") authorization_code = self.get_auth() token_request_data = { "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "http://example.org", } auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) self.assertTrue("refresh_token" in content) token_request_data = { "grant_type": "refresh_token", "refresh_token": content["refresh_token"], "scope": content["scope"], } response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) # try refreshing outside the refresh window, see #497 rt = RefreshToken.objects.get(token=content["refresh_token"]) self.assertIsNotNone(rt.revoked) rt.revoked = timezone.now() - datetime.timedelta(minutes=10) # instead of mocking out datetime rt.save() response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 400) def test_refresh_repeating_requests_non_rotating_tokens(self): """ Try refreshing an access token with the same refresh token more than once when not rotating tokens. """ self.client.login(username="test_user", password="123456") authorization_code = self.get_auth() token_request_data = { "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "http://example.org", } auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) self.assertTrue("refresh_token" in content) token_request_data = { "grant_type": "refresh_token", "refresh_token": content["refresh_token"], "scope": content["scope"], } self.oauth2_settings.ROTATE_REFRESH_TOKEN = False response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) def test_basic_auth_bad_authcode(self): """ Request an access token using a bad authorization code """ self.client.login(username="test_user", password="123456") token_request_data = { "grant_type": "authorization_code", "code": "BLAH", "redirect_uri": "http://example.org", } auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 400) def test_basic_auth_bad_granttype(self): """ Request an access token using a bad grant_type string """ self.client.login(username="test_user", password="123456") token_request_data = {"grant_type": "UNKNOWN", "code": "BLAH", "redirect_uri": "http://example.org"} auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 400) def test_basic_auth_grant_expired(self): """ Request an access token using an expired grant token """ self.client.login(username="test_user", password="123456") g = Grant( application=self.application, user=self.test_user, code="BLAH", expires=timezone.now(), redirect_uri="", scope="", ) g.save() token_request_data = { "grant_type": "authorization_code", "code": "BLAH", "redirect_uri": "http://example.org", } auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 400) def test_basic_auth_bad_secret(self): """ Request an access token using basic authentication for client authentication """ self.client.login(username="test_user", password="123456") authorization_code = self.get_auth() token_request_data = { "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "http://example.org", } auth_headers = get_basic_auth_header(self.application.client_id, "BOOM!") response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 401) def test_basic_auth_wrong_auth_type(self): """ Request an access token using basic authentication for client authentication """ self.client.login(username="test_user", password="123456") authorization_code = self.get_auth() token_request_data = { "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "http://example.org", } user_pass = "{0}:{1}".format(self.application.client_id, CLEARTEXT_SECRET) auth_string = base64.b64encode(user_pass.encode("utf-8")) auth_headers = { "HTTP_AUTHORIZATION": "Wrong " + auth_string.decode("utf-8"), } response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 401) def test_request_body_params(self): """ Request an access token using client_type: public """ self.client.login(username="test_user", password="123456") authorization_code = self.get_auth() token_request_data = { "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "http://example.org", "client_id": self.application.client_id, "client_secret": CLEARTEXT_SECRET, } response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") self.assertEqual(content["scope"], "read write") self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) def test_public(self): """ Request an access token using client_type: public """ self.client.login(username="test_user", password="123456") self.application.client_type = Application.CLIENT_PUBLIC self.application.save() authorization_code = self.get_auth() token_request_data = { "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "http://example.org", "client_id": self.application.client_id, } response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") self.assertEqual(content["scope"], "read write") self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) def test_public_pkce_S256_authorize_get(self): """ Request an access token using client_type: public and PKCE enabled. Tests if the authorize get is successful for the S256 algorithm and form data are properly passed. """ self.client.login(username="test_user", password="123456") self.application.client_type = Application.CLIENT_PUBLIC self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("S256") query_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": "read write", "redirect_uri": "http://example.org", "response_type": "code", "allow": True, "code_challenge": code_challenge, "code_challenge_method": "S256", } response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertContains(response, 'value="S256"', count=1, status_code=200) self.assertContains(response, 'value="{0}"'.format(code_challenge), count=1, status_code=200) def test_public_pkce_plain_authorize_get(self): """ Request an access token using client_type: public and PKCE enabled. Tests if the authorize get is successful for the plain algorithm and form data are properly passed. """ self.client.login(username="test_user", password="123456") self.application.client_type = Application.CLIENT_PUBLIC self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("plain") query_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": "read write", "redirect_uri": "http://example.org", "response_type": "code", "allow": True, "code_challenge": code_challenge, "code_challenge_method": "plain", } response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertContains(response, 'value="plain"', count=1, status_code=200) self.assertContains(response, 'value="{0}"'.format(code_challenge), count=1, status_code=200) def test_public_pkce_S256(self): """ Request an access token using client_type: public and PKCE enabled with the S256 algorithm """ self.client.login(username="test_user", password="123456") self.application.client_type = Application.CLIENT_PUBLIC self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("S256") authorization_code = self.get_pkce_auth(code_challenge, "S256") token_request_data = { "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "http://example.org", "client_id": self.application.client_id, "code_verifier": code_verifier, } response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") self.assertEqual(content["scope"], "read write") self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) def test_public_pkce_plain(self): """ Request an access token using client_type: public and PKCE enabled with the plain algorithm """ self.client.login(username="test_user", password="123456") self.application.client_type = Application.CLIENT_PUBLIC self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("plain") authorization_code = self.get_pkce_auth(code_challenge, "plain") token_request_data = { "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "http://example.org", "client_id": self.application.client_id, "code_verifier": code_verifier, } response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") self.assertEqual(content["scope"], "read write") self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) def test_public_pkce_invalid_algorithm(self): """ Request an access token using client_type: public and PKCE enabled with an invalid algorithm """ self.client.login(username="test_user", password="123456") self.application.client_type = Application.CLIENT_PUBLIC self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("invalid") query_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": "read write", "redirect_uri": "http://example.org", "response_type": "code", "allow": True, "code_challenge": code_challenge, "code_challenge_method": "invalid", } response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 302) self.assertIn("error=invalid_request", response["Location"]) def test_public_pkce_missing_code_challenge(self): """ Request an access token using client_type: public and PKCE enabled but with the code_challenge missing """ self.oauth2_settings.PKCE_REQUIRED = True self.client.login(username="test_user", password="123456") self.application.client_type = Application.CLIENT_PUBLIC self.application.skip_authorization = True self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("S256") query_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": "read write", "redirect_uri": "http://example.org", "response_type": "code", "allow": True, "code_challenge_method": "S256", } response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 302) self.assertIn("error=invalid_request", response["Location"]) def test_public_pkce_missing_code_challenge_method(self): """ Request an access token using client_type: public and PKCE enabled but with the code_challenge_method missing """ self.client.login(username="test_user", password="123456") self.application.client_type = Application.CLIENT_PUBLIC self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("S256") query_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": "read write", "redirect_uri": "http://example.org", "response_type": "code", "allow": True, "code_challenge": code_challenge, } response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 200) def test_public_pkce_S256_invalid_code_verifier(self): """ Request an access token using client_type: public and PKCE enabled with the S256 algorithm and an invalid code_verifier """ self.client.login(username="test_user", password="123456") self.application.client_type = Application.CLIENT_PUBLIC self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("S256") authorization_code = self.get_pkce_auth(code_challenge, "S256") token_request_data = { "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "http://example.org", "client_id": self.application.client_id, "code_verifier": "invalid", } response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) self.assertEqual(response.status_code, 400) def test_public_pkce_plain_invalid_code_verifier(self): """ Request an access token using client_type: public and PKCE enabled with the plain algorithm and an invalid code_verifier """ self.client.login(username="test_user", password="123456") self.application.client_type = Application.CLIENT_PUBLIC self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("plain") authorization_code = self.get_pkce_auth(code_challenge, "plain") token_request_data = { "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "http://example.org", "client_id": self.application.client_id, "code_verifier": "invalid", } response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) self.assertEqual(response.status_code, 400) def test_public_pkce_S256_missing_code_verifier(self): """ Request an access token using client_type: public and PKCE enabled with the S256 algorithm and the code_verifier missing """ self.client.login(username="test_user", password="123456") self.application.client_type = Application.CLIENT_PUBLIC self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("S256") authorization_code = self.get_pkce_auth(code_challenge, "S256") token_request_data = { "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "http://example.org", "client_id": self.application.client_id, } response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) self.assertEqual(response.status_code, 400) def test_public_pkce_plain_missing_code_verifier(self): """ Request an access token using client_type: public and PKCE enabled with the plain algorithm and the code_verifier missing """ self.client.login(username="test_user", password="123456") self.application.client_type = Application.CLIENT_PUBLIC self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("plain") authorization_code = self.get_pkce_auth(code_challenge, "plain") token_request_data = { "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "http://example.org", "client_id": self.application.client_id, } response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) self.assertEqual(response.status_code, 400) def test_malicious_redirect_uri(self): """ Request an access token using client_type: public and ensure redirect_uri is properly validated. """ self.client.login(username="test_user", password="123456") self.application.client_type = Application.CLIENT_PUBLIC self.application.save() authorization_code = self.get_auth() token_request_data = { "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "/../", "client_id": self.application.client_id, } response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) self.assertEqual(response.status_code, 400) data = response.json() self.assertEqual(data["error"], "invalid_request") self.assertEqual( data["error_description"], oauthlib_errors.MismatchingRedirectURIError.description, ) def test_code_exchange_succeed_when_redirect_uri_match(self): """ Tests code exchange succeed when redirect uri matches the one used for code request """ self.client.login(username="test_user", password="123456") # retrieve a valid authorization code authcode_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": "read write", "redirect_uri": "http://example.org?foo=bar", "response_type": "code", "allow": True, } response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) query_dict = parse_qs(urlparse(response["Location"]).query) authorization_code = query_dict["code"].pop() # exchange authorization code for a valid access token token_request_data = { "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "http://example.org?foo=bar", } auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") self.assertEqual(content["scope"], "read write") self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) def test_code_exchange_fails_when_redirect_uri_does_not_match(self): """ Tests code exchange fails when redirect uri does not match the one used for code request """ self.client.login(username="test_user", password="123456") # retrieve a valid authorization code authcode_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": "read write", "redirect_uri": "http://example.org?foo=bar", "response_type": "code", "allow": True, } response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) query_dict = parse_qs(urlparse(response["Location"]).query) authorization_code = query_dict["code"].pop() # exchange authorization code for a valid access token token_request_data = { "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "http://example.org?foo=baraa", } auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 400) data = response.json() self.assertEqual(data["error"], "invalid_request") self.assertEqual( data["error_description"], oauthlib_errors.MismatchingRedirectURIError.description, ) def test_code_exchange_succeed_when_redirect_uri_match_with_multiple_query_params( self, ): """ Tests code exchange succeed when redirect uri matches the one used for code request """ self.client.login(username="test_user", password="123456") self.application.redirect_uris = "http://localhost http://example.com?foo=bar" self.application.save() # retrieve a valid authorization code authcode_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": "read write", "redirect_uri": "http://example.com?bar=baz&foo=bar", "response_type": "code", "allow": True, } response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) query_dict = parse_qs(urlparse(response["Location"]).query) authorization_code = query_dict["code"].pop() # exchange authorization code for a valid access token token_request_data = { "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "http://example.com?bar=baz&foo=bar", } auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") self.assertEqual(content["scope"], "read write") self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) class TestOIDCAuthorizationCodeTokenView(BaseAuthorizationCodeTokenView): def setUp(self): super().setUp() self.application.algorithm = Application.RS256_ALGORITHM self.application.save() def test_id_token_public(self): """ Request an access token using client_type: public """ self.client.login(username="test_user", password="123456") self.application.client_type = Application.CLIENT_PUBLIC self.application.save() authorization_code = self.get_auth(scope="openid") token_request_data = { "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "http://example.org", "client_id": self.application.client_id, "scope": "openid", } response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") self.assertEqual(content["scope"], "openid") self.assertIn("access_token", content) self.assertIn("id_token", content) self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) def test_id_token_code_exchange_succeed_when_redirect_uri_match_with_multiple_query_params( self, ): """ Tests code exchange succeed when redirect uri matches the one used for code request """ self.client.login(username="test_user", password="123456") self.application.redirect_uris = "http://localhost http://example.com?foo=bar" self.application.save() # retrieve a valid authorization code authcode_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": "openid", "redirect_uri": "http://example.com?bar=baz&foo=bar", "response_type": "code", "allow": True, } response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) query_dict = parse_qs(urlparse(response["Location"]).query) authorization_code = query_dict["code"].pop() # exchange authorization code for a valid access token token_request_data = { "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "http://example.com?bar=baz&foo=bar", } auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") self.assertEqual(content["scope"], "openid") self.assertIn("access_token", content) self.assertIn("id_token", content) self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) class TestOIDCAuthorizationCodeHSAlgorithm(BaseAuthorizationCodeTokenView): def setUp(self): super().setUp() self.oauth2_settings.OIDC_RSA_PRIVATE_KEY = None self.application.algorithm = Application.HS256_ALGORITHM self.application.save() def test_id_token(self): """ Request an access token using an HS256 application """ self.client.login(username="test_user", password="123456") authorization_code = self.get_auth(scope="openid") token_request_data = { "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "http://example.org", "client_id": self.application.client_id, "client_secret": CLEARTEXT_SECRET, "scope": "openid", } response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) self.assertEqual(response.status_code, 200) content = response.json() self.assertEqual(content["token_type"], "Bearer") self.assertEqual(content["scope"], "openid") self.assertIn("access_token", content) self.assertIn("id_token", content) self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) # Check decoding JWT using HS256 key = self.application.jwk_key assert key.key_type == "oct" jwt_token = jwt.JWT(key=key, jwt=content["id_token"]) claims = json.loads(jwt_token.claims) assert claims["sub"] == "1" @pytest.mark.oauth2_settings(presets.DEFAULT_SCOPES_RW) class TestAuthorizationCodeProtectedResource(BaseTest): def test_resource_access_allowed(self): self.client.login(username="test_user", password="123456") # retrieve a valid authorization code authcode_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": "read write", "redirect_uri": "http://example.org", "response_type": "code", "allow": True, } response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) query_dict = parse_qs(urlparse(response["Location"]).query) authorization_code = query_dict["code"].pop() # exchange authorization code for a valid access token token_request_data = { "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "http://example.org", } auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) access_token = content["access_token"] # use token to access the resource auth_headers = { "HTTP_AUTHORIZATION": "Bearer " + access_token, } request = self.factory.get("/fake-resource", **auth_headers) request.user = self.test_user view = ResourceView.as_view() response = view(request) self.assertEqual(response, "This is a protected resource") def test_resource_access_deny(self): auth_headers = { "HTTP_AUTHORIZATION": "Bearer " + "faketoken", } request = self.factory.get("/fake-resource", **auth_headers) request.user = self.test_user view = ResourceView.as_view() response = view(request) self.assertEqual(response.status_code, 403) @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) class TestOIDCAuthorizationCodeProtectedResource(BaseTest): def setUp(self): super().setUp() self.application.algorithm = Application.RS256_ALGORITHM self.application.save() def test_id_token_resource_access_allowed(self): self.client.login(username="test_user", password="123456") # retrieve a valid authorization code authcode_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": "openid", "redirect_uri": "http://example.org", "response_type": "code", "allow": True, } response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) query_dict = parse_qs(urlparse(response["Location"]).query) authorization_code = query_dict["code"].pop() # exchange authorization code for a valid access token token_request_data = { "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "http://example.org", } auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) access_token = content["access_token"] id_token = content["id_token"] # use token to access the resource auth_headers = { "HTTP_AUTHORIZATION": "Bearer " + access_token, } request = self.factory.get("/fake-resource", **auth_headers) request.user = self.test_user view = ResourceView.as_view() response = view(request) self.assertEqual(response, "This is a protected resource") # use id_token to access the resource auth_headers = { "HTTP_AUTHORIZATION": "Bearer " + id_token, } request = self.factory.get("/fake-resource", **auth_headers) request.user = self.test_user view = ResourceView.as_view() response = view(request) self.assertEqual(response, "This is a protected resource") @pytest.mark.oauth2_settings(presets.DEFAULT_SCOPES_RO) class TestDefaultScopes(BaseTest): def test_pre_auth_default_scopes(self): """ Test response for a valid client_id with response_type: code using default scopes """ self.client.login(username="test_user", password="123456") query_data = { "client_id": self.application.client_id, "response_type": "code", "state": "random_state_string", "redirect_uri": "http://example.org", } response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 200) # check form is in context and form params are valid self.assertIn("form", response.context) form = response.context["form"] self.assertEqual(form["redirect_uri"].value(), "http://example.org") self.assertEqual(form["state"].value(), "random_state_string") self.assertEqual(form["scope"].value(), "read") self.assertEqual(form["client_id"].value(), self.application.client_id) django-oauth-toolkit-2.3.0/tests/test_client_credential.py000066400000000000000000000162731443573112200240150ustar00rootroot00000000000000import json from unittest.mock import patch 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 from oauthlib.oauth2 import BackendApplicationServer from oauth2_provider.models import get_access_token_model, get_application_model from oauth2_provider.oauth2_backends import OAuthLibCore from oauth2_provider.oauth2_validators import OAuth2Validator from oauth2_provider.views import ProtectedResourceView from oauth2_provider.views.mixins import OAuthLibMixin from . import presets from .utils import get_basic_auth_header Application = get_application_model() AccessToken = get_access_token_model() UserModel = get_user_model() CLEARTEXT_SECRET = "abcdefghijklmnopqrstuvwxyz1234567890" # mocking a protected resource view class ResourceView(ProtectedResourceView): def get(self, request, *args, **kwargs): return "This is a protected resource" @pytest.mark.usefixtures("oauth2_settings") @pytest.mark.oauth2_settings(presets.DEFAULT_SCOPES_RW) class BaseTest(TestCase): def setUp(self): self.factory = RequestFactory() self.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") self.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") self.application = Application.objects.create( name="test_client_credentials_app", user=self.dev_user, client_type=Application.CLIENT_PUBLIC, authorization_grant_type=Application.GRANT_CLIENT_CREDENTIALS, client_secret=CLEARTEXT_SECRET, ) def tearDown(self): self.application.delete() self.test_user.delete() self.dev_user.delete() class TestClientCredential(BaseTest): def test_client_credential_access_allowed(self): """ Request an access token using Client Credential Flow with hashed secrets """ self.assertNotEqual(self.application.client_secret, CLEARTEXT_SECRET) token_request_data = { "grant_type": "client_credentials", } auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) # secret mismatch should return a 401 auth_headers = get_basic_auth_header(self.application.client_id, "not-the-secret") response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 401) def test_client_credential_does_not_issue_refresh_token(self): token_request_data = { "grant_type": "client_credentials", } auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertNotIn("refresh_token", content) def test_client_credential_user_is_none_on_access_token(self): token_request_data = {"grant_type": "client_credentials"} auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) access_token = AccessToken.objects.get(token=content["access_token"]) 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): cls.request_factory = RequestFactory() super().setUpClass() def test_extended_request(self): token_request_data = { "grant_type": "client_credentials", } auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) access_token = content["access_token"] # use token to access the resource auth_headers = { "HTTP_AUTHORIZATION": "Bearer " + access_token, } request = self.request_factory.get("/fake-req", **auth_headers) request.user = "fake" test_view = TestView() self.assertIsInstance(test_view.get_server(), BackendApplicationServer) valid, r = test_view.verify_request(request) self.assertTrue(valid) self.assertIsNone(r.user) 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): """ Request an access token using Resource Owner Password Based flow """ self.application.delete() self.application = Application.objects.create( name="test_client_credentials_app", user=self.dev_user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_PASSWORD, client_secret=CLEARTEXT_SECRET, ) token_request_data = {"grant_type": "password", "username": "test_user", "password": "123456"} auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) access_token = content["access_token"] # use token to access the resource auth_headers = { "HTTP_AUTHORIZATION": "Bearer " + access_token, } request = self.factory.get("/fake-resource", **auth_headers) request.user = self.test_user view = ResourceView.as_view() response = view(request) self.assertEqual(response, "This is a protected resource") django-oauth-toolkit-2.3.0/tests/test_commands.py000066400000000000000000000112241443573112200221350ustar00rootroot00000000000000from io import StringIO import pytest from django.contrib.auth import get_user_model from django.contrib.auth.hashers import check_password from django.core.management import call_command from django.core.management.base import CommandError from django.test import TestCase from oauth2_provider.models import get_application_model from . import presets Application = get_application_model() class CreateApplicationTest(TestCase): def test_command_creates_application(self): output = StringIO() self.assertEqual(Application.objects.count(), 0) call_command( "createapplication", "confidential", "authorization-code", "--redirect-uris=http://example.com http://example2.com", stdout=output, ) self.assertEqual(Application.objects.count(), 1) self.assertIn("created successfully", output.getvalue()) def test_missing_required_args(self): self.assertEqual(Application.objects.count(), 0) with self.assertRaises(CommandError) as ctx: call_command( "createapplication", "--redirect-uris=http://example.com http://example2.com", ) self.assertIn("client_type", ctx.exception.args[0]) self.assertIn("authorization_grant_type", ctx.exception.args[0]) self.assertEqual(Application.objects.count(), 0) def test_command_creates_application_with_skipped_auth(self): self.assertEqual(Application.objects.count(), 0) call_command( "createapplication", "confidential", "authorization-code", "--redirect-uris=http://example.com http://example2.com", "--skip-authorization", ) app = Application.objects.get() self.assertTrue(app.skip_authorization) def test_application_created_normally_with_no_skipped_auth(self): call_command( "createapplication", "confidential", "authorization-code", "--redirect-uris=http://example.com http://example2.com", ) app = Application.objects.get() self.assertFalse(app.skip_authorization) def test_application_created_with_name(self): call_command( "createapplication", "confidential", "authorization-code", "--redirect-uris=http://example.com http://example2.com", "--name=TEST", ) app = Application.objects.get() self.assertEqual(app.name, "TEST") def test_application_created_with_client_secret(self): call_command( "createapplication", "confidential", "authorization-code", "--redirect-uris=http://example.com http://example2.com", "--client-secret=SECRET", ) app = Application.objects.get() self.assertTrue(check_password("SECRET", app.client_secret)) def test_application_created_with_client_id(self): call_command( "createapplication", "confidential", "authorization-code", "--redirect-uris=http://example.com http://example2.com", "--client-id=someId", ) app = Application.objects.get() self.assertEqual(app.client_id, "someId") def test_application_created_with_user(self): User = get_user_model() user = User.objects.create() call_command( "createapplication", "confidential", "authorization-code", "--redirect-uris=http://example.com http://example2.com", "--user=%s" % user.pk, ) app = Application.objects.get() self.assertEqual(app.user, user) @pytest.mark.usefixtures("oauth2_settings") @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) def test_application_created_with_algorithm(self): call_command( "createapplication", "confidential", "authorization-code", "--redirect-uris=http://example.com http://example2.com", "--algorithm=RS256", ) app = Application.objects.get() self.assertEqual(app.algorithm, "RS256") def test_validation_failed_message(self): output = StringIO() call_command( "createapplication", "confidential", "authorization-code", "--redirect-uris=http://example.com http://example2.com", "--user=783", stdout=output, ) self.assertIn("user", output.getvalue()) self.assertIn("783", output.getvalue()) self.assertIn("does not exist", output.getvalue()) django-oauth-toolkit-2.3.0/tests/test_decorators.py000066400000000000000000000062741443573112200225120ustar00rootroot00000000000000from datetime import timedelta from django.contrib.auth import get_user_model from django.test import RequestFactory, TestCase from django.utils import timezone from oauth2_provider.decorators import protected_resource, rw_protected_resource from oauth2_provider.models import get_access_token_model, get_application_model Application = get_application_model() AccessToken = get_access_token_model() UserModel = get_user_model() class TestProtectedResourceDecorator(TestCase): @classmethod def setUpClass(cls): cls.request_factory = RequestFactory() super().setUpClass() def setUp(self): self.user = UserModel.objects.create_user("test_user", "test@example.com", "123456") self.application = Application.objects.create( name="test_client_credentials_app", user=self.user, client_type=Application.CLIENT_PUBLIC, authorization_grant_type=Application.GRANT_CLIENT_CREDENTIALS, ) self.access_token = AccessToken.objects.create( user=self.user, scope="read write", expires=timezone.now() + timedelta(seconds=300), token="secret-access-token-key", application=self.application, ) def test_access_denied(self): @protected_resource() def view(request, *args, **kwargs): return "protected contents" request = self.request_factory.get("/fake-resource") response = view(request) self.assertEqual(response.status_code, 403) def test_access_allowed(self): @protected_resource() def view(request, *args, **kwargs): return "protected contents" @protected_resource(scopes=["can_touch_this"]) def scoped_view(request, *args, **kwargs): return "moar protected contents" auth_headers = { "HTTP_AUTHORIZATION": "Bearer " + self.access_token.token, } request = self.request_factory.get("/fake-resource", **auth_headers) response = view(request) self.assertEqual(response, "protected contents") # now with scopes self.access_token.scope = "can_touch_this" self.access_token.save() auth_headers = { "HTTP_AUTHORIZATION": "Bearer " + self.access_token.token, } request = self.request_factory.get("/fake-resource", **auth_headers) response = scoped_view(request) self.assertEqual(response, "moar protected contents") def test_rw_protected(self): self.access_token.scope = "exotic_scope write" self.access_token.save() auth_headers = { "HTTP_AUTHORIZATION": "Bearer " + self.access_token.token, } @rw_protected_resource(scopes=["exotic_scope"]) def scoped_view(request, *args, **kwargs): return "other protected contents" request = self.request_factory.post("/fake-resource", **auth_headers) response = scoped_view(request) self.assertEqual(response, "other protected contents") request = self.request_factory.get("/fake-resource", **auth_headers) response = scoped_view(request) self.assertEqual(response.status_code, 403) django-oauth-toolkit-2.3.0/tests/test_generator.py000066400000000000000000000017361443573112200223310ustar00rootroot00000000000000import pytest from django.test import TestCase from oauth2_provider.generators import BaseHashGenerator, generate_client_id, generate_client_secret class MockHashGenerator(BaseHashGenerator): def hash(self): return 42 @pytest.mark.usefixtures("oauth2_settings") class TestGenerators(TestCase): def test_generate_client_id(self): g = self.oauth2_settings.CLIENT_ID_GENERATOR_CLASS() self.assertEqual(len(g.hash()), 40) self.oauth2_settings.CLIENT_ID_GENERATOR_CLASS = MockHashGenerator self.assertEqual(generate_client_id(), 42) def test_generate_secret_id(self): g = self.oauth2_settings.CLIENT_SECRET_GENERATOR_CLASS() self.assertEqual(len(g.hash()), 128) self.oauth2_settings.CLIENT_SECRET_GENERATOR_CLASS = MockHashGenerator self.assertEqual(generate_client_secret(), 42) def test_basegen_misuse(self): g = BaseHashGenerator() self.assertRaises(NotImplementedError, g.hash) django-oauth-toolkit-2.3.0/tests/test_hybrid.py000066400000000000000000001607531443573112200216310ustar00rootroot00000000000000import base64 import datetime import json from urllib.parse import parse_qs, urlencode, urlparse import pytest from django.contrib.auth import get_user_model from django.test import RequestFactory, TestCase from django.urls import reverse from django.utils import timezone from jwcrypto import jwt from oauthlib.oauth2.rfc6749 import errors as oauthlib_errors from oauth2_provider.models import ( get_access_token_model, get_application_model, get_grant_model, get_refresh_token_model, ) from oauth2_provider.oauth2_validators import OAuth2Validator from oauth2_provider.views import ProtectedResourceView, ScopedProtectedResourceView from . import presets from .utils import get_basic_auth_header, spy_on Application = get_application_model() AccessToken = get_access_token_model() Grant = get_grant_model() RefreshToken = get_refresh_token_model() UserModel = get_user_model() CLEARTEXT_SECRET = "1234567890abcdefghijklmnopqrstuvwxyz" # mocking a protected resource view class ResourceView(ProtectedResourceView): def get(self, request, *args, **kwargs): return "This is a protected resource" class ScopedResourceView(ScopedProtectedResourceView): required_scopes = ["read"] def get(self, request, *args, **kwargs): return "This is a protected resource" @pytest.mark.usefixtures("oauth2_settings") class BaseTest(TestCase): def setUp(self): self.factory = RequestFactory() self.hy_test_user = UserModel.objects.create_user("hy_test_user", "test_hy@example.com", "123456") self.hy_dev_user = UserModel.objects.create_user("hy_dev_user", "dev_hy@example.com", "123456") self.oauth2_settings.PKCE_REQUIRED = False self.oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES = ["http", "custom-scheme"] self.application = Application( name="Hybrid Test Application", redirect_uris=( "http://localhost http://example.com http://example.org custom-scheme://example.com" ), user=self.hy_dev_user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_OPENID_HYBRID, algorithm=Application.RS256_ALGORITHM, client_secret=CLEARTEXT_SECRET, ) self.application.save() def tearDown(self): self.application.delete() self.hy_test_user.delete() self.hy_dev_user.delete() @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) class TestRegressionIssue315Hybrid(BaseTest): """ Test to avoid regression for the issue 315: request object was being reassigned when getting AuthorizationView """ def test_request_is_not_overwritten_code_token(self): self.client.login(username="hy_test_user", password="123456") query_string = urlencode( { "client_id": self.application.client_id, "response_type": "code token", "state": "random_state_string", "scope": "openid read write", "redirect_uri": "http://example.org", } ) url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) response = self.client.get(url) self.assertEqual(response.status_code, 200) assert "request" not in response.context_data def test_request_is_not_overwritten_code_id_token(self): self.client.login(username="hy_test_user", password="123456") query_string = urlencode( { "client_id": self.application.client_id, "response_type": "code id_token", "state": "random_state_string", "scope": "openid read write", "redirect_uri": "http://example.org", "nonce": "nonce", } ) url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) response = self.client.get(url) self.assertEqual(response.status_code, 200) assert "request" not in response.context_data def test_request_is_not_overwritten_code_id_token_token(self): self.client.login(username="hy_test_user", password="123456") query_string = urlencode( { "client_id": self.application.client_id, "response_type": "code id_token token", "state": "random_state_string", "scope": "openid read write", "redirect_uri": "http://example.org", "nonce": "nonce", } ) url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) response = self.client.get(url) self.assertEqual(response.status_code, 200) assert "request" not in response.context_data @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) class TestHybridView(BaseTest): def test_skip_authorization_completely(self): """ If application.skip_authorization = True, should skip the authorization page. """ self.client.login(username="hy_test_user", password="123456") self.application.skip_authorization = True self.application.save() query_string = urlencode( { "client_id": self.application.client_id, "response_type": "code", "state": "random_state_string", "scope": "read write", "redirect_uri": "http://example.org", } ) url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) response = self.client.get(url) self.assertEqual(response.status_code, 302) def test_id_token_skip_authorization_completely(self): """ If application.skip_authorization = True, should skip the authorization page. """ self.client.login(username="hy_test_user", password="123456") self.application.skip_authorization = True self.application.save() query_string = urlencode( { "client_id": self.application.client_id, "response_type": "code", "state": "random_state_string", "scope": "openid", "redirect_uri": "http://example.org", } ) url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) response = self.client.get(url) self.assertEqual(response.status_code, 302) def test_pre_auth_invalid_client(self): """ Test error for an invalid client_id with response_type: code """ self.client.login(username="hy_test_user", password="123456") query_string = urlencode( { "client_id": "fakeclientid", "response_type": "code", } ) url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) response = self.client.get(url) self.assertEqual(response.status_code, 400) self.assertEqual( response.context_data["url"], "?error=invalid_request&error_description=Invalid+client_id+parameter+value.", ) def test_pre_auth_valid_client(self): """ Test response for a valid client_id with response_type: code """ self.client.login(username="hy_test_user", password="123456") query_string = urlencode( { "client_id": self.application.client_id, "response_type": "code id_token", "state": "random_state_string", "scope": "read write", "redirect_uri": "http://example.org", } ) url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) response = self.client.get(url) self.assertEqual(response.status_code, 200) # check form is in context and form params are valid self.assertIn("form", response.context) form = response.context["form"] self.assertEqual(form["redirect_uri"].value(), "http://example.org") self.assertEqual(form["state"].value(), "random_state_string") self.assertEqual(form["scope"].value(), "read write") self.assertEqual(form["client_id"].value(), self.application.client_id) def test_id_token_pre_auth_valid_client(self): """ Test response for a valid client_id with response_type: code """ self.client.login(username="hy_test_user", password="123456") query_string = urlencode( { "client_id": self.application.client_id, "response_type": "code id_token", "state": "random_state_string", "scope": "openid", "redirect_uri": "http://example.org", "nonce": "nonce", } ) url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) response = self.client.get(url) self.assertEqual(response.status_code, 200) # check form is in context and form params are valid self.assertIn("form", response.context) form = response.context["form"] self.assertEqual(form["redirect_uri"].value(), "http://example.org") self.assertEqual(form["state"].value(), "random_state_string") self.assertEqual(form["scope"].value(), "openid") self.assertEqual(form["client_id"].value(), self.application.client_id) def test_pre_auth_valid_client_custom_redirect_uri_scheme(self): """ Test response for a valid client_id with response_type: code using a non-standard, but allowed, redirect_uri scheme. """ self.client.login(username="hy_test_user", password="123456") query_string = urlencode( { "client_id": self.application.client_id, "response_type": "code id_token", "state": "random_state_string", "scope": "read write", "redirect_uri": "custom-scheme://example.com", } ) url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) response = self.client.get(url) self.assertEqual(response.status_code, 200) # check form is in context and form params are valid self.assertIn("form", response.context) form = response.context["form"] self.assertEqual(form["redirect_uri"].value(), "custom-scheme://example.com") self.assertEqual(form["state"].value(), "random_state_string") self.assertEqual(form["scope"].value(), "read write") self.assertEqual(form["client_id"].value(), self.application.client_id) def test_pre_auth_approval_prompt(self): tok = AccessToken.objects.create( user=self.hy_test_user, token="1234567890", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), scope="read write", ) self.client.login(username="hy_test_user", password="123456") query_string = urlencode( { "client_id": self.application.client_id, "response_type": "code id_token", "state": "random_state_string", "scope": "read write", "redirect_uri": "http://example.org", "approval_prompt": "auto", } ) url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) response = self.client.get(url) self.assertEqual(response.status_code, 302) # user already authorized the application, but with different scopes: prompt them. tok.scope = "read" tok.save() response = self.client.get(url) self.assertEqual(response.status_code, 200) def test_pre_auth_approval_prompt_default(self): self.oauth2_settings.REQUEST_APPROVAL_PROMPT = "force" self.assertEqual(self.oauth2_settings.REQUEST_APPROVAL_PROMPT, "force") AccessToken.objects.create( user=self.hy_test_user, token="1234567890", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), scope="read write", ) self.client.login(username="hy_test_user", password="123456") query_string = urlencode( { "client_id": self.application.client_id, "response_type": "code id_token", "state": "random_state_string", "scope": "read write", "redirect_uri": "http://example.org", } ) url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) response = self.client.get(url) self.assertEqual(response.status_code, 200) def test_pre_auth_approval_prompt_default_override(self): self.oauth2_settings.REQUEST_APPROVAL_PROMPT = "auto" AccessToken.objects.create( user=self.hy_test_user, token="1234567890", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), scope="read write", ) self.client.login(username="hy_test_user", password="123456") query_string = urlencode( { "client_id": self.application.client_id, "response_type": "code", "state": "random_state_string", "scope": "read write", "redirect_uri": "http://example.org", } ) url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) response = self.client.get(url) self.assertEqual(response.status_code, 302) 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( { "client_id": self.application.client_id, "response_type": "code id_token", } ) url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) response = self.client.get(url) self.assertEqual(response.status_code, 200) form = response.context["form"] self.assertEqual(form["redirect_uri"].value(), "http://localhost") def test_pre_auth_forbibben_redirect(self): """ Test error when passing a forbidden redirect_uri in query string with response_type: code """ self.client.login(username="hy_test_user", password="123456") query_string = urlencode( { "client_id": self.application.client_id, "response_type": "code", "redirect_uri": "http://forbidden.it", } ) url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) response = self.client.get(url) self.assertEqual(response.status_code, 400) def test_pre_auth_wrong_response_type(self): """ Test error when passing a wrong response_type in query string """ self.client.login(username="hy_test_user", password="123456") query_string = urlencode( { "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) response = self.client.get(url) self.assertEqual(response.status_code, 302) self.assertIn("error=unsupported_response_type", response["Location"]) def test_code_post_auth_allow_code_token(self): """ Test authorization code is given for an allowed request with response_type: code """ self.client.login(username="hy_test_user", password="123456") form_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": "openid read write", "redirect_uri": "http://example.org", "response_type": "code token", "allow": True, } response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 302) self.assertIn("http://example.org", response["Location"]) self.assertIn("state=random_state_string", response["Location"]) self.assertIn("code=", response["Location"]) self.assertIn("access_token=", response["Location"]) def test_code_post_auth_allow_code_id_token(self): """ Test authorization code is given for an allowed request with response_type: code """ self.client.login(username="hy_test_user", password="123456") form_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": "openid read write", "redirect_uri": "http://example.org", "response_type": "code id_token", "allow": True, "nonce": "nonce", } response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 302) self.assertIn("http://example.org", response["Location"]) self.assertIn("state=random_state_string", response["Location"]) self.assertIn("code=", response["Location"]) self.assertIn("id_token=", response["Location"]) def test_code_post_auth_allow_code_id_token_token(self): """ Test authorization code is given for an allowed request with response_type: code """ self.client.login(username="hy_test_user", password="123456") form_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": "openid read write", "redirect_uri": "http://example.org", "response_type": "code id_token token", "allow": True, "nonce": "nonce", } response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 302) self.assertIn("http://example.org", response["Location"]) self.assertIn("state=random_state_string", response["Location"]) self.assertIn("code=", response["Location"]) self.assertIn("id_token=", response["Location"]) self.assertIn("access_token=", response["Location"]) def test_id_token_code_post_auth_allow(self): """ Test authorization code is given for an allowed request with response_type: code """ self.client.login(username="hy_test_user", password="123456") form_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": "openid", "redirect_uri": "http://example.org", "response_type": "code id_token", "allow": True, "nonce": "nonce", } response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 302) self.assertIn("http://example.org", response["Location"]) self.assertIn("state=random_state_string", response["Location"]) self.assertIn("code=", response["Location"]) self.assertIn("id_token=", response["Location"]) def test_code_post_auth_deny(self): """ Test error when resource owner deny access """ self.client.login(username="hy_test_user", password="123456") form_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": "read write", "redirect_uri": "http://example.org", "response_type": "code", "allow": False, } response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 302) self.assertIn("error=access_denied", response["Location"]) def test_code_post_auth_bad_responsetype(self): """ Test authorization code is given for an allowed request with a response_type not supported """ self.client.login(username="hy_test_user", password="123456") form_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": "read write", "redirect_uri": "http://example.org", "response_type": "UNKNOWN", "allow": True, } response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 302) self.assertIn("http://example.org?error", response["Location"]) def test_code_post_auth_forbidden_redirect_uri(self): """ Test authorization code is given for an allowed request with a forbidden redirect_uri """ self.client.login(username="hy_test_user", password="123456") form_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": "read write", "redirect_uri": "http://forbidden.it", "response_type": "code", "allow": True, } response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 400) def test_code_post_auth_malicious_redirect_uri(self): """ Test validation of a malicious redirect_uri """ self.client.login(username="hy_test_user", password="123456") form_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": "read write", "redirect_uri": "/../", "response_type": "code", "allow": True, } response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 400) def test_code_post_auth_allow_custom_redirect_uri_scheme_code_token(self): """ Test authorization code is given for an allowed request with response_type: code using a non-standard, but allowed, redirect_uri scheme. """ self.client.login(username="hy_test_user", password="123456") form_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": "openid read write", "redirect_uri": "custom-scheme://example.com", "response_type": "code token", "allow": True, } response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 302) self.assertIn("custom-scheme://example.com", response["Location"]) self.assertIn("state=random_state_string", response["Location"]) self.assertIn("code=", response["Location"]) self.assertIn("access_token=", response["Location"]) def test_code_post_auth_allow_custom_redirect_uri_scheme_code_id_token(self): """ Test authorization code is given for an allowed request with response_type: code using a non-standard, but allowed, redirect_uri scheme. """ self.client.login(username="hy_test_user", password="123456") form_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": "openid read write", "redirect_uri": "custom-scheme://example.com", "response_type": "code id_token", "allow": True, "nonce": "nonce", } response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 302) self.assertIn("custom-scheme://example.com", response["Location"]) self.assertIn("state=random_state_string", response["Location"]) self.assertIn("code=", response["Location"]) self.assertIn("id_token=", response["Location"]) def test_code_post_auth_allow_custom_redirect_uri_scheme_code_id_token_token(self): """ Test authorization code is given for an allowed request with response_type: code using a non-standard, but allowed, redirect_uri scheme. """ self.client.login(username="hy_test_user", password="123456") form_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": "openid read write", "redirect_uri": "custom-scheme://example.com", "response_type": "code id_token token", "allow": True, "nonce": "nonce", } response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 302) self.assertIn("custom-scheme://example.com", response["Location"]) self.assertIn("state=random_state_string", response["Location"]) self.assertIn("code=", response["Location"]) self.assertIn("id_token=", response["Location"]) self.assertIn("access_token=", response["Location"]) def test_code_post_auth_deny_custom_redirect_uri_scheme(self): """ Test error when resource owner deny access using a non-standard, but allowed, redirect_uri scheme. """ self.client.login(username="hy_test_user", password="123456") form_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": "read write", "redirect_uri": "custom-scheme://example.com", "response_type": "code", "allow": False, } response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 302) self.assertIn("custom-scheme://example.com?", response["Location"]) self.assertIn("error=access_denied", response["Location"]) def test_code_post_auth_redirection_uri_with_querystring_code_token(self): """ Tests that a redirection uri with query string is allowed and query string is retained on redirection. See http://tools.ietf.org/html/rfc6749#section-3.1.2 """ self.client.login(username="hy_test_user", password="123456") form_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": "openid read write", "redirect_uri": "http://example.com?foo=bar", "response_type": "code token", "allow": True, } response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 302) self.assertIn("http://example.com?foo=bar", response["Location"]) self.assertIn("code=", response["Location"]) self.assertIn("access_token=", response["Location"]) def test_code_post_auth_redirection_uri_with_querystring_code_id_token(self): """ Tests that a redirection uri with query string is allowed and query string is retained on redirection. See http://tools.ietf.org/html/rfc6749#section-3.1.2 """ self.client.login(username="hy_test_user", password="123456") form_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": "openid read write", "redirect_uri": "http://example.com?foo=bar", "response_type": "code id_token", "allow": True, "nonce": "nonce", } response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 302) self.assertIn("http://example.com?foo=bar", response["Location"]) self.assertIn("code=", response["Location"]) self.assertIn("id_token=", response["Location"]) def test_code_post_auth_redirection_uri_with_querystring_code_id_token_token(self): """ Tests that a redirection uri with query string is allowed and query string is retained on redirection. See http://tools.ietf.org/html/rfc6749#section-3.1.2 """ self.client.login(username="hy_test_user", password="123456") form_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": "openid read write", "redirect_uri": "http://example.com?foo=bar", "response_type": "code id_token token", "allow": True, "nonce": "nonce", } response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 302) self.assertIn("http://example.com?foo=bar", response["Location"]) self.assertIn("code=", response["Location"]) self.assertIn("id_token=", response["Location"]) self.assertIn("access_token=", response["Location"]) def test_code_post_auth_failing_redirection_uri_with_querystring(self): """ Test that in case of error the querystring of the redirection uri is preserved See https://github.com/evonove/django-oauth-toolkit/issues/238 """ self.client.login(username="hy_test_user", password="123456") form_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": "read write", "redirect_uri": "http://example.com?foo=bar", "response_type": "code", "allow": False, } response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 302) self.assertEqual( "http://example.com?foo=bar&error=access_denied&state=random_state_string", response["Location"] ) def test_code_post_auth_fails_when_redirect_uri_path_is_invalid(self): """ Tests that a redirection uri is matched using scheme + netloc + path """ self.client.login(username="hy_test_user", password="123456") form_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": "read write", "redirect_uri": "http://example.com/a?foo=bar", "response_type": "code", "allow": True, } response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 400) @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) class TestHybridTokenView(BaseTest): def get_auth(self, scope="read write"): """ Helper method to retrieve a valid authorization code """ authcode_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": scope, "redirect_uri": "http://example.org", "response_type": "code id_token", "allow": True, "nonce": "nonce", } response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) fragment_dict = parse_qs(urlparse(response["Location"]).fragment) return fragment_dict["code"].pop() def test_basic_auth(self): """ Request an access token using basic authentication for client authentication """ self.client.login(username="hy_test_user", password="123456") authorization_code = self.get_auth() token_request_data = { "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "http://example.org", } auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") self.assertEqual(content["scope"], "read write") self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) def test_basic_auth_bad_authcode(self): """ Request an access token using a bad authorization code """ self.client.login(username="hy_test_user", password="123456") token_request_data = { "grant_type": "authorization_code", "code": "BLAH", "redirect_uri": "http://example.org", } auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 400) def test_basic_auth_bad_granttype(self): """ Request an access token using a bad grant_type string """ self.client.login(username="hy_test_user", password="123456") token_request_data = {"grant_type": "UNKNOWN", "code": "BLAH", "redirect_uri": "http://example.org"} auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 400) def test_basic_auth_grant_expired(self): """ Request an access token using an expired grant token """ self.client.login(username="hy_test_user", password="123456") g = Grant( application=self.application, user=self.hy_test_user, code="BLAH", expires=timezone.now(), redirect_uri="", scope="", ) g.save() token_request_data = { "grant_type": "authorization_code", "code": "BLAH", "redirect_uri": "http://example.org", } auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 400) def test_basic_auth_bad_secret(self): """ Request an access token using basic authentication for client authentication """ self.client.login(username="hy_test_user", password="123456") authorization_code = self.get_auth() token_request_data = { "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "http://example.org", } auth_headers = get_basic_auth_header(self.application.client_id, "BOOM!") response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 401) def test_basic_auth_wrong_auth_type(self): """ Request an access token using basic authentication for client authentication """ self.client.login(username="hy_test_user", password="123456") authorization_code = self.get_auth() token_request_data = { "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "http://example.org", } user_pass = "{0}:{1}".format(self.application.client_id, CLEARTEXT_SECRET) auth_string = base64.b64encode(user_pass.encode("utf-8")) auth_headers = { "HTTP_AUTHORIZATION": "Wrong " + auth_string.decode("utf-8"), } response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 401) def test_request_body_params(self): """ Request an access token using client_type: public """ self.client.login(username="hy_test_user", password="123456") authorization_code = self.get_auth() token_request_data = { "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "http://example.org", "client_id": self.application.client_id, "client_secret": CLEARTEXT_SECRET, } response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") self.assertEqual(content["scope"], "read write") self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) def test_public(self): """ Request an access token using client_type: public """ self.client.login(username="hy_test_user", password="123456") self.application.client_type = Application.CLIENT_PUBLIC self.application.save() authorization_code = self.get_auth() token_request_data = { "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "http://example.org", "client_id": self.application.client_id, } response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") self.assertEqual(content["scope"], "read write") self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) def test_id_token_public(self): """ Request an access token using client_type: public """ self.client.login(username="hy_test_user", password="123456") self.application.client_type = Application.CLIENT_PUBLIC self.application.save() authorization_code = self.get_auth(scope="openid") token_request_data = { "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "http://example.org", "client_id": self.application.client_id, "scope": "openid", } response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") self.assertEqual(content["scope"], "openid") self.assertIn("access_token", content) self.assertIn("id_token", content) self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) def test_malicious_redirect_uri(self): """ Request an access token using client_type: public and ensure redirect_uri is properly validated. """ self.client.login(username="hy_test_user", password="123456") self.application.client_type = Application.CLIENT_PUBLIC self.application.save() authorization_code = self.get_auth() token_request_data = { "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "/../", "client_id": self.application.client_id, } response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) self.assertEqual(response.status_code, 400) data = response.json() self.assertEqual(data["error"], "invalid_request") self.assertEqual(data["error_description"], oauthlib_errors.MismatchingRedirectURIError.description) def test_code_exchange_succeed_when_redirect_uri_match(self): """ Tests code exchange succeed when redirect uri matches the one used for code request """ self.client.login(username="hy_test_user", password="123456") # retrieve a valid authorization code authcode_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": "openid read write", "redirect_uri": "http://example.org?foo=bar", "response_type": "code token", "allow": True, } response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) fragment_dict = parse_qs(urlparse(response["Location"]).fragment) authorization_code = fragment_dict["code"].pop() # exchange authorization code for a valid access token token_request_data = { "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "http://example.org?foo=bar", } auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") self.assertEqual(content["scope"], "openid read write") self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) def test_code_exchange_fails_when_redirect_uri_does_not_match(self): """ Tests code exchange fails when redirect uri does not match the one used for code request """ self.client.login(username="hy_test_user", password="123456") # retrieve a valid authorization code authcode_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": "openid read write", "redirect_uri": "http://example.org?foo=bar", "response_type": "code token", "allow": True, } response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) query_dict = parse_qs(urlparse(response["Location"]).fragment) authorization_code = query_dict["code"].pop() # exchange authorization code for a valid access token token_request_data = { "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "http://example.org?foo=baraa", } auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 400) data = response.json() self.assertEqual(data["error"], "invalid_request") self.assertEqual(data["error_description"], oauthlib_errors.MismatchingRedirectURIError.description) def test_code_exchange_succeed_when_redirect_uri_match_with_multiple_query_params(self): """ Tests code exchange succeed when redirect uri matches the one used for code request """ self.client.login(username="hy_test_user", password="123456") self.application.redirect_uris = "http://localhost http://example.com?foo=bar" self.application.save() # retrieve a valid authorization code authcode_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": "openid read write", "redirect_uri": "http://example.com?bar=baz&foo=bar", "response_type": "code token", "allow": True, } response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) fragment_dict = parse_qs(urlparse(response["Location"]).fragment) authorization_code = fragment_dict["code"].pop() # exchange authorization code for a valid access token token_request_data = { "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "http://example.com?bar=baz&foo=bar", } auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") self.assertEqual(content["scope"], "openid read write") self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) def test_id_token_code_exchange_succeed_when_redirect_uri_match_with_multiple_query_params(self): """ Tests code exchange succeed when redirect uri matches the one used for code request """ self.client.login(username="hy_test_user", password="123456") self.application.redirect_uris = "http://localhost http://example.com?foo=bar" self.application.save() # retrieve a valid authorization code authcode_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": "openid", "redirect_uri": "http://example.com?bar=baz&foo=bar", "response_type": "code token", "allow": True, } response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) fragment_dict = parse_qs(urlparse(response["Location"]).fragment) authorization_code = fragment_dict["code"].pop() # exchange authorization code for a valid access token token_request_data = { "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "http://example.com?bar=baz&foo=bar", } auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") self.assertEqual(content["scope"], "openid") self.assertIn("access_token", content) self.assertIn("id_token", content) self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) class TestHybridProtectedResource(BaseTest): def test_resource_access_allowed(self): self.client.login(username="hy_test_user", password="123456") # retrieve a valid authorization code authcode_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": "openid read write", "redirect_uri": "http://example.org", "response_type": "code token", "allow": True, } response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) fragment_dict = parse_qs(urlparse(response["Location"]).fragment) authorization_code = fragment_dict["code"].pop() # exchange authorization code for a valid access token token_request_data = { "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "http://example.org", } auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) access_token = content["access_token"] # use token to access the resource auth_headers = { "HTTP_AUTHORIZATION": "Bearer " + access_token, } request = self.factory.get("/fake-resource", **auth_headers) request.user = self.hy_test_user view = ResourceView.as_view() response = view(request) self.assertEqual(response, "This is a protected resource") def test_id_token_resource_access_allowed(self): self.client.login(username="hy_test_user", password="123456") # retrieve a valid authorization code authcode_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": "openid", "redirect_uri": "http://example.org", "response_type": "code token", "allow": True, } response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) fragment_dict = parse_qs(urlparse(response["Location"]).fragment) authorization_code = fragment_dict["code"].pop() # exchange authorization code for a valid access token token_request_data = { "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "http://example.org", } auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) access_token = content["access_token"] id_token = content["id_token"] # use token to access the resource auth_headers = { "HTTP_AUTHORIZATION": "Bearer " + access_token, } request = self.factory.get("/fake-resource", **auth_headers) request.user = self.hy_test_user view = ResourceView.as_view() response = view(request) self.assertEqual(response, "This is a protected resource") # use id_token to access the resource auth_headers = { "HTTP_AUTHORIZATION": "Bearer " + id_token, } request = self.factory.get("/fake-resource", **auth_headers) request.user = self.hy_test_user view = ResourceView.as_view() response = view(request) self.assertEqual(response, "This is a protected resource") # If the resource requires more scopes than we requested, we should get an error view = ScopedResourceView.as_view() response = view(request) self.assertEqual(response.status_code, 403) def test_resource_access_deny(self): auth_headers = { "HTTP_AUTHORIZATION": "Bearer " + "faketoken", } request = self.factory.get("/fake-resource", **auth_headers) request.user = self.hy_test_user view = ResourceView.as_view() response = view(request) self.assertEqual(response.status_code, 403) @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RO) class TestDefaultScopesHybrid(BaseTest): def test_pre_auth_default_scopes(self): """ Test response for a valid client_id with response_type: code using default scopes """ self.client.login(username="hy_test_user", password="123456") query_string = urlencode( { "client_id": self.application.client_id, "response_type": "code token", "state": "random_state_string", "redirect_uri": "http://example.org", } ) url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) response = self.client.get(url) self.assertEqual(response.status_code, 200) # check form is in context and form params are valid self.assertIn("form", response.context) form = response.context["form"] self.assertEqual(form["redirect_uri"].value(), "http://example.org") self.assertEqual(form["state"].value(), "random_state_string") self.assertEqual(form["scope"].value(), "read") self.assertEqual(form["client_id"].value(), self.application.client_id) @pytest.mark.django_db @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) def test_id_token_nonce_in_token_response(oauth2_settings, test_user, hybrid_application, client, oidc_key): client.force_login(test_user) auth_rsp = client.post( reverse("oauth2_provider:authorize"), data={ "client_id": hybrid_application.client_id, "state": "random_state_string", "scope": "openid", "redirect_uri": "http://example.org", "response_type": "code id_token", "nonce": "random_nonce_string", "allow": True, }, ) assert auth_rsp.status_code == 302 auth_data = parse_qs(urlparse(auth_rsp["Location"]).fragment) assert "code" in auth_data assert "id_token" in auth_data # Decode the id token - is the nonce correct jwt_token = jwt.JWT(key=oidc_key, jwt=auth_data["id_token"][0]) claims = json.loads(jwt_token.claims) assert "nonce" in claims assert claims["nonce"] == "random_nonce_string" code = auth_data["code"][0] client.logout() # Get the token response using the code token_rsp = client.post( reverse("oauth2_provider:token"), data={ "grant_type": "authorization_code", "code": code, "redirect_uri": "http://example.org", "client_id": hybrid_application.client_id, "client_secret": CLEARTEXT_SECRET, "scope": "openid", }, ) assert token_rsp.status_code == 200 token_data = token_rsp.json() assert "id_token" in token_data # The nonce should be present in this id token also jwt_token = jwt.JWT(key=oidc_key, jwt=token_data["id_token"]) claims = json.loads(jwt_token.claims) assert "nonce" in claims assert claims["nonce"] == "random_nonce_string" @pytest.mark.django_db @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) def test_claims_passed_to_code_generation( oauth2_settings, test_user, hybrid_application, client, mocker, oidc_key ): # Add a spy on to OAuth2Validator.finalize_id_token mocker.patch.object( OAuth2Validator, "finalize_id_token", spy_on(OAuth2Validator.finalize_id_token), ) claims = {"id_token": {"email": {"essential": True}}} client.force_login(test_user) auth_form_rsp = client.get( reverse("oauth2_provider:authorize"), data={ "client_id": hybrid_application.client_id, "state": "random_state_string", "scope": "openid", "redirect_uri": "http://example.org", "response_type": "code id_token", "nonce": "random_nonce_string", "claims": json.dumps(claims), }, ) # Check that claims has made it in to the form to be submitted assert auth_form_rsp.status_code == 200 form_initial_data = auth_form_rsp.context_data["form"].initial assert "claims" in form_initial_data assert json.loads(form_initial_data["claims"]) == claims # Filter out not specified values form_data = {key: value for key, value in form_initial_data.items() if value is not None} # Now submitting the form (with allow=True) should persist requested claims auth_rsp = client.post( reverse("oauth2_provider:authorize"), data={"allow": True, **form_data}, ) assert auth_rsp.status_code == 302 auth_data = parse_qs(urlparse(auth_rsp["Location"]).fragment) assert "code" in auth_data assert "id_token" in auth_data assert OAuth2Validator.finalize_id_token.spy.call_count == 1 oauthlib_request = OAuth2Validator.finalize_id_token.spy.call_args[0][4] assert oauthlib_request.claims == claims assert Grant.objects.get().claims == json.dumps(claims) OAuth2Validator.finalize_id_token.spy.reset_mock() # Get the token response using the code client.logout() code = auth_data["code"][0] token_rsp = client.post( reverse("oauth2_provider:token"), data={ "grant_type": "authorization_code", "code": code, "redirect_uri": "http://example.org", "client_id": hybrid_application.client_id, "client_secret": CLEARTEXT_SECRET, "scope": "openid", }, ) assert token_rsp.status_code == 200 token_data = token_rsp.json() assert "id_token" in token_data assert OAuth2Validator.finalize_id_token.spy.call_count == 1 oauthlib_request = OAuth2Validator.finalize_id_token.spy.call_args[0][4] assert oauthlib_request.claims == claims django-oauth-toolkit-2.3.0/tests/test_implicit.py000066400000000000000000000437411443573112200221570ustar00rootroot00000000000000import json from urllib.parse import parse_qs, urlparse import pytest from django.contrib.auth import get_user_model from django.test import RequestFactory, TestCase from django.urls import reverse from jwcrypto import jwt from oauth2_provider.models import get_application_model from oauth2_provider.views import ProtectedResourceView from . import presets Application = get_application_model() UserModel = get_user_model() # mocking a protected resource view class ResourceView(ProtectedResourceView): def get(self, request, *args, **kwargs): return "This is a protected resource" @pytest.mark.usefixtures("oauth2_settings") class BaseTest(TestCase): def setUp(self): self.factory = RequestFactory() self.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") self.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") self.application = Application.objects.create( name="Test Implicit Application", redirect_uris="http://localhost http://example.com http://example.org", user=self.dev_user, client_type=Application.CLIENT_PUBLIC, authorization_grant_type=Application.GRANT_IMPLICIT, ) def tearDown(self): self.application.delete() self.test_user.delete() self.dev_user.delete() @pytest.mark.oauth2_settings(presets.DEFAULT_SCOPES_RO) class TestImplicitAuthorizationCodeView(BaseTest): def test_pre_auth_valid_client_default_scopes(self): """ Test response for a valid client_id with response_type: token and default_scopes """ self.client.login(username="test_user", password="123456") query_data = { "client_id": self.application.client_id, "response_type": "token", "state": "random_state_string", "redirect_uri": "http://example.org", } response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 200) self.assertIn("form", response.context) form = response.context["form"] self.assertEqual(form["scope"].value(), "read") def test_pre_auth_valid_client(self): """ Test response for a valid client_id with response_type: token """ self.client.login(username="test_user", password="123456") query_data = { "client_id": self.application.client_id, "response_type": "token", "state": "random_state_string", "scope": "read write", "redirect_uri": "http://example.org", } response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 200) # check form is in context and form params are valid self.assertIn("form", response.context) form = response.context["form"] self.assertEqual(form["redirect_uri"].value(), "http://example.org") self.assertEqual(form["state"].value(), "random_state_string") self.assertEqual(form["scope"].value(), "read write") self.assertEqual(form["client_id"].value(), self.application.client_id) def test_pre_auth_invalid_client(self): """ Test error for an invalid client_id with response_type: token """ self.client.login(username="test_user", password="123456") query_data = { "client_id": "fakeclientid", "response_type": "token", } response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 400) 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, "response_type": "token", } response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 200) form = response.context["form"] self.assertEqual(form["redirect_uri"].value(), "http://localhost") def test_pre_auth_forbibben_redirect(self): """ Test error when passing a forbidden redirect_uri in query string with response_type: token """ self.client.login(username="test_user", password="123456") query_data = { "client_id": self.application.client_id, "response_type": "token", "redirect_uri": "http://forbidden.it", } response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 400) def test_post_auth_allow(self): """ Test authorization code is given for an allowed request with response_type: token """ self.client.login(username="test_user", password="123456") form_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": "read write", "redirect_uri": "http://example.org", "response_type": "token", "allow": True, } response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 302) self.assertIn("http://example.org#", response["Location"]) self.assertIn("access_token=", response["Location"]) self.assertIn("state=random_state_string", response["Location"]) def test_skip_authorization_completely(self): """ If application.skip_authorization = True, should skip the authorization page. """ self.client.login(username="test_user", password="123456") self.application.skip_authorization = True self.application.save() query_data = { "client_id": self.application.client_id, "response_type": "token", "state": "random_state_string", "scope": "read write", "redirect_uri": "http://example.org", } response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 302) self.assertIn("http://example.org#", response["Location"]) self.assertIn("access_token=", response["Location"]) self.assertIn("state=random_state_string", response["Location"]) def test_token_post_auth_deny(self): """ Test error when resource owner deny access """ self.client.login(username="test_user", password="123456") form_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": "read write", "redirect_uri": "http://example.org", "response_type": "token", "allow": False, } response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 302) self.assertIn("error=access_denied", response["Location"]) def test_implicit_redirection_uri_with_querystring(self): """ Tests that a redirection uri with query string is allowed and query string is retained on redirection. See http://tools.ietf.org/html/rfc6749#section-3.1.2 """ self.client.login(username="test_user", password="123456") form_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": "read write", "redirect_uri": "http://example.com?foo=bar", "response_type": "token", "allow": True, } response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 302) self.assertIn("http://example.com?foo=bar", response["Location"]) self.assertIn("access_token=", response["Location"]) def test_implicit_fails_when_redirect_uri_path_is_invalid(self): """ Tests that a redirection uri is matched using scheme + netloc + path """ self.client.login(username="test_user", password="123456") form_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": "read write", "redirect_uri": "http://example.com/a?foo=bar", "response_type": "code", "allow": True, } response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 400) @pytest.mark.oauth2_settings(presets.DEFAULT_SCOPES_RO) class TestImplicitTokenView(BaseTest): def test_resource_access_allowed(self): self.client.login(username="test_user", password="123456") # retrieve a valid authorization code authcode_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": "read write", "redirect_uri": "http://example.org", "response_type": "token", "allow": True, } response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) # within implicit grant, access token is in the url fragment frag_dict = parse_qs(urlparse(response["Location"]).fragment) access_token = frag_dict["access_token"].pop() # use token to access the resource auth_headers = { "HTTP_AUTHORIZATION": "Bearer " + access_token, } request = self.factory.get("/fake-resource", **auth_headers) request.user = self.test_user view = ResourceView.as_view() response = view(request) self.assertEqual(response, "This is a protected resource") @pytest.mark.usefixtures("oidc_key") @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) class TestOpenIDConnectImplicitFlow(BaseTest): def setUp(self): super().setUp() self.application.algorithm = Application.RS256_ALGORITHM self.application.save() def test_id_token_post_auth_allow(self): """ Test authorization code is given for an allowed request with response_type: id_token """ self.client.login(username="test_user", password="123456") form_data = { "client_id": self.application.client_id, "state": "random_state_string", "nonce": "random_nonce_string", "scope": "openid", "redirect_uri": "http://example.org", "response_type": "id_token", "allow": True, } response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 302) self.assertIn("http://example.org#", response["Location"]) self.assertNotIn("access_token=", response["Location"]) self.assertIn("id_token=", response["Location"]) self.assertIn("state=random_state_string", response["Location"]) uri_query = urlparse(response["Location"]).fragment uri_query_params = dict(parse_qs(uri_query, keep_blank_values=True, strict_parsing=True)) id_token = uri_query_params["id_token"][0] jwt_token = jwt.JWT(key=self.key, jwt=id_token) claims = json.loads(jwt_token.claims) self.assertIn("nonce", claims) self.assertNotIn("at_hash", claims) def test_id_token_skip_authorization_completely(self): """ If application.skip_authorization = True, should skip the authorization page. """ self.client.login(username="test_user", password="123456") self.application.skip_authorization = True self.application.save() query_data = { "client_id": self.application.client_id, "response_type": "id_token", "state": "random_state_string", "nonce": "random_nonce_string", "scope": "openid", "redirect_uri": "http://example.org", } response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 302) self.assertIn("http://example.org#", response["Location"]) self.assertNotIn("access_token=", response["Location"]) self.assertIn("id_token=", response["Location"]) self.assertIn("state=random_state_string", response["Location"]) uri_query = urlparse(response["Location"]).fragment uri_query_params = dict(parse_qs(uri_query, keep_blank_values=True, strict_parsing=True)) id_token = uri_query_params["id_token"][0] jwt_token = jwt.JWT(key=self.key, jwt=id_token) claims = json.loads(jwt_token.claims) self.assertIn("nonce", claims) self.assertNotIn("at_hash", claims) def test_id_token_skip_authorization_completely_missing_nonce(self): """ If application.skip_authorization = True, should skip the authorization page. """ self.client.login(username="test_user", password="123456") self.application.skip_authorization = True self.application.save() query_data = { "client_id": self.application.client_id, "response_type": "id_token", "state": "random_state_string", "scope": "openid", "redirect_uri": "http://example.org", } response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 302) self.assertIn("error=invalid_request", response["Location"]) self.assertIn("error_description=Request+is+missing+mandatory+nonce+paramete", response["Location"]) def test_id_token_post_auth_deny(self): """ Test error when resource owner deny access """ self.client.login(username="test_user", password="123456") form_data = { "client_id": self.application.client_id, "state": "random_state_string", "nonce": "random_nonce_string", "scope": "openid", "redirect_uri": "http://example.org", "response_type": "id_token", "allow": False, } response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 302) self.assertIn("error=access_denied", response["Location"]) def test_access_token_and_id_token_post_auth_allow(self): """ Test authorization code is given for an allowed request with response_type: token """ self.client.login(username="test_user", password="123456") form_data = { "client_id": self.application.client_id, "state": "random_state_string", "nonce": "random_nonce_string", "scope": "openid", "redirect_uri": "http://example.org", "response_type": "id_token token", "allow": True, } response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 302) self.assertIn("http://example.org#", response["Location"]) self.assertIn("access_token=", response["Location"]) self.assertIn("id_token=", response["Location"]) self.assertIn("state=random_state_string", response["Location"]) uri_query = urlparse(response["Location"]).fragment uri_query_params = dict(parse_qs(uri_query, keep_blank_values=True, strict_parsing=True)) id_token = uri_query_params["id_token"][0] jwt_token = jwt.JWT(key=self.key, jwt=id_token) claims = json.loads(jwt_token.claims) self.assertIn("nonce", claims) self.assertIn("at_hash", claims) def test_access_token_and_id_token_skip_authorization_completely(self): """ If application.skip_authorization = True, should skip the authorization page. """ self.client.login(username="test_user", password="123456") self.application.skip_authorization = True self.application.save() query_data = { "client_id": self.application.client_id, "response_type": "id_token token", "state": "random_state_string", "nonce": "random_nonce_string", "scope": "openid", "redirect_uri": "http://example.org", } response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 302) self.assertIn("http://example.org#", response["Location"]) self.assertIn("access_token=", response["Location"]) self.assertIn("id_token=", response["Location"]) self.assertIn("state=random_state_string", response["Location"]) uri_query = urlparse(response["Location"]).fragment uri_query_params = dict(parse_qs(uri_query, keep_blank_values=True, strict_parsing=True)) id_token = uri_query_params["id_token"][0] jwt_token = jwt.JWT(key=self.key, jwt=id_token) claims = json.loads(jwt_token.claims) self.assertIn("nonce", claims) self.assertIn("at_hash", claims) def test_access_token_and_id_token_post_auth_deny(self): """ Test error when resource owner deny access """ self.client.login(username="test_user", password="123456") form_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": "openid", "redirect_uri": "http://example.org", "response_type": "id_token token", "allow": False, } response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 302) self.assertIn("error=access_denied", response["Location"]) django-oauth-toolkit-2.3.0/tests/test_introspection_auth.py000066400000000000000000000204041443573112200242550ustar00rootroot00000000000000import calendar 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 from django.test import TestCase, override_settings from django.urls import path from django.utils import timezone from oauthlib.common import Request 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 try: from unittest import mock except ImportError: import mock Application = get_application_model() AccessToken = get_access_token_model() UserModel = get_user_model() exp = datetime.datetime.now() + datetime.timedelta(days=1) class ScopeResourceView(ScopedProtectedResourceView): required_scopes = ["dolphin"] def get(self, request, *args, **kwargs): return HttpResponse("This is a protected resource", 200) def post(self, request, *args, **kwargs): return HttpResponse("This is a protected resource", 200) def mocked_requests_post(url, data, *args, **kwargs): """ Mock the response from the authentication server """ class MockResponse: def __init__(self, json_data, status_code): self.json_data = json_data self.status_code = status_code def json(self): return self.json_data if "token" in data and data["token"] and data["token"] != "12345678900": return MockResponse( { "active": True, "scope": "read write dolphin", "client_id": "client_id_{}".format(data["token"]), "username": "{}_user".format(data["token"]), "exp": int(calendar.timegm(exp.timetuple())), }, 200, ) return MockResponse( { "active": False, }, 200, ) urlpatterns = [ path("oauth2/", include("oauth2_provider.urls")), path("oauth2-test-resource/", ScopeResourceView.as_view()), ] @override_settings(ROOT_URLCONF=__name__) @pytest.mark.usefixtures("oauth2_settings") @pytest.mark.oauth2_settings(presets.INTROSPECTION_SETTINGS) class TestTokenIntrospectionAuth(TestCase): """ Tests for Authorization through token introspection """ def setUp(self): self.validator = OAuth2Validator() self.request = mock.MagicMock(wraps=Request) self.resource_server_user = UserModel.objects.create_user( "resource_server", "test@example.com", "123456" ) self.application = Application.objects.create( name="Test Application", redirect_uris="http://localhost http://example.com http://example.org", user=self.resource_server_user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, ) self.resource_server_token = AccessToken.objects.create( user=self.resource_server_user, token="12345678900", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), scope="introspection", ) self.invalid_token = AccessToken.objects.create( user=self.resource_server_user, token="12345678901", application=self.application, expires=timezone.now() + datetime.timedelta(days=-1), scope="read write dolphin", ) self.oauth2_settings.RESOURCE_SERVER_AUTH_TOKEN = self.resource_server_token.token def tearDown(self): self.resource_server_token.delete() self.application.delete() AccessToken.objects.all().delete() UserModel.objects.all().delete() @mock.patch("requests.post", side_effect=mocked_requests_post) def test_get_token_from_authentication_server_not_existing_token(self, mock_get): """ Test method _get_token_from_authentication_server with non existing token """ token = self.validator._get_token_from_authentication_server( self.resource_server_token.token, self.oauth2_settings.RESOURCE_SERVER_INTROSPECTION_URL, self.oauth2_settings.RESOURCE_SERVER_AUTH_TOKEN, self.oauth2_settings.RESOURCE_SERVER_INTROSPECTION_CREDENTIALS, ) self.assertIsNone(token) @mock.patch("requests.post", side_effect=mocked_requests_post) def test_get_token_from_authentication_server_existing_token(self, mock_get): """ Test method _get_token_from_authentication_server with existing token """ token = self.validator._get_token_from_authentication_server( "foo", self.oauth2_settings.RESOURCE_SERVER_INTROSPECTION_URL, self.oauth2_settings.RESOURCE_SERVER_AUTH_TOKEN, self.oauth2_settings.RESOURCE_SERVER_INTROSPECTION_CREDENTIALS, ) self.assertIsInstance(token, AccessToken) 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): """ Test method validate_bearer_token """ # with token = None self.assertFalse(self.validator.validate_bearer_token(None, ["dolphin"], self.request)) # with valid token and scope self.assertTrue( self.validator.validate_bearer_token( self.resource_server_token.token, ["introspection"], self.request ) ) # with initially invalid token, but validated through request self.assertTrue( self.validator.validate_bearer_token(self.invalid_token.token, ["dolphin"], self.request) ) # with locally unavailable token, but validated through request self.assertTrue(self.validator.validate_bearer_token("butzi", ["dolphin"], self.request)) # with valid token but invalid scope self.assertFalse(self.validator.validate_bearer_token("foo", ["kaudawelsch"], self.request)) # with token validated through request, but invalid scope self.assertFalse(self.validator.validate_bearer_token("butz", ["kaudawelsch"], self.request)) # with token validated through request and valid scope self.assertTrue(self.validator.validate_bearer_token("butzi", ["dolphin"], self.request)) @mock.patch("requests.post", side_effect=mocked_requests_post) def test_get_resource(self, mock_get): """ Test that we can access the resource with a get request and a remotely validated token """ auth_headers = { "HTTP_AUTHORIZATION": "Bearer bar", } response = self.client.get("/oauth2-test-resource/", **auth_headers) self.assertEqual(response.content.decode("utf-8"), "This is a protected resource") @mock.patch("requests.post", side_effect=mocked_requests_post) def test_post_resource(self, mock_get): """ Test that we can access the resource with a post request and a remotely validated token """ auth_headers = { "HTTP_AUTHORIZATION": "Bearer batz", } response = self.client.post("/oauth2-test-resource/", **auth_headers) self.assertEqual(response.content.decode("utf-8"), "This is a protected resource") django-oauth-toolkit-2.3.0/tests/test_introspection_view.py000066400000000000000000000305541443573112200242750ustar00rootroot00000000000000import calendar import datetime import pytest from django.contrib.auth import get_user_model from django.test import TestCase from django.urls import reverse from django.utils import timezone from oauth2_provider.models import get_access_token_model, get_application_model from . import presets from .utils import get_basic_auth_header Application = get_application_model() AccessToken = get_access_token_model() UserModel = get_user_model() CLEARTEXT_SECRET = "1234567890abcdefghijklmnopqrstuvwxyz" @pytest.mark.usefixtures("oauth2_settings") @pytest.mark.oauth2_settings(presets.INTROSPECTION_SETTINGS) class TestTokenIntrospectionViews(TestCase): """ Tests for Authorized Token Introspection Views """ def setUp(self): self.resource_server_user = UserModel.objects.create_user("resource_server", "test@example.com") self.test_user = UserModel.objects.create_user("bar_user", "dev@example.com") self.application = Application.objects.create( name="Test Application", redirect_uris="http://localhost http://example.com http://example.org", user=self.test_user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, client_secret=CLEARTEXT_SECRET, ) self.resource_server_token = AccessToken.objects.create( user=self.resource_server_user, token="12345678900", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), scope="introspection", ) self.valid_token = AccessToken.objects.create( user=self.test_user, token="12345678901", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), scope="read write dolphin", ) self.invalid_token = AccessToken.objects.create( user=self.test_user, token="12345678902", application=self.application, expires=timezone.now() + datetime.timedelta(days=-1), scope="read write dolphin", ) self.token_without_user = AccessToken.objects.create( user=None, token="12345678903", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), scope="read write dolphin", ) self.token_without_app = AccessToken.objects.create( user=self.test_user, token="12345678904", application=None, expires=timezone.now() + datetime.timedelta(days=1), scope="read write dolphin", ) def tearDown(self): AccessToken.objects.all().delete() Application.objects.all().delete() UserModel.objects.all().delete() def test_view_forbidden(self): """ Test that the view is restricted for logged-in users. """ response = self.client.get(reverse("oauth2_provider:introspect")) self.assertEqual(response.status_code, 403) def test_view_get_valid_token(self): """ Test that when you pass a valid token as URL parameter, a json with an active token state is provided """ auth_headers = { "HTTP_AUTHORIZATION": "Bearer " + self.resource_server_token.token, } response = self.client.get( reverse("oauth2_provider:introspect"), {"token": self.valid_token.token}, **auth_headers ) self.assertEqual(response.status_code, 200) content = response.json() self.assertIsInstance(content, dict) self.assertDictEqual( content, { "active": True, "scope": self.valid_token.scope, "client_id": self.valid_token.application.client_id, "username": self.valid_token.user.get_username(), "exp": int(calendar.timegm(self.valid_token.expires.timetuple())), }, ) def test_view_get_valid_token_without_user(self): """ Test that when you pass a valid token as URL parameter, a json with an active token state is provided """ auth_headers = { "HTTP_AUTHORIZATION": "Bearer " + self.resource_server_token.token, } response = self.client.get( reverse("oauth2_provider:introspect"), {"token": self.token_without_user.token}, **auth_headers ) self.assertEqual(response.status_code, 200) content = response.json() self.assertIsInstance(content, dict) self.assertDictEqual( content, { "active": True, "scope": self.token_without_user.scope, "client_id": self.token_without_user.application.client_id, "exp": int(calendar.timegm(self.token_without_user.expires.timetuple())), }, ) def test_view_get_valid_token_without_app(self): """ Test that when you pass a valid token as URL parameter, a json with an active token state is provided """ auth_headers = { "HTTP_AUTHORIZATION": "Bearer " + self.resource_server_token.token, } response = self.client.get( reverse("oauth2_provider:introspect"), {"token": self.token_without_app.token}, **auth_headers ) self.assertEqual(response.status_code, 200) content = response.json() self.assertIsInstance(content, dict) self.assertDictEqual( content, { "active": True, "scope": self.token_without_app.scope, "username": self.token_without_app.user.get_username(), "exp": int(calendar.timegm(self.token_without_app.expires.timetuple())), }, ) def test_view_get_invalid_token(self): """ Test that when you pass an invalid token as URL parameter, a json with an inactive token state is provided """ auth_headers = { "HTTP_AUTHORIZATION": "Bearer " + self.resource_server_token.token, } response = self.client.get( reverse("oauth2_provider:introspect"), {"token": self.invalid_token.token}, **auth_headers ) self.assertEqual(response.status_code, 200) content = response.json() self.assertIsInstance(content, dict) self.assertDictEqual( content, { "active": False, }, ) def test_view_get_notexisting_token(self): """ Test that when you pass an non existing token as URL parameter, a json with an inactive token state is provided """ auth_headers = { "HTTP_AUTHORIZATION": "Bearer " + self.resource_server_token.token, } response = self.client.get( reverse("oauth2_provider:introspect"), {"token": "kaudawelsch"}, **auth_headers ) self.assertEqual(response.status_code, 200) content = response.json() self.assertIsInstance(content, dict) self.assertDictEqual( content, { "active": False, }, ) def test_view_post_valid_token(self): """ Test that when you pass a valid token as form parameter, a json with an active token state is provided """ auth_headers = { "HTTP_AUTHORIZATION": "Bearer " + self.resource_server_token.token, } response = self.client.post( reverse("oauth2_provider:introspect"), {"token": self.valid_token.token}, **auth_headers ) self.assertEqual(response.status_code, 200) content = response.json() self.assertIsInstance(content, dict) self.assertDictEqual( content, { "active": True, "scope": self.valid_token.scope, "client_id": self.valid_token.application.client_id, "username": self.valid_token.user.get_username(), "exp": int(calendar.timegm(self.valid_token.expires.timetuple())), }, ) def test_view_post_invalid_token(self): """ Test that when you pass an invalid token as form parameter, a json with an inactive token state is provided """ auth_headers = { "HTTP_AUTHORIZATION": "Bearer " + self.resource_server_token.token, } response = self.client.post( reverse("oauth2_provider:introspect"), {"token": self.invalid_token.token}, **auth_headers ) self.assertEqual(response.status_code, 200) content = response.json() self.assertIsInstance(content, dict) self.assertDictEqual( content, { "active": False, }, ) def test_view_post_notexisting_token(self): """ Test that when you pass an non existing token as form parameter, a json with an inactive token state is provided """ auth_headers = { "HTTP_AUTHORIZATION": "Bearer " + self.resource_server_token.token, } response = self.client.post( reverse("oauth2_provider:introspect"), {"token": "kaudawelsch"}, **auth_headers ) self.assertEqual(response.status_code, 200) content = response.json() self.assertIsInstance(content, dict) self.assertDictEqual( content, { "active": False, }, ) def test_view_post_valid_client_creds_basic_auth(self): """Test HTTP basic auth working""" auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post( reverse("oauth2_provider:introspect"), {"token": self.valid_token.token}, **auth_headers ) self.assertEqual(response.status_code, 200) content = response.json() self.assertIsInstance(content, dict) self.assertDictEqual( content, { "active": True, "scope": self.valid_token.scope, "client_id": self.valid_token.application.client_id, "username": self.valid_token.user.get_username(), "exp": int(calendar.timegm(self.valid_token.expires.timetuple())), }, ) def test_view_post_invalid_client_creds_basic_auth(self): """Must fail for invalid client credentials""" auth_headers = get_basic_auth_header(self.application.client_id, f"{CLEARTEXT_SECRET}_so_wrong") response = self.client.post( reverse("oauth2_provider:introspect"), {"token": self.valid_token.token}, **auth_headers ) self.assertEqual(response.status_code, 403) def test_view_post_valid_client_creds_plaintext(self): """Test introspecting with credentials in request body""" response = self.client.post( reverse("oauth2_provider:introspect"), { "token": self.valid_token.token, "client_id": self.application.client_id, "client_secret": CLEARTEXT_SECRET, }, ) self.assertEqual(response.status_code, 200) content = response.json() self.assertIsInstance(content, dict) self.assertDictEqual( content, { "active": True, "scope": self.valid_token.scope, "client_id": self.valid_token.application.client_id, "username": self.valid_token.user.get_username(), "exp": int(calendar.timegm(self.valid_token.expires.timetuple())), }, ) def test_view_post_invalid_client_creds_plaintext(self): """Must fail for invalid creds in request body.""" response = self.client.post( reverse("oauth2_provider:introspect"), { "token": self.valid_token.token, "client_id": self.application.client_id, "client_secret": f"{CLEARTEXT_SECRET}_so_wrong", }, ) self.assertEqual(response.status_code, 403) def test_select_related_in_view_for_less_db_queries(self): with self.assertNumQueries(1): self.client.post(reverse("oauth2_provider:introspect")) django-oauth-toolkit-2.3.0/tests/test_mixins.py000066400000000000000000000153701443573112200216510ustar00rootroot00000000000000import logging import pytest from django.core.exceptions import ImproperlyConfigured from django.http import HttpResponse from django.test import RequestFactory, TestCase from django.views.generic import View from oauthlib.oauth2 import Server from oauth2_provider.oauth2_backends import OAuthLibCore from oauth2_provider.oauth2_validators import OAuth2Validator from oauth2_provider.views.mixins import ( OAuthLibMixin, OIDCLogoutOnlyMixin, OIDCOnlyMixin, ProtectedResourceMixin, ScopedResourceMixin, ) from . import presets @pytest.mark.usefixtures("oauth2_settings") class BaseTest(TestCase): @classmethod def setUpClass(cls): cls.request_factory = RequestFactory() super().setUpClass() class TestOAuthLibMixin(BaseTest): def test_missing_oauthlib_backend_class_uses_fallback(self): class CustomOauthLibBackend: def __init__(self, *args, **kwargs): pass self.oauth2_settings.OAUTH2_BACKEND_CLASS = CustomOauthLibBackend class TestView(OAuthLibMixin, View): server_class = Server validator_class = OAuth2Validator test_view = TestView() self.assertEqual(CustomOauthLibBackend, test_view.get_oauthlib_backend_class()) core = test_view.get_oauthlib_core() self.assertTrue(isinstance(core, CustomOauthLibBackend)) def test_missing_server_class_uses_fallback(self): class CustomServer: def __init__(self, *args, **kwargs): pass self.oauth2_settings.OAUTH2_SERVER_CLASS = CustomServer class TestView(OAuthLibMixin, View): validator_class = OAuth2Validator oauthlib_backend_class = OAuthLibCore test_view = TestView() self.assertEqual(CustomServer, test_view.get_server_class()) core = test_view.get_oauthlib_core() self.assertTrue(isinstance(core.server, CustomServer)) def test_missing_validator_class_uses_fallback(self): class CustomValidator: pass self.oauth2_settings.OAUTH2_VALIDATOR_CLASS = CustomValidator class TestView(OAuthLibMixin, View): server_class = Server oauthlib_backend_class = OAuthLibCore test_view = TestView() self.assertEqual(CustomValidator, test_view.get_validator_class()) core = test_view.get_oauthlib_core() self.assertTrue(isinstance(core.server.request_validator, CustomValidator)) def test_correct_server(self): class TestView(OAuthLibMixin, View): server_class = Server validator_class = OAuth2Validator oauthlib_backend_class = OAuthLibCore request = self.request_factory.get("/fake-req") request.user = "fake" test_view = TestView() self.assertIsInstance(test_view.get_server(), Server) def test_custom_backend(self): class AnotherOauthLibBackend: pass class TestView(OAuthLibMixin, View): server_class = Server validator_class = OAuth2Validator oauthlib_backend_class = AnotherOauthLibBackend request = self.request_factory.get("/fake-req") request.user = "fake" test_view = TestView() self.assertEqual(test_view.get_oauthlib_backend_class(), AnotherOauthLibBackend) class TestScopedResourceMixin(BaseTest): def test_missing_required_scopes(self): class TestView(ScopedResourceMixin, View): pass test_view = TestView() self.assertRaises(ImproperlyConfigured, test_view.get_scopes) def test_correct_required_scopes(self): class TestView(ScopedResourceMixin, View): required_scopes = ["scope1", "scope2"] test_view = TestView() self.assertEqual(test_view.get_scopes(), ["scope1", "scope2"]) class TestProtectedResourceMixin(BaseTest): def test_options_shall_pass(self): class TestView(ProtectedResourceMixin, View): server_class = Server validator_class = OAuth2Validator request = self.request_factory.options("/fake-req") view = TestView.as_view() response = view(request) self.assertEqual(response.status_code, 200) @pytest.fixture def oidc_only_view(): class TView(OIDCOnlyMixin, View): def get(self, *args, **kwargs): return HttpResponse("OK") return TView.as_view() @pytest.fixture def oidc_logout_only_view(): class TView(OIDCLogoutOnlyMixin, View): def get(self, *args, **kwargs): return HttpResponse("OK") return TView.as_view() @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) def test_oidc_only_mixin_oidc_enabled(oauth2_settings, rf, oidc_only_view): assert oauth2_settings.OIDC_ENABLED rsp = oidc_only_view(rf.get("/")) assert rsp.status_code == 200 assert rsp.content.decode("utf-8") == "OK" @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RP_LOGOUT) def test_oidc_logout_only_mixin_oidc_enabled(oauth2_settings, rf, oidc_only_view): assert oauth2_settings.OIDC_RP_INITIATED_LOGOUT_ENABLED rsp = oidc_only_view(rf.get("/")) assert rsp.status_code == 200 assert rsp.content.decode("utf-8") == "OK" def test_oidc_only_mixin_oidc_disabled_debug(oauth2_settings, rf, settings, oidc_only_view): assert oauth2_settings.OIDC_ENABLED is False settings.DEBUG = True with pytest.raises(ImproperlyConfigured) as exc: oidc_only_view(rf.get("/")) assert "OIDC views are not enabled" in str(exc.value) def test_oidc_logout_only_mixin_oidc_disabled_debug(oauth2_settings, rf, settings, oidc_logout_only_view): assert oauth2_settings.OIDC_RP_INITIATED_LOGOUT_ENABLED is False settings.DEBUG = True with pytest.raises(ImproperlyConfigured) as exc: oidc_logout_only_view(rf.get("/")) assert str(exc.value) == OIDCLogoutOnlyMixin.debug_error_message def test_oidc_only_mixin_oidc_disabled_no_debug(oauth2_settings, rf, settings, oidc_only_view, caplog): assert oauth2_settings.OIDC_ENABLED is False settings.DEBUG = False with caplog.at_level(logging.WARNING, logger="oauth2_provider"): rsp = oidc_only_view(rf.get("/")) assert rsp.status_code == 404 assert len(caplog.records) == 1 assert "OIDC views are not enabled" in caplog.records[0].message def test_oidc_logout_only_mixin_oidc_disabled_no_debug( oauth2_settings, rf, settings, oidc_logout_only_view, caplog ): assert oauth2_settings.OIDC_RP_INITIATED_LOGOUT_ENABLED is False settings.DEBUG = False with caplog.at_level(logging.WARNING, logger="oauth2_provider"): rsp = oidc_logout_only_view(rf.get("/")) assert rsp.status_code == 404 assert len(caplog.records) == 1 assert caplog.records[0].message == OIDCLogoutOnlyMixin.debug_error_message django-oauth-toolkit-2.3.0/tests/test_models.py000066400000000000000000000525301443573112200216240ustar00rootroot00000000000000from datetime import timedelta import pytest from django.contrib.auth import get_user_model from django.core.exceptions import ImproperlyConfigured, ValidationError from django.test import TestCase from django.test.utils import override_settings from django.utils import timezone from oauth2_provider.models import ( clear_expired, get_access_token_model, get_application_model, get_grant_model, get_id_token_model, get_refresh_token_model, ) from . import presets Application = get_application_model() Grant = get_grant_model() AccessToken = get_access_token_model() RefreshToken = get_refresh_token_model() UserModel = get_user_model() IDToken = get_id_token_model() class BaseTestModels(TestCase): def setUp(self): self.user = UserModel.objects.create_user("test_user", "test@example.com", "123456") def tearDown(self): self.user.delete() class TestModels(BaseTestModels): def test_allow_scopes(self): self.client.login(username="test_user", password="123456") app = Application.objects.create( name="test_app", redirect_uris="http://localhost http://example.com http://example.org", user=self.user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, ) access_token = AccessToken(user=self.user, scope="read write", expires=0, token="", application=app) self.assertTrue(access_token.allow_scopes(["read", "write"])) self.assertTrue(access_token.allow_scopes(["write", "read"])) self.assertTrue(access_token.allow_scopes(["write", "read", "read"])) self.assertTrue(access_token.allow_scopes([])) self.assertFalse(access_token.allow_scopes(["write", "destroy"])) def test_grant_authorization_code_redirect_uris(self): app = Application( name="test_app", redirect_uris="", user=self.user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, ) self.assertRaises(ValidationError, app.full_clean) def test_grant_implicit_redirect_uris(self): app = Application( name="test_app", redirect_uris="", user=self.user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_IMPLICIT, ) self.assertRaises(ValidationError, app.full_clean) def test_str(self): app = Application( redirect_uris="", user=self.user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_IMPLICIT, ) self.assertEqual("%s" % app, app.client_id) app.name = "test_app" self.assertEqual("%s" % app, "test_app") def test_scopes_property(self): self.client.login(username="test_user", password="123456") app = Application.objects.create( name="test_app", redirect_uris="http://localhost http://example.com http://example.org", user=self.user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, ) access_token = AccessToken(user=self.user, scope="read write", expires=0, token="", application=app) access_token2 = AccessToken(user=self.user, scope="write", expires=0, token="", application=app) self.assertEqual(access_token.scopes, {"read": "Reading scope", "write": "Writing scope"}) self.assertEqual(access_token2.scopes, {"write": "Writing scope"}) @override_settings( OAUTH2_PROVIDER_APPLICATION_MODEL="tests.SampleApplication", OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL="tests.SampleAccessToken", OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL="tests.SampleRefreshToken", OAUTH2_PROVIDER_GRANT_MODEL="tests.SampleGrant", ) @pytest.mark.usefixtures("oauth2_settings") class TestCustomModels(BaseTestModels): def test_custom_application_model(self): """ If a custom application model is installed, it should be present in the related objects and not the swapped out one. See issue #90 (https://github.com/jazzband/django-oauth-toolkit/issues/90) """ related_object_names = [ f.name for f in UserModel._meta.get_fields() if (f.one_to_many or f.one_to_one) and f.auto_created and not f.concrete ] self.assertNotIn("oauth2_provider:application", related_object_names) self.assertIn("tests_sampleapplication", related_object_names) def test_custom_application_model_incorrect_format(self): # Patch oauth2 settings to use a custom Application model self.oauth2_settings.APPLICATION_MODEL = "IncorrectApplicationFormat" self.assertRaises(ValueError, get_application_model) def test_custom_application_model_not_installed(self): # Patch oauth2 settings to use a custom Application model self.oauth2_settings.APPLICATION_MODEL = "tests.ApplicationNotInstalled" self.assertRaises(LookupError, get_application_model) def test_custom_access_token_model(self): """ If a custom access token model is installed, it should be present in the related objects and not the swapped out one. """ # Django internals caches the related objects. related_object_names = [ f.name for f in UserModel._meta.get_fields() if (f.one_to_many or f.one_to_one) and f.auto_created and not f.concrete ] self.assertNotIn("oauth2_provider:access_token", related_object_names) self.assertIn("tests_sampleaccesstoken", related_object_names) def test_custom_access_token_model_incorrect_format(self): # Patch oauth2 settings to use a custom AccessToken model self.oauth2_settings.ACCESS_TOKEN_MODEL = "IncorrectAccessTokenFormat" self.assertRaises(ValueError, get_access_token_model) def test_custom_access_token_model_not_installed(self): # Patch oauth2 settings to use a custom AccessToken model self.oauth2_settings.ACCESS_TOKEN_MODEL = "tests.AccessTokenNotInstalled" self.assertRaises(LookupError, get_access_token_model) def test_custom_refresh_token_model(self): """ If a custom refresh token model is installed, it should be present in the related objects and not the swapped out one. """ # Django internals caches the related objects. related_object_names = [ f.name for f in UserModel._meta.get_fields() if (f.one_to_many or f.one_to_one) and f.auto_created and not f.concrete ] self.assertNotIn("oauth2_provider:refresh_token", related_object_names) self.assertIn("tests_samplerefreshtoken", related_object_names) def test_custom_refresh_token_model_incorrect_format(self): # Patch oauth2 settings to use a custom RefreshToken model self.oauth2_settings.REFRESH_TOKEN_MODEL = "IncorrectRefreshTokenFormat" self.assertRaises(ValueError, get_refresh_token_model) def test_custom_refresh_token_model_not_installed(self): # Patch oauth2 settings to use a custom AccessToken model self.oauth2_settings.REFRESH_TOKEN_MODEL = "tests.RefreshTokenNotInstalled" self.assertRaises(LookupError, get_refresh_token_model) def test_custom_grant_model(self): """ If a custom grant model is installed, it should be present in the related objects and not the swapped out one. """ # Django internals caches the related objects. related_object_names = [ f.name for f in UserModel._meta.get_fields() if (f.one_to_many or f.one_to_one) and f.auto_created and not f.concrete ] self.assertNotIn("oauth2_provider:grant", related_object_names) self.assertIn("tests_samplegrant", related_object_names) def test_custom_grant_model_incorrect_format(self): # Patch oauth2 settings to use a custom Grant model self.oauth2_settings.GRANT_MODEL = "IncorrectGrantFormat" self.assertRaises(ValueError, get_grant_model) def test_custom_grant_model_not_installed(self): # Patch oauth2 settings to use a custom AccessToken model self.oauth2_settings.GRANT_MODEL = "tests.GrantNotInstalled" self.assertRaises(LookupError, get_grant_model) class TestGrantModel(BaseTestModels): def setUp(self): super().setUp() self.application = Application.objects.create( name="Test Application", redirect_uris="", user=self.user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, ) def tearDown(self): self.application.delete() super().tearDown() def test_str(self): grant = Grant(code="test_code") self.assertEqual("%s" % grant, grant.code) def test_expires_can_be_none(self): grant = Grant(code="test_code") self.assertIsNone(grant.expires) self.assertTrue(grant.is_expired()) def test_redirect_uri_can_be_longer_than_255_chars(self): long_redirect_uri = "http://example.com/{}".format("authorized/" * 25) self.assertTrue(len(long_redirect_uri) > 255) grant = Grant.objects.create( user=self.user, code="test_code", application=self.application, expires=timezone.now(), redirect_uri=long_redirect_uri, scope="", ) grant.refresh_from_db() # It would be necessary to run test using another DB engine than sqlite # that transform varchar(255) into text data type. # https://sqlite.org/datatype3.html#affinity_name_examples self.assertEqual(grant.redirect_uri, long_redirect_uri) class TestAccessTokenModel(BaseTestModels): def test_str(self): access_token = AccessToken(token="test_token") self.assertEqual("%s" % access_token, access_token.token) def test_user_can_be_none(self): app = Application.objects.create( name="test_app", redirect_uris="http://localhost http://example.com http://example.org", user=self.user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, ) access_token = AccessToken.objects.create(token="test_token", application=app, expires=timezone.now()) self.assertIsNone(access_token.user) def test_expires_can_be_none(self): access_token = AccessToken(token="test_token") self.assertIsNone(access_token.expires) self.assertTrue(access_token.is_expired()) class TestRefreshTokenModel(BaseTestModels): def test_str(self): refresh_token = RefreshToken(token="test_token") self.assertEqual("%s" % refresh_token, refresh_token.token) @pytest.mark.usefixtures("oauth2_settings") class TestClearExpired(BaseTestModels): def setUp(self): super().setUp() # Insert many tokens, both expired and not, and grants. self.num_tokens = 100 self.delta_secs = 1000 self.now = timezone.now() self.earlier = self.now - timedelta(seconds=self.delta_secs) self.later = self.now + timedelta(seconds=self.delta_secs) app = Application.objects.create( name="test_app", redirect_uris="http://localhost http://example.com http://example.org", user=self.user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, ) # make 200 access tokens, half current and half expired. expired_access_tokens = [ AccessToken(token="expired AccessToken {}".format(i), expires=self.earlier) for i in range(self.num_tokens) ] for a in expired_access_tokens: a.save() current_access_tokens = [ AccessToken(token=f"current AccessToken {i}", expires=self.later) for i in range(self.num_tokens) ] for a in current_access_tokens: a.save() # Give the first half of the access tokens a refresh token, # alternating between current and expired ones. for i in range(0, len(expired_access_tokens) // 2, 2): RefreshToken( token=f"expired AT's refresh token {i}", application=app, access_token=expired_access_tokens[i], user=self.user, ).save() for i in range(1, len(current_access_tokens) // 2, 2): RefreshToken( token=f"current AT's refresh token {i}", application=app, access_token=current_access_tokens[i], user=self.user, ).save() # Make some grants, half of which are expired. for i in range(self.num_tokens): Grant( user=self.user, code=f"old grant code {i}", application=app, expires=self.earlier, redirect_uri="https://localhost/redirect", ).save() for i in range(self.num_tokens): Grant( user=self.user, code=f"new grant code {i}", application=app, expires=self.later, redirect_uri="https://localhost/redirect", ).save() def test_clear_expired_tokens_incorect_timetype(self): self.oauth2_settings.REFRESH_TOKEN_EXPIRE_SECONDS = "A" with pytest.raises(ImproperlyConfigured) as excinfo: clear_expired() result = excinfo.value.__class__.__name__ assert result == "ImproperlyConfigured" def test_clear_expired_tokens_with_tokens(self): self.oauth2_settings.CLEAR_EXPIRED_TOKENS_BATCH_SIZE = 10 self.oauth2_settings.CLEAR_EXPIRED_TOKENS_BATCH_INTERVAL = 0.0 self.oauth2_settings.REFRESH_TOKEN_EXPIRE_SECONDS = self.delta_secs // 2 # before clear_expired(), confirm setup as expected initial_at_count = AccessToken.objects.count() assert initial_at_count == 2 * self.num_tokens, f"{2 * self.num_tokens} access tokens should exist." initial_expired_at_count = AccessToken.objects.filter(expires__lte=self.now).count() assert ( initial_expired_at_count == self.num_tokens ), f"{self.num_tokens} expired access tokens should exist." initial_current_at_count = AccessToken.objects.filter(expires__gt=self.now).count() assert ( initial_current_at_count == self.num_tokens ), f"{self.num_tokens} current access tokens should exist." initial_rt_count = RefreshToken.objects.count() assert ( initial_rt_count == self.num_tokens // 2 ), f"{self.num_tokens // 2} refresh tokens should exist." initial_rt_expired_at_count = RefreshToken.objects.filter(access_token__expires__lte=self.now).count() assert ( initial_rt_expired_at_count == initial_rt_count / 2 ), "half the refresh tokens should be for expired access tokens." initial_rt_current_at_count = RefreshToken.objects.filter(access_token__expires__gt=self.now).count() assert ( initial_rt_current_at_count == initial_rt_count / 2 ), "half the refresh tokens should be for current access tokens." initial_gt_count = Grant.objects.count() assert initial_gt_count == self.num_tokens * 2, f"{self.num_tokens * 2} grants should exist." clear_expired() # after clear_expired(): remaining_at_count = AccessToken.objects.count() assert ( remaining_at_count == initial_at_count // 2 ), "half the initial access tokens should still exist." remaining_expired_at_count = AccessToken.objects.filter(expires__lte=self.now).count() assert remaining_expired_at_count == 0, "no remaining expired access tokens should still exist." remaining_current_at_count = AccessToken.objects.filter(expires__gt=self.now).count() assert ( remaining_current_at_count == initial_current_at_count ), "all current access tokens should still exist." remaining_rt_count = RefreshToken.objects.count() assert remaining_rt_count == initial_rt_count // 2, "half the refresh tokens should still exist." remaining_rt_expired_at_count = RefreshToken.objects.filter( access_token__expires__lte=self.now ).count() assert remaining_rt_expired_at_count == 0, "no refresh tokens for expired AT's should still exist." remaining_rt_current_at_count = RefreshToken.objects.filter( access_token__expires__gt=self.now ).count() assert ( remaining_rt_current_at_count == initial_rt_current_at_count ), "all the refresh tokens for current access tokens should still exist." remaining_gt_count = Grant.objects.count() assert remaining_gt_count == initial_gt_count // 2, "half the remaining grants should still exist." @pytest.mark.django_db @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) def test_id_token_methods(oidc_tokens, rf): id_token = IDToken.objects.get() # Token was just created, so should be valid assert id_token.is_valid() # if expires is None, it should always be expired # the column is NOT NULL, but could be NULL in sub-classes id_token.expires = None assert id_token.is_expired() # if no scopes are passed, they should be valid assert id_token.allow_scopes(None) # if the requested scopes are in the token, they should be valid assert id_token.allow_scopes(["openid"]) # if the requested scopes are not in the token, they should not be valid assert id_token.allow_scopes(["fizzbuzz"]) is False # we should be able to get a list of the scopes on the token assert id_token.scopes == {"openid": "OpenID connect"} # the id token should stringify as the JWT token id_token_str = str(id_token) assert str(id_token.jti) in id_token_str assert id_token_str.endswith(str(id_token.user_id)) # revoking the token should delete it id_token.revoke() assert IDToken.objects.filter(jti=id_token.jti).count() == 0 @pytest.mark.django_db @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) def test_clear_expired_id_tokens(oauth2_settings, oidc_tokens, rf): id_token = IDToken.objects.get() access_token = id_token.access_token # All tokens still valid clear_expired() assert IDToken.objects.filter(jti=id_token.jti).exists() earlier = timezone.now() - timedelta(minutes=1) id_token.expires = earlier id_token.save() # ID token should be preserved until the access token is deleted clear_expired() assert IDToken.objects.filter(jti=id_token.jti).exists() access_token.expires = earlier access_token.save() # ID and access tokens are expired but refresh token is still valid clear_expired() assert IDToken.objects.filter(jti=id_token.jti).exists() # Mark refresh token as expired delta = timedelta(seconds=oauth2_settings.REFRESH_TOKEN_EXPIRE_SECONDS + 60) access_token.expires = timezone.now() - delta access_token.save() # With the refresh token expired, the ID token should be deleted clear_expired() assert not IDToken.objects.filter(jti=id_token.jti).exists() @pytest.mark.django_db @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) def test_application_key(oauth2_settings, application): # RS256 key key = application.jwk_key assert key.key_type == "RSA" # RS256 key, but not configured oauth2_settings.OIDC_RSA_PRIVATE_KEY = None with pytest.raises(ImproperlyConfigured) as exc: application.jwk_key assert "You must set OIDC_RSA_PRIVATE_KEY" in str(exc.value) # HS256 key application.algorithm = Application.HS256_ALGORITHM key = application.jwk_key assert key.key_type == "oct" # No algorithm application.algorithm = Application.NO_ALGORITHM with pytest.raises(ImproperlyConfigured) as exc: application.jwk_key assert "This application does not support signed tokens" == str(exc.value) @pytest.mark.django_db @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) def test_application_clean(oauth2_settings, application): # RS256, RSA key is configured application.clean() # RS256, RSA key is not configured oauth2_settings.OIDC_RSA_PRIVATE_KEY = None with pytest.raises(ValidationError) as exc: application.clean() assert "You must set OIDC_RSA_PRIVATE_KEY" in str(exc.value) # HS256 algorithm, auth code + confidential -> allowed application.algorithm = Application.HS256_ALGORITHM application.clean() # HS256, auth code + public -> forbidden application.client_type = Application.CLIENT_PUBLIC with pytest.raises(ValidationError) as exc: application.clean() assert "You cannot use HS256" in str(exc.value) # HS256, hybrid + confidential -> forbidden application.client_type = Application.CLIENT_CONFIDENTIAL application.authorization_grant_type = Application.GRANT_OPENID_HYBRID with pytest.raises(ValidationError) as exc: application.clean() assert "You cannot use HS256" in str(exc.value) django-oauth-toolkit-2.3.0/tests/test_oauth2_backends.py000066400000000000000000000203601443573112200233710ustar00rootroot00000000000000import base64 import json import pytest from django.contrib.auth import get_user_model from django.test import RequestFactory, TestCase from django.utils.timezone import now, timedelta from oauth2_provider.backends import get_oauthlib_core from oauth2_provider.models import get_access_token_model, get_application_model, redirect_to_uri_allowed from oauth2_provider.oauth2_backends import JSONOAuthLibCore, OAuthLibCore try: from unittest import mock except ImportError: import mock @pytest.mark.usefixtures("oauth2_settings") class TestOAuthLibCoreBackend(TestCase): def setUp(self): self.factory = RequestFactory() self.oauthlib_core = OAuthLibCore() def test_swappable_server_class(self): self.oauth2_settings.OAUTH2_SERVER_CLASS = mock.MagicMock oauthlib_core = OAuthLibCore() self.assertTrue(isinstance(oauthlib_core.server, mock.MagicMock)) def test_form_urlencoded_extract_params(self): payload = "grant_type=password&username=john&password=123456" request = self.factory.post("/o/token/", payload, content_type="application/x-www-form-urlencoded") uri, http_method, body, headers = self.oauthlib_core._extract_params(request) self.assertIn("grant_type=password", body) self.assertIn("username=john", body) self.assertIn("password=123456", body) def test_application_json_extract_params(self): payload = json.dumps( { "grant_type": "password", "username": "john", "password": "123456", } ) request = self.factory.post("/o/token/", payload, content_type="application/json") uri, http_method, body, headers = self.oauthlib_core._extract_params(request) self.assertNotIn("grant_type=password", body) self.assertNotIn("username=john", body) self.assertNotIn("password=123456", body) UserModel = get_user_model() ApplicationModel = get_application_model() AccessTokenModel = get_access_token_model() @pytest.mark.usefixtures("oauth2_settings") class TestOAuthLibCoreBackendErrorHandling(TestCase): def setUp(self): self.factory = RequestFactory() self.oauthlib_core = OAuthLibCore() self.user = UserModel.objects.create_user("john", "test@example.com", "123456") self.app = ApplicationModel.objects.create( name="app", client_id="app_id", client_secret="app_secret", client_type=ApplicationModel.CLIENT_CONFIDENTIAL, authorization_grant_type=ApplicationModel.GRANT_PASSWORD, user=self.user, ) def tearDown(self): self.user.delete() self.app.delete() def test_create_token_response_valid(self): payload = ( "grant_type=password&username=john&password=123456&client_id=app_id&client_secret=app_secret" ) request = self.factory.post( "/o/token/", payload, content_type="application/x-www-form-urlencoded", HTTP_AUTHORIZATION="Basic %s" % base64.b64encode(b"john:123456").decode(), ) uri, headers, body, status = self.oauthlib_core.create_token_response(request) self.assertEqual(status, 200) def test_create_token_response_query_params(self): payload = ( "grant_type=password&username=john&password=123456&client_id=app_id&client_secret=app_secret" ) request = self.factory.post( "/o/token/?test=foo", payload, content_type="application/x-www-form-urlencoded", HTTP_AUTHORIZATION="Basic %s" % base64.b64encode(b"john:123456").decode(), ) uri, headers, body, status = self.oauthlib_core.create_token_response(request) self.assertEqual(status, 400) self.assertDictEqual( json.loads(body), {"error": "invalid_request", "error_description": "URL query parameters are not allowed"}, ) def test_create_revocation_response_valid(self): AccessTokenModel.objects.create( user=self.user, token="tokstr", application=self.app, expires=now() + timedelta(days=365) ) payload = "client_id=app_id&client_secret=app_secret&token=tokstr" request = self.factory.post( "/o/revoke_token/", payload, content_type="application/x-www-form-urlencoded", HTTP_AUTHORIZATION="Basic %s" % base64.b64encode(b"john:123456").decode(), ) uri, headers, body, status = self.oauthlib_core.create_revocation_response(request) self.assertEqual(status, 200) def test_create_revocation_response_query_params(self): token = AccessTokenModel.objects.create( user=self.user, token="tokstr", application=self.app, expires=now() + timedelta(days=365) ) payload = "client_id=app_id&client_secret=app_secret&token=tokstr" request = self.factory.post( "/o/revoke_token/?test=foo", payload, content_type="application/x-www-form-urlencoded", HTTP_AUTHORIZATION="Basic %s" % base64.b64encode(b"john:123456").decode(), ) uri, headers, body, status = self.oauthlib_core.create_revocation_response(request) self.assertEqual(status, 400) self.assertDictEqual( json.loads(body), {"error": "invalid_request", "error_description": "URL query parameters are not allowed"}, ) token.delete() class TestCustomOAuthLibCoreBackend(TestCase): """ Tests that the public API behaves as expected when we override the OAuthLibCoreBackend core methods. """ class MyOAuthLibCore(OAuthLibCore): def _get_extra_credentials(self, request): return 1 def setUp(self): self.factory = RequestFactory() def test_create_token_response_gets_extra_credentials(self): """ Make sures that extra_credentials parameter is passed to oauthlib """ payload = "grant_type=password&username=john&password=123456" request = self.factory.post("/o/token/", payload, content_type="application/x-www-form-urlencoded") with mock.patch("oauthlib.oauth2.Server.create_token_response") as create_token_response: mocked = mock.MagicMock() create_token_response.return_value = mocked, mocked, mocked core = self.MyOAuthLibCore() core.create_token_response(request) self.assertTrue(create_token_response.call_args[0][4] == 1) class TestJSONOAuthLibCoreBackend(TestCase): def setUp(self): self.factory = RequestFactory() self.oauthlib_core = JSONOAuthLibCore() def test_application_json_extract_params(self): payload = json.dumps( { "grant_type": "password", "username": "john", "password": "123456", } ) request = self.factory.post("/o/token/", payload, content_type="application/json") uri, http_method, body, headers = self.oauthlib_core._extract_params(request) self.assertIn("grant_type=password", body) self.assertIn("username=john", body) self.assertIn("password=123456", body) class TestOAuthLibCore(TestCase): def setUp(self): self.factory = RequestFactory() def test_validate_authorization_request_unsafe_query(self): auth_headers = { "HTTP_AUTHORIZATION": "Bearer " + "a_casual_token", } request = self.factory.get("/fake-resource?next=/fake", **auth_headers) 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) django-oauth-toolkit-2.3.0/tests/test_oauth2_validators.py000066400000000000000000000527171443573112200240020ustar00rootroot00000000000000import contextlib import datetime import json import pytest from django.contrib.auth import get_user_model from django.test import TestCase, TransactionTestCase from django.utils import timezone from jwcrypto import jwt from oauthlib.common import Request from oauth2_provider.exceptions import FatalClientError from oauth2_provider.models import get_access_token_model, get_application_model, get_refresh_token_model from oauth2_provider.oauth2_backends import get_oauthlib_core from oauth2_provider.oauth2_validators import OAuth2Validator from . import presets from .utils import get_basic_auth_header try: from unittest import mock except ImportError: import mock UserModel = get_user_model() Application = get_application_model() AccessToken = get_access_token_model() RefreshToken = get_refresh_token_model() CLEARTEXT_SECRET = "1234567890abcdefghijklmnopqrstuvwxyz" CLEARTEXT_BLANK_SECRET = "" @contextlib.contextmanager def always_invalid_token(): # NOTE: This can happen if someone swaps the AccessToken model and # updates `is_valid` such that it has some criteria on top of # `is_expired` and `allow_scopes`. original_is_valid = AccessToken.is_valid AccessToken.is_valid = mock.MagicMock(return_value=False) try: yield finally: AccessToken.is_valid = original_is_valid class TestOAuth2Validator(TransactionTestCase): def setUp(self): self.user = UserModel.objects.create_user("user", "test@example.com", "123456") self.request = mock.MagicMock(wraps=Request) self.request.user = self.user self.request.grant_type = "not client" self.validator = OAuth2Validator() self.application = Application.objects.create( client_id="client_id", client_secret=CLEARTEXT_SECRET, user=self.user, client_type=Application.CLIENT_PUBLIC, authorization_grant_type=Application.GRANT_PASSWORD, ) self.request.client = self.application self.blank_secret_request = mock.MagicMock(wraps=Request) self.blank_secret_request.user = self.user self.blank_secret_request.grant_type = "not client" self.blank_secret_application = Application.objects.create( client_id="blank_secret_client_id", client_secret=CLEARTEXT_BLANK_SECRET, user=self.user, client_type=Application.CLIENT_PUBLIC, authorization_grant_type=Application.GRANT_PASSWORD, ) self.blank_secret_request.client = self.blank_secret_application def tearDown(self): self.application.delete() def test_authenticate_request_body(self): self.request.client_id = "client_id" self.assertFalse(self.validator._authenticate_request_body(self.request)) self.request.client_secret = "" self.assertFalse(self.validator._authenticate_request_body(self.request)) self.request.client_secret = "wrong_client_secret" self.assertFalse(self.validator._authenticate_request_body(self.request)) self.request.client_secret = CLEARTEXT_SECRET self.assertTrue(self.validator._authenticate_request_body(self.request)) self.blank_secret_request.client_id = "blank_secret_client_id" self.assertTrue(self.validator._authenticate_request_body(self.blank_secret_request)) self.blank_secret_request.client_secret = CLEARTEXT_BLANK_SECRET self.assertTrue(self.validator._authenticate_request_body(self.blank_secret_request)) self.blank_secret_request.client_secret = "wrong_client_secret" self.assertFalse(self.validator._authenticate_request_body(self.blank_secret_request)) def test_extract_basic_auth(self): self.request.headers = {"HTTP_AUTHORIZATION": "Basic 123456"} self.assertEqual(self.validator._extract_basic_auth(self.request), "123456") self.request.headers = {} self.assertIsNone(self.validator._extract_basic_auth(self.request)) self.request.headers = {"HTTP_AUTHORIZATION": "Dummy 123456"} self.assertIsNone(self.validator._extract_basic_auth(self.request)) self.request.headers = {"HTTP_AUTHORIZATION": "Basic"} self.assertIsNone(self.validator._extract_basic_auth(self.request)) self.request.headers = {"HTTP_AUTHORIZATION": "Basic 123456 789"} self.assertEqual(self.validator._extract_basic_auth(self.request), "123456 789") def test_authenticate_basic_auth(self): self.request.encoding = "utf-8" self.request.headers = get_basic_auth_header("client_id", CLEARTEXT_SECRET) self.assertTrue(self.validator._authenticate_basic_auth(self.request)) def test_authenticate_basic_auth_default_encoding(self): self.request.encoding = None self.request.headers = get_basic_auth_header("client_id", CLEARTEXT_SECRET) self.assertTrue(self.validator._authenticate_basic_auth(self.request)) def test_authenticate_basic_auth_wrong_client_id(self): self.request.encoding = "utf-8" self.request.headers = get_basic_auth_header("wrong_id", CLEARTEXT_SECRET) self.assertFalse(self.validator._authenticate_basic_auth(self.request)) def test_authenticate_basic_auth_wrong_client_secret(self): self.request.encoding = "utf-8" self.request.headers = get_basic_auth_header("client_id", "wrong_secret") self.assertFalse(self.validator._authenticate_basic_auth(self.request)) def test_authenticate_basic_auth_not_b64_auth_string(self): self.request.encoding = "utf-8" # Can"t b64decode self.request.headers = {"HTTP_AUTHORIZATION": "Basic not_base64"} self.assertFalse(self.validator._authenticate_basic_auth(self.request)) def test_authenticate_basic_auth_invalid_b64_string(self): self.request.encoding = "utf-8" self.request.headers = {"HTTP_AUTHORIZATION": "Basic ZHVtbXk=:ZHVtbXk=\n"} self.assertFalse(self.validator._authenticate_basic_auth(self.request)) def test_authenticate_basic_auth_not_utf8(self): self.request.encoding = "utf-8" # b64decode("test") will become b"\xb5\xeb-", it can"t be decoded as utf-8 self.request.headers = {"HTTP_AUTHORIZATION": "Basic test"} self.assertFalse(self.validator._authenticate_basic_auth(self.request)) def test_authenticate_client_id(self): self.assertTrue(self.validator.authenticate_client_id("client_id", self.request)) def test_authenticate_client_id_fail(self): self.application.client_type = Application.CLIENT_CONFIDENTIAL self.application.save() self.assertFalse(self.validator.authenticate_client_id("client_id", self.request)) self.assertFalse(self.validator.authenticate_client_id("fake_client_id", self.request)) def test_client_authentication_required(self): self.request.headers = {"HTTP_AUTHORIZATION": "Basic 123456"} self.assertTrue(self.validator.client_authentication_required(self.request)) self.request.headers = {} self.request.client_id = "client_id" self.request.client_secret = CLEARTEXT_SECRET self.assertTrue(self.validator.client_authentication_required(self.request)) self.request.client_secret = "" self.assertFalse(self.validator.client_authentication_required(self.request)) self.application.client_type = Application.CLIENT_CONFIDENTIAL self.application.save() self.request.client = "" self.assertTrue(self.validator.client_authentication_required(self.request)) def test_load_application_fails_when_request_has_no_client(self): self.assertRaises(AssertionError, self.validator.authenticate_client_id, "client_id", {}) def test_rotate_refresh_token__is_true(self): self.assertTrue(self.validator.rotate_refresh_token(mock.MagicMock())) def test_save_bearer_token__without_user__raises_fatal_client(self): token = {} with self.assertRaises(FatalClientError): self.validator.save_bearer_token(token, mock.MagicMock()) def test_save_bearer_token__with_existing_tokens__does_not_create_new_tokens(self): rotate_token_function = mock.MagicMock() rotate_token_function.return_value = False self.validator.rotate_refresh_token = rotate_token_function access_token = AccessToken.objects.create( token="123", user=self.user, expires=timezone.now() + datetime.timedelta(seconds=60), application=self.application, ) refresh_token = RefreshToken.objects.create( access_token=access_token, token="abc", user=self.user, application=self.application ) self.request.refresh_token_instance = refresh_token token = { "scope": "foo bar", "refresh_token": "abc", "access_token": "123", } self.assertEqual(1, RefreshToken.objects.count()) self.assertEqual(1, AccessToken.objects.count()) self.validator.save_bearer_token(token, self.request) self.assertEqual(1, RefreshToken.objects.count()) self.assertEqual(1, AccessToken.objects.count()) def test_save_bearer_token__checks_to_rotate_tokens(self): rotate_token_function = mock.MagicMock() rotate_token_function.return_value = False self.validator.rotate_refresh_token = rotate_token_function access_token = AccessToken.objects.create( token="123", user=self.user, expires=timezone.now() + datetime.timedelta(seconds=60), application=self.application, ) refresh_token = RefreshToken.objects.create( access_token=access_token, token="abc", user=self.user, application=self.application ) self.request.refresh_token_instance = refresh_token token = { "scope": "foo bar", "refresh_token": "abc", "access_token": "123", } self.validator.save_bearer_token(token, self.request) rotate_token_function.assert_called_once_with(self.request) def test_save_bearer_token__with_new_token__creates_new_tokens(self): token = { "scope": "foo bar", "refresh_token": "abc", "access_token": "123", } self.assertEqual(0, RefreshToken.objects.count()) self.assertEqual(0, AccessToken.objects.count()) self.validator.save_bearer_token(token, self.request) self.assertEqual(1, RefreshToken.objects.count()) self.assertEqual(1, AccessToken.objects.count()) def test_save_bearer_token__with_new_token_equal_to_existing_token__revokes_old_tokens(self): access_token = AccessToken.objects.create( token="123", user=self.user, expires=timezone.now() + datetime.timedelta(seconds=60), application=self.application, ) refresh_token = RefreshToken.objects.create( access_token=access_token, token="abc", user=self.user, application=self.application ) self.request.refresh_token_instance = refresh_token token = { "scope": "foo bar", "refresh_token": "abc", "access_token": "123", } self.assertEqual(1, RefreshToken.objects.count()) self.assertEqual(1, AccessToken.objects.count()) self.validator.save_bearer_token(token, self.request) self.assertEqual(1, RefreshToken.objects.filter(revoked__isnull=True).count()) self.assertEqual(1, AccessToken.objects.count()) def test_save_bearer_token__with_no_refresh_token__creates_new_access_token_only(self): token = { "scope": "foo bar", "access_token": "123", } self.validator.save_bearer_token(token, self.request) self.assertEqual(0, RefreshToken.objects.count()) self.assertEqual(1, AccessToken.objects.count()) def test_save_bearer_token__with_new_token__calls_methods_to_create_access_and_refresh_tokens(self): token = { "scope": "foo bar", "refresh_token": "abc", "access_token": "123", } # Mock private methods to create access and refresh tokens create_access_token_mock = mock.MagicMock() create_refresh_token_mock = mock.MagicMock() self.validator._create_refresh_token = create_refresh_token_mock self.validator._create_access_token = create_access_token_mock self.validator.save_bearer_token(token, self.request) assert create_access_token_mock.call_count == 1 assert create_refresh_token_mock.call_count == 1 class TestOAuth2ValidatorProvidesErrorData(TransactionTestCase): """These test cases check that the recommended error codes are returned when token authentication fails. RFC-6750: https://tools.ietf.org/html/rfc6750 > If the protected resource request does not include authentication > credentials or does not contain an access token that enables access > to the protected resource, the resource server MUST include the HTTP > "WWW-Authenticate" response header field[.] > > ... > > If the request lacks any authentication information..., the > resource server SHOULD NOT include an error code or other error > information. > > ... > > If the protected resource request included an access token and failed > authentication, the resource server SHOULD include the "error" > attribute to provide the client with the reason why the access > request was declined. See https://tools.ietf.org/html/rfc6750#section-3.1 for the allowed error codes. """ def setUp(self): self.user = UserModel.objects.create_user( "user", "test@example.com", "123456", ) self.request = mock.MagicMock(wraps=Request) self.request.user = self.user self.request.grant_type = "not client" self.validator = OAuth2Validator() self.application = Application.objects.create( client_id="client_id", client_secret=CLEARTEXT_SECRET, user=self.user, client_type=Application.CLIENT_PUBLIC, authorization_grant_type=Application.GRANT_PASSWORD, ) self.request.client = self.application def test_validate_bearer_token_does_not_add_error_when_no_token_is_provided(self): self.assertFalse(self.validator.validate_bearer_token(None, ["dolphin"], self.request)) with self.assertRaises(AttributeError): self.request.oauth2_error def test_validate_bearer_token_adds_error_to_the_request_when_an_invalid_token_is_provided(self): access_token = mock.MagicMock(token="some_invalid_token") self.assertFalse( self.validator.validate_bearer_token( access_token.token, [], self.request, ) ) self.assertDictEqual( self.request.oauth2_error, { "error": "invalid_token", "error_description": "The access token is invalid.", }, ) def test_validate_bearer_token_adds_error_to_the_request_when_an_expired_token_is_provided(self): access_token = AccessToken.objects.create( token="some_valid_token", user=self.user, expires=timezone.now() - datetime.timedelta(seconds=1), application=self.application, ) self.assertFalse( self.validator.validate_bearer_token( access_token.token, [], self.request, ) ) self.assertDictEqual( self.request.oauth2_error, { "error": "invalid_token", "error_description": "The access token has expired.", }, ) def test_validate_bearer_token_adds_error_to_the_request_when_a_valid_token_has_insufficient_scope(self): access_token = AccessToken.objects.create( token="some_valid_token", user=self.user, expires=timezone.now() + datetime.timedelta(seconds=1), application=self.application, ) self.assertFalse( self.validator.validate_bearer_token( access_token.token, ["some_extra_scope"], self.request, ) ) self.assertDictEqual( self.request.oauth2_error, { "error": "insufficient_scope", "error_description": "The access token is valid but does not have enough scope.", }, ) def test_validate_bearer_token_adds_error_to_the_request_when_a_invalid_custom_token_is_provided(self): access_token = AccessToken.objects.create( token="some_valid_token", user=self.user, expires=timezone.now() + datetime.timedelta(seconds=1), application=self.application, ) with always_invalid_token(): self.assertFalse( self.validator.validate_bearer_token( access_token.token, [], self.request, ) ) self.assertDictEqual( self.request.oauth2_error, { "error": "invalid_token", }, ) class TestOAuth2ValidatorErrorResourceToken(TestCase): """The following tests check logger information when response from oauth2 is unsuccessful. """ def setUp(self): self.token = "test_token" self.introspection_url = "http://example.com/token/introspection/" self.introspection_token = "test_introspection_token" self.validator = OAuth2Validator() def test_response_when_auth_server_response_return_404(self): with self.assertLogs(logger="oauth2_provider") as mock_log: self.validator._get_token_from_authentication_server( self.token, self.introspection_url, self.introspection_token, None ) self.assertIn( "ERROR:oauth2_provider:Introspection: Failed to " "get a valid response from authentication server. " "Status code: 404, Reason: " "Not Found.\nNoneType: None", mock_log.output, ) @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) def test_oidc_endpoint_generation(oauth2_settings, rf): oauth2_settings.OIDC_ISS_ENDPOINT = "" django_request = rf.get("/") request = Request("/", headers=django_request.META) validator = OAuth2Validator() oidc_issuer_endpoint = validator.get_oidc_issuer_endpoint(request) assert oidc_issuer_endpoint == "http://testserver/o" @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) def test_oidc_endpoint_generation_ssl(oauth2_settings, rf, settings): oauth2_settings.OIDC_ISS_ENDPOINT = "" django_request = rf.get("/", secure=True) # Calling the settings method with a django https request should generate a https url oidc_issuer_endpoint = oauth2_settings.oidc_issuer(django_request) assert oidc_issuer_endpoint == "https://testserver/o" # Should also work with an oauthlib request (via validator) core = get_oauthlib_core() uri, http_method, body, headers = core._extract_params(django_request) request = Request(uri=uri, http_method=http_method, body=body, headers=headers) validator = OAuth2Validator() oidc_issuer_endpoint = validator.get_oidc_issuer_endpoint(request) assert oidc_issuer_endpoint == "https://testserver/o" @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) def test_get_jwt_bearer_token(oauth2_settings, mocker): # oauthlib instructs us to make get_jwt_bearer_token call get_id_token request = mocker.MagicMock(wraps=Request) validator = OAuth2Validator() mock_get_id_token = mocker.patch.object(validator, "get_id_token") validator.get_jwt_bearer_token(None, None, request) assert mock_get_id_token.call_count == 1 assert mock_get_id_token.call_args[0] == (None, None, request) assert mock_get_id_token.call_args[1] == {} @pytest.mark.django_db @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) def test_validate_id_token_expired_jwt(oauth2_settings, mocker, oidc_tokens): mocker.patch("oauth2_provider.oauth2_validators.jwt.JWT", side_effect=jwt.JWTExpired) validator = OAuth2Validator() status = validator.validate_id_token(oidc_tokens.id_token, ["openid"], mocker.sentinel.request) assert status is False @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) def test_validate_id_token_no_token(oauth2_settings, mocker): validator = OAuth2Validator() status = validator.validate_id_token("", ["openid"], mocker.sentinel.request) assert status is False @pytest.mark.django_db @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) def test_validate_id_token_app_removed(oauth2_settings, mocker, oidc_tokens): oidc_tokens.application.delete() validator = OAuth2Validator() status = validator.validate_id_token(oidc_tokens.id_token, ["openid"], mocker.sentinel.request) assert status is False @pytest.mark.django_db @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) def test_validate_id_token_bad_token_no_aud(oauth2_settings, mocker, oidc_key): token = jwt.JWT(header=json.dumps({"alg": "RS256"}), claims=json.dumps({"bad": "token"})) token.make_signed_token(oidc_key) validator = OAuth2Validator() status = validator.validate_id_token(token.serialize(), ["openid"], mocker.sentinel.request) assert status is False django-oauth-toolkit-2.3.0/tests/test_oidc_views.py000066400000000000000000000647211443573112200225010ustar00rootroot00000000000000import pytest from django.contrib.auth import get_user from django.contrib.auth.models import AnonymousUser from django.test import RequestFactory, TestCase from django.urls import reverse from django.utils import timezone from pytest_django.asserts import assertRedirects from oauth2_provider.exceptions import ClientIdMissmatch, InvalidOIDCClientError, InvalidOIDCRedirectURIError from oauth2_provider.models import get_access_token_model, get_id_token_model, get_refresh_token_model from oauth2_provider.oauth2_validators import OAuth2Validator from oauth2_provider.settings import oauth2_settings from oauth2_provider.views.oidc import _load_id_token, _validate_claims, validate_logout_request from . import presets @pytest.mark.usefixtures("oauth2_settings") @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) class TestConnectDiscoveryInfoView(TestCase): def test_get_connect_discovery_info(self): expected_response = { "issuer": "http://localhost/o", "authorization_endpoint": "http://localhost/o/authorize/", "token_endpoint": "http://localhost/o/token/", "userinfo_endpoint": "http://localhost/o/userinfo/", "jwks_uri": "http://localhost/o/.well-known/jwks.json", "scopes_supported": ["read", "write", "openid"], "response_types_supported": [ "code", "token", "id_token", "id_token token", "code token", "code id_token", "code id_token token", ], "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) assert response.json() == expected_response def expect_json_response_with_rp(self, base): expected_response = { "issuer": f"{base}", "authorization_endpoint": f"{base}/authorize/", "token_endpoint": f"{base}/token/", "userinfo_endpoint": f"{base}/userinfo/", "jwks_uri": f"{base}/.well-known/jwks.json", "scopes_supported": ["read", "write", "openid"], "response_types_supported": [ "code", "token", "id_token", "id_token token", "code token", "code id_token", "code id_token token", ], "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"], "end_session_endpoint": f"{base}/logout/", } response = self.client.get(reverse("oauth2_provider:oidc-connect-discovery-info")) self.assertEqual(response.status_code, 200) assert response.json() == expected_response def test_get_connect_discovery_info_with_rp_logout(self): self.oauth2_settings.OIDC_RP_INITIATED_LOGOUT_ENABLED = True self.expect_json_response_with_rp(self.oauth2_settings.OIDC_ISS_ENDPOINT) def test_get_connect_discovery_info_without_issuer_url(self): self.oauth2_settings.OIDC_ISS_ENDPOINT = None self.oauth2_settings.OIDC_USERINFO_ENDPOINT = None expected_response = { "issuer": "http://testserver/o", "authorization_endpoint": "http://testserver/o/authorize/", "token_endpoint": "http://testserver/o/token/", "userinfo_endpoint": "http://testserver/o/userinfo/", "jwks_uri": "http://testserver/o/.well-known/jwks.json", "scopes_supported": ["read", "write", "openid"], "response_types_supported": [ "code", "token", "id_token", "id_token token", "code token", "code id_token", "code id_token token", ], "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) assert response.json() == expected_response def test_get_connect_discovery_info_without_issuer_url_with_rp_logout(self): self.oauth2_settings.OIDC_RP_INITIATED_LOGOUT_ENABLED = True self.oauth2_settings.OIDC_ISS_ENDPOINT = None self.oauth2_settings.OIDC_USERINFO_ENDPOINT = None self.expect_json_response_with_rp("http://testserver/o") def test_get_connect_discovery_info_without_rsa_key(self): self.oauth2_settings.OIDC_RSA_PRIVATE_KEY = None response = self.client.get(reverse("oauth2_provider:oidc-connect-discovery-info")) self.assertEqual(response.status_code, 200) assert response.json()["id_token_signing_alg_values_supported"] == ["HS256"] @pytest.mark.usefixtures("oauth2_settings") @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": [ { "alg": "RS256", "use": "sig", "kid": "s4a1o8mFEd1tATAIH96caMlu4hOxzBUaI2QTqbYNBHs", "e": "AQAB", "kty": "RSA", "n": "mwmIeYdjZkLgalTuhvvwjvnB5vVQc7G9DHgOm20Hw524bLVTk49IXJ2Scw42HOmowWWX-oMVT_ca3ZvVIeffVSN1-TxVy2zB65s0wDMwhiMoPv35z9IKHGMZgl9vlyso_2b7daVF_FQDdgIayUn8TQylBxEU1RFfW0QSYOBdAt8", # noqa } ] } response = self.client.get(reverse("oauth2_provider:jwks-info")) self.assertEqual(response.status_code, 200) assert response.json() == expected_response def test_get_jwks_info_no_rsa_key(self): self.oauth2_settings.OIDC_RSA_PRIVATE_KEY = None response = self.client.get(reverse("oauth2_provider:jwks-info")) 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 def mock_request(): """ Dummy request with an AnonymousUser attached. """ return mock_request_for(AnonymousUser()) def mock_request_for(user): """ Dummy request with the `user` attached. """ request = RequestFactory().get("") request.user = user return request @pytest.mark.django_db @pytest.mark.parametrize("ALWAYS_PROMPT", [True, False]) def test_validate_logout_request(oidc_tokens, public_application, other_user, rp_settings, ALWAYS_PROMPT): rp_settings.OIDC_RP_INITIATED_LOGOUT_ALWAYS_PROMPT = ALWAYS_PROMPT oidc_tokens = oidc_tokens application = oidc_tokens.application client_id = application.client_id id_token = oidc_tokens.id_token assert validate_logout_request( request=mock_request_for(oidc_tokens.user), id_token_hint=None, client_id=None, post_logout_redirect_uri=None, ) == (True, (None, None), None) assert validate_logout_request( request=mock_request_for(oidc_tokens.user), id_token_hint=None, client_id=client_id, post_logout_redirect_uri=None, ) == (True, (None, application), None) assert validate_logout_request( request=mock_request_for(oidc_tokens.user), id_token_hint=None, client_id=client_id, post_logout_redirect_uri="http://example.org", ) == (True, ("http://example.org", application), None) assert validate_logout_request( request=mock_request_for(oidc_tokens.user), id_token_hint=id_token, client_id=None, post_logout_redirect_uri="http://example.org", ) == (ALWAYS_PROMPT, ("http://example.org", application), oidc_tokens.user) assert validate_logout_request( request=mock_request_for(other_user), id_token_hint=id_token, client_id=None, post_logout_redirect_uri="http://example.org", ) == (True, ("http://example.org", application), oidc_tokens.user) assert validate_logout_request( request=mock_request_for(oidc_tokens.user), id_token_hint=id_token, client_id=client_id, post_logout_redirect_uri="http://example.org", ) == (ALWAYS_PROMPT, ("http://example.org", application), oidc_tokens.user) with pytest.raises(ClientIdMissmatch): validate_logout_request( request=mock_request_for(oidc_tokens.user), id_token_hint=id_token, client_id=public_application.client_id, post_logout_redirect_uri="http://other.org", ) with pytest.raises(InvalidOIDCClientError): validate_logout_request( request=mock_request_for(oidc_tokens.user), id_token_hint=None, client_id=None, post_logout_redirect_uri="http://example.org", ) with pytest.raises(InvalidOIDCRedirectURIError): validate_logout_request( request=mock_request_for(oidc_tokens.user), id_token_hint=None, client_id=client_id, post_logout_redirect_uri="example.org", ) with pytest.raises(InvalidOIDCRedirectURIError): validate_logout_request( request=mock_request_for(oidc_tokens.user), id_token_hint=None, client_id=client_id, post_logout_redirect_uri="imap://example.org", ) with pytest.raises(InvalidOIDCRedirectURIError): validate_logout_request( request=mock_request_for(oidc_tokens.user), id_token_hint=None, client_id=client_id, post_logout_redirect_uri="http://other.org", ) def test__load_id_token(): assert _load_id_token("Not a Valid ID Token.") == (None, None) def is_logged_in(client): return get_user(client).is_authenticated @pytest.mark.django_db def test_rp_initiated_logout_get(logged_in_client, rp_settings): rsp = logged_in_client.get(reverse("oauth2_provider:rp-initiated-logout"), data={}) assert rsp.status_code == 200 assert is_logged_in(logged_in_client) @pytest.mark.django_db def test_rp_initiated_logout_get_id_token(logged_in_client, oidc_tokens, rp_settings): rsp = logged_in_client.get( reverse("oauth2_provider:rp-initiated-logout"), data={"id_token_hint": oidc_tokens.id_token} ) assert rsp.status_code == 302 assert rsp["Location"] == "http://testserver/" assert not is_logged_in(logged_in_client) @pytest.mark.django_db def test_rp_initiated_logout_get_revoked_id_token(logged_in_client, oidc_tokens, rp_settings): validator = oauth2_settings.OAUTH2_VALIDATOR_CLASS() validator._load_id_token(oidc_tokens.id_token).revoke() rsp = logged_in_client.get( reverse("oauth2_provider:rp-initiated-logout"), data={"id_token_hint": oidc_tokens.id_token} ) assert rsp.status_code == 400 assert is_logged_in(logged_in_client) @pytest.mark.django_db def test_rp_initiated_logout_get_id_token_redirect(logged_in_client, oidc_tokens, rp_settings): rsp = logged_in_client.get( reverse("oauth2_provider:rp-initiated-logout"), data={"id_token_hint": oidc_tokens.id_token, "post_logout_redirect_uri": "http://example.org"}, ) assert rsp.status_code == 302 assert rsp["Location"] == "http://example.org" assert not is_logged_in(logged_in_client) @pytest.mark.django_db def test_rp_initiated_logout_get_id_token_redirect_with_state(logged_in_client, oidc_tokens, rp_settings): rsp = logged_in_client.get( reverse("oauth2_provider:rp-initiated-logout"), data={ "id_token_hint": oidc_tokens.id_token, "post_logout_redirect_uri": "http://example.org", "state": "987654321", }, ) assert rsp.status_code == 302 assert rsp["Location"] == "http://example.org?state=987654321" assert not is_logged_in(logged_in_client) @pytest.mark.django_db def test_rp_initiated_logout_get_id_token_missmatch_client_id( logged_in_client, oidc_tokens, public_application, rp_settings ): rsp = logged_in_client.get( reverse("oauth2_provider:rp-initiated-logout"), data={"id_token_hint": oidc_tokens.id_token, "client_id": public_application.client_id}, ) assert rsp.status_code == 400 assert is_logged_in(logged_in_client) @pytest.mark.django_db def test_rp_initiated_logout_public_client_redirect_client_id( logged_in_client, oidc_non_confidential_tokens, public_application, rp_settings ): rsp = logged_in_client.get( reverse("oauth2_provider:rp-initiated-logout"), data={ "id_token_hint": oidc_non_confidential_tokens.id_token, "client_id": public_application.client_id, "post_logout_redirect_uri": "http://other.org", }, ) assert rsp.status_code == 302 assert not is_logged_in(logged_in_client) @pytest.mark.django_db def test_rp_initiated_logout_public_client_strict_redirect_client_id( logged_in_client, oidc_non_confidential_tokens, public_application, oauth2_settings ): oauth2_settings.update(presets.OIDC_SETTINGS_RP_LOGOUT_STRICT_REDIRECT_URI) rsp = logged_in_client.get( reverse("oauth2_provider:rp-initiated-logout"), data={ "id_token_hint": oidc_non_confidential_tokens.id_token, "client_id": public_application.client_id, "post_logout_redirect_uri": "http://other.org", }, ) assert rsp.status_code == 400 assert is_logged_in(logged_in_client) @pytest.mark.django_db def test_rp_initiated_logout_get_id_token_client_id(logged_in_client, oidc_tokens, rp_settings): rsp = logged_in_client.get( reverse("oauth2_provider:rp-initiated-logout"), data={"client_id": oidc_tokens.application.client_id} ) assert rsp.status_code == 200 assert is_logged_in(logged_in_client) @pytest.mark.django_db def test_rp_initiated_logout_post(logged_in_client, oidc_tokens, rp_settings): form_data = { "client_id": oidc_tokens.application.client_id, } rsp = logged_in_client.post(reverse("oauth2_provider:rp-initiated-logout"), form_data) assert rsp.status_code == 400 assert is_logged_in(logged_in_client) @pytest.mark.django_db def test_rp_initiated_logout_post_allowed(logged_in_client, oidc_tokens, rp_settings): form_data = {"client_id": oidc_tokens.application.client_id, "allow": True} rsp = logged_in_client.post(reverse("oauth2_provider:rp-initiated-logout"), form_data) assert rsp.status_code == 302 assert rsp["Location"] == "http://testserver/" assert not is_logged_in(logged_in_client) @pytest.mark.django_db @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RP_LOGOUT) def test_rp_initiated_logout_expired_tokens_accept(logged_in_client, application, expired_id_token): # Accepting expired (but otherwise valid and signed by us) tokens is enabled. Logout should go through. rsp = logged_in_client.get( reverse("oauth2_provider:rp-initiated-logout"), data={ "id_token_hint": expired_id_token, "client_id": application.client_id, }, ) assert rsp.status_code == 302 assert not is_logged_in(logged_in_client) @pytest.mark.django_db @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RP_LOGOUT_DENY_EXPIRED) def test_rp_initiated_logout_expired_tokens_deny(logged_in_client, application, expired_id_token): # Expired tokens should not be accepted by default. rsp = logged_in_client.get( reverse("oauth2_provider:rp-initiated-logout"), data={ "id_token_hint": expired_id_token, "client_id": application.client_id, }, ) assert rsp.status_code == 400 assert is_logged_in(logged_in_client) @pytest.mark.django_db @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RP_LOGOUT) def test_load_id_token_accept_expired(expired_id_token): id_token, _ = _load_id_token(expired_id_token) assert isinstance(id_token, get_id_token_model()) @pytest.mark.django_db @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RP_LOGOUT) def test_load_id_token_wrong_aud(id_token_wrong_aud): id_token, claims = _load_id_token(id_token_wrong_aud) assert id_token is None assert claims is None @pytest.mark.django_db @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RP_LOGOUT_DENY_EXPIRED) def test_load_id_token_deny_expired(expired_id_token): id_token, claims = _load_id_token(expired_id_token) assert id_token is None assert claims is None @pytest.mark.django_db @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RP_LOGOUT) def test_validate_claims_wrong_iss(id_token_wrong_iss): id_token, claims = _load_id_token(id_token_wrong_iss) assert id_token is not None assert claims is not None assert not _validate_claims(mock_request(), claims) @pytest.mark.django_db @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RP_LOGOUT) def test_validate_claims(oidc_tokens): id_token, claims = _load_id_token(oidc_tokens.id_token) assert claims is not None assert _validate_claims(mock_request_for(oidc_tokens.user), claims) @pytest.mark.django_db @pytest.mark.parametrize("method", ["get", "post"]) def test_userinfo_endpoint(oidc_tokens, client, method): auth_header = "Bearer %s" % oidc_tokens.access_token rsp = getattr(client, method)( reverse("oauth2_provider:user-info"), HTTP_AUTHORIZATION=auth_header, ) data = rsp.json() assert "sub" in data assert data["sub"] == str(oidc_tokens.user.pk) @pytest.mark.django_db def test_userinfo_endpoint_bad_token(oidc_tokens, client): # No access token rsp = client.get(reverse("oauth2_provider:user-info")) assert rsp.status_code == 401 # Bad access token rsp = client.get( reverse("oauth2_provider:user-info"), HTTP_AUTHORIZATION="Bearer not-a-real-token", ) assert rsp.status_code == 401 @pytest.mark.django_db def test_token_deletion_on_logout(oidc_tokens, logged_in_client, rp_settings): AccessToken = get_access_token_model() IDToken = get_id_token_model() RefreshToken = get_refresh_token_model() assert AccessToken.objects.count() == 1 assert IDToken.objects.count() == 1 assert RefreshToken.objects.count() == 1 rsp = logged_in_client.get( reverse("oauth2_provider:rp-initiated-logout"), data={ "id_token_hint": oidc_tokens.id_token, "client_id": oidc_tokens.application.client_id, }, ) assert rsp.status_code == 302 assert not is_logged_in(logged_in_client) # Check that all tokens have either been deleted or expired. assert all([token.is_expired() for token in AccessToken.objects.all()]) assert all([token.is_expired() for token in IDToken.objects.all()]) assert all([token.revoked <= timezone.now() for token in RefreshToken.objects.all()]) @pytest.mark.django_db def test_token_deletion_on_logout_expired_session(oidc_tokens, client, rp_settings): AccessToken = get_access_token_model() IDToken = get_id_token_model() RefreshToken = get_refresh_token_model() assert AccessToken.objects.count() == 1 assert IDToken.objects.count() == 1 assert RefreshToken.objects.count() == 1 rsp = client.get( reverse("oauth2_provider:rp-initiated-logout"), data={ "id_token_hint": oidc_tokens.id_token, "client_id": oidc_tokens.application.client_id, }, ) assert rsp.status_code == 200 assert not is_logged_in(client) # Check that all tokens are active. access_token = AccessToken.objects.get() assert not access_token.is_expired() id_token = IDToken.objects.get() assert not id_token.is_expired() refresh_token = RefreshToken.objects.get() assert refresh_token.revoked is None rsp = client.post( reverse("oauth2_provider:rp-initiated-logout"), data={ "id_token_hint": oidc_tokens.id_token, "client_id": oidc_tokens.application.client_id, "allow": True, }, ) assertRedirects(rsp, "http://testserver/", fetch_redirect_response=False) assert not is_logged_in(client) # Check that all tokens have either been deleted or expired. assert all(token.is_expired() for token in AccessToken.objects.all()) assert all(token.is_expired() for token in IDToken.objects.all()) assert all(token.revoked <= timezone.now() for token in RefreshToken.objects.all()) @pytest.mark.django_db @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RP_LOGOUT_KEEP_TOKENS) def test_token_deletion_on_logout_disabled(oidc_tokens, logged_in_client, rp_settings): rp_settings.OIDC_RP_INITIATED_LOGOUT_DELETE_TOKENS = False AccessToken = get_access_token_model() IDToken = get_id_token_model() RefreshToken = get_refresh_token_model() assert AccessToken.objects.count() == 1 assert IDToken.objects.count() == 1 assert RefreshToken.objects.count() == 1 rsp = logged_in_client.get( reverse("oauth2_provider:rp-initiated-logout"), data={ "id_token_hint": oidc_tokens.id_token, "client_id": oidc_tokens.application.client_id, }, ) assert rsp.status_code == 302 assert not is_logged_in(logged_in_client) # Check that the tokens have not been expired or deleted. assert AccessToken.objects.count() == 1 assert not any([token.is_expired() for token in AccessToken.objects.all()]) assert IDToken.objects.count() == 1 assert not any([token.is_expired() for token in IDToken.objects.all()]) assert RefreshToken.objects.count() == 1 assert not any([token.revoked is not None for token in RefreshToken.objects.all()]) EXAMPLE_EMAIL = "example.email@example.com" def claim_user_email(request): return EXAMPLE_EMAIL @pytest.mark.django_db def test_userinfo_endpoint_custom_claims_callable(oidc_tokens, client, oauth2_settings): class CustomValidator(OAuth2Validator): oidc_claim_scope = None def get_additional_claims(self): return { "username": claim_user_email, "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_email_scope_callable( oidc_email_scope_tokens, client, oauth2_settings ): class CustomValidator(OAuth2Validator): def get_additional_claims(self): return { "username": claim_user_email, "email": claim_user_email, } oidc_email_scope_tokens.oauth2_settings.OAUTH2_VALIDATOR_CLASS = CustomValidator auth_header = "Bearer %s" % oidc_email_scope_tokens.access_token rsp = client.get( reverse("oauth2_provider:user-info"), HTTP_AUTHORIZATION=auth_header, ) data = rsp.json() assert "sub" in data assert data["sub"] == str(oidc_email_scope_tokens.user.pk) assert "username" not in data assert "email" in data assert data["email"] == EXAMPLE_EMAIL @pytest.mark.django_db def test_userinfo_endpoint_custom_claims_plain(oidc_tokens, client, oauth2_settings): class CustomValidator(OAuth2Validator): oidc_claim_scope = None def get_additional_claims(self, request): return { "username": EXAMPLE_EMAIL, "email": EXAMPLE_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_email_scopeplain(oidc_email_scope_tokens, client, oauth2_settings): class CustomValidator(OAuth2Validator): def get_additional_claims(self, request): return { "username": EXAMPLE_EMAIL, "email": EXAMPLE_EMAIL, } oidc_email_scope_tokens.oauth2_settings.OAUTH2_VALIDATOR_CLASS = CustomValidator auth_header = "Bearer %s" % oidc_email_scope_tokens.access_token rsp = client.get( reverse("oauth2_provider:user-info"), HTTP_AUTHORIZATION=auth_header, ) data = rsp.json() assert "sub" in data assert data["sub"] == str(oidc_email_scope_tokens.user.pk) assert "username" not in data assert "email" in data assert data["email"] == EXAMPLE_EMAIL django-oauth-toolkit-2.3.0/tests/test_password.py000066400000000000000000000072011443573112200221760ustar00rootroot00000000000000import json import pytest from django.contrib.auth import get_user_model from django.test import RequestFactory, TestCase from django.urls import reverse from oauth2_provider.models import get_application_model from oauth2_provider.views import ProtectedResourceView from .utils import get_basic_auth_header Application = get_application_model() UserModel = get_user_model() CLEARTEXT_SECRET = "1234567890abcdefghijklmnopqrstuvwxyz" # mocking a protected resource view class ResourceView(ProtectedResourceView): def get(self, request, *args, **kwargs): return "This is a protected resource" @pytest.mark.usefixtures("oauth2_settings") class BaseTest(TestCase): def setUp(self): self.factory = RequestFactory() self.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") self.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") self.application = Application.objects.create( name="Test Password Application", user=self.dev_user, client_type=Application.CLIENT_PUBLIC, authorization_grant_type=Application.GRANT_PASSWORD, client_secret=CLEARTEXT_SECRET, ) def tearDown(self): self.application.delete() self.test_user.delete() self.dev_user.delete() class TestPasswordTokenView(BaseTest): def test_get_token(self): """ Request an access token using Resource Owner Password Flow """ token_request_data = { "grant_type": "password", "username": "test_user", "password": "123456", } auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") self.assertEqual(set(content["scope"].split()), {"read", "write"}) self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) def test_bad_credentials(self): """ Request an access token using Resource Owner Password Flow """ token_request_data = { "grant_type": "password", "username": "test_user", "password": "NOT_MY_PASS", } auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 400) class TestPasswordProtectedResource(BaseTest): def test_password_resource_access_allowed(self): token_request_data = { "grant_type": "password", "username": "test_user", "password": "123456", } auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) access_token = content["access_token"] # use token to access the resource auth_headers = { "HTTP_AUTHORIZATION": "Bearer " + access_token, } request = self.factory.get("/fake-resource", **auth_headers) request.user = self.test_user view = ResourceView.as_view() response = view(request) self.assertEqual(response, "This is a protected resource") django-oauth-toolkit-2.3.0/tests/test_rest_framework.py000066400000000000000000000407221443573112200233730ustar00rootroot00000000000000from datetime import timedelta import pytest from django.conf.urls import include from django.contrib.auth import get_user_model from django.core.exceptions import ImproperlyConfigured from django.http import HttpResponse from django.test import TestCase from django.test.utils import override_settings from django.urls import path, re_path from django.utils import timezone from rest_framework import permissions from rest_framework.authentication import BaseAuthentication from rest_framework.test import APIRequestFactory, force_authenticate from rest_framework.views import APIView from oauth2_provider.contrib.rest_framework import ( IsAuthenticatedOrTokenHasScope, OAuth2Authentication, TokenHasReadWriteScope, TokenHasResourceScope, TokenHasScope, TokenMatchesOASRequirements, ) from oauth2_provider.models import get_access_token_model, get_application_model from . import presets Application = get_application_model() AccessToken = get_access_token_model() UserModel = get_user_model() class MockView(APIView): permission_classes = (permissions.IsAuthenticated,) def get(self, request): return HttpResponse({"a": 1, "b": 2, "c": 3}) def post(self, request): return HttpResponse({"a": 1, "b": 2, "c": 3}) def put(self, request): return HttpResponse({"a": 1, "b": 2, "c": 3}) class OAuth2View(MockView): authentication_classes = [OAuth2Authentication] class ScopedView(OAuth2View): permission_classes = [permissions.IsAuthenticated, TokenHasScope] required_scopes = ["scope1", "another"] class AuthenticatedOrScopedView(OAuth2View): permission_classes = [IsAuthenticatedOrTokenHasScope] required_scopes = ["scope1"] class ReadWriteScopedView(OAuth2View): permission_classes = [permissions.IsAuthenticated, TokenHasReadWriteScope] class ResourceScopedView(OAuth2View): permission_classes = [permissions.IsAuthenticated, TokenHasResourceScope] required_scopes = ["resource1"] class MethodScopeAltView(OAuth2View): permission_classes = [TokenMatchesOASRequirements] required_alternate_scopes = { "GET": [["read"]], "POST": [["create"]], "PUT": [["update", "put"], ["update", "edit"]], "DELETE": [["delete"], ["deleter", "write"]], } class MethodScopeAltViewBad(OAuth2View): permission_classes = [TokenMatchesOASRequirements] class MissingAuthentication(BaseAuthentication): def authenticate(self, request): return ( "junk", "junk", ) class BrokenOAuth2View(MockView): authentication_classes = [MissingAuthentication] class TokenHasScopeViewWrongAuth(BrokenOAuth2View): permission_classes = [TokenHasScope] class MethodScopeAltViewWrongAuth(BrokenOAuth2View): permission_classes = [TokenMatchesOASRequirements] class AuthenticationNone(OAuth2Authentication): def authenticate(self, request): return None class AuthenticationNoneOAuth2View(MockView): authentication_classes = [AuthenticationNone] urlpatterns = [ path("oauth2/", include("oauth2_provider.urls")), path("oauth2-test/", OAuth2View.as_view()), path("oauth2-scoped-test/", ScopedView.as_view()), path("oauth2-scoped-missing-auth/", TokenHasScopeViewWrongAuth.as_view()), path("oauth2-read-write-test/", ReadWriteScopedView.as_view()), path("oauth2-resource-scoped-test/", ResourceScopedView.as_view()), path("oauth2-authenticated-or-scoped-test/", AuthenticatedOrScopedView.as_view()), re_path(r"oauth2-method-scope-test/.*$", MethodScopeAltView.as_view()), path("oauth2-method-scope-fail/", MethodScopeAltViewBad.as_view()), path("oauth2-method-scope-missing-auth/", MethodScopeAltViewWrongAuth.as_view()), path("oauth2-authentication-none/", AuthenticationNoneOAuth2View.as_view()), ] @override_settings(ROOT_URLCONF=__name__) @pytest.mark.usefixtures("oauth2_settings") @pytest.mark.oauth2_settings(presets.REST_FRAMEWORK_SCOPES) class TestOAuth2Authentication(TestCase): def setUp(self): self.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") self.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") self.application = Application.objects.create( name="Test Application", redirect_uris="http://localhost http://example.com http://example.org", user=self.dev_user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, ) self.access_token = AccessToken.objects.create( user=self.test_user, scope="read write", expires=timezone.now() + timedelta(seconds=300), token="secret-access-token-key", application=self.application, ) def _create_authorization_header(self, token): return "Bearer {0}".format(token) def test_authentication_allow(self): auth = self._create_authorization_header(self.access_token.token) response = self.client.get("/oauth2-test/", HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 200) def test_authentication_denied(self): response = self.client.get("/oauth2-test/") self.assertEqual(response.status_code, 401) self.assertEqual( response["WWW-Authenticate"], 'Bearer realm="api"', ) def test_authentication_denied_because_of_invalid_token(self): auth = self._create_authorization_header("fake-token") response = self.client.get("/oauth2-test/", HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 401) self.assertEqual( response["WWW-Authenticate"], 'Bearer realm="api",error="invalid_token",error_description="The access token is invalid."', ) def test_authentication_or_scope_denied(self): # user is not authenticated # not a correct token auth = self._create_authorization_header("fake-token") response = self.client.get("/oauth2-authenticated-or-scoped-test/", HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 401) # token doesn"t have correct scope auth = self._create_authorization_header(self.access_token.token) factory = APIRequestFactory() request = factory.get("/oauth2-authenticated-or-scoped-test/") request.auth = auth force_authenticate(request, token=self.access_token) response = AuthenticatedOrScopedView.as_view()(request) # authenticated but wrong scope, this is 403, not 401 self.assertEqual(response.status_code, 403) def test_scoped_permission_allow(self): self.access_token.scope = "scope1 another" self.access_token.save() auth = self._create_authorization_header(self.access_token.token) response = self.client.get("/oauth2-scoped-test/", HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 200) def test_scope_missing_scope_attr(self): auth = self._create_authorization_header("fake-token") with self.assertRaises(AssertionError) as e: self.client.get("/oauth2-scoped-missing-auth/", HTTP_AUTHORIZATION=auth) self.assertTrue("`oauth2_provider.rest_framework.OAuth2Authentication`" in str(e.exception)) def test_authenticated_or_scoped_permission_allow(self): self.access_token.scope = "scope1" self.access_token.save() # correct token and correct scope auth = self._create_authorization_header(self.access_token.token) response = self.client.get("/oauth2-authenticated-or-scoped-test/", HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 200) auth = self._create_authorization_header("fake-token") # incorrect token but authenticated factory = APIRequestFactory() request = factory.get("/oauth2-authenticated-or-scoped-test/") request.auth = auth force_authenticate(request, self.test_user) response = AuthenticatedOrScopedView.as_view()(request) self.assertEqual(response.status_code, 200) # correct token but not authenticated request = factory.get("/oauth2-authenticated-or-scoped-test/") request.auth = auth self.access_token.scope = "scope1" self.access_token.save() force_authenticate(request, token=self.access_token) response = AuthenticatedOrScopedView.as_view()(request) self.assertEqual(response.status_code, 200) def test_scoped_permission_deny(self): self.access_token.scope = "scope2" self.access_token.save() auth = self._create_authorization_header(self.access_token.token) response = self.client.get("/oauth2-scoped-test/", HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 403) def test_read_write_permission_get_allow(self): self.access_token.scope = "read" self.access_token.save() auth = self._create_authorization_header(self.access_token.token) response = self.client.get("/oauth2-read-write-test/", HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 200) def test_read_write_permission_post_allow(self): self.access_token.scope = "write" self.access_token.save() auth = self._create_authorization_header(self.access_token.token) response = self.client.post("/oauth2-read-write-test/", HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 200) def test_read_write_permission_get_deny(self): self.access_token.scope = "write" self.access_token.save() auth = self._create_authorization_header(self.access_token.token) response = self.client.get("/oauth2-read-write-test/", HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 403) def test_read_write_permission_post_deny(self): self.access_token.scope = "read" self.access_token.save() auth = self._create_authorization_header(self.access_token.token) response = self.client.post("/oauth2-read-write-test/", HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 403) def test_resource_scoped_permission_get_allow(self): self.access_token.scope = "resource1:read" self.access_token.save() auth = self._create_authorization_header(self.access_token.token) response = self.client.get("/oauth2-resource-scoped-test/", HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 200) def test_resource_scoped_permission_post_allow(self): self.access_token.scope = "resource1:write" self.access_token.save() auth = self._create_authorization_header(self.access_token.token) response = self.client.post("/oauth2-resource-scoped-test/", HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 200) def test_resource_scoped_permission_get_denied(self): self.access_token.scope = "resource1:write" self.access_token.save() auth = self._create_authorization_header(self.access_token.token) response = self.client.get("/oauth2-resource-scoped-test/", HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 403) def test_resource_scoped_permission_post_denied(self): self.access_token.scope = "resource1:read" self.access_token.save() auth = self._create_authorization_header(self.access_token.token) response = self.client.post("/oauth2-resource-scoped-test/", HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 403) def test_required_scope_in_response(self): self.oauth2_settings.ERROR_RESPONSE_WITH_SCOPES = True self.access_token.scope = "scope2" self.access_token.save() auth = self._create_authorization_header(self.access_token.token) response = self.client.get("/oauth2-scoped-test/", HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 403) self.assertEqual(response.data["required_scopes"], ["scope1", "another"]) def test_required_scope_not_in_response_by_default(self): self.access_token.scope = "scope2" self.access_token.save() auth = self._create_authorization_header(self.access_token.token) response = self.client.get("/oauth2-scoped-test/", HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 403) self.assertNotIn("required_scopes", response.data) def test_method_scope_alt_permission_get_allow(self): self.access_token.scope = "read" self.access_token.save() auth = self._create_authorization_header(self.access_token.token) response = self.client.get("/oauth2-method-scope-test/", HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 200) def test_method_scope_alt_permission_post_allow(self): self.access_token.scope = "create" self.access_token.save() auth = self._create_authorization_header(self.access_token.token) response = self.client.post("/oauth2-method-scope-test/", HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 200) def test_method_scope_alt_permission_put_allow(self): self.access_token.scope = "edit update" self.access_token.save() auth = self._create_authorization_header(self.access_token.token) response = self.client.put("/oauth2-method-scope-test/123", HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 200) def test_method_scope_alt_permission_put_fail(self): self.access_token.scope = "edit" self.access_token.save() auth = self._create_authorization_header(self.access_token.token) response = self.client.put("/oauth2-method-scope-test/123", HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 403) def test_method_scope_alt_permission_get_deny(self): self.access_token.scope = "write" self.access_token.save() auth = self._create_authorization_header(self.access_token.token) response = self.client.get("/oauth2-method-scope-test/", HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 403) def test_method_scope_alt_permission_post_deny(self): self.access_token.scope = "read" self.access_token.save() auth = self._create_authorization_header(self.access_token.token) response = self.client.post("/oauth2-method-scope-test/", HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 403) def test_method_scope_alt_no_token(self): self.access_token.scope = "" self.access_token.save() auth = self._create_authorization_header(self.access_token.token) self.access_token = None response = self.client.post("/oauth2-method-scope-test/", HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 403) def test_method_scope_alt_missing_attr(self): self.access_token.scope = "read" self.access_token.save() auth = self._create_authorization_header(self.access_token.token) with self.assertRaises(ImproperlyConfigured): self.client.post("/oauth2-method-scope-fail/", HTTP_AUTHORIZATION=auth) def test_method_scope_alt_missing_patch_method(self): self.access_token.scope = "update" self.access_token.save() auth = self._create_authorization_header(self.access_token.token) response = self.client.patch("/oauth2-method-scope-test/", HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 403) def test_method_scope_alt_empty_scope(self): self.access_token.scope = "" self.access_token.save() auth = self._create_authorization_header(self.access_token.token) response = self.client.patch("/oauth2-method-scope-test/", HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 403) def test_method_scope_alt_missing_scope_attr(self): auth = self._create_authorization_header("fake-token") with self.assertRaises(AssertionError) as e: self.client.get("/oauth2-method-scope-missing-auth/", HTTP_AUTHORIZATION=auth) self.assertTrue("`oauth2_provider.rest_framework.OAuth2Authentication`" in str(e.exception)) def test_authentication_none(self): auth = self._create_authorization_header(self.access_token.token) response = self.client.get("/oauth2-authentication-none/", HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 401) django-oauth-toolkit-2.3.0/tests/test_scopes.py000066400000000000000000000373441443573112200216430ustar00rootroot00000000000000import json from urllib.parse import parse_qs, urlparse import pytest from django.contrib.auth import get_user_model from django.core.exceptions import ImproperlyConfigured from django.test import RequestFactory, TestCase from django.urls import reverse from oauth2_provider.models import get_access_token_model, get_application_model, get_grant_model from oauth2_provider.views import ReadWriteScopedResourceView, ScopedProtectedResourceView from .utils import get_basic_auth_header Application = get_application_model() AccessToken = get_access_token_model() Grant = get_grant_model() UserModel = get_user_model() CLEARTEXT_SECRET = "1234567890abcdefghijklmnopqrstuvwxyz" # mocking a protected resource view class ScopeResourceView(ScopedProtectedResourceView): required_scopes = ["scope1"] def get(self, request, *args, **kwargs): return "This is a protected resource" class MultiScopeResourceView(ScopedProtectedResourceView): required_scopes = ["scope1", "scope2"] def get(self, request, *args, **kwargs): return "This is a protected resource" class ReadWriteResourceView(ReadWriteScopedResourceView): def get(self, request, *args, **kwargs): return "This is a read protected resource" def post(self, request, *args, **kwargs): return "This is a write protected resource" SCOPE_SETTINGS = { "SCOPES": { "read": "Read scope", "write": "Write scope", "scope1": "Custom scope 1", "scope2": "Custom scope 2", "scope3": "Custom scope 3", }, } @pytest.mark.usefixtures("oauth2_settings") @pytest.mark.oauth2_settings(SCOPE_SETTINGS) class BaseTest(TestCase): def setUp(self): self.factory = RequestFactory() self.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") self.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") self.application = Application.objects.create( name="Test Application", redirect_uris="http://localhost http://example.com http://example.org", user=self.dev_user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, client_secret=CLEARTEXT_SECRET, ) def tearDown(self): self.application.delete() self.test_user.delete() self.dev_user.delete() class TestScopesSave(BaseTest): def test_scopes_saved_in_grant(self): """ Test scopes are properly saved in grant """ self.oauth2_settings.PKCE_REQUIRED = False self.client.login(username="test_user", password="123456") # retrieve a valid authorization code authcode_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": "scope1 scope2", "redirect_uri": "http://example.org", "response_type": "code", "allow": True, } response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) query_dict = parse_qs(urlparse(response["Location"]).query) authorization_code = query_dict["code"].pop() grant = Grant.objects.get(code=authorization_code) self.assertEqual(grant.scope, "scope1 scope2") def test_scopes_save_in_access_token(self): """ Test scopes are properly saved in access token """ self.oauth2_settings.PKCE_REQUIRED = False self.client.login(username="test_user", password="123456") # retrieve a valid authorization code authcode_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": "scope1 scope2", "redirect_uri": "http://example.org", "response_type": "code", "allow": True, } response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) query_dict = parse_qs(urlparse(response["Location"]).query) authorization_code = query_dict["code"].pop() # exchange authorization code for a valid access token token_request_data = { "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "http://example.org", } auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) access_token = content["access_token"] at = AccessToken.objects.get(token=access_token) self.assertEqual(at.scope, "scope1 scope2") class TestScopesProtection(BaseTest): def test_scopes_protection_valid(self): """ Test access to a scope protected resource with correct scopes provided """ self.oauth2_settings.PKCE_REQUIRED = False self.client.login(username="test_user", password="123456") # retrieve a valid authorization code authcode_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": "scope1 scope2", "redirect_uri": "http://example.org", "response_type": "code", "allow": True, } response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) query_dict = parse_qs(urlparse(response["Location"]).query) authorization_code = query_dict["code"].pop() # exchange authorization code for a valid access token token_request_data = { "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "http://example.org", } auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) access_token = content["access_token"] # use token to access the resource auth_headers = { "HTTP_AUTHORIZATION": "Bearer " + access_token, } request = self.factory.get("/fake-resource", **auth_headers) request.user = self.test_user view = ScopeResourceView.as_view() response = view(request) self.assertEqual(response, "This is a protected resource") def test_scopes_protection_fail(self): """ Test access to a scope protected resource with wrong scopes provided """ self.oauth2_settings.PKCE_REQUIRED = False self.client.login(username="test_user", password="123456") # retrieve a valid authorization code authcode_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": "scope2", "redirect_uri": "http://example.org", "response_type": "code", "allow": True, } response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) query_dict = parse_qs(urlparse(response["Location"]).query) authorization_code = query_dict["code"].pop() # exchange authorization code for a valid access token token_request_data = { "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "http://example.org", } auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) access_token = content["access_token"] # use token to access the resource auth_headers = { "HTTP_AUTHORIZATION": "Bearer " + access_token, } request = self.factory.get("/fake-resource", **auth_headers) request.user = self.test_user view = ScopeResourceView.as_view() response = view(request) self.assertEqual(response.status_code, 403) def test_multi_scope_fail(self): """ Test access to a multi-scope protected resource with wrong scopes provided """ self.oauth2_settings.PKCE_REQUIRED = False self.client.login(username="test_user", password="123456") # retrieve a valid authorization code authcode_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": "scope1 scope3", "redirect_uri": "http://example.org", "response_type": "code", "allow": True, } response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) query_dict = parse_qs(urlparse(response["Location"]).query) authorization_code = query_dict["code"].pop() # exchange authorization code for a valid access token token_request_data = { "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "http://example.org", } auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) access_token = content["access_token"] # use token to access the resource auth_headers = { "HTTP_AUTHORIZATION": "Bearer " + access_token, } request = self.factory.get("/fake-resource", **auth_headers) request.user = self.test_user view = MultiScopeResourceView.as_view() response = view(request) self.assertEqual(response.status_code, 403) def test_multi_scope_valid(self): """ Test access to a multi-scope protected resource with correct scopes provided """ self.oauth2_settings.PKCE_REQUIRED = False self.client.login(username="test_user", password="123456") # retrieve a valid authorization code authcode_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": "scope1 scope2", "redirect_uri": "http://example.org", "response_type": "code", "allow": True, } response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) query_dict = parse_qs(urlparse(response["Location"]).query) authorization_code = query_dict["code"].pop() # exchange authorization code for a valid access token token_request_data = { "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "http://example.org", } auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) access_token = content["access_token"] # use token to access the resource auth_headers = { "HTTP_AUTHORIZATION": "Bearer " + access_token, } request = self.factory.get("/fake-resource", **auth_headers) request.user = self.test_user view = MultiScopeResourceView.as_view() response = view(request) self.assertEqual(response, "This is a protected resource") class TestReadWriteScope(BaseTest): def get_access_token(self, scopes): self.oauth2_settings.PKCE_REQUIRED = False self.client.login(username="test_user", password="123456") # retrieve a valid authorization code authcode_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": scopes, "redirect_uri": "http://example.org", "response_type": "code", "allow": True, } response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) query_dict = parse_qs(urlparse(response["Location"]).query) authorization_code = query_dict["code"].pop() # exchange authorization code for a valid access token token_request_data = { "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "http://example.org", } auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) return content["access_token"] def test_improperly_configured(self): self.oauth2_settings.SCOPES = {"scope1": "Scope 1"} request = self.factory.get("/fake") view = ReadWriteResourceView.as_view() self.assertRaises(ImproperlyConfigured, view, request) self.oauth2_settings.SCOPES = {"read": "Read Scope", "write": "Write Scope"} self.oauth2_settings.READ_SCOPE = "ciccia" view = ReadWriteResourceView.as_view() self.assertRaises(ImproperlyConfigured, view, request) def test_properly_configured(self): self.oauth2_settings.SCOPES = {"scope1": "Scope 1"} request = self.factory.get("/fake") view = ReadWriteResourceView.as_view() self.assertRaises(ImproperlyConfigured, view, request) self.oauth2_settings.SCOPES = {"read": "Read Scope", "write": "Write Scope"} self.oauth2_settings.READ_SCOPE = "ciccia" view = ReadWriteResourceView.as_view() self.assertRaises(ImproperlyConfigured, view, request) def test_has_read_scope(self): access_token = self.get_access_token("read") # use token to access the resource auth_headers = { "HTTP_AUTHORIZATION": "Bearer " + access_token, } request = self.factory.get("/fake-resource", **auth_headers) request.user = self.test_user view = ReadWriteResourceView.as_view() response = view(request) self.assertEqual(response, "This is a read protected resource") def test_no_read_scope(self): access_token = self.get_access_token("scope1") # use token to access the resource auth_headers = { "HTTP_AUTHORIZATION": "Bearer " + access_token, } request = self.factory.get("/fake-resource", **auth_headers) request.user = self.test_user view = ReadWriteResourceView.as_view() response = view(request) self.assertEqual(response.status_code, 403) def test_has_write_scope(self): access_token = self.get_access_token("write") # use token to access the resource auth_headers = { "HTTP_AUTHORIZATION": "Bearer " + access_token, } request = self.factory.post("/fake-resource", **auth_headers) request.user = self.test_user view = ReadWriteResourceView.as_view() response = view(request) self.assertEqual(response, "This is a write protected resource") def test_no_write_scope(self): access_token = self.get_access_token("scope1") # use token to access the resource auth_headers = { "HTTP_AUTHORIZATION": "Bearer " + access_token, } request = self.factory.post("/fake-resource", **auth_headers) request.user = self.test_user view = ReadWriteResourceView.as_view() response = view(request) self.assertEqual(response.status_code, 403) django-oauth-toolkit-2.3.0/tests/test_scopes_backend.py000066400000000000000000000005261443573112200233020ustar00rootroot00000000000000from oauth2_provider.scopes import SettingsScopes def test_settings_scopes_get_available_scopes(): scopes = SettingsScopes() assert set(scopes.get_available_scopes()) == {"read", "write"} def test_settings_scopes_get_default_scopes(): scopes = SettingsScopes() assert set(scopes.get_default_scopes()) == {"read", "write"} django-oauth-toolkit-2.3.0/tests/test_settings.py000066400000000000000000000152111443573112200221740ustar00rootroot00000000000000import pytest from django.core.exceptions import ImproperlyConfigured from django.test import TestCase from django.test.utils import override_settings from oauthlib.common import Request from oauth2_provider.admin import ( get_access_token_admin_class, get_application_admin_class, get_grant_admin_class, get_id_token_admin_class, get_refresh_token_admin_class, ) from oauth2_provider.settings import OAuth2ProviderSettings, oauth2_settings, perform_import from tests.admin import ( CustomAccessTokenAdmin, CustomApplicationAdmin, CustomGrantAdmin, CustomIDTokenAdmin, CustomRefreshTokenAdmin, ) from . import presets class TestAdminClass(TestCase): def test_import_error_message_maintained(self): """ Make sure import errors are captured and raised sensibly. """ settings = OAuth2ProviderSettings({"CLIENT_ID_GENERATOR_CLASS": "invalid_module.InvalidClassName"}) with self.assertRaises(ImportError): settings.CLIENT_ID_GENERATOR_CLASS def test_get_application_admin_class(self): """ Test for getting class for application admin. """ application_admin_class = get_application_admin_class() default_application_admin_class = oauth2_settings.APPLICATION_ADMIN_CLASS assert application_admin_class == default_application_admin_class def test_get_access_token_admin_class(self): """ Test for getting class for access token admin. """ access_token_admin_class = get_access_token_admin_class() default_access_token_admin_class = oauth2_settings.ACCESS_TOKEN_ADMIN_CLASS assert access_token_admin_class == default_access_token_admin_class def test_get_grant_admin_class(self): """ Test for getting class for grant admin. """ grant_admin_class = get_grant_admin_class() default_grant_admin_class = oauth2_settings.GRANT_ADMIN_CLASS assert grant_admin_class == default_grant_admin_class def test_get_id_token_admin_class(self): """ Test for getting class for ID token admin. """ id_token_admin_class = get_id_token_admin_class() default_id_token_admin_class = oauth2_settings.ID_TOKEN_ADMIN_CLASS assert id_token_admin_class == default_id_token_admin_class def test_get_refresh_token_admin_class(self): """ Test for getting class for refresh token admin. """ refresh_token_admin_class = get_refresh_token_admin_class() default_refresh_token_admin_class = oauth2_settings.REFRESH_TOKEN_ADMIN_CLASS assert refresh_token_admin_class == default_refresh_token_admin_class @override_settings(OAUTH2_PROVIDER={"APPLICATION_ADMIN_CLASS": "tests.admin.CustomApplicationAdmin"}) def test_get_custom_application_admin_class(self): """ Test for getting custom class for application admin. """ application_admin_class = get_application_admin_class() assert application_admin_class == CustomApplicationAdmin @override_settings(OAUTH2_PROVIDER={"ACCESS_TOKEN_ADMIN_CLASS": "tests.admin.CustomAccessTokenAdmin"}) def test_get_custom_access_token_admin_class(self): """ Test for getting custom class for access token admin. """ access_token_admin_class = get_access_token_admin_class() assert access_token_admin_class == CustomAccessTokenAdmin @override_settings(OAUTH2_PROVIDER={"GRANT_ADMIN_CLASS": "tests.admin.CustomGrantAdmin"}) def test_get_custom_grant_admin_class(self): """ Test for getting custom class for grant admin. """ grant_admin_class = get_grant_admin_class() assert grant_admin_class == CustomGrantAdmin @override_settings(OAUTH2_PROVIDER={"ID_TOKEN_ADMIN_CLASS": "tests.admin.CustomIDTokenAdmin"}) def test_get_custom_id_token_admin_class(self): """ Test for getting custom class for ID token admin. """ id_token_admin_class = get_id_token_admin_class() assert id_token_admin_class == CustomIDTokenAdmin @override_settings(OAUTH2_PROVIDER={"REFRESH_TOKEN_ADMIN_CLASS": "tests.admin.CustomRefreshTokenAdmin"}) def test_get_custom_refresh_token_admin_class(self): """ Test for getting custom class for refresh token admin. """ refresh_token_admin_class = get_refresh_token_admin_class() assert refresh_token_admin_class == CustomRefreshTokenAdmin def test_perform_import_when_none(): assert perform_import(None, "REFRESH_TOKEN_ADMIN_CLASS") is None def test_perform_import_list(): imports = ["tests.admin.CustomIDTokenAdmin", "tests.admin.CustomGrantAdmin"] assert perform_import(imports, "SOME_CLASSES") == [CustomIDTokenAdmin, CustomGrantAdmin] def test_perform_import_already_imported(): cls = perform_import(CustomRefreshTokenAdmin, "REFRESH_TOKEN_ADMIN_CLASS") assert cls == CustomRefreshTokenAdmin def test_invalid_scopes_raises_error(): settings = OAuth2ProviderSettings( { "SCOPES": {"foo": "foo scope"}, "DEFAULT_SCOPES": ["bar"], } ) with pytest.raises(ImproperlyConfigured) as exc: settings._DEFAULT_SCOPES assert str(exc.value) == "Defined DEFAULT_SCOPES not present in SCOPES" def test_missing_mandatory_setting_raises_error(): settings = OAuth2ProviderSettings( user_settings={}, defaults={"very_important": None}, mandatory=["very_important"] ) with pytest.raises(AttributeError) as exc: settings.very_important assert str(exc.value) == "OAuth2Provider setting: very_important is mandatory" @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) @pytest.mark.parametrize("issuer_setting", ["http://foo.com/", None]) @pytest.mark.parametrize("request_type", ["django", "oauthlib"]) def test_generating_iss_endpoint(oauth2_settings, issuer_setting, request_type, rf): oauth2_settings.OIDC_ISS_ENDPOINT = issuer_setting if request_type == "django": request = rf.get("/") elif request_type == "oauthlib": request = Request("/", headers=rf.get("/").META) expected = issuer_setting or "http://testserver/o" assert oauth2_settings.oidc_issuer(request) == expected @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) def test_generating_iss_endpoint_type_error(oauth2_settings): oauth2_settings.OIDC_ISS_ENDPOINT = None with pytest.raises(TypeError) as exc: oauth2_settings.oidc_issuer(None) assert str(exc.value) == "request must be a django or oauthlib request: got None" def test_pkce_required_is_default(): settings = OAuth2ProviderSettings() assert settings.PKCE_REQUIRED is True django-oauth-toolkit-2.3.0/tests/test_token_revocation.py000066400000000000000000000167321443573112200237160ustar00rootroot00000000000000import datetime from django.contrib.auth import get_user_model from django.test import RequestFactory, TestCase from django.urls import reverse from django.utils import timezone from oauth2_provider.models import get_access_token_model, get_application_model, get_refresh_token_model Application = get_application_model() AccessToken = get_access_token_model() RefreshToken = get_refresh_token_model() UserModel = get_user_model() CLEARTEXT_SECRET = "1234567890abcdefghijklmnopqrstuvwxyz" class BaseTest(TestCase): def setUp(self): self.factory = RequestFactory() self.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") self.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") self.application = Application.objects.create( name="Test Application", redirect_uris="http://localhost http://example.com http://example.org", user=self.dev_user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, client_secret=CLEARTEXT_SECRET, ) def tearDown(self): self.application.delete() self.test_user.delete() self.dev_user.delete() class TestRevocationView(BaseTest): def test_revoke_access_token(self): tok = AccessToken.objects.create( user=self.test_user, token="1234567890", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), scope="read write", ) data = { "client_id": self.application.client_id, "client_secret": CLEARTEXT_SECRET, "token": tok.token, } url = reverse("oauth2_provider:revoke-token") response = self.client.post(url, data=data) self.assertEqual(response.status_code, 200) self.assertEqual(response.content, b"") self.assertFalse(AccessToken.objects.filter(id=tok.id).exists()) def test_revoke_access_token_public(self): public_app = Application( name="Test Application", redirect_uris="http://localhost http://example.com http://example.org", user=self.dev_user, client_type=Application.CLIENT_PUBLIC, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, ) public_app.save() tok = AccessToken.objects.create( user=self.test_user, token="1234567890", application=public_app, expires=timezone.now() + datetime.timedelta(days=1), scope="read write", ) data = { "client_id": public_app.client_id, "token": tok.token, } url = reverse("oauth2_provider:revoke-token") response = self.client.post(url, data=data) self.assertEqual(response.status_code, 200) def test_revoke_access_token_with_hint(self): tok = AccessToken.objects.create( user=self.test_user, token="1234567890", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), scope="read write", ) data = { "client_id": self.application.client_id, "client_secret": CLEARTEXT_SECRET, "token": tok.token, "token_type_hint": "access_token", } url = reverse("oauth2_provider:revoke-token") response = self.client.post(url, data=data) self.assertEqual(response.status_code, 200) self.assertFalse(AccessToken.objects.filter(id=tok.id).exists()) def test_revoke_access_token_with_invalid_hint(self): tok = AccessToken.objects.create( user=self.test_user, token="1234567890", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), scope="read write", ) # invalid hint should have no effect data = { "client_id": self.application.client_id, "client_secret": CLEARTEXT_SECRET, "token": tok.token, "token_type_hint": "bad_hint", } url = reverse("oauth2_provider:revoke-token") response = self.client.post(url, data=data) self.assertEqual(response.status_code, 200) self.assertFalse(AccessToken.objects.filter(id=tok.id).exists()) def test_revoke_refresh_token(self): tok = AccessToken.objects.create( user=self.test_user, token="1234567890", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), scope="read write", ) rtok = RefreshToken.objects.create( user=self.test_user, token="999999999", application=self.application, access_token=tok ) data = { "client_id": self.application.client_id, "client_secret": CLEARTEXT_SECRET, "token": rtok.token, } url = reverse("oauth2_provider:revoke-token") response = self.client.post(url, data=data) self.assertEqual(response.status_code, 200) refresh_token = RefreshToken.objects.filter(id=rtok.id).first() self.assertIsNotNone(refresh_token.revoked) self.assertFalse(AccessToken.objects.filter(id=rtok.access_token.id).exists()) def test_revoke_refresh_token_with_revoked_access_token(self): tok = AccessToken.objects.create( user=self.test_user, token="1234567890", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), scope="read write", ) rtok = RefreshToken.objects.create( user=self.test_user, token="999999999", application=self.application, access_token=tok ) for token in (tok.token, rtok.token): data = { "client_id": self.application.client_id, "client_secret": CLEARTEXT_SECRET, "token": token, } url = reverse("oauth2_provider:revoke-token") response = self.client.post(url, data=data) self.assertEqual(response.status_code, 200) self.assertFalse(AccessToken.objects.filter(id=tok.id).exists()) refresh_token = RefreshToken.objects.filter(id=rtok.id).first() self.assertIsNotNone(refresh_token.revoked) def test_revoke_token_with_wrong_hint(self): """ From the revocation rfc, `Section 4.1.2`_ : If the server is unable to locate the token using the given hint, it MUST extend its search across all of its supported token types .. _`Section 4.1.2`: http://tools.ietf.org/html/draft-ietf-oauth-revocation-11#section-4.1.2 """ tok = AccessToken.objects.create( user=self.test_user, token="1234567890", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), scope="read write", ) data = { "client_id": self.application.client_id, "client_secret": CLEARTEXT_SECRET, "token": tok.token, "token_type_hint": "refresh_token", } url = reverse("oauth2_provider:revoke-token") response = self.client.post(url, data=data) self.assertEqual(response.status_code, 200) self.assertFalse(AccessToken.objects.filter(id=tok.id).exists()) django-oauth-toolkit-2.3.0/tests/test_token_view.py000066400000000000000000000175651443573112200225240ustar00rootroot00000000000000import datetime from django.contrib.auth import get_user_model from django.test import TestCase from django.urls import reverse from django.utils import timezone from oauth2_provider.models import get_access_token_model, get_application_model Application = get_application_model() AccessToken = get_access_token_model() UserModel = get_user_model() class TestAuthorizedTokenViews(TestCase): """ TestCase superclass for Authorized Token Views" Test Cases """ def setUp(self): self.foo_user = UserModel.objects.create_user("foo_user", "test@example.com", "123456") self.bar_user = UserModel.objects.create_user("bar_user", "dev@example.com", "123456") self.application = Application.objects.create( name="Test Application", redirect_uris="http://localhost http://example.com http://example.org", user=self.bar_user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, ) def tearDown(self): self.foo_user.delete() self.bar_user.delete() class TestAuthorizedTokenListView(TestAuthorizedTokenViews): """ Tests for the Authorized Token ListView """ def test_list_view_authorization_required(self): """ Test that the view redirects to login page if user is not logged-in. """ response = self.client.get(reverse("oauth2_provider:authorized-token-list")) self.assertEqual(response.status_code, 302) self.assertTrue("/accounts/login/?next=" in response["Location"]) def test_empty_list_view(self): """ Test that when you have no tokens, an appropriate message is shown """ self.client.login(username="foo_user", password="123456") response = self.client.get(reverse("oauth2_provider:authorized-token-list")) self.assertEqual(response.status_code, 200) self.assertIn(b"There are no authorized tokens yet.", response.content) def test_list_view_one_token(self): """ Test that the view shows your token """ self.client.login(username="bar_user", password="123456") AccessToken.objects.create( user=self.bar_user, token="1234567890", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), scope="read write", ) response = self.client.get(reverse("oauth2_provider:authorized-token-list")) self.assertEqual(response.status_code, 200) self.assertIn(b"read", response.content) self.assertIn(b"write", response.content) self.assertNotIn(b"There are no authorized tokens yet.", response.content) def test_list_view_two_tokens(self): """ Test that the view shows your tokens """ self.client.login(username="bar_user", password="123456") AccessToken.objects.create( user=self.bar_user, token="1234567890", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), scope="read write", ) AccessToken.objects.create( user=self.bar_user, token="0123456789", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), scope="read write", ) response = self.client.get(reverse("oauth2_provider:authorized-token-list")) self.assertEqual(response.status_code, 200) self.assertNotIn(b"There are no authorized tokens yet.", response.content) def test_list_view_shows_correct_user_token(self): """ Test that only currently logged-in user"s tokens are shown """ self.client.login(username="bar_user", password="123456") AccessToken.objects.create( user=self.foo_user, token="1234567890", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), scope="read write", ) response = self.client.get(reverse("oauth2_provider:authorized-token-list")) self.assertEqual(response.status_code, 200) self.assertIn(b"There are no authorized tokens yet.", response.content) class TestAuthorizedTokenDeleteView(TestAuthorizedTokenViews): """ Tests for the Authorized Token DeleteView """ def test_delete_view_authorization_required(self): """ Test that the view redirects to login page if user is not logged-in. """ self.token = AccessToken.objects.create( user=self.foo_user, token="1234567890", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), scope="read write", ) url = reverse("oauth2_provider:authorized-token-delete", kwargs={"pk": self.token.pk}) response = self.client.get(url) self.assertEqual(response.status_code, 302) self.assertTrue("/accounts/login/?next=" in response["Location"]) def test_delete_view_works(self): """ Test that a GET on this view returns 200 if the token belongs to the logged-in user. """ self.token = AccessToken.objects.create( user=self.foo_user, token="1234567890", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), scope="read write", ) self.client.login(username="foo_user", password="123456") url = reverse("oauth2_provider:authorized-token-delete", kwargs={"pk": self.token.pk}) response = self.client.get(url) self.assertEqual(response.status_code, 200) def test_delete_view_token_belongs_to_user(self): """ Test that a 404 is returned when trying to GET this view with someone else"s tokens. """ self.token = AccessToken.objects.create( user=self.foo_user, token="1234567890", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), scope="read write", ) self.client.login(username="bar_user", password="123456") url = reverse("oauth2_provider:authorized-token-delete", kwargs={"pk": self.token.pk}) response = self.client.get(url) self.assertEqual(response.status_code, 404) def test_delete_view_post_actually_deletes(self): """ Test that a POST on this view works if the token belongs to the logged-in user. """ self.token = AccessToken.objects.create( user=self.foo_user, token="1234567890", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), scope="read write", ) self.client.login(username="foo_user", password="123456") url = reverse("oauth2_provider:authorized-token-delete", kwargs={"pk": self.token.pk}) response = self.client.post(url) self.assertFalse(AccessToken.objects.exists()) self.assertRedirects(response, reverse("oauth2_provider:authorized-token-list")) def test_delete_view_only_deletes_user_own_token(self): """ Test that a 404 is returned when trying to POST on this view with someone else"s tokens. """ self.token = AccessToken.objects.create( user=self.foo_user, token="1234567890", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), scope="read write", ) self.client.login(username="bar_user", password="123456") url = reverse("oauth2_provider:authorized-token-delete", kwargs={"pk": self.token.pk}) response = self.client.post(url) self.assertTrue(AccessToken.objects.exists()) self.assertEqual(response.status_code, 404) django-oauth-toolkit-2.3.0/tests/test_validators.py000066400000000000000000000041431443573112200225060ustar00rootroot00000000000000import pytest from django.core.validators import ValidationError from django.test import TestCase from oauth2_provider.validators import RedirectURIValidator @pytest.mark.usefixtures("oauth2_settings") class TestValidators(TestCase): def test_validate_good_uris(self): validator = RedirectURIValidator(allowed_schemes=["https"]) good_uris = [ "https://example.com/", "https://example.org/?key=val", "https://example", "https://localhost", "https://1.1.1.1", "https://127.0.0.1", "https://255.255.255.255", ] for uri in good_uris: # Check ValidationError not thrown validator(uri) def test_validate_custom_uri_scheme(self): validator = RedirectURIValidator(allowed_schemes=["my-scheme", "https", "git+ssh"]) good_uris = [ "my-scheme://example.com", "my-scheme://example", "my-scheme://localhost", "https://example.com", "HTTPS://example.com", "git+ssh://example.com", ] for uri in good_uris: # Check ValidationError not thrown validator(uri) def test_validate_bad_uris(self): validator = RedirectURIValidator(allowed_schemes=["https"]) self.oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES = ["https", "good"] bad_uris = [ "http:/example.com", "HTTP://localhost", "HTTP://example.com", "HTTP://example.com.", "http://example.com/#fragment", "123://example.com", "http://fe80::1", "git+ssh://example.com", "my-scheme://example.com", "uri-without-a-scheme", "https://example.com/#fragment", "good://example.com/#fragment", " ", "", # Bad IPv6 URL, urlparse behaves differently for these 'https://[">', ] for uri in bad_uris: with self.assertRaises(ValidationError): validator(uri) django-oauth-toolkit-2.3.0/tests/urls.py000066400000000000000000000003451443573112200202640ustar00rootroot00000000000000from django.contrib import admin from django.urls import include, path admin.autodiscover() urlpatterns = [ path("o/", include("oauth2_provider.urls", namespace="oauth2_provider")), path("admin/", admin.site.urls), ] django-oauth-toolkit-2.3.0/tests/utils.py000066400000000000000000000014131443573112200204340ustar00rootroot00000000000000import base64 from unittest import mock def get_basic_auth_header(user, password): """ Return a dict containing the correct headers to set to make HTTP Basic Auth request """ user_pass = "{0}:{1}".format(user, password) auth_string = base64.b64encode(user_pass.encode("utf-8")) auth_headers = { "HTTP_AUTHORIZATION": "Basic " + auth_string.decode("utf-8"), } return auth_headers def spy_on(meth): """ Util function to add a spy onto a method of a class. """ spy = mock.MagicMock() def wrapper(self, *args, **kwargs): spy(self, *args, **kwargs) return_value = meth(self, *args, **kwargs) spy.returned = return_value return return_value wrapper.spy = spy return wrapper django-oauth-toolkit-2.3.0/tox.ini000066400000000000000000000060151443573112200170760ustar00rootroot00000000000000[tox] envlist = flake8, migrations, migrate_swapped, docs, sphinxlint, py{37,38,39}-dj22, py{37,38,39,310}-dj32, py{38,39,310}-dj40, py{38,39,310,311}-dj41, py{38,39,310,311}-dj42, py{310,311}-djmain, [gh-actions] python = 3.7: py37 3.8: py38, docs, flake8, migrations, migrate_swapped, sphinxlint 3.9: py39 3.10: py310 3.11: py311 [gh-actions:env] DJANGO = 2.2: dj22 3.2: dj32 4.0: dj40 4.1: dj41 4.2: dj42 main: djmain [pytest] django_find_project = false addopts = --cov=oauth2_provider --cov-report= --cov-append -s markers = oauth2_settings: Custom OAuth2 settings to use - use with oauth2_settings fixture [testenv] commands = pytest {posargs} coverage report coverage xml setenv = DJANGO_SETTINGS_MODULE = tests.settings PYTHONPATH = {toxinidir} PYTHONWARNINGS = all deps = dj22: Django>=2.2,<3 dj32: Django>=3.2,<3.3 dj40: Django>=4.0.0,<4.1 dj41: Django>=4.1,<4.2 dj42: Django>=4.2,<4.3 djmain: https://github.com/django/django/archive/main.tar.gz djangorestframework oauthlib>=3.1.0 jwcrypto coverage pytest pytest-cov pytest-django pytest-xdist pytest-mock requests passenv = PYTEST_ADDOPTS [testenv:py{38,39,310}-djmain] ignore_errors = true ignore_outcome = true [testenv:sphinxlint] deps = sphinx-lint skip_install = True commands = sphinx-lint docs/ [testenv:{docs,livedocs}] basepython = python3.8 changedir = docs allowlist_externals = make commands = docs: make html livedocs: make livehtml deps = Jinja2<3.1 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 skip_install = True commands = flake8 {toxinidir} deps = flake8 flake8-isort flake8-quotes flake8-black [testenv:migrations] setenv = DJANGO_SETTINGS_MODULE = tests.mig_settings PYTHONPATH = {toxinidir} PYTHONWARNINGS = all commands = django-admin makemigrations --dry-run --check [testenv:migrate_swapped] setenv = DJANGO_SETTINGS_MODULE = tests.settings_swapped PYTHONPATH = {toxinidir} PYTHONWARNINGS = all commands = django-admin migrate [testenv:build] deps = setuptools>=39.0 wheel whitelist_externals = rm commands = rm -rf dist python setup.py sdist bdist_wheel [coverage:run] source = oauth2_provider omit = */migrations/* [coverage:report] show_missing = True [flake8] max-line-length = 110 exclude = docs/, oauth2_provider/migrations/, tests/migrations/, .tox/, build/, dist/ application-import-names = oauth2_provider inline-quotes = double extend-ignore = E203, W503 [isort] default_section = THIRDPARTY known_first_party = oauth2_provider line_length = 110 lines_after_imports = 2 multi_line_output = 3 include_trailing_comma = True force_grid_wrap = 0 use_parentheses = True ensure_newline_before_comments = True skip = oauth2_provider/migrations/, .tox/, tests/migrations/