pax_global_header00006660000000000000000000000064146670451230014522gustar00rootroot0000000000000052 comment=1d19e54c926f475b4b090533cb23d184ae2f39e2 django-oauth-toolkit-3.0.1/000077500000000000000000000000001466704512300155665ustar00rootroot00000000000000django-oauth-toolkit-3.0.1/.dockerignore000066400000000000000000000014161466704512300202440ustar00rootroot00000000000000venv __pycache__ .tox .github .vscode .django_oauth_toolkit.egg-info .coverage coverage.xml # every time we change this we need to do the COPY . /code and # RUN pip install -r requirements.txt again # so don't include the Dockerfile in the context. Dockerfile docker-compose.yml # from .gitignore *.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 db.sqlite3 venv/ django-oauth-toolkit-3.0.1/.editorconfig000066400000000000000000000003361466704512300202450ustar00rootroot00000000000000root = 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-3.0.1/.github/000077500000000000000000000000001466704512300171265ustar00rootroot00000000000000django-oauth-toolkit-3.0.1/.github/ISSUE_TEMPLATE/000077500000000000000000000000001466704512300213115ustar00rootroot00000000000000django-oauth-toolkit-3.0.1/.github/ISSUE_TEMPLATE/bug_report.md000066400000000000000000000013551466704512300240070ustar00rootroot00000000000000--- 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-3.0.1/.github/ISSUE_TEMPLATE/feature_request.md000066400000000000000000000012011466704512300250300ustar00rootroot00000000000000--- 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-3.0.1/.github/ISSUE_TEMPLATE/question.md000066400000000000000000000002331466704512300235000ustar00rootroot00000000000000--- name: Question about: Ask a question about using django-oauth-toolkit title: '' labels: question assignees: '' --- django-oauth-toolkit-3.0.1/.github/pull_request_template.md000066400000000000000000000011511466704512300240650ustar00rootroot00000000000000 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-3.0.1/.github/workflows/000077500000000000000000000000001466704512300211635ustar00rootroot00000000000000django-oauth-toolkit-3.0.1/.github/workflows/release.yml000066400000000000000000000016041466704512300233270ustar00rootroot00000000000000name: Release on: push: tags: - '*' jobs: build: if: github.repository == 'jazzband/django-oauth-toolkit' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up Python uses: actions/setup-python@v5 with: python-version: '3.12' - name: Install dependencies run: | python -m pip install -U pip build twine - name: Build package run: | python -m build 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-3.0.1/.github/workflows/test.yml000066400000000000000000000047371466704512300227000ustar00rootroot00000000000000name: Test on: [push, pull_request] jobs: test-package: name: Test Package (Python ${{ matrix.python-version }}, Django ${{ matrix.django-version }}) runs-on: ubuntu-latest strategy: fail-fast: false matrix: python-version: - '3.10' - '3.11' - '3.12' django-version: - '4.2' - '5.0' - '5.1' - 'main' include: # https://docs.djangoproject.com/en/dev/faq/install/#what-python-version-can-i-use-with-django - python-version: '3.8' django-version: '4.2' - python-version: '3.9' django-version: '4.2' steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 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('**/pyproject.toml') }}-${{ 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 }} test-demo-rp: name: Test Demo Relying Party runs-on: ubuntu-latest strategy: fail-fast: false matrix: node-version: - "18.x" - "20.x" steps: - name: Checkout uses: actions/checkout@v4 - name: Set up NodeJS uses: actions/setup-node@v2 with: node-version: ${{ matrix.node-version }} - name: Install dependencies run: npm install working-directory: tests/app/rp - name: Run Lint run: npm run lint working-directory: tests/app/rp - name: Run build run: npm run build working-directory: tests/app/rp success: needs: - test-package - test-demo-rp runs-on: ubuntu-latest name: Test successful steps: - name: Success run: echo Test successful django-oauth-toolkit-3.0.1/.gitignore000066400000000000000000000010061466704512300175530ustar00rootroot00000000000000*.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/ .ruff_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 db.sqlite3 venv/ /tests/app/idp/static django-oauth-toolkit-3.0.1/.pre-commit-config.yaml000066400000000000000000000014421466704512300220500ustar00rootroot00000000000000repos: - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.6.3 hooks: - id: ruff args: [ --fix ] - id: ruff-format - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.6.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/sphinx-contrib/sphinx-lint rev: v0.9.1 hooks: - id: sphinx-lint # Configuration for codespell is in pyproject.toml - repo: https://github.com/codespell-project/codespell rev: v2.3.0 hooks: - id: codespell exclude: (package-lock.json|/locale/) additional_dependencies: - tomli django-oauth-toolkit-3.0.1/.readthedocs.yml000066400000000000000000000013051466704512300206530ustar00rootroot00000000000000# .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-3.0.1/AUTHORS000066400000000000000000000035061466704512300166420ustar00rootroot00000000000000Authors ------- Massimiliano Pippi Federico Frenguelli Contributors ------------ Abhishek Patel Adam Johnson Adam Zahradník Adheeth P Praveen Alan Crosswell Alan Rominger Alejandro Mantecon Guillen Aleksander Vaskevich Alessandro De Angelis Alex Manning Alex Szabó Aliaksei Kanstantsinau Allisson Azevedo Andrea Greco Andrej Zbín Andrew Chen Wang Andrew Zickler Antoine Laurent Anvesh Agarwal Aristóbulo Meneses Aryan Iyappan Asaf Klibansky Ash Christopher Asif Saif Uddin Bart Merenda Bas van Oostveen Brian Helba Carl Schwan Daniel Golding 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 Fazeel Ghafoor Federico Dolce Florian Demmer Frederico Vieira Gaël Utard Glauco Junior Giovanni Giampauli Hasan Ramezani Hiroki Kiyohara Hossein Shakiba Islam Kamel Ivan Lukyanets Jaap Roes Jadiel Teófilo Jens Timmerman Jerome Leclanche Jesse Gibbs Jim Graham John Byrne Jonas Nygaard Pedersen Jonathan Steffan Jordi Sanchez Joseph Abrahams Josh Thomas Jozef Knaperek Julian Mundhahs Julien Palard Jun Zhou Kaleb Porter Kristian Rune Larsen Lazaros Toumanidis Ludwig Hähne Łukasz Skarżyński Madison Swain-Bowden Marcus Sonestedt Matias Seniquiel Michael Howitz Owen Gong Patrick Palacin Paul Dekkers Paul Oswald Pavel Tvrdík Peter Carnesciali Peter Karman Peter McDonald Petr Dlouhý pySilver Rodney Richardson Rustem Saiargaliev Rustem Saiargaliev Sandro Rodrigues Sean 'Shaleh' Perry Shaheed Haque Shaun Stanworth Sayyid Hamid Mahdavi Silvano Cerza Sora Yanai Sören Wegener Spencer Carroll Stéphane Raimbault Tom Evans Vinay Karanam Víðir Valberg Guðmundsson Will Beaufoy pySilver Łukasz Skarżyński Wouter Klein Heerenbrink Yaroslav Halchenko Yuri Savin Miriam Forner django-oauth-toolkit-3.0.1/CHANGELOG.md000066400000000000000000000735301466704512300174070ustar00rootroot00000000000000# 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). ## [3.0.1] - 2024-09-07 ### Fixed * #1491 Fix migration error when there are pre-existing Access Tokens. ## [3.0.0] - 2024-09-05 ### WARNING - POTENTIAL BREAKING CHANGES * Changes to the `AbstractAccessToken` model require doing a `manage.py migrate` after upgrading. * If you use swappable models you will need to make sure your custom models are also updated (usually `manage.py makemigrations`). * Old Django versions below 4.2 are no longer supported. * A few deprecations warned about in 2.4.0 (#1345) have been removed. See below. ### Added * #1366 Add Docker containerized apps for testing IDP and RP. * #1454 Added compatibility with `LoginRequiredMiddleware` introduced in Django 5.1. ### Changed * Many documentation and project internals improvements. * #1446 Use generic models `pk` instead of `id`. This enables, for example, custom swapped models to have a different primary key field. * #1447 Update token to TextField from CharField. Removing the 255 character limit enables supporting JWT tokens with additional claims. This adds a SHA-256 `token_checksum` field that is used to validate tokens. * #1450 Transactions wrapping writes of the Tokens now rely on Django's database routers to determine the correct database to use instead of assuming that 'default' is the correct one. * #1455 Changed minimum supported Django version to >=4.2. ### Removed * #1425 Remove deprecated `RedirectURIValidator`, `WildcardSet` per #1345; `validate_logout_request` per #1274 ### Fixed * #1444, #1476 Fix several 500 errors to instead raise appropriate errors. * #1469 Fix `ui_locales` request parameter triggers `AttributeError` under certain circumstances ### Security * #1452 Add a new setting [`REFRESH_TOKEN_REUSE_PROTECTION`](https://django-oauth-toolkit.readthedocs.io/en/latest/settings.html#refresh-token-reuse-protection). In combination with [`ROTATE_REFRESH_TOKEN`](https://django-oauth-toolkit.readthedocs.io/en/latest/settings.html#rotate-refresh-token), this prevents refresh tokens from being used more than once. See more at [OAuth 2.0 Security Best Current Practice](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics-29#name-recommendations) * #1481 Bump oauthlib version required to 3.2.2 and above to address [CVE-2022-36087](https://github.com/advisories/GHSA-3pgj-pg6c-r5p7). ## [2.4.0] - 2024-05-13 ### 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. If you are going to revert migration 0006 make note that previously hashed client_secret cannot be reverted! ### Added * #1304 Add `OAuth2ExtraTokenMiddleware` for adding access token to request. See [Setup a provider](https://django-oauth-toolkit.readthedocs.io/en/latest/tutorial/tutorial_03.html#setup-a-provider) in the Tutorial. * #1273 Performance improvement: Add caching of loading of OIDC private key. * #1285 Add `post_logout_redirect_uris` field in the [Application Registration form](https://django-oauth-toolkit.readthedocs.io/en/latest/templates.html#application-registration-form-html) * #1311,#1334 (**Security**) Add option to disable client_secret hashing to allow verifying JWTs' signatures when using [HS256 keys](https://django-oauth-toolkit.readthedocs.io/en/latest/oidc.html#using-hs256-keys). This means your client secret will be stored in cleartext but is the only way to successfully use HS256 signed JWT's. * #1350 Support Python 3.12 and Django 5.0 * #1367 Add `code_challenge_methods_supported` property to auto discovery information, per [RFC 8414 section 2](https://www.rfc-editor.org/rfc/rfc8414.html#page-7) * #1328 Adds the ability to [define how to store a user profile](https://django-oauth-toolkit.readthedocs.io/en/latest/oidc.html#define-where-to-store-the-profile). ### Fixed * #1292 Interpret `EXP` in AccessToken always as UTC instead of (possibly) local timezone. Use setting `AUTHENTICATION_SERVER_EXP_TIME_ZONE` to enable different time zone in case the remote authentication server does not provide EXP in UTC. * #1323 Fix instructions in [documentation](https://django-oauth-toolkit.readthedocs.io/en/latest/getting_started.html#authorization-code) on how to create a code challenge and code verifier * #1284 Fix a 500 error when trying to logout with no id_token_hint even if the browser session already expired. * #1296 Added reverse function in migration `0006_alter_application_client_secret`. Note that reversing this migration cannot undo a hashed `client_secret`. * #1345 Fix encapsulation for Redirect URI scheme validation. Deprecates `RedirectURIValidator` in favor of `AllowedURIValidator`. * #1357 Move import of setting_changed signal from test to django core modules. * #1361 Fix prompt=none redirects to login screen * #1380 Fix AttributeError in OAuth2ExtraTokenMiddleware when a custom AccessToken model is used. * #1288 Fix #1276 which attempted to resolve #1092 for requests that don't have a client_secret per [RFC 6749 4.1.1](https://www.rfc-editor.org/rfc/rfc6749.html#section-4.1.1) * #1337 Gracefully handle expired or deleted refresh tokens, in `validate_user`. * Various documentation improvements: #1410, #1408, #1405, #1399, #1401, #1396, #1375, #1162, #1315, #1307 ### Removed * #1350 Remove support for Python 3.7 and Django 2.2 ## [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) * #1264 Support Django 4.2. ### 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 numbers 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 compatibility 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 `localhost: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 information 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-3.0.1/CODE_OF_CONDUCT.md000066400000000000000000000045071466704512300203730ustar00rootroot00000000000000# 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-3.0.1/CONTRIBUTING.md000066400000000000000000000010261466704512300200160ustar00rootroot00000000000000[![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-3.0.1/Dockerfile000066400000000000000000000042351466704512300175640ustar00rootroot00000000000000# syntax=docker/dockerfile:1.6.0 # this Dockerfile is located at the root so the build context # includes oauth2_provider which is a requirement of the # tests/app/idp. This way we build images with the source # code from the repos for validation before publishing packages. FROM python:3.11.6-slim as builder ENV PYTHONDONTWRITEBYTECODE 1 ENV PYTHONUNBUFFERED 1 ENV DEBUG=False ENV ALLOWED_HOSTS="*" ENV TEMPLATES_DIRS="/data/templates" ENV STATIC_ROOT="/data/static" ENV DATABASE_URL="sqlite:////data/db.sqlite3" RUN apt-get update # Build Deps RUN apt-get install -y --no-install-recommends gcc libc-dev python3-dev git openssh-client libpq-dev file libev-dev # bundle code in a virtual env to make copying to the final image without all the upstream stuff easier. RUN python -m venv /opt/venv ENV PATH="/opt/venv/bin:$PATH" # need to update pip and setuptools for pep517 support required by gevent. RUN pip install --upgrade pip RUN pip install --upgrade setuptools COPY . /code WORKDIR /code/tests/app/idp RUN pip install -r requirements.txt RUN pip install gunicorn RUN python manage.py collectstatic --noinput FROM python:3.11.6-slim # allow embed sha1 at build time as release. ARG GIT_SHA1 LABEL org.opencontainers.image.authors="https://jazzband.co/projects/django-oauth-toolkit" LABEL org.opencontainers.image.source="https://github.com/jazzband/django-oauth-toolkit" LABEL org.opencontainers.image.revision=${GIT_SHA1} ENV SENTRY_RELEASE=${GIT_SHA1} # disable debug mode, but allow all hosts by default when running in docker ENV DEBUG=False ENV ALLOWED_HOSTS="*" ENV TEMPLATES_DIRS="/data/templates" ENV STATIC_ROOT="/data/static" ENV DATABASE_URL="sqlite:////data/db.sqlite3" COPY --from=builder /opt/venv /opt/venv ENV PATH="/opt/venv/bin:$PATH" COPY --from=builder /code /code RUN mkdir -p /code/tests/app/idp/static /code/tests/app/idp/templates WORKDIR /code/tests/app/idp RUN apt-get update && apt-get install -y \ libpq5 \ && rm -rf /var/lib/apt/lists/* EXPOSE 80 VOLUME ["/data" ] CMD ["gunicorn", "idp.wsgi:application", "-w 4 -b 0.0.0.0:80 --chdir=/code --worker-tmp-dir /dev/shm --timeout 120 --error-logfile '-' --log-level debug --access-logfile '-'"] django-oauth-toolkit-3.0.1/LICENSE000066400000000000000000000030241466704512300165720ustar00rootroot00000000000000Copyright (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-3.0.1/MANIFEST.in000066400000000000000000000001161466704512300173220ustar00rootroot00000000000000include README.rst LICENSE recursive-include oauth2_provider/templates *.html django-oauth-toolkit-3.0.1/README.rst000066400000000000000000000106721466704512300172630ustar00rootroot00000000000000Django 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.8+ * Django 4.2, 5.0 or 5.1 * oauthlib 3.2.2+ 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``. .. code-block:: python from oauth2_provider import urls as oauth2_urls urlpatterns = [ ... path('o/', include(oauth2_urls)), ] 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 `__. Discussions ~~~~~~~~~~~ Have questions or want to discuss the project? See `the discussions `__. 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 take a look at the `discussion about this `__. django-oauth-toolkit-3.0.1/docker-compose.yml000066400000000000000000000014721466704512300212270ustar00rootroot00000000000000volumes: idp-data: x-idp: &idp image: django-oauth-toolkit/idp volumes: - idp-data:/data services: idp-migrate: <<: *idp build: . command: python manage.py migrate idp-loaddata: <<: *idp command: python manage.py loaddata fixtures/seed.json depends_on: idp-migrate: condition: service_completed_successfully idp: <<: *idp command: gunicorn idp.wsgi:application -w 4 -b 0.0.0.0:80 --chdir=/code --timeout 120 --error-logfile '-' --log-level debug --access-logfile '-' ports: # map to dev port. - "8000:80" depends_on: idp-loaddata: condition: service_completed_successfully rp: image: django-oauth-toolkit/rp build: ./tests/app/rp ports: # map to dev port. - "5173:3000" depends_on: - idpdjango-oauth-toolkit-3.0.1/docs/000077500000000000000000000000001466704512300165165ustar00rootroot00000000000000django-oauth-toolkit-3.0.1/docs/Makefile000066400000000000000000000153471466704512300201700ustar00rootroot00000000000000# 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-3.0.1/docs/_images/000077500000000000000000000000001466704512300201225ustar00rootroot00000000000000django-oauth-toolkit-3.0.1/docs/_images/application-authorize-web-app.png000066400000000000000000000417671466704512300265130ustar00rootroot00000000000000PNG  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-3.0.1/docs/_images/application-register-auth-code.png000066400000000000000000004344171466704512300266410ustar00rootroot00000000000000PNG  IHDR@zeXIfII* @(1 2iHHGIMP 2.10.342023:09:30 22:13:00iCCPICC profilex}=H@_S*vP.*XP Vh/hҐ8 ?.κ: "ƃ~{ SͮjNlnUyE/FB3T* u_<ܟ_ɛ 1̦s'OtAG.q.:,̰IbJJr&GU xiTXtXML:com.adobe.xmp mbKGD pHYs  tIME  H IDATxwUZefާ'tB t*bE_^ұ ]Ă/bCPzJHB B 9m=3XIy\svvfͬ%=!B1yND"H$߂sZF^{)%CgѢ(R)Z )%N"H$D=vf*~qk-Zk4%˲OgH$D"t4H[UհbZ;b%EAoo/YW%ZkD"H_rD!nOww?(VΗ;9PJmyH$D"M:zQ: Hk{zS!Rޏl|SP%IīD"Hc ZksH)QJڦ cLe !pDD"H_z3Q1tt䡒^+Z;H$D"m:nNh Z&W-?H$Dts:ʯe6:26a$D"7D"H$P$D"H@H$D"QE"H$DD"H$P$D"H@H$D"QE"H$DD"H$P$D"H@H$D"QE"H$DD"H$P$D"H@H$D"QE"H$(D"H$ H$D"("H$DD"H$(D"H$ H$D"("H$DD"H$(D"H$ H$D"("H$DD"H$(D"Ho:_8Vo$՛5߹Lۉ/7ۿG1icv}HL?Ҝg͎U/&q6-7~^A}Fs6iS_Nyfz7U#o6^M6ޫyo|xjīߵ}!_x;؆#)ߤ Gc7ه|qn.5.k] {5r#ig(69oٷw=__>gcO$-@@6Pw§£%S6m;_ yNqᯯ>0pXSXoF޶z fJzAu\#ǗE8j{9I8,%%qv;ykrkPZG$h!ja 9%`A(0h T529Zy j@靲~tm c|o* jσs]):. LK27H USA(2,0V @KࢲpOǥ~ #}:_8L7sU/ogR܄DK$%"XZ%HiP:SZ32AiqtpH+Q FoGHDЪ, Z[z؞ ^Mg+C{qxt#c٤'%wP5h֨@%R @"KjG}Z6'QQKGc#&"H̅'Y0W!HU#XtBKHZa$#4A.ۭ0&I5 O"ujhiu-> UV0 KLN` xI*aQS:{xQ&Cgn R胰9{$I@)֖6'%z(HFD:_GȴT"XWH8epJ}cہ&>Yʁ,n#kkC+zt OcKtx*!Q.I+[^WO)ùơxQGㄢP&V$1e*, 2 `Ւx,s^]3PZȤ (RmK.%ItK sQD"ȏZ(AkjIخlӅϧ%޾߶d l\p<,ُ•=4@PpiIC"qyP"B[Cn"DP8A7% .&Bu]BA%5Η#B:$Ifؠ ֐)2;p9Ę/aT $$7!Q:v@TQ"C%xU)h(w*MipTNܑf$huhH#*E)wH)Ce4MRVe%L+Hx+LAiKIE%-/h栲,82b2p$M4 O]w:mP0R&2e Ia1a.ґdUw JoP:O[2`KR=҅h2UL&KI-rs]I=U fz"!)˲aQ^gHtBrf*X$LJj$XFMe5S)@&L(дGQmXSgx4[#\+GEG EY% )H JKҡR$c^x*߅5brphA@n& UÅD?jHБȿxO*Y A O$`*w 0ƒT IcO1PnDhs_f](6G(Xs[yxZFO/'L#:wq GIl e+/,ҵ똸Ŗ|Õ7̸͒}|/f*N1,_f>3;> aK4` =F̛E!5`?M s;̧//YPLd :>3Ч 6W7{z+Vn;27OĤ.4ѶO|g/aWx!Ӹ \o #.v![no7¬33o;|pLQf* ]I/>ç?yV j=Od;;{N>{ HϔFm2fv,X2ͦemIa&S-/_?t"Mkeu ;8?x!az[-w¶y{80\f3M\~ELc^ .p6wst/,|u0mݞw>x*&'_c}?X|-L|%}Al]OӀU+ࠅl"_-v%wrK}ּwsc\ Atu-2AQ*ld__y^o=V\짞`` O~7hkvV C8 E"(x;"!@Qg1Ɛų>2JKc1`I,߻+5^z.3z .のāg峾 e]SW,_ҹsh洜Gor-R lM1(_ *([Umnq3N;b{ԛ!)U>6eu4[9X']s̯^59nZ[p%2ۢEpѡ0߼ُ?N[i~Zqg/ >M0eN9Y3yv}.%U'4FSXo1qlpK墋ŋA8iHky|A+Q[V/'#2L/71o91ݶ[2e0ʵl5(byf"}3-[aQY %oi{Ewoۭ%`]$\ p'%IYn57ᙳqߓ\vy4dzX;MJ0 ߀%Z[.zW2K\ C t= A2<y酬_n vAWp?9ë. Ӑ5X_PZ1ҹ*+0}-Vu+ Ţ3i2Iy5+p_W3c W/G\9P1b<1q~`6|Sei*X?\;WwiҶ9}c4 9C"0E 8,Y̗:^\AvQh#S%?}p}|ޤYRQRX8̟OEk u+ l5'|S9R!X:H_?@M B)@((6xӪ0.X4B7vɤ[R6Ook~w.-?8_yM`a`=W~ 8U'O>| >{y;yjäRv#29GZS\[OW;nF)clig}W\_r'OHR%cʍ ./q*d WZ s.+ aD7淿MC,[^m !,H`3n2{ uWJwuqpu@-k ]9C_]ȸYUJy5umSo{d<<.=I[nܝ%4ŵK2ݠm,?4zrgsrA!9sյWT3(K;9ۮDmYJ^4c8/Be\jjm9Ӹ8ol _̜I`\~,ev;qW?\ˉ-7"CG?!{Oߋv-=o;H~+OWHRhZP>Cq:-|ҧ'~.sS'a9hpUz2d/޾}GIH/s_fzxf1> BvIHT!(I2C$ iyﻛ˿uFaLrGzNz kXwrIhO8 <}ӄK/d)C$ &չoQG3вa@*(]wݙF+V#BMme(Z?L`Ƕ[aHi`*I&Yg1~Tx{{'ޙy!NḢX`=#uԫlݐaI7Uc@~`Ǽ 6oɇd=g&>04 KMF!PޣFlF="a>OYxWccsQ1a52mЪr%1mzhKni_t(|ea xO~ k=YWC=f<̲ŋY>k|Eb }y*NVW|ڰv{61{qmГxZ$h$'%hoܕ/|?yfz4)q ǟp $@d2zNʫ~qOȂ%k3Nk N(uHC*BКy E[xqT.hCfhZf>3c7^+i YEfȀǡ;V]>H[wؓs~ۃ?q+zz242I6蝰5[#!}zFjbqp VN@qɥq; ݶ'KCM/碳?Ij K`(%k\ɸ wy0_;|myt,sJ C4FA@ȿCJj'BԇZ#Bz~|D K*liҡFXɐM]|+S (0Vp4yai Mk o~M5Ug NBH)Γbc7¤cHk >pI)&㧃BSJ!2XIRn;zy $ ^7mWt6]. Lhc '#U`F2GSӾ NvJO. IDAT Xi9C("UQ!Ɓ!^ơ8w.[`B[j[x|LlfҔJ857a ؘÎ;L-U`,//YBxRYbn;w?.;9:t/,]Nsu}=2* p>˪L~t]x)kF$TL8pE$Գ 7H&MNB{meQukY͖ڭGPY{?6bE5jùR]lXغNhWȮU$SuH& -SY+@-ZDdXC8!CyaP&2qdT: $ZYJQoqGݟdhHvBcLu{wvƍ8<  *ȤɭAk$nm` &Gfs_AFwZöqb3Bۙ cie>佯L x{ƿ2E xRx0YF頴 7Җa RIe0_x]$2ƃjrl5yE9Wcp2p1vYk.ȃDݴS^Ki6Ȯe,`[[bZVy!DZӘl?kxn|qYZm(b70OG+Ȏ{ͷ3{\V_ECktѦO:18;t*Fb%{n/8=˒=L-۬Yשջ.4{0?ۊ^iZyM8E+DF)2PEż:6e]^GZe K#QLa'&cHbܞC~Snw&/,^FP$h+ k⁶) A$R 6Z8'F'-rmim,mWg; ZV~LSv[<0J\@ d"Qj*ak*'w8z iZ^cD}caHV@d1e|ZXjR`s^tJco2i&kQ^5ڥdf(;Z2p W-ʆDk@j7;/6CQKʶ8Aݮ/K'oE~_].^90 m|f>w?sh AYkXxch6Z!!B=_.ȝap+ C݌E 6ki R'#Ueh+MR)KIWIbFY"UV' ˅BYϏO1!eAHoHM!=DjB撰h9JRȄ—D-7O_dz˞`/>?x&Ne߃D.[rBAO~g=KtLG;|gҵ%?RAbH&L:uoY%reAI5UEZ;P$ -VxᰥE9:9)5:IHt(X)Q 'K9 h]Д"ʼnYe[$$!ZWoXĄPƅZh73O92]ڎm܊q `՚O_'}|"~}•q!fklbd-Y-Ѹғ%)#EUF;|B^G߿PTu=aZ<`}t s<ʾ 1H9hx{yl5O[HLS }cEHC5P Ƴb۟p~N×d5߾_/d:%5 KLbKH#( OO-Ii4-6,Qp%TT m0:Eu$^uU-@/J)Y h$!@]'.*Ynqau;CݎK-޾TAsu A(J_Ce$IB&U..!Aađ"dJwO6Ⓜ R*4JfJ)ip:`r6^!SbQ\Յ7)DK‘67]ں4(sLk<Ҋ‡{=-6;zu:em iQXʚJ lfۭi5gY 9uex: ^ji?>d€J:jwwǟŗ\x] ^VԴE3G۪lc++Wƶm Aʊ5di7!IRTΡT;(ۡu¯aR)F>_WK=CCC$#>.m9Ehđ*&#h?xW ,uno+0"T&v,a&o46T2щWntgop*% VS$IX[X+ C%*"iCY\ZHQp0%>ow h7yɺƱ9؀htc:2ڥUH|,TZAsKX!12őldڢ] .6s EG3nL==k,;Z˰ E4JĶ#+n ъNfe$@At;t9R:i7-apm>Ա|)ȕ)CV$qTTChA#^.:lzWˋpv}} Fafv?^vTYpdhp#[ 4x4ʰTT͹[_?S{g9 b㪕3O" J5艟/3,$s'tɜ%|'>ޛ S&aYNQlW}:~ ~?7XQmf&uvjx<7u6ٜ3n\{{n$#:UuJ/(A>XEZ^g_k,v; ki]SN1YBjeEΊE0u[ϐduZ lKDuTEYR'16XJ $=qL:6Ξ\z {Mہ_NvqکV 3a_ggh,nj=(}L{OV>8_uD}~=5FøEf]Uo펤 K\?z^z}2TV')rtfbfZvڂ)IzN)(qƗN?fEx3=)8 $^b݆~\-c9|Ϝg?]k~~ ֋:q8wBNuw(Zժ%~f J; cՔ",ؤa#ȿR9ߪF^EM=U t }9e&q;z/yЃ^Gn)^a#;oDh[lW;1ifc&S^@84!?P6 aּ8p5W!ZTѢ'Iv/}/І!}̼Tƣ:I9CqoF<1q];y;.]O[z~3}jο;~i Y{, xdI|dם`k4U7LS-jaUnVYkp=~71vw7yv[.seŤë8>W52:+cB0hPI- BȬNY dx2B;β?Uuν{z0C *AWT0`BEEń]QQ@Qք &a߮ P1g ? f;WwpB z6o;x1LoZiXp^.ga{Z --^Ccmxl8uj-qUMxԨУESJ,)WƄ'9(u)%M4Zr ukd+:X~kAWn;vqB=ơG?Q>,(= ;+wܑÎx)\uh|>O8~,cSz]6bo:x})hQT 6CgcPhպz>sr_-5cɒ-Cgz*@Yw sW LJnk}~1Ń/_lLtZ_9ؑ c]{7[#|%ݽQmPy ema||z·x?1* ˶ޑf>޷1ae tЭ, \cXIb[c 5mq1@ه .8W%llx!(͢93??vPhy<% Zaxͫ_;O}?c8#)ƍgKXl?elbeG< J8ӴP[,^ |^!da V{OUǼ/\l|IxZp]NPcG n8:tia]Kzq"ԉyGLfS&''ge{֭[NJ+vRѿ Lu5+GaZp)Z⢚497Rxeqg./`c`nHO.x1.}N} ߊn-Ѓuʣ^onIͫVᰧLAO6FgKՊs4) }(*E^*v+Z|6@S\d}hV&1ͦB>!nK2Y9i;8+1_)Pbs(ll)"ݦ^tNӥꁟnƖ2 I[75Xu(X0-t ےksU6)1Ӓ|P)CH7@u}<Ћ1&SMYI+~u.n*8A:7*ǩ=~ˑ/|.^[0قcN|,v v&Uv|"v >pnYul1ǿ$81PGU 4=t_hNtٌZ jXX@˯eƩCA0b9OkBjϪpCÂ}Q6!%YjVUPi$$K_q@<5b}x&*״R;0؄VCi1%Ä}5 IPRئPqIL&s57J#<5S@Ek,&_רDa3ﭐ0Nb^f=ZR)WЈMfno}WfqeGA[CY_-^NtUU qT0CC)Ai/PC˩Cʙ6nz=hw:\eQMY"eb+T8 q{"12(uuڀ Ԥ4QӤk3F1:e;LK hٞ ZQu@KYThYR1Ez{yV5E;E=( *N(2In u'H2*7CU;c ֺhQ]& -MW0/j[jJheQ\QJBq߰X LXE#LF"`-ʔQ+2oU0&Wlj)0^j`mˍvt?.E0IҶQ\AB\Rι+ZUEQI)=Pl݈QGCi]f8Sa&kLoV JET. %:lQX7HUJfEgF\̎4v;n-!>^'ykX{-[OD3NUl.X܉UYO,7dc@B)t%1H+5`@XkѦ$hqtmFEh2\ݥS#GT\Vs?7! Jcvl >\R`FT6i:& x-i G^#(Ճ(90Hl@¹BUF e, PTU.>8|p )6P"()Ilf_5J"MTEETXWnэ؀cRru/&1"xKQg'v E{߿ƔЫ|ä9BGZg]̱R@붎"1YL=>^\$QZF'C &FY-!P1H?yRtp63r ~w~tZ uگKK2:Y4.0e*xV RUq>@yQ*ZŪ^i&@̦du_)Nl#Rsb?e>UK3@{F7dU>$! A_2x2ZJ(s9Gsb]6=)Ż8₋1 <"qWu+@s1a»4Mx-h!PUE1dѦNcOnj(jDzd pXqSt&oAPsu6ihtƹJ .$& 1}wKJ"Sd*~>m 8lsWSP[t(m²Ll$P#ϷHCSOE#\ 88ZMMVbR`PS9ڳIz%qc6E鹛6!eqmtXPqJ <>%Ll\x v&BB7;VѲEd #/}~u~&@eL7 qKja$fG\c %˦HFkM6Hl#C9|JGl8Z+)cPr t&i/2:ϱVf5!j/+x75% ӸVuHq(i0 ;T{f!C|Ȕq!H٢&7*@Mƹ%Ph1#@=wJՈo[PM^?ۚ %\]WQ_ن0Xa~o Ϝ#ZCܼ5 @u&iu"JBZ}fpnS$YځǖKnS>ФKCR*q@k\$)n6 ¢ZҟӴkﻨڐOr[|Ȉ`t|zU&8h)s)T}ŕen±RLP bSjCq3 'KXPЕh6d ۦ2D(oiQ)BP EX5MZLH$V:F-AtuTfyaTEbu7рSBUWtbnzaD<^A's*D!eՠeX&s/_{tBƨ8 `J hn$Mfi} Cn7i)ӡۦ_j36dҰ锪cF |CRDo#b.&yeZRF! 2i6m>Ɣ́Hz*ס\65`I>&b܌4qЊ8T|T>4JSk N:^IލT1E *ba3hM0.xo?9  3|h8!dxtK-?20*%i߈чQhKՏ3_}Jאu]k1L 3H[EY ~h{ #n6*,)3nJ-=N9lfw7]PA>P/԰5O)z-NaV7>,nAKy*q AY786!e3fw%'F}fZ̰TH9e֟LoX|/lޮ) hO5Ѻtz86Ct]Ye &'C:N>VPSlpUwH=nsHsA6&;8IS:d*/ C36vc\\,v2|:jCysT'̺5*psO&м 1x  > o4jh6fY $ THM~62Ȱ`֝ 9xc+FPW|A`$VbƲ9$f}>;WYvqoyji]yyY<ؗ ~Rw; PS+g1EȬ2aY~K6p- m=γ2rȚ,~%5^טd yE}D$7ކ/I1$r{-Vػ;ߑ|?BO_j rwΫqy_l~+{ܳ뿻,l:r+d2L&P&d2L@L&d2Ye2L&dd2L&P&d2_2&A&CkMUUr-LNNn:LgbbqrKʲ췹L& Lffzz뮻1,YŠ+7d2z\s5lvt:\8,2MI3]z5eY|rDcrd/:!nFV^MVL@̦ovvqGʲkG[l5\Ê+l/PUccc8 'hڒs1ʅ(xޣ d[fCmiwL@̦j`I#"u=^&okD=!ܾ2Ye #/3C 'l#ƾ7 U)W|EDL ִ d> Йzp'vj];P">y#x6'6/OWtOfͬt2̽hC(0ƧTsV9 rTtچƀ:Z Q e4YBP ] 10 Rfd2Ye2Lr቏9W}5B;_*x˙GAYj( @mZ{` x))B AG"J#J㬏 iF#d2L@=X0Q. Wjo`CSCYI!E)#Qi " *a'{]xeX]hmf4 ?O& 'BlRkײ]prHvf xOVzXw|cf-W_+x mz|`ن_o[ti71=RZˇ"_בz l-o>4vmW9)HCL&k#[2\`m\ßF1} OhFg2L毁lz d vښCN?4wUi|;+%t޷xN1 1Q(h"` $c PWZD)>/׾{PMQti<(![z2LlBDnћ x!=|募;W3`J~}χ}Ѭ_[V݈")BEUoeˠuAXKm0*Ы[5Ƶj;kkmx2L& LuC РOڇ]?f`Xv\i*>Pu{r- Nt:Q8!tZ06| ~ \yxG\Z Agi~e2 ,iX?| 5ε Py%?|t1yޯnE>,樣b=g`\5E{.2czvQ`q?DٵX;YD󔥚{ ï9D&5""]u[+V &!7u;xP@0x(u+сitƀWBg:h Vq@Exk04"81@ǍȘR sG5е!?9On;1.c}7ԛo R(ޏ1afӡPU%`I^z([E_s :h([Ѐ vmP7J. Z id2_l2<8 Σ6VEIGuPPF`]5,` BeH; =P9E|P zulcކ:L&(/5pPET4(T[i9 J )0J 1E(D)se UU%>XsZ-ZZq?9Od2 ̦EH(y/QHvC7^=Z (mv$6, ~4~ik-"C78LӴve6{sL@p$3L16|ǻrC[?N{ֺ9d~jRcmZ\dd61{Zׯglll3Ye6!68Z"uC#`JC@aEE8k4+pqDFJBx/%i&^\tEW tuQ2oFǁhZT {cqLU9%AcI44=EBn C7NbClMi|ncH*QIGj vAQ=J)j[zǕ"JJER P(Eh=I%TwAT`HK8mX4: ^,U0x hmۋ7REq]!BwoŌq$>UQԀj.QRCFC`Z!$[ՈI_T|{w},JV=m* zAZ𵋂7X,s8T2 xU gĶi]%QU`RЩm=G@Q8b5IT.{Zvy_>;٥c3gT6_,81-Ǝuԯ4hbl26#8ns5W^}Gݪ|\0˷ GL qAShP- r4ā>!+|?XҡbN+uwf|"DA!G>3]{}k9D)z,,Ak-J>,$"]rt`-?s7#oQZ .YڤP4,5y{wDaƕA z?\v/Xv>xi,]9gX(Rһ,{߿ف W:(2Kq q;zG;h/|1g{~,g|,'ox9VX4JzirL4z(wrv{+uQϫV/^zU6M,fʡ/z ;YyzApbꖛ9J6K-a=y9K 4G}W^'NGy?<$_ep`ii0D,yɌo==e31~/Y͘Qz|8+OA[-┷ϔdE*V5;O'\s %{qg>V˖riM`Nc3z =x389/`aqi*O:11sIjMLOGr'fm(AsO>vTi~|/Ĺivr^Qa<]//~^q տ-W^u't,,۷x{_堕\^RK~ȪoavGTuSu+y0o9,۬wRNx)'Xp:D%V8˫x,<&k濿opxɧv+si]<'d8oyb]sǚoZf;,kWs몛xϥ= _OЦ$Pz./Xt9U-v[SPEoziЇAO}r2v( Żs6|' :S|/ßטcU&PfzӔ2m=f(n\k)p ŏ~}~B /w`feCEk.ߑ}7yHc| ozQM .-ףS(*1?߼.~3~yO~c>~0(k%+{?gC㠧x: Y8ъV+](Nw19 .xo$ͯYum=7} b72۔?\˩7O~bK;cL:7\%ڛMf [ hZ4맦Y~KN LOwWro_> j8򉏜n;|tulN1F@6?]wҗ}Ksd:Cy_7UqL$w[ Vay$Z.br>_kg$'~<'rEZc}~={Ge{\p苏ؓߊ’yOK?󿿏n/'?E0=5IO]p!wN|c_~Ski-O{.{?b9g]?BacL̃.:X/uy-~la]pFƘ6 4~A\MvGUaTǣ^̹(U@܍?LٚJ׬b=9/;E].be~:j.gӐX*Q<4FEϨ fSvjӾ}9p^x~~+ lM|?Ksߓ,d+EW0gmT>bEĬM6оڗxgNH)uU*Ck !;R{5r8ggDQgdєI:Q픕Zgb_JX3l6Y=.kUN@$=Rj*x)gԨHBz{€MifiI/V83.xXC2qb/Zڂ;}:\O +٤YGeL418OYy"qAa 5L!6GW_C0en-Bꈞq>S;Gx:;@fm:m(Ђ3RY7j&9>Y3&)Y4M % KSV#qXA-8cn:Gt_Q!.UX7",#Ij\#ZBe޷~ttn˿sW~zS :q<.S7VvaԄ, :S/c B'R{7m9Zv~L<m]tRDְ!&#WjDQhe5:+%z %A GXS:~9X@ T);z۸CԒcCŊ`>m'@jj"LcV}9FjjC*DT QZxp.<3Pr|@d$YAEHXdK,f)t-G 577ăd5Zyj?,@o]jNORq̂hYF%8[2qie*jCTj%nFxBFMq'hAfhUyܿK\gD ;:j:] Nf=ʓ CFaT8C"Ju+e)[нmnm툨L5ıBڨ'76Ex$H5uL$vލ}mtL$܁eӴ+&8!L>ѣ:='B7a=r(j)meVg$Хe+@H:ǎF4/,{)g2SBS\|b2#6]*C ʢ{T[LD^U k{W?X{$.C3n c?{xx1-dM})s6937橇'^2jx&F#Bymn-W`&m%8I{{7Yj!4#:1YBB\(͸nm\ﯽ;-Bd!: U`?fKŀS*EᬦF\XJc*b؄y3&#SZFևH]wDd>u֭ k.1Xs+ޅ %QTQc<X1j(fO@\(fmR,7s+ @P#*IBGS DI֒'zgHM2>#)q\7jVJ\RP\RQwyʿHV%y|S|ȹg tuw&TZ@ݣØ1=jl Ghg@Liooo -A+R稴@ʔ#- +&r-^b236ߘK.9xIˌ8is3EA}` R9z׬s;_[Ey3j$Q[`R%KB'W^ͦeH&6fժa/,[IG[9jX|߽̙ De_c%K5kfs̅t2c<|-;mApq"bҤFm`0$4\ ˜qbp0T'](<~$/ÓO<ƙg|O:{{9R`LJ\rc=dE;bwQҪrȡ"-BGE&BQGųn2|H eV !;:;x\~}/Yu}Uο4A=񪆮7 q^b,.gKX?>W]+|tĚ Xr 3W\ә$" j?><叿e„z:]<&[EP5JK$w>d#r B+p3>|?kկ.V{Tt:ZL}B c4ԫzJYjkWQ[]eݪ\",cYo +!5A%tQS`"KSj0]8X` 4Zk\XIysQ*&)1C:Z\rSq͕SOoU n["Ռx'}qꩧy&NMgI$8H$ReJZ)DΉ3f&ox𪫙DQ=]#]Q@Vß$  ̘~}9? [e!'MnQdDW1"`|Y?G\H%o{O~TT$$0DQ"(c<~~~LЙN %԰v*#ܗ'mOF|栃yK*ayFKz(ޤ^*.ʫGe0{&|#;w&v8sXs̜1*B`Iiz3{\_dyS&t &NU,0j( 9Q3fo]t?3L6q8#FuB_?THc]7(2eJPnk~M7܀HS$+NL9<(*m3HBL}(g 1Ti1yLq ru(EF-QK11XoBNRyp1NhpH9gO~S?~"wx ~:q4ǝ|2'Mįͯ/=gq}* 3g(-B-CmOcsO1fX>D0R9?>\Ahocz/&wq>{!Gu8owN`…v}lpAPbĄI;Xl E晝UĖ ߂FvaJQ gS*$6C LM$|cr~qG|6x˶|Ooݙ{YNKN6 ~ppeB!s~&N8t>YOHmFCi/q֣TϬ`"A0qdu"j6FiACԤ֣*Y.iGa6" {.A+I e8|jw<ȋG֜?!^zwp ̝P;-c#o,^8 2KQsZYHEXʡnuh &ˢp@IR(uR_B)IrZ)oD4>@93d#sIrdGC6`VQA7aB,!)HtPF\5A CA"%:Fَdơ(jSO;.Ezf)ǝp"GƌvP_Lw) oAe2@(Aڼ%L<8gZ_KRJ͉mguD c4x q XJz^ O`欲tQ^42BN'v)yRY!ya]$XR)$)DݡP su^C _={i(O YlH/L`tVq؍%Cu%'t7KʊpҔ!HyBE╞̓1Ey]f Bܯ9.嵛"%DQ k+냏W@(q ](*J^3ey]  :SIuUc3j;Y6c\E{v:|;yZױf.G!rH <_!i6AFQ0s2 h!C!o3]( * `Ks8QU%b& %-,#t[x!m1Lh, d5B¦$RXXP͌JF<;X%r &켞RuO o(wC֡)./E 2R,53yzQB~"U>FEDf[*P }CIT|6/D0IDW$R ޸t%[TqxD8*K1L.>yBzCDG z燆UE ZwTU{GݣMDa$H5kIdKS[[u4rބZƪQ D!MX,;ei~G025Ɗaqx,CFnI0>t<4å `6k?.dc[4ǧ62`q![.4-d;-gi~]>5C>\K;a<糾|5a\;|N8d;! 16 xKBk<8$x;dxo1Y>T}x4ixg1ֿP:O0½z>l(PZ$yh&!2!B*azNkrlCC9XD$z׊!r!xءohY @n[|_Z5Yx5A ܛ:%`IG7¸&ͥ|,J; A68H,w^v][^.MبdBLNE L:gu nN}Yk!$ӡR6sMf}B{ԭCLp?ؘ0zYNXd%ؓ !!5)օyBEGBǡ/B eT}%e /D`Ϩ4p.#@G`Z()w@"fL>DΉ)Ҕmg2U:p@o RCJѰ)дT8zj%cRk1ƅ6.P @p$rbaCyDG8D CZ0 @h,)SJ ߼ ? * EX"h<DcS\)Zv^QA|7(@,@5vH7U< G$s %Y=%.I2V"{|Hη T@HƐs65BFmCĤArL!2J9OQ2 O4xIYdN|ypVDQ7_!%Я1,s(Ah -,g-lpC20 R6dan+ϭ|I-0Rɂaݸ$"_k,"x>30utf*%9)N#C:@$%(.K& m>X4,)榰uJ8aMxOŤI RٔGXCT>uG $@8tx2 %?J(ŧ@A qhv d awa$^$i/nC`yJz2&\&yyMNA4-QZcl᠑&남f)uR(ZdrI AK45N#@b2샜k6)ZNC\ @W@9IާC S#lZE*A ,P*4d VG%( UssColP7%Y(PoaEː ^K)M@>(< -q7}T8ٶ Y'2J z1D!2mH$CICf*7l`7NB R-E 8n|S$,JiFJ2LH]VXWABM *t0AHմx4\'sxRT0;H !TH5^`>jX,YR$h%iܠp(Xq@>nT4KPDDMPfr4DtJT eF AνY)DKS(E!T.5̀NH6͊wgMZe|JHjU\l,"=*fq`:(x$R kPD"!xPq+%5㚤!Fm9(*sޖАJF4\bH[ (?r:\OΏޛ\V,|M9#9ߐ}.짛f!BCii174ne\ ۆE2DkS\s4EaaokI~ZI4 R.Z-W֣k2I>Oq!߃"JRO@Xh#S.!_uQ$C)0z~? gږ'*3G?6YKT.7.qX3ȸOf'_@>gqsک9qZ(JŠ8+0B 9k: ifbF 5"J6ay|N Eʒ۠jXgKf   U.4g!"Wb4ETiC1VYtTje=Ar: gR5￵],KHcd,NDZ, \Ͳg J9Y~|I-(b C-IbrwQDyxc(Dp/rcz\_xM^X|/fAdS^}&P>W*Đ㵂8@DÜAۘ9s_o m =((8\q_ m%\-;wrw/:be0iՍ#sʷYbT:矦<Q;uÈQwNeDw2q\v%/ѻv :y{ٮӾIzxQ lZ;?Z/Ln@LK._'Jm#1Aux1g'~p WV~iFb8 y(xuZ/Қu|a6f%8C8['ݼѴIVa?~"(6|NBtn&pN&7|3cRG[NβoP@b[/e$yM! yary_cG__+s_&L"rG7W>uMl`ĩwk΃2~sjrCX> ^;ZX IDAT}aN8}|8{+bzU I0YWlS' ϥTAjb ',G?X8ws?gu6{$&r)s7[_<8bsO胷?ǧ>@/uG3Q&t$H]j-R85J յuqXǁ36 Ÿng { w۟3\y^ĪԲ;va"P3l7V03py\}5<‹L6a/зvՌ'0XK5w 7qͷ]vr >'tRz~v!|{g09~ztP.E(ɉ:bf*%Fvw׾%A_;Rg{4$H[/fڔC?E:˜*#;#{9o101kL|SO9>'[o =/yO:JI7g8) }-dx2T8}ބU^\g׀ˉNGE<`W糴w~ )lVK~e-8"%sYps9|q#L|w,帣9[yɲ>e{Oqa|ݟ_O?<~|gpaպ*='r?Ǯx{9[駞1]܇wAt`*{wӏ?Ƙ/΋drIDZ{_^N<؊ξ+2eTy|Ӎww=x vLNDºRo}<+tAYru/ݟ78gYd\{.BG, W\]wI'sDD5I)G W_s5 z[o<kβնa%[7ͶڑvމN2v}znf6:hQl:w$gO.`mbyc>O 尼DNLpAӻ Ux{r'^q kRǟά݈̂8pt&ϙN,KPK8c3?O2m& "QB"Hf_pm&ނ8Íy۽c(,HC>ʙ9z(]]1K{&g,f҄Ѭ7<26;㱇`iSac~Ŝ9s8~qG *֬|%=ɗv#Ǝb$pP+!D6(F:S㫟?j—<o>;.?.gtÁ 뮼3N}A3\z1[VFvL}_tsF\r3R`?v.R׾D=]mL=1e7馒#"M5ʬQMTd}.P9ئ4h+,AzM1#ҳ{g?J,GDz"ӑ׿}.ǟ l>~b̤w5]{Ni,< t͟8ꄯdCήj=s~_@ \9iH,G}3܍_\gr,cƌ{q:}GnN;6~mұlB~~!;}M7ۚy. //5>m{vq=|siy2{I-NYԣw a񣚡0g\fo4g'B+!ߍc]34^F12O ǟbӅq?Gg]w϶emvc4۰X i*R?8o[;njRpYp`JNmſg_Xw8aVswNg?;^pu]Ťn;d6s-l-7]N[MdDw'l4ٳaҗ3wrN9\~oI+mHpB[w搃>΀4& ;tBTfì;=߷ {~To ųϿ6PBG}W=TVӎ-Nu^[wٝ`ܹ,Xf6'Z:~~>QTua6:O‹NGZJ)I' 2p: n,*@ 8{GOK.bT"Ij%;o%`Iǎ =bԂ:Ԁ$!"Ȏ\wgOvcmXt)>;(dFU|IƌU;Ck;w`ي)4SM ]ÎuY?qa]eWIε~="Z{X~7 d-RXIRI$ YRC(T6 tJ(5n<'3k۹L<cKD\ X,|0@ԲF^+rhFtTPCŮ':dR5tUerX.XYi$,y J%A-qY(#(JmH)-ؔ4Mte'"ŗkџ9msr!X>JꗘQ^df")Sg0njl=w{޽--[E@[os6Fd7C0N &MB<2߳nuGRjb$xxJᒔx;l*T:{=:gҋ/x*ŗrǶ!|Bzh5&K5mmd|OC8͸N^E8OEd6r`l<GR$.7k/\@-nyd,v}p\_>b%IDs6j͛'#K\{:tᥡR^@AE!r^@:;;᭻..T`RZ:K!Q|D1JhV))eҹs!ck)-wVUw `#( $ *願EɈ D@PI""@  ]X6laQ==s]AG\vBwuUu_Ωs"E|xǹr'sϿǙ9aϦ[{P@qRyiH<#jTIu Uz)A34jZ-&HAuf/`bKT(\m( ;-q˭Mw -~vyN%}+vpe(+EQ3eX4O`SҡFX-xdl+F$*AZ̵36 gֻA֮_e/}"Sq?em_[#5X>g1ޙ&0u$vvuDc;m`S9 ] I  hgV9ݔ?tK,oӦ?^j0ne#7@RWL荘}x$X1-+O{q%O`ƚ=$b&8"vX0PqSO0Fa=̡ ִZ 2*V#Z Z &Lb\?OΙV[oBk Vs |?aËc}g\c__|$:YVyqL@d 9euzi]4ZeDYqX -hΦ,*@G5v}ӛM2%.`֮a􍓿׿ \ =u+iYY}V,ZE9Z K 1FvOV5,?rRkpk-l*>u91EʍX8o.׭l)C,vkMُ`Z Ht57~1԰"4&LdH%' ,U-QYc fpm澇fہ+'sz&Na^{:2q[ ,]L(a§ q@IR.Z"Mo#f!҂+[Z{ի77Pk Fl b6Uzh5/vaaڤq\zމ[csMKRUw/9/d֢l!' iJuz{x|ЏqWĢeuϝ|sʛ~^o')C68 `j:~}-@&za>so#h |U}\9(`; [mQ+09g|_Xiy`SA<~f'IJxŐ,XC>%N@.;|3h>7p#_=L{V#Od4>T ~iW~}e~` ]u׿t.1'rPt%E|$X)1e͇H Ef"&LoۍS}:}?w7x]kL@ M_o?3/nLXa\vw3m(6k,xPnœ<x] 4toڝﻓ?x3KqW3gB6|K"3b1˗,YY<ƥO=ؓ6z%Z(j˙͍FD1q-)iθq\j"Zk8f(@eϽZ)_8{^'f=uWȗO=gӈsX}R_=?/<<[_ˉ'H V6{=W_k[E 9mDRrSfdlFp VƯdc7M7\E+`MI0v7Z~rK>?׿F*jݩ\Yh|_ڛz!Xhb-]¢%yzr/Xʢ-q M>KWYf02oBARc}ԯ˭,\_K1}GÝriԀIĽlsDIC>ȱ-'l2Z_e+2yj+49u֞*3 ,R:]x\2wS5xݦ[s5Lyl\qߣpg.;L~lÎnױbp9*nκ. ΝO9/Sa)>x.;tZ i~X>!7>4Cc>_2!g`"tt DB|ru7pe?/ [lnh:(~}+Xdf4vq'vk »߷??5zM_%} M6ݒ.W~[E..w̘zFƄ{]>+1}hLYgM&N\ղe\t[X{JX}ﻸ yͫg|#ajN{zREA93ykI A8#ll|Se}{rױQ(9Y#YmT\|1BȌ3fpg׽,,}zgew2s7֜1}\zOhl7S~t9L<7i_n[q?wq16A Ic\t"WsVQ> ߱#.!Ƹ'+V0mڴe뀗zX0r bD-)> }&hE惱cI"(ܮ(WF#~bH&6EBU+Ixʔ.Cq)YSP3qlh}]iV\.AOg LGQV[dҖ8\BL7sVD,PQpsQI(Y&\Cg:["GlQL:~)Kvw}^>ug8ш TDLDA+QӞZQp}S]&]*U}#:\㢿(NjKJbᬁK/;_>+je sR]$ E W׽0z>+j#T泌{-اX;w.HʶXk k0]-U“reJNTiAeTA d",Tgad+s}K*䇪AȵRی3bxkO}7zqmN5\f/UCA wb6ĭj BNeDq\aJ!bg7lT̸JiJD/=VZ{,  jۓv=0)vDI "}n&[Y*r-V ^IJ$@V S'?qH߀DhKs EJ'D4\~0Bz/)< xPm`` LH%7XkPqTb*C굘ȗx!n 3檟@^`4 7^/1TU 2%+P9CCC4p xo0<~x5kf: !D(UK1~*=l65kÌ?c!AP$ /GlvDUW"rYkL4 &P@L >ILkQ>!(:GYų 2KyZ`Ev̶.@^2,ˈO8{)V}wc8+ ɹ(! D)OgDE`Ϥ舮9V2- E*@^%V+mww}&Veg_ٟ X0 z?&F ー_=Qp8 TJ  {VJ./ x؇n7AXr>ϖbb* j Ui G(X6Arb7VY!-KL& U0f!jg BF g$BPoDõYkB[MHI0"2ʍeT}d((bK4U☲C\x_飐]ZULJ)mϷpOw~Swm<'E@OI92 6b4YZ+YrUg" I v]zY)#RaFcF$v*&+XN9&RF% A9*(-G'iJmE-<ǀhU>|:9lzBaS(좍sᎷ^YIfDĩ.YRT '+W!HKᕿRSpCv*?[1ڡ\TvKeBV A\[ QD[j;FV(ix!'&8c;]vE(Suo0mR(=֭ Ml S!LVu&Lj_t*[)p=kZNzTLKHI$tzk48;uzܤcXa@@@@@ @iĢpx7O; Ub73lS%GV@s-2 eŤ[:& Cɸ\2_# [~-KFW6qZdRt UcD[aU! v%E:Q܊p[9A䨤ע۪`e+'<™W>=fbdLSg$qM"ΟBٜo"eɱ{N,9$Lj xNE VtRFwt(TUgFsO} k/ $r$ RR0m"!0; ~r9qA{BPL[LT(F}ӽ4C͠<+槝,X/Vv)@KkOEV;ySˬ'aB^uILI'f2J%ej2L`Hcb-9,b|_؂ ^.t/b+< 7o@@@@ @/)lGT$cFc 6gyҼKXgzܙum4&FtVdH)1Ɛ)JAA6c 3 rZC%S|ⳟ\ʱڴsX[Ih@8`򂘸N~;=6$oEҟEhsG",`nS5Z0+h>V)0"tk\ڔc6;:OKCG"Pm5#k>~I0z%)pJD89Aܐm¡-!n4 Ҁ@ȉ5  +σ#U#urj1S8$bu+ !ÙAJ6mI3- gHHZD[`mEH<˴ѥ`(K`q\{7qnqR`H=%P,\|C \{H8" I~)); nW$!‘Q+m47X. &2R78W\~)IyζJbq f{m"9=b ʡNΔd)챕IF$ ;68R ,[ʷN>=vߓۙ=C Q$A:K~Ιq0d+_}ή;[y?zo5_aq '޻=v+~N1ox+.~H{5ix9gM޴l݌S}pnGvm/N8,VJlq-xow%^>~oڃw x}+֞g:}s5^Hqΰ?FtOʭ|ⓜ{ .YSsW˟r ^|)y/_3 }9}^~q!O@K2~|l\vOۭwpŗqooۛ/%/^Q_9 6XDyᎿHRtE0aEͩTÚu޼+9kLk&.C^M`iðȨc>8?<#*Vɻ%,#h?Bk La$Ɣ3qR^Ꙭ3s=l?%ӷFhx[;5ُz^L_c!2 *5hknԡL4(9 gC+%G*L(It7ll6ulF\ݵqIrmV8Jd̛3 M[[޴-xӶԲ ꫯn5>i ۶{nc{-vub|$eObt;?L\s ^#avゟ_wm_EedvW!*2&ё> \,m;YO9k\_>FӑqHVud T( ,Z>ڂ,DR~qzabU\kXbM6}?nlիy{&[_Xk?~qq:˛O~ O B3߸xd:zВM"\*T14,[m 'H.r"Ug`u@ g'鉁\M\8kdۍ qđ'R)PUn/ܙovq? fzFen 0iJAĉ!f %\53=},85>zSH,#R>]p[ f< kl g+"So=Ӿu*7'/qj _z@V:}L"i5$u?:d\w/!3DN97I55xS>R|O|+)z3b?4͈jQX"(IK;i:CO>}?ƌu_ɜйWbxxd3eS2W 6ՕHQkbiz$R*- 5D5HG>AtW'l<=9NTP+NbXÿx@<Kb"oWUv+zȏ+A5IIDFd*34T`kLlۂ~mn_Pr>OpQA5 Y4"˜'0\^;qQG|0^x_6b5b7; ‚OqGpw9vڃ%ϯ ]> [3ʸ6F ';sE1)*>yɅ sGNuQʫ.T3UT?- j6`XH-M)&i} t+G6 9u93'(r9e߱SNZ@@@@Gp=$Ȋ[LHj=☴5>k3LTgß|;{#Xw>Y{{Xc/pƙg[]ufQG>PJh-2jbc2HjhE!g4~lq &I@Z;pgu7Cw0s:=f=2P h8TV _ :SO=4MN_9WlZSO'|2?=Xk{Ͻ8ñ"fi/~}? L< \:x1 P|c` ؁ֺqL,gϷs?{O= "3*.E'&}2;_˅<½sCclZ]C5ȇb2*H!˶k +V`ڴizkTy.Wi\%vr񍫚VSeFaE՝uw aFx}2:ydsriy c+NB.%^2]Q8.c_.(mYxՔw{6 {^D,FH#NO"ƺd';1h{x.>١ЬZ+(s+ǘkҌ01\ $19Q+k+ (@/³2#+; X)q.j_Mn7*ˮ=W(De_1Z~t. ˙|aHړ"X\ˬ2i|;k2-}]7Q]D'>0 +"sċٴDJQ[K /\PNb0aX=@؍N KGBZI~C*EW'Anyө%YA%PA6W-?"Œȕcڴ䃗E"9dj؋J{uv$ٰXBآ\)ٕus7}u3wwJ,8C$*d=9YSNcWURL~ I*1HU?jK\1J_D4ZQ;?Tce)IDHE2 k/uo*I+sd2uTbW.2X6 RRFz %iD(Q} +:Tvm2XqQYmed&Rj˂UyvՔn}KPu*[$2rv.E3Ƴ*4Q]nEz){6;}KTI2&kN !`ڄ@جEa{Q6YmWGv9+F1X`k:hX+Y"h1E dTL- P.$=@d'@ ?~Az ! 0k,Zk-F@*«+DtiH«3zEmG]w h'0cRʍkAĞ}xA>(zIfTɪi4L:M-gr4wr3 X??BTmGBtOx5LX͊}ڵ)DI,W,?ᷩ4wJ_u3! ׺O}BpӾQ~׊{/ j>U%[$@Q*'(jDWZS +ꈕ-]/%J2M~FUij?mSOz땟Z-$(&Pj|e Ex%tTSjVe,kGXuOh,_F=amyl.4:ԈR>K“"2)=º !=+n=4V0qD}Q&OP@ @H3R#El+(n4#)m GRR#`tqCqQ@Jr]wsġty>rRv­iIBP"\%/sy$1@#?9}wZʺkMg=4}u&%msW`+JaM7C)9c/ BHZ6igz{d4+ȇ~]HԎHIL HpEIe ) dW&qLgjQ]Jp V y 9_p|ȸ~RBR9)!AaQZIt8Iwַ}vۙf-@4-6,Ky5^EȩkjJ)I@jϱE__G|H&NWÅ'H'eŪ".c(Bk$DTwDˀ8rs{EXJ>O0cu8; /C W_}%G9 d0m! ^=sS4M,3DͽPNh rD&[}^<xǾ̗ky"$\<XM* B],oQױ8|o.t?KVcقE|sGd: N)z~|~h,^ĠDgC g2B h2D2|+7z5֜<չȂ?&դ-| 2(%PBs)o1]IkBAHEp寯fΜy%zh<2`~BII"y>/E%d(*|ϥhʰ@~R8If .Z~>p?Ail9K"ZԨŒAwOn H8roaxFpcM7$[471?n5>}ԉlFwҥ~1!{bъe.|yQ=3{ι%$*R?)6Ă4QGHH*$$ܴ[ݙ1{{S#N@5\r,7v=|s8yyYeJBث"NRTi NLXm<(G,kN>i=̞&cm{<~w<˘@rwKԻ9ce/sǃp:tԑUN9\g}j-9#_{Q-◿:j[Gu cҤFxE2:仜wx执)xG˟?<ɓb+fkk8﹟vܞSw6&͟ĸq+r# +$J 92~?|N?tO[n:JBX'\y\{|8e1|\uU\~Mbw( PE DʬYYj(F^*slɝ)m')6Րs׭S85j4J&=z(v~ףk^Sˌ@ejmHVXeU6Zkmr=woرK'<{6?->T$ĝ8Ӟo͞3w3~!7b}̘>W7m˶;9dv쿶fxGUZ2kԭOFhYEkaB#&c <+3ߐJ:m *߭g6ߚǯlwG8ڟZV^(0a$YfQ*X+P*ĦOBN<:#Y5W_W M|{ +0uTNN(E9NF.3;C  ӻf FmHFBGJAZGTG秃 y$̛K[0 ]I%^ ni BIFP5V]wSu#0:E*AA@lsn"IߥxՀ<\9xOC[Yk3Gx CêQ9?Ð_wqtIq̴iXc58cpS0 LQ$@%iAJP!"]&̦1i, 6 mR! 0[5jiFބ1! 澆TJ[ 21ПB!1"JwAB0z66]o<6\slZ[|\"S3+dEPiJRA 8߱F&ѱ:;}ksנW5/`kϲ O? Ha=$ nDO֎`Cu, B@`cI%p{a  H ^L}7AKPoc l# bVQ4꽠I! 7a~[oE W\בRPOb*},ItIuQXk7n&M93?0VC!% B-h͚kaø+H_=^Ba4T iEMup:;y7=qh4!w{`<@j$aEؠ *:a% $w؆︛km7ч`ZWcMNN}P5OzKAbb+?i#hM ":j[n} &MI\z3c{Xv> ̹cWZE$@3rR mIB64u0fe3o.@ugZLr„<`R);gibQ>Ȣz [cknF.5) fLN%k='Oh*XY$C)EEtI7VZO<(Qc`=Qc8_軔ͷ_9f+[',Gb-VJz H+yLo\iN_1@J O7[tKH G A_ޙ B6$>>:eIThEY\X^Ǟ`6N~{j^|uVw_xm'L+Q(. /]f{SEp!.`^k B*H(YP!T$Dޏ11bcPqۀ/~qw~i~xMyy<[sWpodKλ&E݌"Cdcʩ&D| />~x8`RVY{rkiaߒ'q;wZ,!ꏵ6?5|קD !0`a,rgu۾wl$& ~" . $>M@V;)6MҙSCZd bYAA<^юfSH"-k$Xt!Ô ! )tt,o_kH6Ws1iÁPׁs$)4S WDG33ᴒo,?8o>! CIsF0(ヿ%.?DY@V,b8vtWN>YW*C G~.FD@@sXkH% +ĉFʕĂhP (R$&T# !5$k㒃$އCXIt~R>9"%K?O8| :cFkiӦى%u&jI@ piJ;A٦NjP!%A6+Jirbl"'D-Ge@T H@)諧~` $$&4IO/7\u ޝwrsS.R/FNQ E=6hcs3̂11aHX+!j" LZRaHBO~$Źoå2Ԡdȏu);PILH~|DQDÍ,R_d\@9pӟ;B!i0Tbt+1F#I{E-NRYP9RI\"坢61( !% '_K(ݟ̙3=u.b2s.8bҨ".[*C UQ %)'$,gs6Ykșz1 c Y'EuѸ|! ^-$GΡ[c N"#H(GVT"s)"de#E @ݖ p&ۙ狔mgRJ$.']EnHűBo>JUTIuZ%Ƙdz>()j\Md6(H1(T^ҝV!  U5TB5(o~ƺ#Q:M ;.P*O:jE$8 W:؝X0OfvZ*o7 NSA`XȉnX ]g /$ L(6s:>/^1L^uzxB/ǒG4*MӖUDIJ|0b-\&_Lqe?:_}Q1`+,&i_6#4y]g ,r8# +'6rTҢ f9gSX1EXB t7V4[߉[D֢= sђ(嵅_(ͯl.eJj6JS$@%(OF;z4-ʀmB9ݪnfH^}%M 0d͉Lƅ7bɯ,(qFX,CR'T/YɄ-bYC*ȂJ"Z{T^ "#<@ `H&gZ8K3L7" 9—kAĒ+BCR)`X,#d qDa1C 1& E,06 IYPhFX!EZ '5x0dR2f[| oS,A_lj?r2,}dkH,J.PO{ /Jnf̘1%)Q!Un$H 4cօHhL[6<<0tXA&Z_ٖ BIߐ Ev|VKWWsh lwA"2,R~^l+9T* DubLseM_laڻ-М9sꢷZvD(M`%WġD5HzM/ 1h'hȾ Hrg U\@L7 b> 2_~y9s&<PfqRM"Ab\%)Ul,2 L`/O6(lZY># :z`BL@#E/F, Do'Vr/ a>|8/|nf.E( P"@Vd$aA*Oȏ̵>#joedI{ʤ9]@LShX~ ^ =ڨj3`R e1EEH\u6CJY EQV3mY+߉CLW o̭L_Yc|% W6JTbQ(M`K4izZ,SbXi]}.l^Gfk@6v;W>fՠy۵>PٝredmvKE»cKT>J ͧb%JB*94W DJ ~:dU5ƿ#[?G+<޾ƿVy5v@Zg:ˬy d?kzQv=a͚g/P\޺Z$A^Plde2[k}eDdcXfC+1/VoEsc@ \e "kl9D4Qu>ʒ%2!y;[\(_) S_}5Z?,ږy]'R2Ň,@ŗ܃.BݞK,.=kо.-w潕CHk]{P0ء*f[0{Y-CRdpj͇џv(.lT/ kl-Bs.yڲaK| 9pH-h9#f"vA, W|G+/kآ%Q9\V;^l֝PC_v=&n&T49"cjO~B|,T)[ͼy$ }bЎ ԕfAY-7u̕Kchn V"iQb'F+P$@? RMza+H BX PkPa-+$Zz X[aфX{H#PFB E0hht R4<95Kc>)EaVHRx5 1HӜT7Űa>|8Æ l|NjER M䇼։At,f^ȍ)'[ ESYuЋP$Kr14Oa!β@dl0=rp#iRdԨQ[͂`} f |ȱ;R!>_B6MSՍ/liwgybÖئNA+Ls[967$&`yYBh z{&{RK؇9sh4x;v,cG8-v'zb `][ Z1_x1( qE\vw2Uy2~/ZŴ,ԍ(٢Q?2n &IB/(:i2K&-,ZvaԴw֎v{p!ຉ Y4M,ɷnl- ZN{B$aȑ#Ic tttb<!C}_{ΏD,ϋNgŽ IV> rٲ+kbz0$*Oh1waذa-NJAGNBS7r0kHX`3==peCݣd-*v# ٕ!2ЄhgM3ܪm0s)6lN^Xh('Lh`q.tr,Dsr!喅蠼S` 6 ) LiQC^'x;aB~`R6] D,5@JIѠ#Y޷V;f/̹ЭǕ{8֟j%U^M]$"Yvd WNєIb9X+PLX_ W.R|9uT9?Ago6jRNZt;uomv"TNJ;o-/ER0VFvttth4`%$ >ޕYr $Q4L7#>OCh5My T6*!:)Z&Q_Ȏ5_+Eֶ_+-4u#.Dz k_8;,pԀ4(ӴՌjZu8n iJ뺵lqgvJ-,:IuͅqQlL#.kZڽU<`MԶÔ)AjLZ\bA(si@_APKMQXM> h";"-a$ :M rbhQͭI-5H~4n1*|B4NQ]dOFOJH dV` 6IB" I'7  _-\}'kQX+=TdG4H2!Fc+"Taɪ4IiH6!hC8.5F -LTڥȆ0TH01 I /M!WTCTʀB`W0 D(e%(:E)=0( D)#4SXEHAc"{"  xS!8^Nz|CI)":⍢7; RĦI=+ Pʍ]L0C WP38e1OqQd K%۹ N- DfqWNaHv3kR>n3NۙHĒnBa?H͐.fx%EeM] -nE b\Rj54智"Is3H?AG⌯pR"H(i5LUc掌,ɀ4Q' a8IQR*FULI3EH IN%8N)L5ڤKRIIu@Bh$€Mq@MhW Iacy內8ppq6tdh lʋO_{ i$Mœ IE%s[1ȳUPo}lVRE9$v7_HxOԈL:V[€]Y+[:L9 BM܂I IDAThܩ@XOUqtSOTcGh4PQ}-jǢhQIe$7S|2g__A|,>|% #~-aF;owؖmngP~}Sxa>_ۋ/ut!xI.|gqOGAVr0;EBwqw`9c^ĝο>OdV;8ޘ76Pa<晧Ïg>o-?r w VI7Uyc<~i&VYo q󅝾ġ~( .B?:?pu Ӓ{%~qIYsU\er{;>n<#tꫭF{g7^wqfwkC~Kw57zoN7[mEiox0LzS@'B\tESO]|[mggȑĩS<0){zQ.*|~>g9SHvyqkr9n8)Uo={o⇇o ^o0A!/"WX+#8wg2kvm q +OX~ H F/q7'| 7|z8XjϯNaQKsW_j[gO~jo~u/.3`B:Tϑ*km>[n>!q€4 vF/q AΡGѣ9C8'Vͷw:?5V']3z뭙|Oyg7aL:W[N8aUl`&8?qur 묻>Oh~J+炋/o5;vK9 yoK9Aކ?]|p=n.kpp4?_y9ko=_u"Q-/hHDhkg=vJj#₳ҝ Uܶ5]A`55fǝn2"GK)ҌB'H F5fc;mw؅ 㖥_ìc=ňvϡH3~4o f v*stHX JCϴp՟K3aeH HLy1Y|QK/Cgg'=}h y09o`6IqeG;]:UQ$H \ hv[r2ҚuF]?9+eCS*RȜjzs:ˏ1ˏK_*1 6}[&)q Xv6)=KoN}PO_$r%hhf0 :{N7VVh\"06FEFvXe-Xz1n> 2 R 3˪Ll*AWfm0b奱5ΜAYXouZ$P7< n&["zcrK@OhBt=v6ԂWC7k'TUBRBmi rPAQVi9GB4$ig^\ďN+NL?9‹/s΅0sfo6 H vT[ 8ڄ( 0E)w^j%tב CF@mѩqŠRqqi0RҨ[::1ss?eF[q/ɨZ/>,'~HI.A:Few4kQ%JHqXv)6x#qlTQQ| |MQl7 1-Rmg&#p'F"#?~N:TE%}F1|y7JD +R!F@R~A7QnGtu&xK(as1yjJ5} ~qڙD B:63Φ3ΫO?Ǒo *QDj B5XRw ( 1}10 IX;.j;&xƀ*mFŨiDS4 jm`HK= A"*!,GS_oM*!z/AFHEf$# rߚg])B ibq9 ^ cǑWڤ\"; |9OS} ~%5}5n\N4 a`]sđ/qƱs%'1y%AInf w=tBzx)=hF mb lsFAYfѡb-;w.kV#y/ۍBl_NDF4 m <0aS2[}*{C^&7 a[GQy~7/]|0qCy0C矠Teҥqmًɡm׬&*F <}؟;n˗c[fjF" ),:#<Ͽek=8pB:8!6x[3O"_~~V(w!M6T._@FriP2'Yxu!:챋!{%-VEp{ӻg~8eHz,[&_x'f{[wr[@#>O:eK;ɻf&-%lMils >2rLb=ϐ:lg?34'|r(XNPeEBQB5B/L5$҆U;ye hkmX,"E^hFTzMw=whJ_3hH, y5TVA8hйt!TT$xM47噹۶<_zE%t[VȰar;N)Yu1&/s_gJOAMNz>^}EA /-ef;vn?o.^Bҳ2J.;o_ ˹<|+`鲕:T&կreܜ::7F}U*X& vxQg;5|ks^63>m;s ]>L|xvɬ^/wךӤ"r3?s9r%䤓Oe>{q/O>{d?_SN! Y9xso_ʰ-nD7 Af[R$si;n ~Wu^{~~7b p{s/no}7㳟9+1n\W JDT)$4j4[L݆}_l1W)G 2N8gy4 ;l( tfȢE8sιqGp'?y>UW\ɷ/q@qcR*K:Ǚ6 $m8+XB:hݝ|.fLg5FHvs&\R emNZE!Z C s?~?q.^sġ(R)p *!e?D6-~)>vQ{1uqx/p OnᔏC=g_e,}lzKEB=EppL:}|Sks:e4Bα|qr%@^ǖAtA8c/Ar'1w<8]ˎz\ëgq~q'MEDpGfs%_ϝK0M-(xpǎbo\I'̟x/ԧ;z^$JY(c'"6L 4|>oqH|'1Ֆ$u./+ojfرuUpq@G(G#XW6$8DtɎ-Okcms*(!PF82.꣔t\"cL(:d;54Bt%opU;!xn*#BJ97 4NPq;q0 8XvZKo5G~}9nw XB0gvqwµ%KjXRע+^ ( Pil9HK  -P(5KjyZ|DZ$pJ&G ]<[Jxn0ϓ`}J9e1H>2 :n&ْ`4F۬e7^)LXt lo!߂lȾr 4D[v#qg "daH7\{L9J)uEZ:|2NӁ_7MƥmdJ2^EG"l¤Qm]qU{S[z[E"l].A 4AͺƮ?$&6yhSPa](d@w%OHIua\ RGv\rDaҖ Y^-%kåInti$Yi$ُ%D|BQG NƲ(`TY1E-n"'"#'M}#.k%GY{)pH#LD Vz $'͇ ajFaHR#?o㢋!I86 N>AA925QjYٺ)7.̵5j$L9) MW G餝"#;$R8DDH!₱_\=My3xR*i%W9U>@^32 EuNIR%jl\<}}d ~@Px ɠ'yTjA'J2˪||n)\ K L[t[}&_kJ49(d@8#sD) !Tsk賑Fdul]MlY_v ֛u=og^ Z#NbxOW}pꧽu+Β;82AL2nbs.A& V +8nezC%f* "lU~ZLj&A~H;NBePA U0(2*LK }do":?E[5c0H@l) p~Ǐ_wD2ATO8UWYIQ ٨g40}r /60hdM7?}Xu[nm5cYQVd|n`yh0PĔɦ"f~(ʒ!fbjzT>rL4i4څ^@E c{}\n1&E`CQn:jbhbzKs5Vc7SX$Aa|8 (fTp?Jc&VS_" mgXgV B.fM=kx7wYS J~rJ&[R>Y:RQ^AiiЈ#(>LLQTTl=THkr5AX'~c]a,WP )/b_66 HA$>K#FXH :Dt.\dd+Xۮ %C0"\O(ud5t'&W6#&M*^r((rI&b4V3GT}oR<63Ze-!ujƚf3^.5>Tא„ėF:)*vu$Fy&|\g2 H؍:C\s=( 0Harn>-1%lȑ#H]u *j +DʑHzK~uQ mM]Ug=̈́A),D?+uVq&i[tDԘ3"N`A_eJ7)5HQ;SJ'y&-}+_Z3dr]Fr Q/U,W;ttt0rȺI&bTA~qZejĪhxs,[m7XLU890vsTNԙX/IyYIM=vrNL|0-E. sMuWR>YQeD@BWgEɔmDH[6ٺ$^y=K Q(^߯Tr:!o(ᶶ6-ZҥKiiie%e- `LF#bv- O .XE'td`it12fn˪t7u} j*d0D 1&)21P‚7dD 8^5L B a5"H҄ DņXR?2H\`񟘁]˖I[d5!vDߤk%QX:ġ Yg];E!%%ȕtLPBՅWϗkbmBߵ c4镲1XN ݅LTާ<ƍ?]/~GUxuTE0uLlµ7#!' _wi jC;>,}f OH~2yOZ;Z7@X_s*90BluGwr@r`A:k}%ޏ.k;mXZ{:azHlh[6.[kT$L[$dI&dI2$L2$L2I&dI&dL2$L2$ eI&dI&d(L2$L2$@dI&dI&$L2$L2P&dI&dI2$L2$L2I&dI&dL2$L2$ eI&dI&d(Lm9PLo73eaM=2P&1!:c6EQ0 :1gcH;R>y1j7۹Aa0?7^:A9&fCzHcj-ؿizPT722dmd] YPt-26%2YA(Z#8Aø3HD`dM! `DuЦm`dq#iPMW7 ;`hF;fƵ?6eĻj&a6_4Ʈ50h\<0wnn)^&d=d[pĺTr]3'AKK$&,}$#lƿ4HAHiBzZf0Ǯ?Ylڑ&6l>Ɵ.1Blq}h,! c: jHZ22$@K-ndI3tHe)eU_4PPtcY:(2uԍ CD7$"Ɲ⛙u8mZ(]#-a Ml13> &w5R>sLl$@i&h?l[h^zLJ)Mww7===#0Zw c<ۇ"lxOal~lW>%Z:}~{\Z#Ɗ/cM] c3l8_9Kw >$YØQFWuQG`!hiiVZEQf$@l8udк$Y2 ,|>cm5)Nww buY/( ih5ЀQ7u2@F",O5!dcD e|zzzT*Zɓ'bdw)^&n1kIz&lUhnnf(p0 ktd ǧl=Nf6oy?hte zqhmm%"-[ƪU3f='3e2pu,x! e:d^y^f4t6mh=[چB\<z4",L2ը.P3ZQv1 m2d F걨+Skhob:v՛LH{ n?cBIhrLkkZS> JmcI8]Q=aCuaP ?͠\#}6aj]ۖgN.ATmM?ovnSOtaØǒyqOw?Ƅ!QM_=ҴR.^&d (=K+!-waĄ!Q RHDa6C)"c#1.6hwi1P OJYܢ.Z`uB@ERJKȄah_xDahYXƉ3iR`P l}ۂFC>"E?Q \0(B)Unm:wR:A]Q#EQzڈ?q 9Q5cXJIRIH# "0s>"u @BJ P҆kadٖI2 !0:0HH XˀP:ґEDaljPD9VHLy\ UGa`7!D>+*4Dm,\J0lAjc}nVJe} |8\.ghqfd_UK#tDCT+%@!lf,U?VC)ojAr-02Eyg x IYWAX okw"UXIN@]r"U݋Jz%LmǿI2B+LlI&z@ 8DFI'+1+@ATݸȠ\giɦQ{:7&JE]O@!- fD(0 _ž#< KLT6,ټҍmA9L8.وGai%Ff Nr4p\FJ@2rh)0ҲVt+j6˒0!D&y䞵svnܯBJٵg\/d Tzla~㳮n&ۺ2P&k]\TN6AaB5J4^bo( ѠI(e5X H,[k6G/@PB'_$iRWJYppkF& FB?nQizaO(6: 4?@b& 4&X% P b -5uWa2 8 e~"~M^/I|ӭ5]𔔯J^|O߃8Q_ZfX,N+1:fѯT fsM`hN+b9O)XMѐC$@l l7 Hidy.BTG zQvaӾażCysxqU5l$pGQ I)䩧/===yY8  57Z3RYHEuhq]VZŹ/{.og}6Ϟ4*H[ ٳ9ϱiNYE&AF8K)Ja0e2 HǙa (u|6u`FfQk'Aqgd<# C|߯cf˃^zi+T<f>r9.Rx≴o-qR/~7Bww? 8x& P<@ryK_ŊO.CHbvw>Dsh.p$hcX:8GDsMTl9Z[lhj4Zk̙3ikkd2d]w&L;)QyyzzzPʂJ9"W)[㏳;RhrhxifؙVwq[oĘ1cBСC ÐB, H* c٦\S۴ DPR! hNj"sa=馛xgZ3v8N:$~:Zk馛=\eǝv裏f䈑A r/o_fԨQo8蠃hjO>$~;;l8؏1c ͛GP`ĈusD{#8rIA<̘1\.Rv-[ԩSS[S47C5aFBL2w7CIL踜DjMkk+w???w.\{qx@qN… H؀7AX˜p]kzk_W|+ uu7Ȫ~\xWX`ntqS'7p'>?Ks^^}_['p׳Ǟq?}R}dyWjm\"" $Hpu|fλ~5f< Nı @Ԯ"ހ7YC]9:d\;y6b 9p¡GK.dҔ-=aȎ{ʩ?W\u-cF 90C RPr #N #4Me|yw7K͖[mygQr`"_ßcb܌C߮)vupյ?7BTb9/P,/E3eU8{}4t(;~KV/$ y 9v9|N?hnɏ=XHAG}!0d {&= 5?0[Nߙ]7yġk<8tyt߻[eY1 ts1}Yr97|/"y6nIg|Xƛ\;Xh m/ǜ|"_x؆N<m yI]\s{<1~AM.<gy \u?xw1ky{\.r>tQ<'8ӸYm}L4_+>v :АsH$4Ra$yOs9㒯_LKA u;ᆟr7g|J'(w#%aOrO֛nf-&poomv3tH:?8¹+~>xc"Fn6뮿r{g#qAxXIڗNG+~Âoo|g\s КB *οK_~::6si;ϣt5U}e ⁀U#"{nO="-Q/އ͌=`=EPc|ю ל瓧BTw%RGT*>MM@ĚbM;OadKO?LېQCP ؏xq[paryЁ|bF) d5 W&+Tar&@2n>GR(0h0vsOA3u: &`e452bʐÈ@"=eyvpnjØ<kVaGGI|7\t79ӟsux;o?<*^ *GJ:53lp;N@eǽdҶλʎ&"EZssE$%at?vɹy$'{( sU>|'8RhBhɻ45%o\9 ]Ig1'm\GADe --TJdMg/WC7n!ôm`S5bHwW/Zʊeq #>~,Z< Cf̤X^è18CxiMb3;G:e:o!HzM8R2c~tU Xܡ(ф|z:X䭷̣Վ3b-qmw̱Ln;Zb܃0(lrB@ tFZLƟΖL2L`aHTnF!";(uUn>s9`}p.^'wކ너R׾оmʁd)Ç AxNFne–[ǘ!YoއD'P!a2xXl!H}`}hVt/A #ip6؀ #*}A@!'Jd>:/L̞{g͘ ׾#G AY \kr J!F#piI|jɠAuAP!wAh2d_gaԈ!̝7IDRP̙= 6QLQ !R=ݔ50W0(\ V);tw~@ooM #w8:RyPn(6 q:W4Z Fdf-zG/1}w}]qTRC;~y=\s .O<(^ss(H("rCFh;AHDJD6@;n7cxW8y2a*LAxmmmRjr^Gwrytdż\?cxU] mO/裏䢋~:[( u%`}᱇ϝ˔iSw.;<7U #+ ޚKS[- XɚL@*@J䙹 #;ylch kVw؋MH>T8Fe{&,`:}@VHHB\CLKye ;lohg|N8PuaF3mU7^:׀ *D1QM7t59e)i A~Hqs-@)LBq8_e̬'G/u_bJ~F !HbD(>6Ůn(#MHtP"I) D+OEhW^}}:9Mꫯ1gƙxmBT=jڵ! <@ p^<r& `%<9 rEPqvjFy4| P2D0 mZ{PlbV{p3xxǸK8|3i4[?l&Ҭx79s"t!؍s^dɄU*>!Æ0Ύ"ƀtK;{C(8𻻐•ȷAѰyQ ]JB 61PkŃ?ǔ)k/Ia7_y(PlFza5؅K]JB ƌLgG;QтXC ^&me &4\/!XJs}8@GhPJ(׵K~ESTbV"3IB! m R)dĉw\slV<5gkW[ Ak ""plM.%=3Y% 9puLI$6.D,^:@`40еDWVS'Xj5[O@ QN3Vq_!,iڻJ5aC ]t^c"/3t~G) ɱ2*4!&,Qp<({p4Cлj^tŰQc ׌9o':,,7+A!iJno#03l8tȪl'ǜ磚L9 (^|c!M-Yƫ XѾ-nCXôڱY,B( ۄxc6*aU~%B!F`aXJz[q;3ޝ?S*CG!q#&MN[}_q~`ѢL6N #Xhqj-TJsM'g]vbQce˖S%ڨCLDd4B5&L`dM m3!$ 5d >١B!b;>~!+X8xۃEꫯq⩟Gg:|:(!4 Ö_w,\klg>l|֝?~tVOw-[,޼ck\_s;i8 T.!2w%lp&+Z@`wdo.ƿ%Xt5l6^޷X0:2kt0m r]p) ,6.óXt.x`Rp5.oY[9sjy/ιہuw1RcBo;CKU~D 6;K化c5RLſO*>Xi{^q'x}Ǚ?{ 9xGǎ{rZg]n歀5:IceGG (3s|OO}O,n/ݓ+!m'>>Nggr}M6Yfn >Hzau@{s߆~}) ^W^cbYb4.'3n^}L vuf=Ѹ4D2$4+wA5sVg ᓟ8{ /.'~ p m>wq'GU/jWݶC1mOλm߁kw6dS L#PpMU+122կ;߸Z/wgb}ԡ|oCL6mw}%/yɎ VKv?Zc Gn>SNbr|̉dYPˁl5[pI_e3{|= (_Ocnkwӿ~ s >ZfY׽P,XHNCN:ʬƶ;k91GN;|W[W}@mw]N:_~׻3o _:lL-KhyF%m.|zJ?͑o;/z[1p!gc?|)iXl9Y514s:C/NWyף,NE+TTDW~Ꜳj]R0ay:lL_Ŧw*ߗLǵܦcV=~R:'.4UųWzI}f|5N5t{Q3|M:[dDퟍ󓃭՞N':C&ƴyrZPU〬4Mz^/t/ڋI-b-PTh$,YZS- >\M謰n0E׸4Stk];lqb \WX颚͖, >;w@Vmg{5Z\Tj(z;ޓvMvg݀_ 'u:D?:mO]- jָU )=c{w)O9Sޯv /&]XZ N85 ɶ5hdF״PjU Ez^w\uP39"鯮d:ﻙt+7S^c:ch72&}WoZlO5!C{Re;D{ϗjH S9ہ$V5Hi-T}uPU-vBPtyyCO9tU\vJiWbOtNSc;7a6´jk(=uϤ{ֵXHB\wA18'D޾278ݡ~ wY71fw# w϶>wKKoW1S>M|Gt 3uh!s`i]j֐MҦ*m'58!nS=d=C'TĪV~jL_rroXKCS֬:Bv>[*uLzحL}떵obv-* @O'\Xl.Y`R_,],S zև0; ]G;է[Xa}빳mK=vKtsOn`rnHp]m ҹP- !bJc3GL~'W:!}|mONI4%DU.k'smOv [0S ը x&0fVwBWeU˜&iQ8OCke=,Йոh'eP {:IÄ`Tw:}l PvLFVS4V7TI7<.GDPL_qgEeX\G,ޞn#x3߽ ݻ٪'n]Sz֏Z4~…rJ#Z kzprø[BmF9C{5x5EY]^sG{] ў 2LBk{99L_ס΄;v߶89dh?=M} םp~I+LgC-=b}mczG;0\UA}vHU;a3=N@g2gLq3kV6S5M&T-3z%=OvpoS걔6[ bN'vG τ;ʎ>ڴoc*E1Y7u*ٴ eCg[[ޞϬ&4}G62i0 :'21k'1J;eXvmy'hQZUSu"ib:TTQ2>NTMFŞ>As҄BfiCg{ڧZtXۗ;̔{f9'L֪#a^=vG[=LW C@>N;lMou+M}AA=UʷS3 =C\Ä L']{92NwtOc PbWy{88_N Li3تGQӜPh7r{0Swe_@YijV+)$3~{f)NJ JPu >VMLZ( `\JazQlozdp[lLphs7Ս?NS{];0/*@O,k|{ Nzto&;eV)>SU NG*T}{rWG-xE7_MghXUmHk(ޭg#e:"eQP:)|`USfr0) zX7z{VqnWO!'|293f(c!3+j Mo?\Ɍp^7ڔwJ~u[2h ׽ 53[| 'w28g.9dAy#0ǐq756qC5f f,[kܟ^.lzk n4=@-xюo/w-_2=y<wsZMRDD?.XQi!xlg׿r}x9@?u."ZfR~h5Asogn=-b h+"Oq 4Glx txddddBXb ,|-ƈJUk|'}vb(1|Yb]1nª%h oh"0K ΥZ\XfM#ڟX]:0Tܽg:ufn>sNGDD)Ykɲ H  y~V%eY(ȓOQ1#<U  yz[ 'Щ<""uaq'vAVKaQHQP%b;מ'8iC9O>/ķNdUTy/ t\gȮBȪyɲfI^s!p)P%Y7N;FAբVc}[Yr%}n"lS*~8rJ[N;4p)âȣ!|NYZ"~By|+_ss)cdҥ̛7>_Ȥ|D !b ,XZDwmedd;a:fy!\_{~6::eܹ =>ϊ+xt712c 9s&F'$X| (vF̙Ce}:b &o'ƈ1ӧ3>>1oȓ?EPIL{}=H 4Y- >YbUŧ70YVЭȤ(gOڅ[=j`25u !xZl~Zn;ՄDcq cS].fiUtWs<IO`zJ%H2CԲLEYM h*Өeq, cLϤ[/CyߕիŢXXxKf}vg08 5[%E+`ڃ-Xc18ʢ MlҶ(}QC굴jSo⩲KYV_~W\9ʢ`xh i("" @!`rY- <ټs/chybk,3q)BQVG<3\ 7Mjh F!Jj, Jj.'3KH\  BՅPR eh`J-Ww  #u>s5qRP3W |4rB2MXl٢905 qs% 01Ñc 6x[M]_d9,O*ppErE %L{gS7%S=ʲ b(Hzά9>3tY3s5`6=Kԗ@DDq!ad .w^.|_ᗿ.Pk3qcdr'YcR $w7t3O,r!2226.;Be<34GKEJzSz}gK2f% ViyA+un?g^d֤S(Kt~lckiF)TԬ!#hpb(g(hc3x+wE;Kwًs!%r%z9UGMQ9K~+>OrO!r߳+gwzWO粟ͫ_ r ^9siZ(wϽsagW24y;Vz=y\ IDAT0c_ic8cx-ۼp[vuW>ɄZ,j^ײ[=_#M9hnx.HK UcobhAݕ'o=0N=d^#9y]~p_ zkviGNR_" a"" @# Rfiӆ&;~c (/?]{_?a4w5 s~'>yοB]>ZO|~x F?ޢ匎B |Q_\?6MC$a?o ?v8"]8R. 3?į~q>p_$fCd4|Y1r OlatAOF0b[GgųW^KN;\uՕȣ8ӸL/q*\yc5,cFmgZ#~Ao:},.~1~\q|;k7-[GgW忾=O}]|ϲkp~RT5_ZMZbr1fppoS.,%sgp܇>•W^ɉ_9.kKUSw7 (=B/=G^GciTS,%^Ƶ^8u[02ǫٛʫ%+FF9&\<9O e7ڃ()!=iQ_wvx!3-O۵/\~"]z{k y( ~۾g<`Zll߲66O`}(XvIU2Jek}q.ll&X[l%?w3~q94W\Zx6}i 5ؐbE[|5~,`OcVs0kW1z\~aNhZ]^ynFV0x)NӐy1`Mo0Q&+r6YpM2d`DcYr2xV[m5b4-Z1+냼 "g8sWߙy`yfpw.򢗽/>k+~Gv{,W#z V&O4[x}3fd%sgĆ;n[-e֌d:P@k7%fۤFx+Y8{+.'Xѭe(yf>8b, m8H5osϻ3mZe,=ԙ>{M9칳1c!wb:88&l4Vt #xߘڌG;hۀoZlFv5 '&sBi\ b1it`t56h#V$<{2il[oÁ37۔n=b_fͪIdEDLH-`Au%&o2w\YÚV1F>41O'6 _<~Oy>s,\i,oz<8>JG%dZNKKSc6@eNPMSkga`h6^}09qnZeIcp8M/PeFB U' [m==L P[23q)5LylO-f%0T)(㔾oj4KjJ,T3sXk `>^yA\yo9p7~I_l5^D I,:!@1Hc`1yg3o|LH)6c&\w_0[ˊ+%̙FZvbZkqڙzrŗ]rhH'bKAJ3g4 Xq}dԀAy?6X֜7 k[(YV[02lp>7_mz<`%,Xk! Lo\,Okdn"j\S5`MFZfp.YCtx5yRh;w.5Wr-YrRV%&`AiB_RkiYtFsǭcx@ b;0EV79p-9FQRM yc#y2:^@*bRf0ig(Zk{sfqih&#"" @)HFVT**JuX d4cgtͥ!Dǵ^Ao8yi3r3ov'я)] 4vQkc[Vg/Y^:=| _;wލ7s >1,5 3.1_-j]2KZ0{﹋XN(-!,cHx|9z1H* s.Moz]wҥKYlkgUyz"-2)}&s2;08ȣXyk#6c'1+y{ǒ{`c5b=w'E_>s-\0>+K 㸏}>=^Ӯr[~ {'Gq+^‹_b4 ;l-#an]zk,\N }ϗ1o5y+_WL0k]ğp-/y j&͔M0^vXmG>|;h{}~bbaeI"8xK+:fi׹m $g9[a.}1`Jf6?~N8K~5o!~lZFx3yÛ80֒PE;~[x+Yoˎ1djLՠ-0z1,>{Ŭ٫N/g=^ih]'2z""OYfdddR5Ci+V`A?D*֏֑#3`b+!f-Z7Jѥ9Řf.U0@VM2XhҤf0iq<-ji=ؒh2ƊH=7ƦxʕC^gp{ʬ 13PYJ5X(Hlxbg%B[;- V0.#2Lj<K/YR|6o>-=h[>@&i ҤZ$ ԙ*yPF!35m =.p}W"`A<ESP>`,cX-K&,{{_s5l換ώȓ6iѢEl:0Odbwğ ڤ]&Sb dՀ4dشh%5S7=ԌahyrV[.F K-XrE.?rW[3su)L_b<ÇidI!_l̞ T@feNV{c:B476͝^ߝ|HN=Yf! @hT)@VMET:lJD!ƴKk|jZ_-[Ҳj c0 e}ƪ3Mu[7{=!#"D~ !ߪ4 UZF[n>uJPU-I?gzi/4Z-N0uګ۪8ga,ZwO?~Yc5^w<gW{f8cWפ g53ٔ;uK]zuW !Tr| ֘qá|bHsR]Wt޷2Vnzoj='MQ}@΄djYmP-["-ݍWFDD1A̓߬T/`|۩Hj7{al`4^җL{XUC5>36MuLA  voza}r6>RyjP-Q}z%3BpY3)8ycIDDDD艨)CQ%Ӣ1s\7$E6#""htayi-eYeYOVC|BݎͩL8t!)8k"""8}rڡw=vVy,Zo  'z&Uhoʤ"""AjmEDDDHQSG1rcN-((((((((((((((g%s- ˲,"E]c+* ~XADMGS!؍-hc!hl?`BG,-3|s ,|<{3syϷI$$I2I$$I @$I I$$IH$$Id$I2I$$I @$I I$$IH$$I @$I I$$IH$$Id$I2I$$I @$I I$$IH$$Id$I2I$$IH$$Id$I2I$$I @$I I$$IH$$Id$I2I$$I @$$Id$I2I$$I @$I I$$IH$$Id$I2I$$I @$I I$$I2I$$I @$I I$$IH$$Id$I2I$$I @$I I$$IH$$Id$I I$$IH$$Id$I2I$$I @$I I$$IH$$Id$I2I$$IH$$Id$I2I$$I @$I I$$IH$$Id$I2I$$I @$I Id$I2I$$I @$I I$$IH$$Id$I2I$$I @$I I$$IH$$I @$I I$$IH$$Id$I2I$$I @$I I$$IH$$Id$I I$$IH$$Id$I2I$$I @$ݴi^YTUE`ѿRJn8$IMGk s"D~>$$6i&&&B 2y>UUEJ Im5V)b077GewqEAG @ۜGy+<333LNNr '$I]3 azz89G&0 $$I)!Qg#!z1F>/x x{Kӡib4(,I ImְO/~1y{N`bbevNggDXÊNJ?w󝣟p evv, @2I;wߝ7> -2Qڡ'x"z׻X~=+Wti-brihM6vEW6Kںi(˒K/]vمIVXᆑnfgS 7ndƍf2~l}ߪ'Vnr7ΑyBZ8I'" 0xÆg0~ 1.Edc#@C=q9А~H k&1z}8 œH6zzmFOlm vIMo[~yk,s5033æMj˗3=={Ash'7~&AG?l6$ ӍW>~Ã˒ քa&V6~f^0D>'mq$4{ b(m&,cnnK/)vm7֮]KUU{y377G㢋.b}arrҍ#~ڈB{bF߯:1#F"GeTH"M44a23ZcdȠ*JOށHHBjE3TXfi;-rێn^_NÚ5k!zҎ299IJ+399iHۺ& O axz~P6 FMB). ;aI[ SsX՝p!b^εMS!.d ) AhiÄl*|uױt(˒(Kzj."֮]khg'? acPyA8 V梥aR8bPq(8Dh7 "LEHYD+tD, Yal6a~t -]~c/u~ߍb/̝X%cqxv,? Ba1Q@3*E}6ϵfe(JU!8H7Q+5KofQj1m{:@7MC4G_?tCp @;saIͨYR=F'Kc&U@EE?o0H.b٬YNW(3,t(Eњ%*ۉ4,Jj>>A%"䅥2,Kr tx; heY.[eIiH)t5,j14<4$|7( -006l⦭m8,-chtBa%a!<-aqxðY/ʇ |՟hWQ@$_)Q3p?V$llj,]ڹhAugPE,4sVAp#Rb͢~<+h`f0I"@AH $M !HA D"RV= Ռ-&iTJdMݎ|+ X J۱n/zUJm녑s~MahIeעC n# dzsUՉzXmD"^/ m/S>oB(8DL4M9Vm2RyFՖASW@MMiAKB_W9Ob,1yh`zuj,աIT b, 5D*N<>oA<@[(Cg~ IDAToXwGq PaHF`ipX5ub6?N#I h M6N M_ȩn|זfuyTBw9EGȳQI5IToM !#fRs U tUv`X, e#oG1;?CCI'Ϙ)RYY`WHe<BT)BH"}#K6,ߟ!BFYVrW7r{uLuڿ}KOŌ,f̗`f .IH[C#rLIt g;ɫ߳v~|跳Ŭ RԄ%trhPDaudAPUUm\#eZ(LYWm'Q CMj+JNH~w<\M'Qt;@M d]p!ѤA4x05ȠhN*ySU [t0$I BÑSy&H U]s/aD˹_QUM'$XDJ9`_:8pȣg<ٍ7/yԛ왧q?fI>8ې'#8{}s9 *7G?IOx2`ګDN$(@9qsr8y;?!V?{7 3/~ p?8<'_<>I۸#.'T ^w׾<቏Q|(|#e_u{qolIH;-cCA&|r;ڇOxW41Afʗ o;>W_rȏct{rɥW23{Z~vEPryn? s9<72rgy߽?1|_^>Ө9&RQe6ԥy6^ ~#_Їg?qHsl2}9c.ˉ1)@ɻO9O}LNz~E'C `[| gg>3p᪫kz}O3g~ǑHH;ڰ"l0DP P0G?Hy?>W]ߣJ294}b,g\pM<zn \zٯx#>g?eX˷}Ǫ=>~ҋ/M%?꩐!ݑ=0&c"N.g1jW>ncx e uyړWD{G<]V,4Gݛc!mw~sXjkwIO< 34Yu>} xAw$}6n7B`zz;ǹ}ev#v|A-¸6K6Es }`zY@3O7+!:lD ̳מ/e{˟zne<{|wCw{~õ\N|_dEקnW`Nssb-aꒋXZ ދٙ0`zJ?K.)%f6қf͚v[8X,:4uM"} v 칖n@FCI࠻݋ax y#˗O͂cQga5$I ,I5'5 )4d! êO}\~s#g'|K{MN) ꐓe}j8!X!e"u;p!rgnvOs-wJ&O.=qļ&LOqқEu|[ߥ' bBg=/0uFip۠ot!I\;5P79c$MUަGIs:TC;4!QNE#q+_ ?׾_?g^ŗ])'~_8Sg;8-\zKt!AՇ,"eэvnܰ*~*A6Wc{x!\r]uY8p;3۟5vaɚ RMm`+ؼf7ΎM]pet0Ck(V] nN-94U|ګ @pE~E$vR~Eʚn9 l=Wԧ)|P"B,n[S# $nvTnb?wg{j^fOu{/':(Y P KG=0ُ|qep؝ֲK>w/tw3bgg"xƒO.|SuW\p {\N3z_sgn`y["Ѕq4]ŗ<./}<T^%=ԣSL ("NO3+n*ynFӔ$ sӆa|&yӟ~u%MJlذkpDwI=eN$ Ck8>EўEGN=l~ӁKdwsdWkϰ{p {2XrW6ޫY]⨧<=w:2Pe~j">~353 M]bNssq`%oʹˡ#csyǽ}o7~zW|rbn߸bdY*{ 1RW[RېQm/rvVbN]׾jTM NYCɊiaK~MhjRUb) I5P y GMU^GRUUɉvW7;JNh˓e;4TM )_q<=4gFP`2Dj,]b_wI"vԘe߆٬2N~4AAȋ~9@6jZM35 J},BӴ<y[|MSU&j D1,XHJvNݡIUoФצD;5ծ+ RhLt:9eURc.r]8ȣ9! k_Є4ř*#NS%t)."@ʲl;*?yn-w7tf[9&\z Hݔt|^5ԤA.mC3N@G՗6 AЦ&EB m44S31}/d9F9y(( (˚"kyޮ02An o򘏞"Qeql늩yӛ<غ]Dە<@;(-`f4XξrfG~YUlĢ|5ьU>Av|;D0fiq,(0z-mofpXEgaI CʖMs[v|QY?ޠ֎YEq!hN T-G}uFMHc37E llDZZZN gKNad{cM->Imՙ%}a?gmusҶ^l8-ljآos <[-$v<H[ D7vNK_K`w0HgYI$$IH$$Id$I2I$$I @$I I$$IH$$Id$I2I$$IH$$Id$I2I$$I @$I I$$IH$$Id$I2I$$I @$$Id$I2I$$I @$I I$$IH$$Id$I2I$$I @$I I$$I2I$$I @$I I$$IH$$Id$I2I$$I @$I I$$IH$$I @nId$I2I$$I @$I I$$IH$$Id$I2I$$I @$I I$$IH$$I @$I I$$IH$$Id$I2I$$I @$I I$$IH$$Id$I I$$IH$$Id$I2I$$I @$I I$$IH$$Id$I2I$$IH$$Id$I2I$$I @$I I$$IH$$Id$I2I$$I @$I Id$I2I$$I @$I I$$IH$$Id$I2I$$I @$I I$$IH$$I @$I I$$IH$$Id$I2I$$I @$I I$$IH$$Id$I2I$$IH$$Id$I2I$$I @$I I$$IH$$Id$I2I$$I @$$iJ)Bkߒn\44Mh_jSJc[{H !? yƑ#Gi"e!^Q5M,${xtfff!c$-*ys8)b$t+K)Q5!-*An|QV},g*DH*-i,[z,#4ڧ(VXevSŢSO<&@UU<ϧ,x2If1255͛ o*&&&5w'th0s)077Ԣ$݊&'']b-݄<)rQs'ȻnO&2֭[ǪUH)9 L7)lq!^شik׮]tE;`!͛k1f͚UosW4 Wfٲenn,\y,_|4.8D˖-p_Q_I7J)111UXr%N tm;8@t:Yw}4f0Vuzv2D[eY@t/0]M2I;zx%:,X ?-߿UTǛdnK&1(vU uBwx:xЖt-]>uMX~g*cdY6j[֞U [ߥgig-mgM I @$I I$$IH$$Id$I2I;D|mY-ćV-M2IPct) Y֝hT i' B)EI_ U.!-]hxuZ׵WؿVq=0[xi,K<]t i(ܯdnm1F:ct /0,cdpׅ7~;=\\wu&B& VZʕ+t:^\$ W7ok%Mx*}h~~+իWl2/.t411ׯ_I ֮ZU蠴6 ȇ_,3S9~k!Q~ bnn΍"~7W`ÊK'aShaz@Cs3Ev ,˖-#-[E ߊZ(3 ZMvRvէ:e;pX$ENкe<vhK4;t(*v.aiN´txsW7܇8R( <4KU[z)rQߢ#apz_cg*3z)p_ih(DۥQ aXI1.l=x0x#eYMc@Vhg-ݦN`cTBH7S)8,4?|܆TAjCB$@*ȻA*: F}jdY)|/z !?$^/W(}Yz\.l9Jna3ھPHȺY%1zR¶ZrCn>x~ErۯfK,~|^3{hn1[`kS%KҶ9i)ihs$gąHUBI!PѐSsOox+q9 L;,0>S=NPU"ohRM&`C2j St41 _*}aLL̲"WB >t WQ'麩 1#vfPE)1FZ&+?agT($*u;W7U;LEp/?ͼ-oe.+(+"R`恪Nd@I*{'?㓟"T wz9cX1 Yj`n#/W#~?.=:f.S#`O@F Y(GOſ1frA8UCp\}{We~/9 o^8*-/7]{ d(6Q=9d  Ǚg|`tWxGYYEF2e&\ h*c"6uY;+ jjBۓwjFks]2=6\>vw9]HUE: ^M !Y$5*%1@Ӿ2Bj$"uɳ20vJ;̕qX]K$"y M'O?_,ɚHN;١OT dEb!Q=375Y_oNӹ}o_q,˦\|%|_˫_w21-h!bio_Ȧ__Ï~S>ygq"L<_>w| {xn/g//6NC]BU:E[4<@Ml@]ylĂ|>b= @JtbCgdq9s+&J5di7?9g~w UCZW4-hFEQ !PŢmV})=T gJ:IHn?!? % b͝<,M=K̺t\ӫ v['/:D^_?C_'+j 7 G4o>Xp_7g<폹|ch:̕ԏ=[y7/y]fy͋wC. .?wo~o|3\n/|9qoϩ!UE 'u"f" !dwtw݇{zgO ]Xn{W++{)Ewn!6MooOcEmNs=CUy3Md!Eu vs۳v}e;>ٳyӎՕ|aG>'s$@5G'w|3kemh"O |7r=ӎ} ?n7mLd5x{?yыgN$]=ڇdzy WߡˮኟG<j6\{'?a"/9yq/b]ŏ~ȿę4O{c'?ϯgV? =veeyl+ZۡN=T=QEv~6mgQG5 FÁjG:6}t8_·ޗ5q'>S)NN]S=SOC> 7sG>D^!1SWx-7Y_bI^WQ}6owNz;za~ ".Ӽj>ا=~u-f{|rIo_/s!Ć/|,9㳟~װϾO~|#csں~{}==Q_Vׁ;q|O|yC1=IٔdOV&x4EC(!6\gEٴ݉mߪlsdžY ˈe`Kwr1B2KΣr?k6pL?T@L,'?cϕfɱ|k{-8tԧ=\E*Blh7M4%M9/.} o}ɼU| ƚU|y{?oqבy'̲ rjRsux{~,p={qO/|r˝~uY/e~~W7׿O((2i@В7 ʲt6l_;=HLXw[uW^l!g;}xqoϺMs !mcDBit:l.36͗<D6},M'KW]u5W^_q3_dƊn#ɺ.+72))~vvov B4u^{ss3P䑹 ~kXX ]VRN1NBS yjB*ʲd 4b=V+Kp{q)'gHEGdNJXnw,{s{ޕ߷*ɋ.w}g5)7y.W\v6l'̣n&~uz&]m)2Oԧϒg9T5ne˗eԩ`~Tw0p,){sLM%})Ё~Mګ)Hɋ@^@)5ri2tۓd٣H5y855|{*) RWujTU2zpӧrR=I;EYU31EjĜ^6 b7zȡ!kGNcpWj*Ӕuzfe)dT .=; _q ]A' Yzu,V.TeI ^Wv|rNͷ >EQbWk9SHeSӃUUѝU bIt@@Hlk&Qluh9,ꌫ[fjjW|\r%\~~gjjjv}"8 ɸ;/̯ĊyepNs9?~o~Ir%CkfYz5˲>}?60Sv_Y?7tϸfCXΪU{sjش Pt۫̓!o%ʺ}9U}d&dLBE@E!,TD@EyYTD Pd>;$$dd2۽9ǩΒ@B2CSsNWճ##zwBnkhGR>uwp=#z06ҙl/m3p͐ҍ,PbJ5%*ɼЌv2zY DR .6 2ǡis1p̖8i&eiaI3C=bI{1~so]5yFZPBȥTD v][`<s?Y~7v'>5Dd.sOy"ׯ#9|.>8=;zuIöx9gosv/@V.TC6K/K.;9^WW_'>yrj ƅ}d)rEo__g=dqǰy=a4nH#neDkѸ@G%0.sN4M&xMcj`+^ ^WM'욈"3nK7 <7^!!8^4/o=]g?Gxγ9/gu[9_{/r嗳жlv=^w+8^i~QA< iw5B3j+Vsޙ_~2QaHK s juU\L1ʎM=pskzYqXY;蹖+xW%ߌGS=xi'm~|>/||.}*g=;< OE}G0B=dN}?΋ *[z:~cX7$$0߫TtmxhQ k6GnK0X=O}/_s95gQGn'<#^w Og7>7h"l޴'>1'7?xrIwgw+GJJӴu=ml#&f򷍫q_.LΙݻwy fvs0[QJݪIJy9Z89rCwv{hʈȕ~4n օsN;lk4> -T5Py!R1l`m]ZX BKN I)Fx4Ş"2iJ4U])SE%4Q/W|SN9eF#NAhֵTBd-"jXUWH1>mą 8b=WDF N@<%TB%-9eƩb_8'c\ %o1Ez2&#j4 ky6T/ s :KdEY3 y :>KZ^3YPIf)9\_y?<܎hNr\I*6SŅj %kB}, 0J Bl3uaa~&zU/{67+k✛Vq+{l*J)=1O d㈳^*c,ͻ.>xذ/DmAU*P<B.- rWUj 9qAPiBxhm:SbkP1=-@c-}nt^ *'2&z"yLUt$Q -TdńN)J| u-PTKA+R.约A:3r  )JBwd{!]*b) h.Th'D#wx|,>53-Zĕl*TdjZ1M$W-[L'zGUR'ރ|W<7ø-]߬W^'ŎK4pji\S" XfU _eass< ^s6=U&қM$RKK .l%WvK(͞4gEIA2mЭulLۦ2i nOB˱A7EIM|l2=6Ol3})(NhvBJ;0-yp Mc|h@"&ADgpKfiN"%]pEhGdAЮWԌ%jb˹vÚs~ G 4fWqU z_ đH@4R7Jhz]tr O,VM^ й&;!%s8/vD4:" s˝ϋ;wnbnN"x~u 6.%%] {,%, T $:KXYIpE%::Js+ !Jcؒ419UT+As x/ӬMI2+RY)!xrOC\DI*OC״Th% m곤ŝb$,R1œkxuw1V;R\fi%\.ԳV뢹~x sݶs?YrK ϸjR; jdA3nd$@Ao#(|&8lIhFRWiit op]SqTZz&eU'LQØKKgZD~64_EpEyOPS&Kci EйF4(-cDi nG;_C} $!ACFJb4+)CKZtKMI9ξ OF9M"L\Fďd_Q-W8b'hrR9_U9 P:SȰRX!2=橄~)K`)JԌwZ,sTEN ҴqjYenO ^ԭl@ zZğSP{CWݥ$1:|n“iD]9rFdEq"ULTl053h{ȪxHT>HJѻrtVV4D)櫙]+MB,8(}ΗYNqYx419r" εSUX&u%9)+qZpvsPⱄ2_#uunrU(.8BpD%ؽd x%hq׉sĤ ״/Y'fP1jl/hן_*JNOtlf*%f?(樾KPq3}*y#ņ_\C' ~_fx MO=dP=dgevK.@1  &^p8 -?XafW"H:R;_p2T9<ʽ3inj9ſ Ol?V,+29MRdv=vT U;?{wWaӶ$gW9t֪lɃe+˜'|8Ufk%|?^XaȸaWcb=#&u%xBV}KpȾ}~vn;?۷SIqe{Y5_Zqϸv|Ms^|޷/{~C=be7R(\ IDATb~{ _ øY9~zغu+;w,w)d7 ܹ[-ʸq a蘔zlٲ;vpWsERqo.ϳfl2mj.0a܊LbTyoT*a7&cs@iahi( ^Qv6rέ2ˏa0RJxWGO^2ޚN~kߞa Əx9ic1#ιߞa2[c`q0-e0daa0 0 aa& 0 0Laa2 0 0daa0 0 aa 0 0 @aa& 0 0Laa2 0 0daa0 0 aa 0 0 @aa2 0 0daa0 0 &q뒗+T_%XiO0Lc:1"@N y? ,& 5}Y9@tP2f7q\Jup]\u}˷adƭt!EjHyw:pH$Bq\H榆-KgY^߭G7d 0t2 M"˓uMAR$vaPˌv^ }m:602Fj0Lq @+'q3:c!wӗ쁻eh9 øEtC&ѫ/SY #4lzZr_ڥ0~$0[/{O[䛖veG+ܬ!wCpҌ3 a?ZuR'Sb@MJ.gV1׷:ۭp8'qPN0d*)%/SU۶TUḘ_)zMBi#DcN㨷1 ;| ? >4GJ&㒻wf 2 @q9#"8稪 2Nn:QUGvB֢s%J_K%|:+8)@n]ScJ,@9_DW}/Sa0@bTUlqP&71+ Y<:/itBW) ġUo颢d2L4UMrjq:rDDxd-f0d,TxL/֎N`?Y$4e TaӲ"MIXE&jOⅦ a_[\Ӹ*@5POrcL 8Xy>;vkeqqh`0mۮQpH(+COJ c1DZ~mG[[*]{;޶mm'喺バd]|@.@J<4AWn$O=<.YaL2\Da2k۶m\uUz{TU"86ɴDk&L34 λcٸWVt5C߸o?s4MC۶xzox7[c45 @W-leo~>y􉎁 ;Sc!da0d1_=[laÆ n DHbJK!T'.6NFhhXqk{RJo, I#!#;=_x ,] ҂3 8zܹ?KSqE*[b!YOYn3SôwV~.KdUTL3$|f}+>o2K;cFgI@"sF4ԁx~ *0wNƇ1jKHY|5]) ʁ_]{h󴮏[|I=#7U$ ! Gc\ݻh| +q]` aL,xI$=˥f̷(qDmՊ &i=H-ٗ A@J*&$TDxiIR]BTpٗ )Zr(K*BkI1) &$T2ԀE<5RPJ[3 )8EBSrw}b'::}W8 &"gX%}9 Nx0dGURU`XE7 zGI1|)0x/x_!x*ډPHMYj"#"TO"ᜣ7yRx!(I=܉ c\ls&32K>JCa& AL^[#X&IՌD2._wm UMT(%%b ^kb4*D+rJgs=jF# 1E'8UȮJ"Iy&BJeڑu@$_,'QAIh8<3^(*IgکJ#UB#ZQCmDXuaF!Ԍ|P4/s =R Ŵ{B5ē#:&ɥʵf TΒ2QOҀ RiQQR1.fLʂUIQk] BӒeUtU 0d?&"hYNLT=D* w3T}CF N&HxRqδM ڪ"Ė4^܀Nk&8T2𪴚I1!W.ڴJ"U8M*ԉ7BM*gOcӱL Gvf$).Q!0'Ockhtٱ8ĭYK+JE~&.){7 vJG&TĜd&Df M ^(IJt=).A=]\ fÒ ?Erv"NY22Fפ4͉q3BshRj|URi[ א!njOL0 ;hf6-xQK\Hi5 s-MeqR@M;f%r.>'=83N_#"lHhZD1+Lʐ3@2zRM&&A1~p4| ˩wdsy hsɓ"G#.f$+׈Ttr4;4wV$9yrrHN1oe5a0~DDJDX!M4i_{䧿Njĩ<ٿɖ61?x1>#1UG]hG#fA߱f:>CR!&^d˱rG=_qjɈ. B'Т䲗)GU8_U4KKځK2ׄbҽ܇^mCJ )5]-ծˠE>Øy}6 gߧqq0}mŵ"|_;0<i$A^ )c$LU;Tnj0$9P߹rq\%_c ~#t܍3a2/rw?ɳEH{]M C^'8ŌeuxKĺ15ω'3~90Q S/s$C7ٯ} p7O{o?j2ٗ Ar3då򺈇Q Qzn~ٹsG#w?8g܏W+/~+8)/~;<~'V<"GTo}+0Kpc^;>3cjQ$)TYKO,I!4 hdY >]#;a d9qU53^ʗrvlH_9N˗n>ۭkc9O~+{F𾷽F|?>tdp`~Kr%i-;^m @Z@ebʈxrLxDD$P\"^fRy\pwyqiDts 6ߙs~'ssi1k17+7GfUAo֮e*\~%g_+yыb3_{\v/IC3ҋ.f~ z_|;x׽lv{qgfMcDɳ?4]'*LCI%wٗ**).Tq!@+|e wqGߞ} d{r;݃g=X!ʅ<n|] ~1$|VS2m8yG9T]0AD?:)0L, U2!<cXTqw݇^8;p9&O}HITS г8ZuPZFDjmdMl^W'?xٟg=G_$A^h,,-2'P$bCq(=r"J+ڶePMB$U(ZHЈi Bu|[K<~wO:{N^K^v.|S yO|}ﵖ7P >o] HYsnXK!5PהB~%T5c?J̡l @&R7r#x76T[zGnb˖DŽ:;oV}R+e8yfR8C 95'Ebr4M&qsءs{?8A~=Ħ߿˜~#~o|b _[gs/ҍv=FTsq%%H+#~1?/>Q!kk29 7hCqCi'?Bݻ9#\2g?}!G n'Y\9fF[{M[^7֬=͎z㆘3x.vI&urmSa2PiV@*J-*!=8Ƕ+? _>OpeW]Ȼ?xYoNb~~#6mc朷ӿs)q: R,mH_Gv^q;wsW#'aXnH߿|e ;Wm\N} 6>o|ᬿz͚ Dx=Nj۶=pDƑ,Kw :5"v/Kէ'LJC6*g= _aM6x]n=̧L5r=|zKkA>yX];gxSNaӼ0.Z{\T[/OLƏY\\W&Cܽ{77o^q00R_*'pBԀ!2 Hv" n_{glg!pq'ϽH4X;;7×5 r/~/caCsJiw?gI+Z/I<yxc6(A[-v{G/缯~sV.b6n9>)抭pٟ㺅]SO.s_kڊ~85{sQ; |gC%Y# N׵˘YeCr6ɐ&۟UϓEpXۻ5KVtR֩Y a=9(`f?+7g UjX}]5Xo&BVҾsWnYHjIGSMNUJr2R.Nb3N.Ce 80Yѱ?q3+FD޷oOY2p2+f;ϙgˮ@+ڐ%Iq8@δ8HQ ^>waKYˏ0 @q I\j_ կ IDATbe_/K,CRϞjSJiSNtUIV$.H|R4*.+Y^e9)eH946ά)Va2#L}eYN"憄͍\^N3G{@yhӓurǨs33qCH A4!uݺn1 ah"xf-A{v񖠽 }/ ЬٗX6߯S3.I i!@RwD-v8.m'D}JWT%#Η8 7a2FvM\I77,)=ѾDބȾ>u d:Fy7kZǴA% 8\7 ~9u[#HZ_E+6.i:3Ȳ aٴi[nEUY~=!t}ZZ~LsRkƉCēr,..r@e?R "S|UM⁩Q;%IKx}iUF.Ȑ4O dj2-)'whs1G"b!q-fLQeAtz4͐̚x2ۯh{6a$LƏer2o Mjۤ"SHNM:(ĨEQθG黊z0@rʹ f1 @q@Y14mے}@2hbDi,ƝP> 1gN0 JBėBX fۛRAz`iܠObE0>a0i~l*yUUI)]"!,<A]D5#.J=N8:IYQ-ex(g R 1%EIФPpa2KLf-@Ʋ dP+&@? fN&fY}09B):ů"/3 aBXMY% f:af=%n cd0dƏ`܋H  ?o|eaa0 0 aa 0 0 @aa& 0 0Laa2 0 0daa0 0 aa 0 0 @aa& 0 0Laa0 0 aa 0 0 @aa& 0 0Laa2 0 0daa0 0 aa 0 0 @aa& 0 0Laa2 0 øHUs7 0 ø؛hUaa`0 0 @aa& 0 0Laa2 0 0daqP >fkxF?TaaƏYݲsnn8{1@M4t# m 0 ø9>9g1mBba_*jVF#ڶe~~^W%eaqK[]1KKK!mVkY\\ܫOk"B C#9gAaa*L,@1 1I,DIMKJUUScn00 0nis ϕsTH !B DJi:{ 0 øEYrὟof]d?Z!{ۘ 0 øcfߠV,-0 0[FY2VF<*i]IENDB`django-oauth-toolkit-3.0.1/docs/_images/application-register-client-credential.png000066400000000000000000004043441466704512300303520ustar00rootroot00000000000000PNG  IHDR@zeXIfII* @(1 2iHHGIMP 2.10.342023:09:30 22:14:05ěX5iCCPICC profilex}=H@_SZAHdqU(BP+`r& IZpc⬫ ~8)HK -b<8ǻ{wЬ2fdBVžW `!IR >x?G0_0L7, MKOfe9O|N ybKGD pHYs  tIME  |KD IDATxwUZefާ'tB t*bE_^ұ ]Ă/bCPzJHB B 9m=3XIy\svvfͬ%=!B1yND"H$߂sZF^{)%CgѢ(R)Z )%N"H$D=vf*~qk-Zk4%˲OgH$D"t4H[UհbZ;b%EAoo/YW%ZkD"H_rD!nOww?(VΗ;9PJmyH$D"M:zQ: Hk{zS!Rޏl|SP%IīD"Hc ZksH)QJڦ cLe !pDD"H_z3Q1tt䡒^+Z;H$D"m:nNh Z&W-?H$Dts:ʯe6:26a$D"7D"H$P$D"H@H$D"QE"H$DD"H$P$D"H@H$D"QE"H$DD"H$P$D"H@H$D"QE"H$DD"H$P$D"H@H$D"QE"H$(D"H$ H$D"("H$DD"H$(D"H$ H$D"("H$DD"H$(D"H$ H$D"("H$DD"H$(D"Ho:_8Vo$՛5߹Lۉ/7ۿG1icv}HL?Ҝg͎U/&q6-7~^A}Fs6iS_Nyfz7U#o6^M6ޫyo|xjīߵ}!_x;؆#)ߤ Gc7ه|qn.5.k] {5r#ig(69oٷw=__>gcO$-@@6Pw§£%S6m;_ yNqᯯ>0pXSXoF޶z fJzAu\#ǗE8j{9I8,%%qv;ykrkPZG$h!ja 9%`A(0h T529Zy j@靲~tm c|o* jσs]):. LK27H USA(2,0V @KࢲpOǥ~ #}:_8L7sU/ogR܄DK$%"XZ%HiP:SZ32AiqtpH+Q FoGHDЪ, Z[z؞ ^Mg+C{qxt#c٤'%wP5h֨@%R @"KjG}Z6'QQKGc#&"H̅'Y0W!HU#XtBKHZa$#4A.ۭ0&I5 O"ujhiu-> UV0 KLN` xI*aQS:{xQ&Cgn R胰9{$I@)֖6'%z(HFD:_GȴT"XWH8epJ}cہ&>Yʁ,n#kkC+zt OcKtx*!Q.I+[^WO)ùơxQGㄢP&V$1e*, 2 `Ւx,s^]3PZȤ (RmK.%ItK sQD"ȏZ(AkjIخlӅϧ%޾߶d l\p<,ُ•=4@PpiIC"qyP"B[Cn"DP8A7% .&Bu]BA%5Η#B:$Ifؠ ֐)2;p9Ę/aT $$7!Q:v@TQ"C%xU)h(w*MipTNܑf$huhH#*E)wH)Ce4MRVe%L+Hx+LAiKIE%-/h栲,82b2p$M4 O]w:mP0R&2e Ia1a.ґdUw JoP:O[2`KR=҅h2UL&KI-rs]I=U fz"!)˲aQ^gHtBrf*X$LJj$XFMe5S)@&L(дGQmXSgx4[#\+GEG EY% )H JKҡR$c^x*߅5brphA@n& UÅD?jHБȿxO*Y A O$`*w 0ƒT IcO1PnDhs_f](6G(Xs[yxZFO/'L#:wq GIl e+/,ҵ똸Ŗ|Õ7̸͒}|/f*N1,_f>3;> aK4` =F̛E!5`?M s;̧//YPLd :>3Ч 6W7{z+Vn;27OĤ.4ѶO|g/aWx!Ӹ \o #.v![no7¬33o;|pLQf* ]I/>ç?yV j=Od;;{N>{ HϔFm2fv,X2ͦemIa&S-/_?t"Mkeu ;8?x!az[-w¶y{80\f3M\~ELc^ .p6wst/,|u0mݞw>x*&'_c}?X|-L|%}Al]OӀU+ࠅl"_-v%wrK}ּwsc\ Atu-2AQ*ld__y^o=V\짞`` O~7hkvV C8 E"(x;"!@Qg1Ɛų>2JKc1`I,߻+5^z.3z .のāg峾 e]SW,_ҹsh洜Gor-R lM1(_ *([Umnq3N;b{ԛ!)U>6eu4[9X']s̯^59nZ[p%2ۢEpѡ0߼ُ?N[i~Zqg/ >M0eN9Y3yv}.%U'4FSXo1qlpK墋ŋA8iHky|A+Q[V/'#2L/71o91ݶ[2e0ʵl5(byf"}3-[aQY %oi{Ewoۭ%`]$\ p'%IYn57ᙳqߓ\vy4dzX;MJ0 ߀%Z[.zW2K\ C t= A2<y酬_n vAWp?9ë. Ӑ5X_PZ1ҹ*+0}-Vu+ Ţ3i2Iy5+p_W3c W/G\9P1b<1q~`6|Sei*X?\;WwiҶ9}c4 9C"0E 8,Y̗:^\AvQh#S%?}p}|ޤYRQRX8̟OEk u+ l5'|S9R!X:H_?@M B)@((6xӪ0.X4B7vɤ[R6Ook~w.-?8_yM`a`=W~ 8U'O>| >{y;yjäRv#29GZS\[OW;nF)clig}W\_r'OHR%cʍ ./q*d WZ s.+ aD7淿MC,[^m !,H`3n2{ uWJwuqpu@-k ]9C_]ȸYUJy5umSo{d<<.=I[nܝ%4ŵK2ݠm,?4zrgsrA!9sյWT3(K;9ۮDmYJ^4c8/Be\jjm9Ӹ8ol _̜I`\~,ev;qW?\ˉ-7"CG?!{Oߋv-=o;H~+OWHRhZP>Cq:-|ҧ'~.sS'a9hpUz2d/޾}GIH/s_fzxf1> BvIHT!(I2C$ iyﻛ˿uFaLrGzNz kXwrIhO8 <}ӄK/d)C$ &չoQG3вa@*(]wݙF+V#BMme(Z?L`Ƕ[aHi`*I&Yg1~Tx{{'ޙy!NḢX`=#uԫlݐaI7Uc@~`Ǽ 6oɇd=g&>04 KMF!PޣFlF="a>OYxWccsQ1a52mЪr%1mzhKni_t(|ea xO~ k=YWC=f<̲ŋY>k|Eb }y*NVW|ڰv{61{qmГxZ$h$'%hoܕ/|?yfz4)q ǟp $@d2zNʫ~qOȂ%k3Nk N(uHC*BКy E[xqT.hCfhZf>3c7^+i YEfȀǡ;V]>H[wؓs~ۃ?q+zz242I6蝰5[#!}zFjbqp VN@qɥq; ݶ'KCM/碳?Ij K`(%k\ɸ wy0_;|myt,sJ C4FA@ȿCJj'BԇZ#Bz~|D K*liҡFXɐM]|+S (0Vp4yai Mk o~M5Ug NBH)Γbc7¤cHk >pI)&㧃BSJ!2XIRn;zy $ ^7mWt6]. Lhc '#U`F2GSӾ NvJO. IDAT Xi9C("UQ!Ɓ!^ơ8w.[`B[j[x|LlfҔJ857a ؘÎ;L-U`,//YBxRYbn;w?.;9:t/,]Nsu}=2* p>˪L~t]x)kF$TL8pE$Գ 7H&MNB{meQukY͖ڭGPY{?6bE5jùR]lXغNhWȮU$SuH& -SY+@-ZDdXC8!CyaP&2qdT: $ZYJQoqGݟdhHvBcLu{wvƍ8<  *ȤɭAk$nm` &Gfs_AFwZöqb3Bۙ cie>佯L x{ƿ2E xRx0YF頴 7Җa RIe0_x]$2ƃjrl5yE9Wcp2p1vYk.ȃDݴS^Ki6Ȯe,`[[bZVy!DZӘl?kxn|qYZm(b70OG+Ȏ{ͷ3{\V_ECktѦO:18;t*Fb%{n/8=˒=L-۬Yשջ.4{0?ۊ^iZyM8E+DF)2PEż:6e]^GZe K#QLa'&cHbܞC~Snw&/,^FP$h+ k⁶) A$R 6Z8'F'-rmim,mWg; ZV~LSv[<0J\@ d"Qj*ak*'w8z iZ^cD}caHV@d1e|ZXjR`s^tJco2i&kQ^5ڥdf(;Z2p W-ʆDk@j7;/6CQKʶ8Aݮ/K'oE~_].^90 m|f>w?sh AYkXxch6Z!!B=_.ȝap+ C݌E 6ki R'#Ueh+MR)KIWIbFY"UV' ˅BYϏO1!eAHoHM!=DjB撰h9JRȄ—D-7O_dz˞`/>?x&Ne߃D.[rBAO~g=KtLG;|gҵ%?RAbH&L:uoY%reAI5UEZ;P$ -VxᰥE9:9)5:IHt(X)Q 'K9 h]Д"ʼnYe[$$!ZWoXĄPƅZh73O92]ڎm܊q `՚O_'}|"~}•q!fklbd-Y-Ѹғ%)#EUF;|B^G߿PTu=aZ<`}t s<ʾ 1H9hx{yl5O[HLS }cEHC5P Ƴb۟p~N×d5߾_/d:%5 KLbKH#( OO-Ii4-6,Qp%TT m0:Eu$^uU-@/J)Y h$!@]'.*Ynqau;CݎK-޾TAsu A(J_Ce$IB&U..!Aađ"dJwO6Ⓜ R*4JfJ)ip:`r6^!SbQ\Յ7)DK‘67]ں4(sLk<Ҋ‡{=-6;zu:em iQXʚJ lfۭi5gY 9uex: ^ji?>d€J:jwwǟŗ\x] ^VԴE3G۪lc++Wƶm Aʊ5di7!IRTΡT;(ۡu¯aR)F>_WK=CCC$#>.m9Ehđ*&#h?xW ,uno+0"T&v,a&o46T2щWntgop*% VS$IX[X+ C%*"iCY\ZHQp0%>ow h7yɺƱ9؀htc:2ڥUH|,TZAsKX!12őldڢ] .6s EG3nL==k,;Z˰ E4JĶ#+n ъNfe$@At;t9R:i7-apm>Ա|)ȕ)CV$qTTChA#^.:lzWˋpv}} Fafv?^vTYpdhp#[ 4x4ʰTT͹[_?S{g9 b㪕3O" J5艟/3,$s'tɜ%|'>ޛ S&aYNQlW}:~ ~?7XQmf&uvjx<7u6ٜ3n\{{n$#:UuJ/(A>XEZ^g_k,v; ki]SN1YBjeEΊE0u[ϐduZ lKDuTEYR'16XJ $=qL:6Ξ\z {Mہ_NvqکV 3a_ggh,nj=(}L{OV>8_uD}~=5FøEf]Uo펤 K\?z^z}2TV')rtfbfZvڂ)IzN)(qƗN?fEx3=)8 $^b݆~\-c9|Ϝg?]k~~ ֋:q8wBNuw(Zժ%~f J; cՔ",ؤa#ȿR9ߪF^EM=U t }9e&q;z/yЃ^Gn)^a#;oDh[lW;1ifc&S^@84!?P6 aּ8p5W!ZTѢ'Iv/}/І!}̼Tƣ:I9CqoF<1q];y;.]O[z~3}jο;~i Y{, xdI|dם`k4U7LS-jaUnVYkp=~71vw7yv[.seŤë8>W52:+cB0hPI- BȬNY dx2B;β?Uuν{z0C *AWT0`BEEń]QQ@Qք &a߮ P1g ? f;WwpB z6o;x1LoZiXp^.ga{Z --^Ccmxl8uj-qUMxԨУESJ,)WƄ'9(u)%M4Zr ukd+:X~kAWn;vqB=ơG?Q>,(= ;+wܑÎx)\uh|>O8~,cSz]6bo:x})hQT 6CgcPhպz>sr_-5cɒ-Cgz*@Yw sW LJnk}~1Ń/_lLtZ_9ؑ c]{7[#|%ݽQmPy ema||z·x?1* ˶ޑf>޷1ae tЭ, \cXIb[c 5mq1@ه .8W%llx!(͢93??vPhy<% Zaxͫ_;O}?c8#)ƍgKXl?elbeG< J8ӴP[,^ |^!da V{OUǼ/\l|IxZp]NPcG n8:tia]Kzq"ԉyGLfS&''ge{֭[NJ+vRѿ Lu5+GaZp)Z⢚497Rxeqg./`c`nHO.x1.}N} ߊn-Ѓuʣ^onIͫVᰧLAO6FgKՊs4) }(*E^*v+Z|6@S\d}hV&1ͦB>!nK2Y9i;8+1_)Pbs(ll)"ݦ^tNӥꁟnƖ2 I[75Xu(X0-t ےksU6)1Ӓ|P)CH7@u}<Ћ1&SMYI+~u.n*8A:7*ǩ=~ˑ/|.^[0قcN|,v v&Uv|"v >pnYul1ǿ$81PGU 4=t_hNtٌZ jXX@˯eƩCA0b9OkBjϪpCÂ}Q6!%YjVUPi$$K_q@<5b}x&*״R;0؄VCi1%Ä}5 IPRئPqIL&s57J#<5S@Ek,&_רDa3ﭐ0Nb^f=ZR)WЈMfno}WfqeGA[CY_-^NtUU qT0CC)Ai/PC˩Cʙ6nz=hw:\eQMY"eb+T8 q{"12(uuڀ Ԥ4QӤk3F1:e;LK hٞ ZQu@KYThYR1Ez{yV5E;E=( *N(2In u'H2*7CU;c ֺhQ]& -MW0/j[jJheQ\QJBq߰X LXE#LF"`-ʔQ+2oU0&Wlj)0^j`mˍvt?.E0IҶQ\AB\Rι+ZUEQI)=Pl݈QGCi]f8Sa&kLoV JET. %:lQX7HUJfEgF\̎4v;n-!>^'ykX{-[OD3NUl.X܉UYO,7dc@B)t%1H+5`@XkѦ$hqtmFEh2\ݥS#GT\Vs?7! Jcvl >\R`FT6i:& x-i G^#(Ճ(90Hl@¹BUF e, PTU.>8|p )6P"()Ilf_5J"MTEETXWnэ؀cRru/&1"xKQg'v E{߿ƔЫ|ä9BGZg]̱R@붎"1YL=>^\$QZF'C &FY-!P1H?yRtp63r ~w~tZ uگKK2:Y4.0e*xV RUq>@yQ*ZŪ^i&@̦du_)Nl#Rsb?e>UK3@{F7dU>$! A_2x2ZJ(s9Gsb]6=)Ż8₋1 <"qWu+@s1a»4Mx-h!PUE1dѦNcOnj(jDzd pXqSt&oAPsu6ihtƹJ .$& 1}wKJ"Sd*~>m 8lsWSP[t(m²Ll$P#ϷHCSOE#\ 88ZMMVbR`PS9ڳIz%qc6E鹛6!eqmtXPqJ <>%Ll\x v&BB7;VѲEd #/}~u~&@eL7 qKja$fG\c %˦HFkM6Hl#C9|JGl8Z+)cPr t&i/2:ϱVf5!j/+x75% ӸVuHq(i0 ;T{f!C|Ȕq!H٢&7*@Mƹ%Ph1#@=wJՈo[PM^?ۚ %\]WQ_ن0Xa~o Ϝ#ZCܼ5 @u&iu"JBZ}fpnS$YځǖKnS>ФKCR*q@k\$)n6 ¢ZҟӴkﻨڐOr[|Ȉ`t|zU&8h)s)T}ŕen±RLP bSjCq3 'KXPЕh6d ۦ2D(oiQ)BP EX5MZLH$V:F-AtuTfyaTEbu7рSBUWtbnzaD<^A's*D!eՠeX&s/_{tBƨ8 `J hn$Mfi} Cn7i)ӡۦ_j36dҰ锪cF |CRDo#b.&yeZRF! 2i6m>Ɣ́Hz*ס\65`I>&b܌4qЊ8T|T>4JSk N:^IލT1E *ba3hM0.xo?9  3|h8!dxtK-?20*%i߈чQhKՏ3_}Jאu]k1L 3H[EY ~h{ #n6*,)3nJ-=N9lfw7]PA>P/԰5O)z-NaV7>,nAKy*q AY786!e3fw%'F}fZ̰TH9e֟LoX|/lޮ) hO5Ѻtz86Ct]Ye &'C:N>VPSlpUwH=nsHsA6&;8IS:d*/ C36vc\\,v2|:jCysT'̺5*psO&м 1x  > o4jh6fY $ THM~62Ȱ`֝ 9xc+FPW|A`$VbƲ9$f}>;WYvqoyji]yyY<ؗ ~Rw; PS+g1EȬ2aY~K6p- m=γ2rȚ,~%5^טd yE}D$7ކ/I1$r{-Vػ;ߑ|?BO_j rwΫqy_l~+{ܳ뿻,l:r+d2L&P&d2L@L&d2Ye2L&dd2L&P&d2_2&A&CkMUUr-LNNn:LgbbqrKʲ췹L& Lffzz뮻1,YŠ+7d2z\s5lvt:\8,2MI3]z5eY|rDcrd/:!nFV^MVL@̦ovvqGʲkG[l5\Ê+l/PUccc8 'hڒs1ʅ(xޣ d[fCmiwL@̦j`I#"u=^&okD=!ܾ2Ye #/3C 'l#ƾ7 U)W|EDL ִ d> Йzp'vj];P">y#x6'6/OWtOfͬt2̽hC(0ƧTsV9 rTtچƀ:Z Q e4YBP ] 10 Rfd2Ye2Lr቏9W}5B;_*x˙GAYj( @mZ{` x))B AG"J#J㬏 iF#d2L@=X0Q. Wjo`CSCYI!E)#Qi " *a'{]xeX]hmf4 ?O& 'BlRkײ]prHvf xOVzXw|cf-W_+x mz|`ن_o[ti71=RZˇ"_בz l-o>4vmW9)HCL&k#[2\`m\ßF1} OhFg2L毁lz d vښCN?4wUi|;+%t޷xN1 1Q(h"` $c PWZD)>/׾{PMQti<(![z2LlBDnћ x!=|募;W3`J~}χ}Ѭ_[V݈")BEUoeˠuAXKm0*Ы[5Ƶj;kkmx2L& LuC РOڇ]?f`Xv\i*>Pu{r- Nt:Q8!tZ06| ~ \yxG\Z Agi~e2 ,iX?| 5ε Py%?|t1yޯnE>,樣b=g`\5E{.2czvQ`q?DٵX;YD󔥚{ ï9D&5""]u[+V &!7u;xP@0x(u+сitƀWBg:h Vq@Exk04"81@ǍȘR sG5е!?9On;1.c}7ԛo R(ޏ1afӡPU%`I^z([E_s :h([Ѐ vmP7J. Z id2_l2<8 Σ6VEIGuPPF`]5,` BeH; =P9E|P zulcކ:L&(/5pPET4(T[i9 J )0J 1E(D)se UU%>XsZ-ZZq?9Od2 ̦EH(y/QHvC7^=Z (mv$6, ~4~ik-"C78LӴve6{sL@p$3L16|ǻrC[?N{ֺ9d~jRcmZ\dd61{Zׯglll3Ye6!68Z"uC#`JC@aEE8k4+pqDFJBx/%i&^\tEW tuQ2oFǁhZT {cqLU9%AcI44=EBn C7NbClMi|ncH*QIGj vAQ=J)j[zǕ"JJER P(Eh=I%TwAT`HK8mX4: ^,U0x hmۋ7REq]!BwoŌq$>UQԀj.QRCFC`Z!$[ՈI_T|{w},JV=m* zAZ𵋂7X,s8T2 xU gĶi]%QU`RЩm=G@Q8b5IT.{Zvy_>;٥c3gT6_,81-Ǝuԯ4hbl26#8ns5W^}Gݪ|\0˷ GL qAShP- r4ā>!+|?XҡbN+uwf|"DA!G>3]{}k9D)z,,Ak-J>,$"]rt`-?s7#oQZ .YڤP4,5y{wDaƕA z?\v/Xv>xi,]9gX(Rһ,{߿ف W:(2Kq q;zG;h/|1g{~,g|,'ox9VX4JzirL4z(wrv{+uQϫV/^zU6M,fʡ/z ;YyzApbꖛ9J6K-a=y9K 4G}W^'NGy?<$_ep`ii0D,yɌo==e31~/Y͘Qz|8+OA[-┷ϔdE*V5;O'\s %{qg>V˖riM`Nc3z =x389/`aqi*O:11sIjMLOGr'fm(AsO>vTi~|/Ĺivr^Qa<]//~^q տ-W^u't,,۷x{_堕\^RK~ȪoavGTuSu+y0o9,۬wRNx)'Xp:D%V8˫x,<&k濿opxɧv+si]<'d8oyb]sǚoZf;,kWs몛xϥ= _OЦ$Pz./Xt9U-v[SPEoziЇAO}r2v( Żs6|' :S|/ßטcU&PfzӔ2m=f(n\k)p ŏ~}~B /w`feCEk.ߑ}7yHc| ozQM .-ףS(*1?߼.~3~yO~c>~0(k%+{?gC㠧x: Y8ъV+](Nw19 .xo$ͯYum=7} b72۔?\˩7O~bK;cL:7\%ڛMf [ hZ4맦Y~KN LOwWro_> j8򉏜n;|tulN1F@6?]wҗ}Ksd:Cy_7UqL$w[ Vay$Z.br>_kg$'~<'rEZc}~={Ge{\p苏ؓߊ’yOK?󿿏n/'?E0=5IO]p!wN|c_~Ski-O{.{?b9g]?BacL̃.:X/uy-~la]pFƘ6 4~A\MvGUaTǣ^̹(U@܍?LٚJ׬b=9/;E].be~:j.gӐX*Q<4FEϨ fSvjӾ}9p^x~~+ lM|?Ksߓ,d+EW0gmT>bEĬM6оڗxgNH)uU*Ck !;R{5r8ggDQgdєI:Q픕Zgb_JX3l6Y=.kUN@$=Rj*x)gԨHBz{€MifiI/V83.xXC2qb/Zڂ;}:\O +٤YGeL418OYy"qAa 5L!6GW_C0en-Bꈞq>S;Gx:;@fm:m(Ђ3RY7j&9>Y3&)Y4M % KSV#qXA-8cn:Gt_Q!.UX7",#Ij\#ZBe޷~ttn˿sW~zS :q<.S7VvaԄ, :S/c B'R{7m9Zv~L<m]tRDְ!&#WjDQhe5:+%z %A GXS:~9X@ T);z۸CԒcCŊ`>m'@jj"LcV}9FjjC*DT QZxp.<3Pr|@d$YAEHXdK,f)t-G 577ăd5Zyj?,@o]jNORq̂hYF%8[2qie*jCTj%nFxBFMq'hAfhUyܿK\gD ;:j:] Nf=ʓ CFaT8C"Ju+e)[нmnm툨L5ıBڨ'76Ex$H5uL$vލ}mtL$܁eӴ+&8!L>ѣ:='B7a=r(j)meVg$Хe+@H:ǎF4/,{)g2SBS\|b2#6]*C ʢ{T[LD^U k{W?X{$.C3n c?{xx1-dM})s6937橇'^2jx&F#Bymn-W`&m%8I{{7Yj!4#:1YBB\(͸nm\ﯽ;-Bd!: U`?fKŀS*EᬦF\XJc*b؄y3&#SZFևH]wDd>u֭ k.1Xs+ޅ %QTQc<X1j(fO@\(fmR,7s+ @P#*IBGS DI֒'zgHM2>#)q\7jVJ\RP\RQwyʿHV%y|S|ȹg tuw&TZ@ݣØ1=jl Ghg@Liooo -A+R稴@ʔ#- +&r-^b236ߘK.9xIˌ8is3EA}` R9z׬s;_[Ey3j$Q[`R%KB'W^ͦeH&6fժa/,[IG[9jX|߽̙ De_c%K5kfs̅t2c<|-;mApq"bҤFm`0$4\ ˜qbp0T'](<~$/ÓO<ƙg|O:{{9R`LJ\rc=dE;bwQҪrȡ"-BGE&BQGųn2|H eV !;:;x\~}/Yu}Uο4A=񪆮7 q^b,.gKX?>W]+|tĚ Xr 3W\ә$" j?><叿e„z:]<&[EP5JK$w>d#r B+p3>|?kկ.V{Tt:ZL}B c4ԫzJYjkWQ[]eݪ\",cYo +!5A%tQS`"KSj0]8X` 4Zk\XIysQ*&)1C:Z\rSq͕SOoU n["Ռx'}qꩧy&NMgI$8H$ReJZ)DΉ3f&ox𪫙DQ=]#]Q@Vß$  ̘~}9? [e!'MnQdDW1"`|Y?G\H%o{O~TT$$0DQ"(c<~~~LЙN %԰v*#ܗ'mOF|栃yK*ayFKz(ޤ^*.ʫGe0{&|#;w&v8sXs̜1*B`Iiz3{\_dyS&t &NU,0j( 9Q3fo]t?3L6q8#FuB_?THc]7(2eJPnk~M7܀HS$+NL9<(*m3HBL}(g 1Ti1yLq ru(EF-QK11XoBNRyp1NhpH9gO~S?~"wx ~:q4ǝ|2'Mįͯ/=gq}* 3g(-B-CmOcsO1fX>D0R9?>\Ahocz/&wq>{!Gu8owN`…v}lpAPbĄI;Xl E晝UĖ ߂FvaJQ gS*$6C LM$|cr~qG|6x˶|Ooݙ{YNKN6 ~ppeB!s~&N8t>YOHmFCi/q֣TϬ`"A0qdu"j6FiACԤ֣*Y.iGa6" {.A+I e8|jw<ȋG֜?!^zwp ̝P;-c#o,^8 2KQsZYHEXʡnuh &ˢp@IR(uR_B)IrZ)oD4>@93d#sIrdGC6`VQA7aB,!)HtPF\5A CA"%:Fَdơ(jSO;.Ezf)ǝp"GƌvP_Lw) oAe2@(Aڼ%L<8gZ_KRJ͉mguD c4x q XJz^ O`欲tQ^42BN'v)yRY!ya]$XR)$)DݡP su^C _={i(O YlH/L`tVq؍%Cu%'t7KʊpҔ!HyBE╞̓1Ey]f Bܯ9.嵛"%DQ k+냏W@(q ](*J^3ey]  :SIuUc3j;Y6c\E{v:|;yZױf.G!rH <_!i6AFQ0s2 h!C!o3]( * `Ks8QU%b& %-,#t[x!m1Lh, d5B¦$RXXP͌JF<;X%r &켞RuO o(wC֡)./E 2R,53yzQB~"U>FEDf[*P }CIT|6/D0IDW$R ޸t%[TqxD8*K1L.>yBzCDG z燆UE ZwTU{GݣMDa$H5kIdKS[[u4rބZƪQ D!MX,;ei~G025Ɗaqx,CFnI0>t<4å `6k?.dc[4ǧ62`q![.4-d;-gi~]>5C>\K;a<糾|5a\;|N8d;! 16 xKBk<8$x;dxo1Y>T}x4ixg1ֿP:O0½z>l(PZ$yh&!2!B*azNkrlCC9XD$z׊!r!xءohY @n[|_Z5Yx5A ܛ:%`IG7¸&ͥ|,J; A68H,w^v][^.MبdBLNE L:gu nN}Yk!$ӡR6sMf}B{ԭCLp?ؘ0zYNXd%ؓ !!5)օyBEGBǡ/B eT}%e /D`Ϩ4p.#@G`Z()w@"fL>DΉ)Ҕmg2U:p@o RCJѰ)дT8zj%cRk1ƅ6.P @p$rbaCyDG8D CZ0 @h,)SJ ߼ ? * EX"h<DcS\)Zv^QA|7(@,@5vH7U< G$s %Y=%.I2V"{|Hη T@HƐs65BFmCĤArL!2J9OQ2 O4xIYdN|ypVDQ7_!%Я1,s(Ah -,g-lpC20 R6dan+ϭ|I-0Rɂaݸ$"_k,"x>30utf*%9)N#C:@$%(.K& m>X4,)榰uJ8aMxOŤI RٔGXCT>uG $@8tx2 %?J(ŧ@A qhv d awa$^$i/nC`yJz2&\&yyMNA4-QZcl᠑&남f)uR(ZdrI AK45N#@b2샜k6)ZNC\ @W@9IާC S#lZE*A ,P*4d VG%( UssColP7%Y(PoaEː ^K)M@>(< -q7}T8ٶ Y'2J z1D!2mH$CICf*7l`7NB R-E 8n|S$,JiFJ2LH]VXWABM *t0AHմx4\'sxRT0;H !TH5^`>jX,YR$h%iܠp(Xq@>nT4KPDDMPfr4DtJT eF AνY)DKS(E!T.5̀NH6͊wgMZe|JHjU\l,"=*fq`:(x$R kPD"!xPq+%5㚤!Fm9(*sޖАJF4\bH[ (?r:\OΏޛ\V,|M9#9ߐ}.짛f!BCii174ne\ ۆE2DkS\s4EaaokI~ZI4 R.Z-W֣k2I>Oq!߃"JRO@Xh#S.!_uQ$C)0z~? gږ'*3G?6YKT.7.qX3ȸOf'_@>gqsک9qZ(JŠ8+0B 9k: ifbF 5"J6ay|N Eʒ۠jXgKf   U.4g!"Wb4ETiC1VYtTje=Ar: gR5￵],KHcd,NDZ, \Ͳg J9Y~|I-(b C-IbrwQDyxc(Dp/rcz\_xM^X|/fAdS^}&P>W*Đ㵂8@DÜAۘ9s_o m =((8\q_ m%\-;wrw/:be0iՍ#sʷYbT:矦<Q;uÈQwNeDw2q\v%/ѻv :y{ٮӾIzxQ lZ;?Z/Ln@LK._'Jm#1Aux1g'~p WV~iFb8 y(xuZ/Қu|a6f%8C8['ݼѴIVa?~"(6|NBtn&pN&7|3cRG[NβoP@b[/e$yM! yary_cG__+s_&L"rG7W>uMl`ĩwk΃2~sjrCX> ^;ZX IDAT}aN8}|8{+bzU I0YWlS' ϥTAjb ',G?X8ws?gu6{$&r)s7[_<8bsO胷?ǧ>@/uG3Q&t$H]j-R85J յuqXǁ36 Ÿng { w۟3\y^ĪԲ;va"P3l7V03py\}5<‹L6a/зvՌ'0XK5w 7qͷ]vr >'tRz~v!|{g09~ztP.E(ɉ:bf*%Fvw׾%A_;Rg{4$H[/fڔC?E:˜*#;#{9o101kL|SO9>'[o =/yO:JI7g8) }-dx2T8}ބU^\g׀ˉNGE<`W糴w~ )lVK~e-8"%sYps9|q#L|w,帣9[yɲ>e{Oqa|ݟ_O?<~|gpaպ*='r?Ǯx{9[駞1]܇wAt`*{wӏ?Ƙ/΋drIDZ{_^N<؊ξ+2eTy|Ӎww=x vLNDºRo}<+tAYru/ݟ78gYd\{.BG, W\]wI'sDD5I)G W_s5 z[o<kβնa%[7ͶڑvމN2v}znf6:hQl:w$gO.`mbyc>O 尼DNLpAӻ Ux{r'^q kRǟά݈̂8pt&ϙN,KPK8c3?O2m& "QB"Hf_pm&ނ8Íy۽c(,HC>ʙ9z(]]1K{&g,f҄Ѭ7<26;㱇`iSac~Ŝ9s8~qG *֬|%=ɗv#Ǝb$pP+!D6(F:S㫟?j—<o>;.?.gtÁ 뮼3N}A3\z1[VFvL}_tsF\r3R`?v.R׾D=]mL=1e7馒#"M5ʬQMTd}.P9ئ4h+,AzM1#ҳ{g?J,GDz"ӑ׿}.ǟ l>~b̤w5]{Ni,< t͟8ꄯdCήj=s~_@ \9iH,G}3܍_\gr,cƌ{q:}GnN;6~mұlB~~!;}M7ۚy. //5>m{vq=|siy2{I-NYԣw a񣚡0g\fo4g'B+!ߍc]34^F12O ǟbӅq?Gg]w϶emvc4۰X i*R?8o[;njRpYp`JNmſg_Xw8aVswNg?;^pu]Ťn;d6s-l-7]N[MdDw'l4ٳaҗ3wrN9\~oI+mHpB[w搃>΀4& ;tBTfì;=߷ {~To ųϿ6PBG}W=TVӎ-Nu^[wٝ`ܹ,Xf6'Z:~~>QTua6:O‹NGZJ)I' 2p: n,*@ 8{GOK.bT"Ij%;o%`Iǎ =bԂ:Ԁ$!"Ȏ\wgOvcmXt)>;(dFU|IƌU;Ck;w`ي)4SM ]ÎuY?qa]eWIε~="Z{X~7 d-RXIRI$ YRC(T6 tJ(5n<'3k۹L<cKD\ X,|0@ԲF^+rhFtTPCŮ':dR5tUerX.XYi$,y J%A-qY(#(JmH)-ؔ4Mte'"ŗkџ9msr!X>JꗘQ^df")Sg0njl=w{޽--[E@[os6Fd7C0N &MB<2߳nuGRjb$xxJᒔx;l*T:{=:gҋ/x*ŗrǶ!|Bzh5&K5mmd|OC8͸N^E8OEd6r`l<GR$.7k/\@-nyd,v}p\_>b%IDs6j͛'#K\{:tᥡR^@AE!r^@:;;᭻..T`RZ:K!Q|D1JhV))eҹs!ck)-wVUw `#( $ *願EɈ D@PI""@  ]X6laQ==s]AG\vBwuUu_Ωs"E|xǹr'sϿǙ9aϦ[{P@qRyiH<#jTIu Uz)A34jZ-&HAuf/`bKT(\m( ;-q˭Mw -~vyN%}+vpe(+EQ3eX4O`SҡFX-xdl+F$*AZ̵36 gֻA֮_e/}"Sq?em_[#5X>g1ޙ&0u$vvuDc;m`S9 ] I  hgV9ݔ?tK,oӦ?^j0ne#7@RWL荘}x$X1-+O{q%O`ƚ=$b&8"vX0PqSO0Fa=̡ ִZ 2*V#Z Z &Lb\?OΙV[oBk Vs |?aËc}g\c__|$:YVyqL@d 9euzi]4ZeDYqX -hΦ,*@G5v}ӛM2%.`֮a􍓿׿ \ =u+iYY}V,ZE9Z K 1FvOV5,?rRkpk-l*>u91EʍX8o.׭l)C,vkMُ`Z Ht57~1԰"4&LdH%' ,U-QYc fpm澇fہ+'sz&Na^{:2q[ ,]L(a§ q@IR.Z"Mo#f!҂+[Z{ի77Pk Fl b6Uzh5/vaaڤq\zމ[csMKRUw/9/d֢l!' iJuz{x|ЏqWĢeuϝ|sʛ~^o')C68 `j:~}-@&za>so#h |U}\9(`; [mQ+09g|_Xiy`SA<~f'IJxŐ,XC>%N@.;|3h>7p#_=L{V#Od4>T ~iW~}e~` ]u׿t.1'rPt%E|$X)1e͇H Ef"&LoۍS}:}?w7x]kL@ M_o?3/nLXa\vw3m(6k,xPnœ<x] 4toڝﻓ?x3KqW3gB6|K"3b1˗,YY<ƥO=ؓ6z%Z(j˙͍FD1q-)iθq\j"Zk8f(@eϽZ)_8{^'f=uWȗO=gӈsX}R_=?/<<[_ˉ'H V6{=W_k[E 9mDRrSfdlFp VƯdc7M7\E+`MI0v7Z~rK>?׿F*jݩ\Yh|_ڛz!Xhb-]¢%yzr/Xʢ-q M>KWYf02oBARc}ԯ˭,\_K1}GÝriԀIĽlsDIC>ȱ-'l2Z_e+2yj+49u֞*3 ,R:]x\2wS5xݦ[s5Lyl\qߣpg.;L~lÎnױbp9*nκ. ΝO9/Sa)>x.;tZ i~X>!7>4Cc>_2!g`"tt DB|ru7pe?/ [lnh:(~}+Xdf4vq'vk »߷??5zM_%} M6ݒ.W~[E..w̘zFƄ{]>+1}hLYgM&N\ղe\t[X{JX}ﻸ yͫg|#ajN{zREA93ykI A8#ll|Se}{rױQ(9Y#YmT\|1BȌ3fpg׽,,}zgew2s7֜1}\zOhl7S~t9L<7i_n[q?wq16A Ic\t"WsVQ> ߱#.!Ƹ'+V0mڴe뀗zX0r bD-)> }&hE惱cI"(ܮ(WF#~bH&6EBU+Ixʔ.Cq)YSP3qlh}]iV\.AOg LGQV[dҖ8\BL7sVD,PQpsQI(Y&\Cg:["GlQL:~)Kvw}^>ug8ш TDLDA+QӞZQp}S]&]*U}#:\㢿(NjKJbᬁK/;_>+je sR]$ E W׽0z>+j#T泌{-اX;w.HʶXk k0]-U“reJNTiAeTA d",Tgad+s}K*䇪AȵRی3bxkO}7zqmN5\f/UCA wb6ĭj BNeDq\aJ!bg7lT̸JiJD/=VZ{,  jۓv=0)vDI "}n&[Y*r-V ^IJ$@V S'?qH߀DhKs EJ'D4\~0Bz/)< xPm`` LH%7XkPqTb*C굘ȗx!n 3檟@^`4 7^/1TU 2%+P9CCC4p xo0<~x5kf: !D(UK1~*=l65kÌ?c!AP$ /GlvDUW"rYkL4 &P@L >ILkQ>!(:GYų 2KyZ`Ev̶.@^2,ˈO8{)V}wc8+ ɹ(! D)OgDE`Ϥ舮9V2- E*@^%V+mww}&Veg_ٟ X0 z?&F ー_=Qp8 TJ  {VJ./ x؇n7AXr>ϖbb* j Ui G(X6Arb7VY!-KL& U0f!jg BF g$BPoDõYkB[MHI0"2ʍeT}d((bK4U☲C\x_飐]ZULJ)mϷpOw~Swm<'E@OI92 6b4YZ+YrUg" I v]zY)#RaFcF$v*&+XN9&RF% A9*(-G'iJmE-<ǀhU>|:9lzBaS(좍sᎷ^YIfDĩ.YRT '+W!HKᕿRSpCv*?[1ڡ\TvKeBV A\[ QD[j;FV(ix!'&8c;]vE(Suo0mR(=֭ Ml S!LVu&Lj_t*[)p=kZNzTLKHI$tzk48;uzܤcXa@@@@@ @iĢpx7O; Ub73lS%GV@s-2 eŤ[:& Cɸ\2_# [~-KFW6qZdRt UcD[aU! v%E:Q܊p[9A䨤ע۪`e+'<™W>=fbdLSg$qM"ΟBٜo"eɱ{N,9$Lj xNE VtRFwt(TUgFsO} k/ $r$ RR0m"!0; ~r9qA{BPL[LT(F}ӽ4C͠<+槝,X/Vv)@KkOEV;ySˬ'aB^uILI'f2J%ej2L`Hcb-9,b|_؂ ^.t/b+< 7o@@@@ @/)lGT$cFc 6gyҼKXgzܙum4&FtVdH)1Ɛ)JAA6c 3 rZC%S|ⳟ\ʱڴsX[Ih@8`򂘸N~;=6$oEҟEhsG",`nS5Z0+h>V)0"tk\ڔc6;:OKCG"Pm5#k>~I0z%)pJD89Aܐm¡-!n4 Ҁ@ȉ5  +σ#U#urj1S8$bu+ !ÙAJ6mI3- gHHZD[`mEH<˴ѥ`(K`q\{7qnqR`H=%P,\|C \{H8" I~)); nW$!‘Q+m47X. &2R78W\~)IyζJbq f{m"9=b ʡNΔd)챕IF$ ;68R ,[ʷN>=vߓۙ=C Q$A:K~Ιq0d+_}ή;[y?zo5_aq '޻=v+~N1ox+.~H{5ix9gM޴l݌S}pnGvm/N8,VJlq-xow%^>~oڃw x}+֞g:}s5^Hqΰ?FtOʭ|ⓜ{ .YSsW˟r ^|)y/_3 }9}^~q!O@K2~|l\vOۭwpŗqooۛ/%/^Q_9 6XDyᎿHRtE0aEͩTÚu޼+9kLk&.C^M`iðȨc>8?<#*Vɻ%,#h?Bk La$Ɣ3qR^Ꙭ3s=l?%ӷFhx[;5ُz^L_c!2 *5hknԡL4(9 gC+%G*L(It7ll6ulF\ݵqIrmV8Jd̛3 M[[޴-xӶԲ ꫯn5>i ۶{nc{-vub|$eObt;?L\s ^#avゟ_wm_EedvW!*2&ё> \,m;YO9k\_>FӑqHVud T( ,Z>ڂ,DR~qzabU\kXbM6}?nlիy{&[_Xk?~qq:˛O~ O B3߸xd:zВM"\*T14,[m 'H.r"Ug`u@ g'鉁\M\8kdۍ qđ'R)PUn/ܙovq? fzFen 0iJAĉ!f %\53=},85>zSH,#R>]p[ f< kl g+"So=Ӿu*7'/qj _z@V:}L"i5$u?:d\w/!3DN97I55xS>R|O|+)z3b?4͈jQX"(IK;i:CO>}?ƌu_ɜйWbxxd3eS2W 6ՕHQkbiz$R*- 5D5HG>AtW'l<=9NTP+NbXÿx@<Kb"oWUv+zȏ+A5IIDFd*34T`kLlۂ~mn_Pr>OpQA5 Y4"˜'0\^;qQG|0^x_6b5b7; ‚OqGpw9vڃ%ϯ ]> [3ʸ6F ';sE1)*>yɅ sGNuQʫ.T3UT?- j6`XH-M)&i} t+G6 9u93'(r9e߱SNZ@@@@Gp=$Ȋ[LHj=☴5>k3LTgß|;{#Xw>Y{{Xc/pƙg[]ufQG>PJh-2jbc2HjhE!g4~lq &I@Z;pgu7Cw0s:=f=2P h8TV _ :SO=4MN_9WlZSO'|2?=Xk{Ͻ8ñ"fi/~}? L< \:x1 P|c` ؁ֺqL,gϷs?{O= "3*.E'&}2;_˅<½sCclZ]C5ȇb2*H!˶k +V`ڴizkTy.Wi\%vr񍫚VSeFaE՝uw aFx}2:ydsriy c+NB.%^2]Q8.c_.(mYxՔw{6 {^D,FH#NO"ƺd';1h{x.>١ЬZ+(s+ǘkҌ01\ $19Q+k+ (@/³2#+; X)q.j_Mn7*ˮ=W(De_1Z~t. ˙|aHړ"X\ˬ2i|;k2-}]7Q]D'>0 +"sċٴDJQ[K /\PNb0aX=@؍N KGBZI~C*EW'Anyө%YA%PA6W-?"Œȕcڴ䃗E"9dj؋J{uv$ٰXBآ\)ٕus7}u3wwJ,8C$*d=9YSNcWURL~ I*1HU?jK\1J_D4ZQ;?Tce)IDHE2 k/uo*I+sd2uTbW.2X6 RRFz %iD(Q} +:Tvm2XqQYmed&Rj˂UyvՔn}KPu*[$2rv.E3Ƴ*4Q]nEz){6;}KTI2&kN !`ڄ@جEa{Q6YmWGv9+F1X`k:hX+Y"h1E dTL- P.$=@d'@ ?~Az ! 0k,Zk-F@*«+DtiH«3zEmG]w h'0cRʍkAĞ}xA>(zIfTɪi4L:M-gr4wr3 X??BTmGBtOx5LX͊}ڵ)DI,W,?ᷩ4wJ_u3! ׺O}BpӾQ~׊{/ j>U%[$@Q*'(jDWZS +ꈕ-]/%J2M~FUij?mSOz땟Z-$(&Pj|e Ex%tTSjVe,kGXuOh,_F=amyl.4:ԈR>K“"2)=º !=+n=4V0qD}Q&OP@ @H3R#El+(n4#)m GRR#`tqCqQ@Jr]wsġty>rRv­iIBP"\%/sy$1@#?9}wZʺkMg=4}u&%msW`+JaM7C)9c/ BHZ6igz{d4+ȇ~]HԎHIL HpEIe ) dW&qLgjQ]Jp V y 9_p|ȸ~RBR9)!AaQZIt8Iwַ}vۙf-@4-6,Ky5^EȩkjJ)I@jϱE__G|H&NWÅ'H'eŪ".c(Bk$DTwDˀ8rs{EXJ>O0cu8; /C W_}%G9 d0m! ^=sS4M,3DͽPNh rD&[}^<xǾ̗ky"$\<XM* B],oQױ8|o.t?KVcقE|sGd: N)z~|~h,^ĠDgC g2B h2D2|+7z5֜<չȂ?&դ-| 2(%PBs)o1]IkBAHEp寯fΜy%zh<2`~BII"y>/E%d(*|ϥhʰ@~R8If .Z~>p?Ail9K"ZԨŒAwOn H8roaxFpcM7$[471?n5>}ԉlFwҥ~1!{bъe.|yQ=3{wOI# (R?@)* ?MXi*E4Rw0*5 H$Oݙ1{wOI*xnwvvvf񪛹Yj5Ts~ƴxXars>l=>hJ_v97y&`qXmeYPm?qUBG1T*\p)VZ#><KOZ/#sg%9/})VYk{>9Ⱦǯ9ދ[υ_ɴ>u&l{È!MHJ@E) x1vmGSSRJ0D)ER;KF455eD(75@9>RaU+Ɏ;A܋Bq/)'ÒK %*wQtqI 2W_~)<,^y%O<8[o 4Hvwq#1S pe7 2UW_n?h1jYss=. :<+-/ &:X ŨW;o璋.rťŗ5^c>ʎGNb\M#?3L8R0iGG:d@e+RϙGˠ\ye3Z?GCPj&6'|_px9LXo-nR)A#<Ï%qT:Goɍ7?.bV_u5^yeg<wqz07t_#SߛAOp=lVSg仙9<1ce׿ X!)Jy1K9s㏸ 0$B [nOiJ' 7^QIs#'@9j+1se9,uIؒJRFTkW$Eomog2cW\dW J A0;c64y0[0WE{,)&|s34QV_9Ob=2@"Z#Q~c՗a:֘6ƍ (ȿ{y6܌e.\^~ s&r˯@7vYjȜ9s((c8y+@N<8CcՕWᝩ!3G5g?_y.,2L:O<.0^cV;NCًb1 X4y1FZ1hs |E;88DIFYjBP@*p\g_Тz0#F6sDKaePkRP=Q*x`c (.Yn<在bSn }-YvOMyFUF I'&9r4Ӧɼ9s3~q8LiNOO'fu9B  >k }OP hێWje3`R,q,Y~8ޠAuV#Z|_ qQ22H?R J2zQ]Uc2=$Q5F+~m<%`tTP=<_!=܊ExjWٕxՀ\?f~FIwRՖOpG |ߧZ2+9S8c>}:'ta2c VYe9ZZZRs ȑÂc%(WX?}ϥ2h4 6 R!KW5[5b˺__!>/潇TBS 2!Q!P)qtk>ӈ|/Xyݘu+1y 3{ٰX)I+L*P&<!UOJ5#txX_EK_yyPR\ž_HYHbe D*{pw&K%(I7 ^o6 Ba1BR*(n.RGB2w\vEL!l٦NnzVA G% )ϔ2&M8{I'qG2m43I&\[Jew+Gϐ<fUWeA\uDY~Bk֛3@(_ #C)LSk+~B<*dμStu'cݷX,/$DJX/xY=Pϰ֛su-'4@@3:VhkcԘqZZԩZלyx" [-F[6%@xh)2fAFcJf6( XCH *(`M\2WJ) ओNb̘1,rx 'G B؂n|Go'^z?2cbqKY2>i""BSߞ 7^}=L wwf}2jcbpsӯAl \ut%bx'1[t F AOG;P[>LQ)O?f,Uhm <7a4V[q7;0{{yGOFExJ11CX(4}»OgY3eʳy$!l81cfpMGUYuֺ@8Q\z (㙀OWP9a0m<"VLyf|o6?<ow/Ny!qג(jf!PBoKTq)B! (R"6;ӦSD G-4s9Hܵ4773i$=Xb9ǿX?cQ<6r m IDATK.JϨtҲcc~D@TUQ(,S,׾Ӄ9p(W1vX;X /`ѬqɿcvUb X{}=6`UԠ]Ư29ꫯNgg'lQXfdُYѵx~0fyoXv҄a})Z8눐:9r^x!W\g.Ott̄ c=(bc8K.c}<Ư6{{> ]4(CbI\)cL~t\ike Vf7yos͵q2llhmFH{.L96h#&x99p|)Ĭ0~ 6d3= |gooIp>o1:'?ZEt <8ɑ_}B1cd{W׼ߍ(]v!B( PH|*)xD"q U ,%cytf&C%iYC *L "<<#Lǰ[D \wџθEH9RS EG]gji$XLz7G1qPVR:MIE?&:FIXeӵB'g9HuҧAsh@x N=X[|@i\O,Ha0:B О"FbBK1"bc}68Hp(+O*.hW| sYwusٴ`A|dƌ"l(F]|O @[2E%aP"DH/ՊRG:14Γh_f#KBCJxM <o5!P z*q2Ua#omJS)W_LM#vQTBQ ̿G@ mS c !֊th _ "dR&M kh,hoHď$q /B6 b`$ +IAGH֕}86 6f8r%}G~+فTJfBbOGnng@Z~M9>h1'?p`H% I$)L*(N8E:DJgBe JD+pQ TAa1h,Oz*\qM@}.|\yMs,9LaqN##Ze\I )V%tQN 5ʲg%g""T*!ƀ+1R )rcSLRBT ]}T5MRa$ӰHʑUk4 ɹK㈢'nKz؄yZu&ݙgBִgRJ$!)Lϻ) 5c@&5tTBX9Y `ɈOT*}u>7 ~^J;5ծtR*TQJHxN\XW $/qB`B40t0 <,c% C.юy _Zh),wHP(G^]/d"kZW ޠXLɸqKQ7)-1%r8)o:D!1VddcCapuvXz]o3 % "-OR*ll|[ =cTw. [*)q5E |wO J)Dy9iDw0ԛe&7d™;M8jND]5u*# z+T"+&}VhUIYR|W(K'I# ]W:E,}'K-~K ]z]LNM^/XHIr_jnӁ6ɵ L(763`gA+vI Qʄ ?: h_>Be_N]<?"4_Wq79rsɄQ*L&RI\r?:IQK BER~/Ukt-ėhNRM{o,vÖL}v"۔:t +^JPa/yF J+j[dktDm:6#Damrpmd?zqֵS9Gt}k)դk-'?9rcG=yHHm^{y>A3`kQHZ8[֓w]IdJzR͈Lʅ$G Ęs,d]B( y|,zQ'zLCl=iHzN,eX)! iEZwN]2mIɨQheH5.E30!}7 22,l?s~0L@ h0Z?F9ʱ &2C=mzQ'YӺTo3l|&eS^SW3e?dm-Ԝ+N/RDww7mmm6\NE dz='PHȡr\!OUR-:q^j24{hjb_gskbt Fww7R km F}rIDDN\Ir#de&?ٷ 3IM2 bSN5?BKK K/4̚5wy'uM2ʼnvK=oY#$e $J[#ԇi&9¤?>XgdmRB8Z0IZuW<e|)1`)P b*$] \0h K/l9rc)IH˓z:#3M/̊%m572[fdI'Iqx:BejZF7,hoOcI_MMMJ%FUQifazZ6fRZhBTьh_ &YP{ .^6[ !̀9/ \WrcaM`5z,Sb)+<®vA{a?^^ŢOL%k j.l&eK4'rYћ--a2nha,|:^RohO*G_T41G\cJOfDC!% Gʄ_;P'띒e-:K^5N|G.~cΫwȬKZϬOb`~5 8̣͟K֎k$D~S<Fs,Vd !S#'@9YPn"iYj', 2K t\$m^@[s?T?C,U__9ir9rȑ#'@9rȑ#G9ʑ#G9r P9rȑ#GNrȑ#G9r#G9rȑ9rȑ#Gȑ#G9r(G9rȑ#'@9rȑ#G9ʑ#G9r P9rȑ#GNrȑ#G9d6$z\EK|olA}YOX4Sgǽ:_%G;v P}l2 3uO4E90 &6k |#G+?: Na*kC$ۤ,"ee:<}CN-jw~Tqy"q&[B$2[k-B؅C̢YצSPGZf:3L][rbf!} /i;.:}w6ӂ>SsdZ69 S.)0h>}AeiǞݗ^|]dO[kډDh_XG|̂j?8ڡDԯt=i惓㫭vk4;D dL.2EȾ 3 v楮 [ZWrWY^^?n/鿤1CzbvߍOF0E? ¬|,T)顣ё&z-Z3q09[ֽl",E?D~βNcD5H$T$M= QzYAbk?drϲP^˾fRoZktwwʈ#XeUX}Md|ꞓ@"֪6u&>&Q#qLՙV?712˅ON#NZ壏>b477c69r|P*D5URR7#1OĈT^BS_߇B3\_4Y|8>Uu4ք)4773|p<(}3}\|hdp4e#p~i jkh bS?C12---9ɱPׯ`QO:f}wС\z'̟i۟Vՙ&V_{ّ~=bDūz)Nb򓍖1|' 4Is_vgAӦ~^:2[O x 7!l,\P?eR#A]0(RϥdРA|'w9r|e5@}-Q4j>L5A[пz-}?(!(zFկ~޵/Lc2џ6 D h"jJKKK泐~o;QҿLZ'D2'h!4E`(k뎃#VOKvK(MiBw0vF$Ki_`Ii}z%ul~z6hvHS9 Ȏjc1 @?z:4 ߅^o|-t יl :_L,bamt΂ԙ1=aeVa)qfEh0Ld잔s~"9\`QXHȮB 4BƇ u u׭Xؿ:+z/ )zMWY"f舤XWctʑ@gktgAX#a&imq&NuM[RZg/(0Qm^֝ohhaX"1Ks5n-ݪ1&۩ćF6R]\ئct'jmlPubtzYId2[Kk"9Fg[qj84e{+2s{P(HbSfAɃvuN=QԇvmDQTڋ:n-kXg~T&5mF:½d T\{ j6碨~$SiTj"&kImCܚ-Fc|6My:i5?nѣM#L'B́PϞ'b@Dx6Fa6¯{ 4D^[ QAtI"D]XSdb1m RLP\C&)2kƀ h/UXki)JHTIR), FRHDqTAL<<$RR4 \8zuc'UBdq{(Ȏq.!Fc"`Ȫ8"<6!ZC0! BzݓR*{ѦR"_J9UF+(!}PSz^2?%f9qюiܭ(%S *S RK/SH%1]%TfbN+ s6G&r{$K)NZ$05X$A'}ƉdPH+x9t#N#BiL(_!=0$E]~_餯& c1(0O0飔C:!q7,hRu#) |g (PT !%R褒юd`-Jlx c)!6⨊* r%$f , 2a"ĄN`W JJN"b!MB* X(L-yd_(~gTHLm1:$ 3KTnLX1xAǜ~i~=H1s0d+H=k0im3A!Pu,^S<)dFq7#WJd=Zf~28L]a+&F'dkt5Ěli6,Fkүg>rBTt,$$ZV.) ZeMASˠ6XRxL7&n9rX2F"+CHB|) 83WJ%ѱNı lb+"DQaq^{6C2~1|AV'F׏DwiXV H|j(Lԃ} Qn |)WߠP(ʊEW5b,6# ~_2>ObLT86xJ:d3Qh:' )KksY@YNBTA礜 #Қ(yXo ([n%o JxhBՈ')jbb2*})Hse$T*e {Tة,ˑ+7 +y0p =,=q6WNH|_0ydpޖ,3hcOlm G~m" NHTNX~1rѱT&Tm @v<AZag#&Fx Lgb?`AMfyRY7|13χ:DmLFz:\bdLpڕb䎍cBS5.@hr뭷:ӎTDS7@T1UwJk]i^dVHafI5q_ + R o2 IDAT92gnr0gy&p̜o{f}c#Z%lIuˉ$VL`;(L{,kfu7#ΒKZ^|y~wbmɧ+ogïo?ٙ;[ CØ6ށ=Ěk9UF ]_vr<O\}tuWx'+8DZVg}v1󮸋;#! {p4lΞCioTGx&_2͞ HR􁸇ン3.*L|# {8I\|EK 5g|<o/')Y}-?Y^kOW^'~$]lϾ\ud.J-3g&?cWvўL5ߚŘV" Cv{Ġ\rx]eV^ye=PYg lyoʿδU(67T\|jyxa1\tyTPbc#sczߢn=8-]Xk{Qaw'cڴ栃bW~?2SLVgҤ:s҇^{w?G'oe]F{;??k8m N:4*x G{,wu3l]x)HҏޝYgǡﻇǯ?(a%(֛o2vh:?9s۸篓y{X~ܲS -RV9Sɹǰa駲%}S^c͵կ~J+y]wݝÇ#;5Rtl)/1fU|\~%w=<}@ĕ]On[oͱ'Tߜ8p|A& f~\fΚ[((B*|A!*ZJ]7r{r6xR+DGCO_k5_U~_1tH~z=h0S̙!?|6r[vq'.R})h zϚkm7koɧUW]Ŧny\HGW7XJgw+s=ƅi+MTse[w&T:dorM71~uQZ& oIMXat$͝ó! E..{B^f9x_bJ FHG 1`x^! ->=1|JXd|+6'*&:FlxltVY{5@Q[dQSM)(n?H3t}ifmXyy뭷8T*p3uTƎ6lEyX|L`R|$ 2G?  o)v{+E"ς"XcDeZKS>4%.PRdK@g=773d(i#)y7} 8h1..r|Y4/.3{<:*BQTUFG]]=F 2Y Y82l ҄FJV(sh29+G 0燤!G,1Vz:ii{~E̘6>P G Q#/9 M(6DZ,E4 9!QB< +MĶ[n-^wfrs/1h&7ܾ o@׼6:::Gу(*$6jh^i)! c>:B]XRL9c_BѺJw̜N>l^A CXf#P,:x qя!Е2CQ"A9kZ%~κkSgh)hjm!淣3>*Zy( 7muPRs6Ng/jbH)}iJ̓ߧ*Ϣ}A =qX妛ʔsD'!]jv؁:)8eƭooj waП#Z[[9ꨣ81c:a %/j]h.5S(ĔAi .W@+Q(ThGC䜶y . ~~Xn`Bܳ/7/fɑ0sbps#.q#b|ϧ=<#^4UXa$Iߵ#!.P-:mF&qUX .4)V,-Myso~͘u7SNaD7^_CTJ$*H#V]ugL-,^|,1_B\! CTcȒXguǣOvTDX l'Һtg. F X[.IۼtWD>%P^@T1EtTsѓٵ= ARKL*$p$r~:k2(TlIo(ebF!pџȱ.9^&4x Y+]jRhB6\v^G}4K/U;#@MC !V?s.^|}:o!'˫!"Z[[9c9c1cq3vX&MK#9r|M`n%3t76۔'p7p5}|HքHkF 3>`t_y.8J(:S,T4V]+>tk;+8#b~2zH kg9Xx ]8񦛱OgwyFIdbOJvrDh'‰얖P-X$mfPOy 2ȕkb Nm sOe Dw.*^};=ZZ^zU*4y`!J.5D"(1YCZM(@$]FJ3c65 7Ǟng„ 6T:d܏ё G'mXzY/1%X|K(hڪX afJ^reXh (-$F # 9#8g,ΩpotOa2"JPD$# Ɋ HgDEQ@tTDŴ]%@˜w1!Lpﭪs~A`ak^tPUVSsꜫFmd32:%iyv X+&;`bb٣4A4IhB1kI=9DD92bĆ rߒ;iMkY~?n0% dDq9ZY_mu:VX!Ys}Q/=-;m6\wQh5[5&&[$I#\[ +H>6CN^Nq};f=bMիj$a(Ӟd wm>c98׳h"Eϴ 57ݜ[l-bR׿rr|W26! .fb|l(^xG{<~=hyKZx7IClm-Ĥ#Y͗>33WޡӜ5yF_0箻_"## ,]z'W.'2I50w{>g_%+{>}]1f9#ydwj=wޑ;. e,YNk08,Ǘ3z_bO\~5ؼ}a&Z)|ָgx0aeqX8F u^}/wܾN{lƗ/^L6$)Kn|*bůwfמ^_nsՏ+^~KZA3 1>L_Qȱ y)wu~g?y NĎOߙ&堈9EC{7s]r?oyW.g媥4RK5ޝ+/x`\LL"p,BYÎNUOʗ0dai$sN;o};X7{g˾êUmVf֏g&կ|ɲ'?I.c䙧4qYNIDjA}U˸g 8=8r͌d1o3b)4i9PVvƛWJ;[mFzj\aq90k֬]_v[sc#S?'lBnp9o{SCCaxo-d-b=Ge+:-Ip4Q)NW~1Ѭ9]4Cʩ&l..q\uU,Y{O{uM8^}km97>=c}ݸ{o;^Wj d.NkO:o:HpMwl0V y^N;fI dVJRĘ;nϑG {l[vۃ~SNdǤnߛȾg~|⫹Yk2TL Æ9- OW<|ү \U.2wy\agp-tF$Gxopi/WDw_N?sO>twlpnY]| r>SMal|7ޘ>q<xxsl1>k%JG xɋ^S{2o9sw ǿ =9#ĉZƬsF3tvtvۅo}9}9w{lr~yceW60TC$6CMx!vw#|9w8X` :_pgN;¬FJk|5 ǝxCOx-{,^k=FbdFsFlH-@IY&1o[1Y^rGqsI'ѯ8=w݅>zq5Oy+^^wW^cN8540I]F0c3gfwe34:Z9ycstޯ@>)Q39F U4}3Lr3iBQ8‘D|F{|tq@q+m8O{&Ǽ`dй|S~l[y5%&IɱTӻoaLv;Bg@b-ZN;4Yd&epI>)aDe66@Bv$eVc~T.ϰI:1P8Ṯ&ȅˁ\^ic{LQ\a Ϭb|(1T'1erl;M]I9+Px)LOdQCY^M˞INЎF5!9pa"V&9Ld>20*yFi344R~a Dp:i.,Y1ew'wpE%q9oXC-㫴1PMS9ky=&z[ζmÜa=@lHKjʃ ijYl65@# {iZRN$y6&IE 6ܵY)gn, y"d9;J5Ew:s<7_\]K\6g<.0If/ɶpPNd/|'<0ü-7!Q"sV_ nN|YPM=6mLҤٰ]a;pT(iZ.S/Q׃(oӲw%iH$hb;lP}tʙɂlX+"br(ɜMN[VmDT zcqt:oo1QZBfg*!Ϫs(tOH $&DVQnWaQl6=w0vBsx$LQi9P IDATţWc)zwacDZ)}yr.D5 qIZp zϷ=ob?(tM&gEYWWJl{r9O},L}Z7i2w~x$ e 'EYc*dnT&, trg.<%ם&|{?w\g]fLS>zw}w~ܿ߯s(ǧ23UX0o鍊}Cg!ڧPem:=Y {!߿/h8pX2D}$w>1lw~FOtOpf<!gnLiI|(}u2nrLsďXRr*ށMЬP6S [h0fd-lϪygN=yrn\_3= jV{CZoǹi&'ʅ;}Ǭ*#0֒3.j ]}0(jyYu 6S} xx۟LSp@Ŭw2 YowGiW2FuҪ<8+A&93{=VIlMhHKdE<=jrLMO vlIbw+n=EFSy;NVG6 sQuem]*·v`Ҥe'5lWkQ}UV"6ny$,QVdq}7}x* }x,UlLW%)vڽmg>~k+36eR[۷ol}a|7P.GBU(4g_=TWL0ժ=ucS?QOxz}6znZvsCd@\~]h:X3ni~[5կQϩ߯&vdhϨ;8pMJe!6D{ӝZ*2qwY}yF~?I1Vii*tu"fZBSbh4h y96ivj, '\\X͔V R1d%7UP}} 07}f 7}K[O(m||ξ(j"Go [\T>T)_#AN' GELLLh4=8߃T_[zʎ}vlnOsi} |+D<*z ]>Go䅫)Qc;,狁@f,1Vۗ{(;zC:9I&)E,[[5i:|T]4C7eg,]@ABur\z%M_FTL϶~KM4}LwӽtDb”c͔N`sLԿvj5_ކ$ }j۳׷ޘji*}1a4ښuI"K.e56ٞEo@w׌Y&Sߗ\Co+Pc۶!.LM<U㗐?,1Ƈ;픭;{DW\ mNU_%l/gLh˫D6QYIǥtT%+vC%VuBavQR6Ag9{,Q-NMHt <{lnv.FFF y/{O,LWJ7eu%M^UDPɱXoˊGwV[)vޢUKO|ҼHݟ >qUxYUk"EjǛP.U }ab%}uo=o9ݾ}u}DT~crr+c#?33wS=1z@pzG3x׍{3L Jf cP]9V@eluSK;kUw~a.0_vmOVu5o۹ȕ )QOHj,|FxWt{aŬ^: Ա 5/8cyҩ?-OTܖ_17yzKgۡi|~QÔQr T.2yWuj= D=܅7Hݻx?﹫,T:YW?ΖIg2s[yDQf͚V[mE4"u2S*Ak\uoi^ځV3L`{z߷Q2\ۭL}iżrفmr;_+ۻf*H0(T(ڔꔉ63W4e6y_]Sj _fenu%~1$5sZSm`0J/[)]3yo׼466Ffٲel4;f14Od)(f|Z>a=| ϲe˘5k .$"8&sSy}htt(Xd ˖-c75ɚ6c=<=𯀙.4Ɍ˙;w.iw)<+})MSΝ;D\ǻ0NF q 4QɽvNCE^9BǕjj|lT=w}2|bttT_K(VoQzƠi!BJĶ"pXy$QDÝλ:hT]ꎏ³[DDDd<ޕe$( LWO+B( ı _`,n˰6eP6HW3X'2(O|t("$s$*Tz*@RZz0I\R Yb',J,Ҧ$oFQi| Y/yn݊zA^fȚPNV*Ч;p9"(܃5eXzD$- -"" @>*BeuTuo ƂS FXkIpQLQN{w^ߛʲUc`L(y -ռ>ι*ĬM/deҥ~\{EVD1L~7vF1rElB"%Mvq嗳3{(g޼y\tEl} Y e>9FGG>y1߻ /8Yېw{&@~Fd5 Q|I_dd3ЌX|(>EgJPw+,yb+O}m=X_,c|ƒ(yS_tf]YFzzo}eUTDd]Suf.3 K"h!#7dc7_y}dxӯ8;o_KǼr| k/䍧Nyߗ_*'>|!GyN8h?]t#8/`ǝE&])ӷh"M(0^ jUcgM?9&o4;W f+g'=eG?s[l.O۞_q&9?: ?t&VmLj16@T0g8᪟^˩gNJ{g׼D:9,]V/s+_̝y/v$G.U [oy=7͜=$e'=/@ GdyaϮ7قlr5+\r'9=aox3oGi;o+T"" <.y M90uz [wPd7קM S_'k9n\G^tѸИ5ǼbM3XWnwcӭeaH69߇etIF7fo4yOb0m,NqbX96ph+ZYS1yly3排eIcɭ+s7؄0PH=w߽;Ykb1 k q$/cr}!ؔccܻr%9&M\M3$CCIgaͶf(&B8&Z9A:̖.N s{?[n]Lޙil{a}Q+p&M%Iv;l_#I&&kwar2gC#'=I?.X}DDP2y\s* y(L;+fE۱ K+^<>ܾd9p>O}#,1ӟ9p5;o?X}Jk`tx# 1SWa"N|_%nXqbN<4>p%`gϢ>~M.}yY;}2IR'n9#o{WbYf:D gHFyXD.HCt:+я~mF'011ebͰ>̵7QG<U;ŧ ØƂ9G&_1v{^v)\tey[¡>Ky/ƾGsdt&&i; $#r]w=9#j8u'|߆sz+k^|3_7mݜgO;|4mXע``Ͻvcʁī=oO+96!ZQBl /_xDZ{p!] c0/:̌O59իdMڞYvFnyÏoß|]0@GD' <_&c1S,tÌ0.&*NvX?72rcz4d@! A nת5-L Q=O6/oׯB+w@\.gV>"[e6TkR fMr݀S?O @uhuYW)/I Bؾi'"S%71a<{ ь` K_GM\Bt 4ކU+:qύi1փSGN7M4.f-Rc \aPú=+#WԿ6g0p#"|S2&PrRI?fw 9>j"U*vVV)%_g/tO_ד.w׉ȺMu8z\*Y VesbSO"[v2W5HDdݤKOخ{pnMKTzf[M_P),ު/Cz /f Fשb=3iÀ[ܿ/:Jum6,OQWĆL Rqϲ4W!zpw<lLAeۋJꦣ)EW6>ӜfUgzl̔e yTsO MbP ?&X`f+zS'Y1H t!~\Xw ,6؁@.?"" @((hJ3Wm7%L\f|Όca{b f(6xȺZ:QQQQQQQQQQQQQQ mQQQQQQQQQQQQQQQ               Q+$ IDATQQQQQQQQQQQQQQ               QQQQQQQQQQQQQQQ                QQQQQQQQQQQQQQQ               QQQQQQQQQQQQQQQ               Yyp`!smQsc ,c֬YE# @""~I18jHGDDdchhaZ8B5HDHDD;}<$IDHDD?US1O>CUHDHDD['t\pA]Q'hY"":袋jPQ'hY+<> yc{Yj1~;8.S_=Od&WU8/65Cֆ*@"UFOuY}2]egp [V/mQQQQQQQQQQQQQQQyXyq.BEaлo9k#>8Wa(sm@/UVQXk1h"9c(jb V^ʕ+uyx3g̝;fٷ(<;Xy;l244X y8Nn6یaQy4\X8?>i1Nm Re2::Jaɒ%X!UXEH-[[lw`NTGAyvm,\PH:A({O8qf>#Ñ+& |<(ˀW- Gt2_mWʁ kqwS/.Õ;E,J\RCO.*(:p6Qe,\X@O=F(A" +ի\;|~><h'4?pa3q|$ CGۓmX6*caAr(r\BuW 4(22ʕoßP`X"4F1XM6p‡G$ [eQU`:P)1LSpEq(jt,}QV|ze8nE&M.bEDD6UeV'c;hXpCBς(_E gnjDiᄝQl=΁[t2 Ww&Ȋ{Rn!8!7;A8!F0͇" &gmle7jf&H2Vզ>e<(:VXεX,ctd!waq("" @ a!rUױ۱N;.dwW/}jVL"(:-"r5Gt 2\M8fYL@1(%J!*`L\vG#bS``'k&r-pBuڡrw[E.`<G) Aآ,XPNs(yhobb824k3|;>ɡK`|LDPm҈ZYl9t}G9&Iy^T2UW\l\>gC7%S69ȗ|׿ ؟?Q“:-L6/W)1c-b Փ9s!`9.[w"4%qMh1YEY@FXZ\E, S6乥%9 p0oIPjgnNK:wՄo|O]_ؚ>O:hA>:c-m-BEj 11xCd8`wbxh(e \a)dYB'A(|9b+KyLRs||撋w8]FbQh .p&Ť lfOvFG@klRUGuD+" ?e@"˄ogކc-6eק?_]}L/8 y/d]k_*CM+.wٗGrжEu]8k#wm]cyK^ϯZ9uO^Q'qN9ȣm=8q. uXt}Q<^y-cm{šnc^l>-o/D  jED & vYF&! W,R~ϟg5{~Yr8|KϮs>?яXt/xb>p),ͤ^ա'LLwη+7{3:U-w/S_5W]Vm/1-]'?~יwK/?z|yY\\яsWpM(`ՆDD! <I#_άȇBg~rc]wyde}SU=3 Q\&E%h4$Fܘh}(wKܯ#( ;0KU7XfVMkO|<15]ဲ۱ Uŝߝo櫟0m'>2:=T>/}#kǣO~X3eu,|`S E*'bHP\0d`~c/=X֬1q,U9pܙw~w##$i_ ,EI&R kW׽׽>!lӛY 0y[˕?H>~_~We]%Ew lUSSSTns5\{]/6k Xw46qeװ 0Nɑw8͡~u4!mVknp V st>kX LNrZ_8j=2 >Х_<'2>0eE`5D / ("]K?>|{an2,?w;am<'p%4AӌʣjVNBYP4Լ $ft`.2eTS5e1ŝ=8Ðpa}0TSRYg9gߟ,𾷽yqw~Mӡ frE.ӃnYR-Pȝ.@L7'pm](9ʐ(DwCfxbjfM3@rb~~^3ԅի{m'DJ{_o_}.tc#e|  S3:f ˒ 4`X/P}>E~Ű 6:ejg殈1@^'Yx_y[_y_ϱlܕ#%i/u;hkf)AwjUtZo8l `Jݗޚ}0#[n7qe'6vOxo}3w:p>OQ=]ՁfD"P4}BdP'zS3T5v m +o !#10GY '.RBLG]|a@K6C*}on+KYLr" 6#F97$L\U[%_H]7?wz\y5 sdݺut.>mÊa;EU=l&,+SfY.8ȣRͷg J>0 °6\|K.36H;7tJ.STeH"N7<׉W3fɎ"4ȶܸa87M|YsX'$t=eX&LIN'B$ jX{ |Cz,x{7|;xS߹:\uffj^~ C |`8k6_>1Gޕ#[I~o|*g>s.0E z?/2fOjN~ޯwYR5Ga3`3YXRѰ"ǂ2)|3N=o~?G^xO{|_j->|0y+_v%o*Ij۞B tr3/pöx[@=\`5>ot2|{YHn6]|.f|8l\wUlr#|C-TUn& 5u54 AEj?<䢋."Ss5psaQPD;t{bj4P>SXjl~.[_f_#7s1w/rЁ#N=z1/ͦkpiN=TǓӀǝH^}W鄦i@#-,PTP?K^7!~$N{%D8ɟcSOy}CofP>gg/9^=cg羘ǜ(tO9׿CV;푼[_}ea[0Ӑnhf&gy>e\{YgC@"-ܗpC~#+O'pO~BbUQԑA.X*4;N~N|#x ^b?#^x_~_b:0s=wyӟ |2!F(yȃO䋟lt 8SN( rh&X\,'Ic!RjVIߺu+7.\^:ӴzB4i(4.Ys"vx3L,f6!P ̔M3G=jl'!1D*4NnF~Ajf=ĊJ懙^'R"SmNҗ%7N݉sدD ;MW-OCK1l٨CkkQЁ#뎦4O'FНi P&%9L$9MD:ULeCM f鱂>MwPUͤuHN͂E=h:S5<2TRnH,)gkYcnuLgjJmNI4}sM/<^I"KK!3î~կr '1t{tp{ ?Nݴik֬!H]q.A IDAT&,rsJ9LH)͐EӋ\1 jXtCAA`PMjެ!!q"7UT@$b9C6ry>Ju/p҉cYϕ:bMSRDY)B3(M*(cPvgk!$B,H9@gF뻓 @!ak26(9Rb]Ԅ2vl"jO:lREWѾAtfCvيYܮ_3Cr>,ƪŏ{. hާ1v-DV$ioW7-v-U{E34:RgMGfۅ; FI'݌ab]=)@N!A ^{u}Ng16NfWGǤ g0=~*kYxw+Ah/@PҍO tf8Y<9)Gw7Vk)<(cRP. uQ4n,⓷v#I<-?+;)t-~JW|<7+RFԺ).!6?ӚVCFa5Vx~a ?$GoI$$IH$ic[j\\=I%I9h+I @K+<$$Isf[[19TۜG^Id$I2I$$I @$I I$$IH$$Id$I2I$$I @$I I$$IH$$I @$I I$$IH$$Id$I2I$$I @$I I$$IH$$Id$I I$$IH$$Id$I2I$$I @$I I$$IH$$Id$I2I$$IH$$Id$I2I$$I @$I I$$IH$$Id$I2I$$I @$I Id$I2I$$I @$I I$$IH$$Id$I2I$$I @$I I$$IH$$I @$I I$$IH$$Id$I2I$$I @$I I$$IH$$Id$I2I$$IH$$Id$I2I$$I @$I I$$IH$$Id$I2I$$I @$$Id$I2I$$I @$I I$$IH$$Id$I2I$$I @$I I$$I2I$$I @$I I$$IH$$Id$I2I$$I @$I I$$IH$$I @$I I$$IH$$Id$I2I$$I @$I I$$IH$$Id$I2I$$IH$$Id$I2I$$I @$I InZJiˆ!UUBX;@ '$iﴰ@qjP%UUs,% CHH)1554EQM,q*ʲV2IDl|)31Fysx@%DyRJ$$I{Qߟ^|^M3g !$ 8G+&F3guyk;yEQd֭c˖-/QI;V)%ys^:9׾͛7Kv19@J[neƍK\GmKZYJpȥ^ڵkf}qH7arx33)zvv-[eCᨣ0 vn@H-j*6l0b'3ѿ+@uM۵B iO9[$ihIQSXUU4 9}jt13GK 6:k-bISwN]8H-45 :댮t&YnEM`g9M$(TU5}tKSSJtvH?3e}&X%ājbY>p`of/i$$Id$I2I$$I @$I I$$IH$$Id$I2I$$I @$I=\^׼Oi Mdy//^w+Ceӷ}܃ܙh6KKTldKnҒ̰!:Fؖ koGe*ۙ<*Jj>> ya iO~b\nkOΙ!ᒿIep8$@J @{y̸IgI &TdsdG6-MOm`ziyqi֎}GʼnkeWSoE| wsX O7X^0jMte"Q'0tƁH-ۿrf~6I=İq$Q:F[ k#EN[E)y*UʶrD@Yҏ=cyn `$Cȑz8Hr"v$[FBN:DUVxޢ64LR)S݌|6ApX'Q(KcFOzUuȹqHk~w[6on+ @{ٜ~^|GD$b" sY~Ֆ '(;a\mjRN:I ʲ ^b$8g>)36?GjNiL;L=uIU 1SĦ:Rcr"Rq9gw Bb(6O_vS evTjDq 3*MqI2iw[lu]l@I zH7/x}<` !mðO(q5,,$՛u!ĢKE;PU4br12ȃ&pD(ȩCs $t˂9(#u[*@,1_!S!BB&t{ 8!Bl`b/~w[,SbC!K(bA UeJ$[!%ws≿?1ni*6 (Q>.}z6y5Uf{`W-L(M&[BJCB'FՑv74#:33kZ8LETM'q *CMj*Jm'`Qw:C^OlX(t{]f!=pqǐr/Oh s:M ESE6@aUJN$[h4r=؏gr'׳Tuͥ_m8ٟ\NT5ݐcS] ..V|wp)?SN{2~mURmO| ofx3Ϗ~2O |3ַvUN|*' ~s+[LI$i  |+p3C|"}Cyk Vmo9<^s:7V<緟?!ȍ_o>wxIOU>S_BfNy 2ދ׿M{sGw7 GjL"fyk___/~ wǗnbvȉo~k;x#%Pq妟m~w߼?}ǜ(+) sɟ5zȣg> y{I=JEkB]^`W'/S~>[V>nn];>󿼓kcI!{+[x{+ox/s\NQ@'$v[|CIO{ w۸˸xk_Ϲg|pyo|ǑHHۨ#DTβpO~G> G:W\ߧP:jEl W߉w !xg>p]5okgIUҟ\#Cg}~5=9tAĘ8яdͪ.~\Kr됇$(ccN?10iZ-|d~=a rx'?>XTU`QGCr̝-[p!j*tQ=^9l'v|A/¸5ekv">Y2rq@p]LY/~;>Ay %,,-@MWo޿]8fuЃM=jt{ԑ/}n>3脊k67C15þ<}G$zf~fH(j.%_ka07涒gVʹz=rn?gÆ \q1Gq 92" v;l8x#U VĐ1ߓ=srء<S˚5MDYyq5IH7K8!QH1j:烟?'0 !Pw67s14,_[L746ͤ]ˡTB|~S~xy/oWw\Ë% ~ ^뮠d2Ѧ8h/p}+|p{ޟ9;_8H65?7Ttz Ptw>l|s[æ.O( dÆÎګ$ |o}"w/_\YԷ A'u]GAb \>'? 5+'>ݺd*NA'r=}/[\{ٵ]P  d,K:nVbm R(|#iOy*_37pW]uhDwI ld!S5Ug?3LN9 ~:o9O)](zH~7'K?,^?ͳtAw;Dj_pa9]p]ѯj>뷸fчK3=8/]/8ػ<-DmgX:E};3^W3Ox"z:YG2UB6ݲǠ_zf)5MY,DRcԧ&䊅yzkլA0z$jX4MbiR"U!l撋~S^rseLTEd3Dr5 tp0δq&!!P0?^NPKfB,:ߔ~p ;]vL ׽O~nڴ5kc$ƸznZ3y&9R5հO#u5u!Gz 5%酜$5:Kz*51*RimSEUST2jrUb$UF!B⨹]ur$UU j{sy{:T9 Myr8]jUB.s]I=PP6O4b.XdCjt!65=4늅94ݒa5CE~[駜Ή~ 0_'Mo),TEopH=)7 !rb86OiF5[zq4 IDAT$tLKCX\t Ӑng9W A)BX|\ N KNu4MzH9bh:g^sݠC,Ǘ$R(GY63 5"ʲYa>mNMDCdj"fg /}ߎ+e9pbݮ@E CY41j[Q@5$vjV4 Ad3^q -!Td{9RVv׍2M8 7sq m cBlVv]}t(hT7x8,0zF3>"Mxt Fd1UEƺlNRhx$zYRSSԮFjiY6WO&vSd,S.>It*Ahv&/wM3jZ_ Kv|bXaqаcª.Y#v ;#Fr{0 2!_g^w!$ݞX&npS5 1 wH3øi3m*5q34,_N43%/b,zm ɣ83P3j#L[.8RsIKǾ-=+V[VG՜@̷a:,38 v'm_ ʋAk1,%/eIh9Z#ZFwHmYTDڮ;s`q8ʙIt<ؖ%`rh&*˃Hs'u3 pt=cm3O^!Qæ$io.5hOy;16wjS.} Ȏ}hvv6W褝>'v$ =akWL۽0jϮ At[<9:-ߢbZ<:@4:Tc'F:q_qLljdRB0&FBJ9),@UDX 35 mElcuw^),M/HdJHMWH;)H,^40dF'ReX~y?;$+|pQY/t^<\?q@te0yzSKq}uf١~i_'?L^79y}h?E0q5lwNEdnװb q7޺{_v,.C$y$I2I$$I @$I I$$IH$$Id$I2I$$I @$I I$$I2I$$I @$I I$$IH$$Id$I2I$$I @$I I$$IH$$I @$I I$$IH$$Id$I2I$$I @$I I$$IH$$Id$I2I$$IH$$Id$I2I$$I @$I I$$IH$$Id$I2I$$I @$&$I I$$IH$$Id$I2I$$I @$I I$$IH$$Id$I2I$$IH$$Id$I2I$$I @$I I$$IH$$Id$I2I$$I @$I Id$I2I$$I @$I I$$IH$$Id$I2I$$I @$I I$$IH$$I @$I I$$IH$$Id$I2I$$I @$I I$$IH$$Id$I I$$IH$$Id$I2I$$I @$I I$$IH$$Id$I2I$$IH$$Id$I2I$$I @$I I$$IH$$Id$I2I$$I @$I I$$I2I$$I @$I I$$IH$$Id$I2I$$I @$I I$$IH$$I @vu]s)%RJK=<7Ƹݾ'/,8.8Ԥ,SQxG]פ{HC{E-ifgg !c,*x38t:$XΙ !lW }`ժUu=JZYQKBOQԳl7t ,,,VXett|Y5pd%sᕯ|%!~6nwoEƓH-Pm~UU155]93y+^1L;;Ub~~%'H MOO377tʲd8.i.>sx^GଳΣ( :<@rΎM } s/lݺ7.9}If۶m\}ٰa~@7ڇ+I)~zV^`gydӦMYf;&t,Cʲ]tˍUUtܯdnk1F.ct O0Fcd p7v zt6: kYXX LFM`SSSxt]O.d3Tm۶qWc䨣bjjg-0ڇ+X~=WB7o_Cw73=ɿyvF}p355|}hjj?6o޼dߓ @ZUKiwq3j*鍆E`0?fy j!Q033E |F,q;򣦢TD 1RyfuW#݊V^ܒ [EUUKfb೻JУ(h z=ȹ ;miŞ8RM&Ia`F.n, oI)֙GG_vN9ˣKgT}0-5ٜYt.g7nN,fs*sKF}~ᒾE1FB_|~=29d豓t˿FhHK ݪ_@~@9gzu]JRqIg @1Fa4= 4 @{jgmi:MTR]U LMLZi8k/+ȉ:!D <^P1y7v䂪i/E^j6E"S>oz+x0~w9.︳9C]^~g}nH=:AJK/_vkɋJۖjmڻ_}|ǩeyvUpܛcԬ2Kߩe]̞Wock.+=ΰI˶nKsr|&s$H5XCyU~9Uuow- (n(8 qC T]T2㊈W e@roUǩ{;p@jnkթSu>x ɧˏ;#^|mx|1$0_R\D -Ɍ(lp^b6ʴ~3݋VkaݥJ:|\I; H!2qZ~_{Vu8ʒOdnϳ`aNȼg7}}4f3P[tpD\3F.sPt_Cnq`֜rءF XQ* ,6x,‡8/`}@@65*Hsеv[C'ͱ K <?QhSOms.ͭK9Q6-:h&:niAEbp8DL\C1xi !tF8ڂwMsD-jQ@5h q;h$x@@he`b+xճ cqyL+ S((+98oWpז`-+o!'\u%;nkZEw%PXg w%˶c잻8‹W򖷾m!3_>=/8soߺn\ yk yuK#N%8CŏHoJ A̰u3?u@BI*ȕ'3gAf[b2M[n,Uq 7rE= ?M[Qf&UiXVUeDdYFUUY͸H$Jl˷4*#;vjWBYEGvwID\`]9}W3wb}ӛ8ȧNqىン~d~k'?ǿl`z^# :/<(75W^JVxȻ^>S9_)֮7S0oeV\mW?Tw~_.{׮嘷!?+J!9hy]@GD͚~;? IL W},Y4CmQѢR9<ȾE'~ƸK8¨(1= :?4qxhGPW\ݞ˶f]wl.g}Oι8~WI`M?E[|\#ۜRxo'8WB3>~z)ͪ06Yrs_JG׽\-u{z8o𶷼7/zߋh"ΣuVm!w6.t*ԾFҎяF{<>峿O| : H ʳ[4=jÉ[wWI@kDM{]PQ le`^dw=†\,Pykg=K-ߎ=[rח?^N:|9/zQ|0z`բD/֑ |'Ϸ_\K*ЎB9.g}WsmnO?Y|ӟgP'8ҁu}Z>/k_j{2>O3k,kmM1(˗snc夓Nk_˗/,Ȳ,C'JuVZh}vM0 02e?g>=pKX$ӴXL׬G+ayK^era76 _u )YܲV9t6Oy3i-ieV@TAP5Kp׼YǮ,[w ce};nI]m5?k?d`X!*y(pԄ= Oךn#?d0YNu;+;Y|lk yZzUS0C0't[n%+WO}{+V`ɒ%p dr"Pp~`7r~5g?gr݋/e" LSjl*T$F5 ۸ѝ,(2꺦.;d @6SiP92wvL08/EPOqK0`tN M P/ںcE5ڊ)`mova1^$kSRQ,ăhДU]@ kP`a]'31cX`mY`E!vo+Ϭ.MI 3WEa֯{EV8IJb!غF=¹~$!t:q.(&ntVY%kbs "< 6E0&ZKj:k} x{WZsV? ѝ`ăc``?wܑ[one˖qI'100?EQD" _5},(-7_nZ_*~c9߽Bw'cQtce… 銑nn &֎N0͖lNw^&w[w#%>ł pe`(pb: /xҬA.n@?TDKQ~QC/Op3ŨYfq [+,A|%$㣓q;]af ȀCl6D1E›wKy{ `+ΚX"'S31r$?)#Ώ} ó|b]s,V[n^ڞŘ-''qx^:xxrBf l9t].cMλ  uEx&'V IDAT``x.sl al$x-Ltx9n,aLeMA,^A;8frK>000O^>" M d]xwq'vyҎ,]: 7gŏa[lOmQ9>P!kֳ[?Ef,eY鎯)q[΢hBGiZCkUu r!Y#L\̳|;J/9L75}Pa:V!@ 0o|s !P[KE\+L%fS6ŗً/{||_U[si_ /+p(nu57pE]wk馛<),VčZ./Zvxn,]5Kl~Kҹ8&ϩeSECgt(f4ֈ^oăoӚv͉'ȇ?~s=kvDQK<) >i YF%a/;18p9gs돠K ~9#yҭk{ ^x]DvʉqAOP{8EO[gB^tCxmy.wW- 0&gd]XT(B 50 VEO薁YB8@ ΢BSN"u_gigo~w=7z+Y(x^Wo>8oxnr?I:A{Q \ßrܻF;!zt! 181 E7]]9wҭafEs8nIh>9[?/ԓOa||-6_G@Yv,ˏx+:7T,ZGO߇N86;ݵw@.;#{تI{̞O}"wPd'>qWp?c`/b|ÙMy1}͎;[~sZJQ .1ǿo;D+S8 ; u IFU{P&'tP^@e[{L`[S%=Jvm6#,aGf͚R_M|SH;MM\)Pfc]48<^ЕWr]rG>F֮c|{[n- .@ŀ+H+] 2cFa+s~Zojֈ(#CF@qGM߬靯t;&PUҲ$Aٸ ^hño|5K wᄓO@Y` #\jM]|VR9uYU-K.J,xO7⩫ SFԵ ]pdF "C0SAF= FDzq-;]o[{BcFQB=>68ہyBrW&8 dhbA4!$+B@\I1FQ T9!4jTfYW=QhQAKB+/j 04`QR7K.qNP(B],4E$OSw^owS/kN$J.;:-"ADa)F!A`cp dW dm\&.;ětr mV&=$Xo?SY=~,>7)\_Qj*fg0+XJTe4&'HhA[h 8Ʊ4ƯABcibUB?.GD%u_Rm u "6B n'4%::1Hc QN8j[bʱ1`W6Ji-lb1x|QoFhQ~T}%&ˢp҆[Z5l0FD%ҩg AMy*D)ls%G-sdQF Im$23+:>HϮD^Tl_W]n> ЦҬibMhз|f~uG)FdFq"T-d`&OOKZ}k1?$]IV6pTŅRd˧5_4 K&΂K@#t;SN ,ňFn7X(3тU,M̑1(cR1NKJeNs b<}0gM:Z2ELFQD%ݳ,8Iu4 W/Y#fPْ)5Bӟ+JtCpUȊs.DJrTKPQw5k4n}6>lq׍ZT+}n ʧi2}cֽ]Lቁw{b e?zh4W0Wh"H*lG+p2d)4̭ͲhO5e?fLh9zQ2}t<SdE3?3o?3MfXz3KLy\ɥh cnT,|?Vt:ED@Ai XOI E|'dJ$\![u>W*$[a.$}S}ÖL_G4S"H(HӦ,$e=hّʋ.?5]!(nK$I%-1A z(H.??Z _Ɓjk @ Hދi$I%Tshɲ =J%3@;r@mBq > t18D,2鍋o_%cP"P"]۽GDPJe!e=8YN(|:G5mR1|Pef=L[0ZqL%o=NH$J$XkɲlBI ţy!M-4@!+.~=&*:*駌oa*,PjoL?gjLŒ'H(x`B,KZVv4ȘtMW!Gpp Y?S C2ƈI=HOCxBÌ~\WFzh jg*IPS?I%I%DzVVst]6u]j*(JC!Rb˘3 ?up1޻k6wm'GC S*O)rny YaL<83U D" DFŵvZN͛6lCe3!+㭧*=n݆E|߿fi-']R5EQ`}͐(ǠUeS љq9>{9- ރFdQD" DLY[K2w\ozA5q@ Xp lb̋ d o '12^ЌAksk_!0%FG0q])fÆ9˶+|"V5H O(H(xSlx3{VPͣ.W,i}Cw. 'V &1oTK?Il?[y 1#Ƴ8# ڴ[c2@Q Y?YRK&bzxd N<3Oиzbjj4 ́߸>j=i^xlYQf閨 TAgjN&MH$G0=KD/KE|l{m|J@iEYMRhP5y+1Ȁq! āB5 ^ Brs:dؠ\בk!Ӥ+Ay|ƛAitT41W;PxoP>FaO Ei1E`>#HcݢX%ځZd brx-50Ծ@mMu-Y." A( _??E| o:+%/X’7F&?u4H$J$"h:}WrQ&ʰu c5P Q8APk-ޕdY -=\.>&+t-R*2 PÉ%/4UeU$ \83p:ZN9kLFӴ'BӼP)-paZ;u^Tl*m,B^YUD54 _΂g{c*n]/)4 9ֹ(t#C$jf! |+ ^W`EI OK0tq%MH@f[/FJPOMd}8bh dc=?=+q]VoM@kV j ]&Pv[V2,:S;!xE 8w!F7[>$X",@ߟI u\(Epx2$8*7ºkW(y6Ȯ߇׾M,]AKKDTPE.U5I~:!*ǏMtmy_fmqrHPMfFYq+5, ,q,:˨&p\`c2J*}XqƐ M2Ct&hZt{,2J§`YsA&6Q Q5(MFjN9rE%eUf$0A7.:dgX1WH07#'H(7<+72eJ9ܓNeV|裏cQ]mA >_cӡ(ZԝjO*5OsGOw٭. }noɫy'W(Y1_^v}주-`FVS٧ @,80Y!fEwQHĈd5jB8Y [̨õ7sWfwbDڲE|MÂEW^_8=_)e6pG2\i'w\ȿ | W$]|֚@Ⱥ1:W$=~)!4X~u,OJ$c%P*Q{o~2[lAS׾]+eeSN>5w=.8 d~YlU}xbxխ0J#runoݟ$ZcccO6[΅~]|Ε+'>K.1oS?[n=m-f>zgxٿ>n_Dm},1HkD/(P..[;Fei>&6v\ÍRz o{Þ8m>f{sPnW{,d,<;mz p͍_[_1N|ck5=PK@\1?qD!vA钝H$ P">M{ iPGad]Lt-K&sjf|:T{^!ZUz*Y uy>F^45hQPWrtzFMGs+I)YC|j1Oڍ'try[/BLۓbv$b'YJ>/C;JENmjfXf\x( ^c\ ⽅[s7 /ޒZZ ]W\Nf>tE:Y W]vݙ\r =mG/}t]œw Յ x9zdx\E{pjt^ 1;H$H]^- yD4:hD!DEUdݒnwdh/y,g ep`e4U][0kxY~vX2dZAS̚Ŭ{ϰ;xq0Qu-یMXw^H2Jyj Z]r薀s,/,q/,OǸ6>vff_9# ;!~UW6s1|0_2{b<puW `_!gלa4X"rdaԃhK*zi/G,---3fqaaݻwOaXDcݻtAv\0O5e .T0b]ģǸo?>wGsm]ľ=x+n溛^HCXx$o:Wyݛ_UM:&cOrrP9y77z{皿|xK^7\W󶷿wz_k/~;x#voWkqƗMMsM~Cǟ5~vS^7<>)[U7/m.{Ec݊o/j t`i/2ny%/d>Oo~Gm<]|7|^z|7Na~n:#|KgwWOαm>R2U3lk37U.C_,S䑣G2??koD/hJgrt:BMd_Є)b|CUbM4LOR&@75ufqarMΙA0L4Dt6ѵ%")EKHR4H2U9]L$7o:BA#}9=XRXnd3!HT^c6z!g6G㚛BZ0&~MK)SBeT2h&5&'@n!pfC'#Xɉ.K\ܗ tL`d );0 `-Ζ`zϡ5h&VeѢ} M97TOMX!C} ƌ~_xU6X f^Ih-6rUMs zc"@2ޤ4SC0틹<Ǟmtn:>9MeCvlN`)4-de2aNl1q+ժqux<&Ajk^UOLBy-P548*@"O f##)3n4fj~ck{U&osNgɺvέ]7cH4˾Slmas\BRMgprb%sna\&ŚsLܶfQl"ە֖DDHdsjd8U #^(Yz?ϧh08V@7ycVN{ul2gvw3,6:~ ѩd(,x!v CXg A.b9<+ssjjhmC2t]Y63 Edc&6v;UШy|0ιUE31zSќgӸ z.DDMLlwָh1KćR!ޮ1^Qf&No0xߣtIl5qm/r|_D;H9L>+@e }ь1>qIC64b9`Y6 SvzT>iۡ1:;ZꏈȦAaUuY} ɔD"6Re'1uM`2d"ƸC932M~}` eS{)(lm%&w: 1l3Ŕaa0d9a'g[A7:DLKk`d &җ29^1f3h1373K&'Q9O;Ao$+CeNK7sP>_lq-#MsI/۟JDgSr V 1j͐Ϊ`hi4ۦQ's?,Y40U1v9RGDHD~:!x`_ for details. Configuring multiple databases ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ There is no requirement that the tokens are stored in the default database or that there is a default database provided the database routers can determine the correct Token locations. Because the Tokens have foreign keys to the ``User`` model, you likely want to keep the tokens in the same database as your User model. It is also important that all of the tokens are stored in the same database. This could happen for instance if one of the Tokens is locally overridden and stored in a separate database. The reason for this is transactions will only be made for the database where AccessToken is stored even when writing to RefreshToken or other tokens. Multiple Grants ~~~~~~~~~~~~~~~ The default application model supports a single OAuth grant (e.g. authorization code, client credentials). If you need applications to support multiple grants, override the ``allows_grant_type`` method. For example, if you want applications to support the authorization code *and* client credentials grants, you might do the following:: from oauth2_provider.models import AbstractApplication class MyApplication(AbstractApplication): def allows_grant_type(self, *grant_types): # Assume, for this example, that self.authorization_grant_type is set to self.GRANT_AUTHORIZATION_CODE return bool( set([self.authorization_grant_type, self.GRANT_CLIENT_CREDENTIALS]) & grant_types ) .. _skip-auth-form: Skip authorization form ======================= Depending on the OAuth2 flow in use and the access token policy, users might be prompted for the same authorization multiple times: sometimes this is acceptable or even desirable but other times it isn't. To control DOT behaviour you can use the ``approval_prompt`` parameter when hitting the authorization endpoint. Possible values are: * ``force`` - users are always prompted for authorization. * ``auto`` - users are prompted only the first time, subsequent authorizations for the same application and scopes will be automatically accepted. Skip authorization completely for trusted applications ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ You might want to completely bypass the authorization form, for instance if your application is an in-house product or if you already trust the application owner by other means. To this end, you have to set ``skip_authorization = True`` on the ``Application`` model, either programmatically or within the Django admin. Users will *not* be prompted for authorization, even on the first use of the application. .. _override-views: Overriding views ================ You may want to override whole views from Django OAuth Toolkit, for instance if you want to change the login view for unregistered users depending on some query params. In order to do that, you need to write a custom urlpatterns .. code-block:: python from django.urls import re_path from oauth2_provider import views as oauth2_views from oauth2_provider import urls from .views import CustomeAuthorizationView app_name = "oauth2_provider" urlpatterns = [ # Base urls re_path(r"^authorize/", CustomeAuthorizationView.as_view(), name="authorize"), re_path(r"^token/$", oauth2_views.TokenView.as_view(), name="token"), re_path(r"^revoke_token/$", oauth2_views.RevokeTokenView.as_view(), name="revoke-token"), re_path(r"^introspect/$", oauth2_views.IntrospectTokenView.as_view(), name="introspect"), ] + urls.management_urlpatterns + urls.oidc_urlpatterns You can then replace ``oauth2_provider.urls`` with the path to your urls file, but make sure you keep the same namespace as before. .. code-block:: python from django.urls import include, path urlpatterns = [ ... path('o/', include('path.to.custom.urls', namespace='oauth2_provider')), ] This method also allows to remove some of the urls (such as managements) urls if you don't want them. django-oauth-toolkit-3.0.1/docs/changelog.rst000066400000000000000000000000371466704512300211770ustar00rootroot00000000000000.. mdinclude:: ../CHANGELOG.md django-oauth-toolkit-3.0.1/docs/conf.py000066400000000000000000000210031466704512300200110ustar00rootroot00000000000000# Django OAuth Toolkit documentation build configuration file, created by # sphinx-quickstart on Mon May 20 19:40:43 2013. # # This file is execfile()d with the current directory set to its containing dir. # # Note that not all possible configuration values are present in this # autogenerated file. # # All configuration values have a default; values that are commented out # serve to show the default. import os import sys import django import oauth2_provider # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. here = os.path.abspath(os.path.dirname(__file__)) sys.path.insert(0, here) sys.path.insert(0, os.path.dirname(here)) os.environ["DJANGO_SETTINGS_MODULE"] = "tests.settings" django.setup() # -- General configuration ----------------------------------------------------- # If your documentation needs a minimal Sphinx version, state it here. # needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. extensions = [ "sphinx.ext.autodoc", "sphinx.ext.todo", "sphinx.ext.coverage", "rfc", "m2r", "sphinx_rtd_theme", ] # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] # The suffix of source filenames. source_suffix = ".rst" # The encoding of source files. # source_encoding = 'utf-8-sig' # The master toctree document. master_doc = "index" # General information about the project. project = "Django OAuth Toolkit" copyright = "2013, Evonove" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. version = oauth2_provider.__version__ # The full version, including alpha/beta/rc tags. release = version # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: # today = '' # Else, today_fmt is used as the format for a strftime call. # today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. exclude_patterns = ["_build"] # The reST default role (used for this markup: `text`) to use for all documents. # default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. # add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). # add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. # show_authors = False # The name of the Pygments (syntax highlighting) style to use. pygments_style = "sphinx" # A list of ignored prefixes for module index sorting. # modindex_common_prefix = [] # If true, keep warnings as "system message" paragraphs in the built documents. # keep_warnings = False # http://www.sphinx-doc.org/en/1.5.1/ext/intersphinx.html extensions.append("sphinx.ext.intersphinx") intersphinx_mapping = { "python3": ("https://docs.python.org/3.6", None), "django": ("http://django.readthedocs.org/en/latest/", None), } # -- Options for HTML output --------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. html_theme = "sphinx_rtd_theme" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. # html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. # html_theme_path = [] # The name for this set of Sphinx documents. If None, it defaults to # " 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-3.0.1/docs/contributing.rst000066400000000000000000000330111466704512300217550ustar00rootroot00000000000000============ 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 `ruff `_ for linting, formatting the code 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 ``ruff``. 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 ``ruff``, 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 ``ruff`` 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 :file:`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 :file:`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 :file:`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 :file:`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 information, see the `GitHub Docs on forking the repository `_. .. 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 :file:`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. The tests are generic and written to work with both single database and multiple database configurations. tox will run tests both ways. You can see the configurations used in tests/settings.py and tests/multi_db_settings.py. When there are multiple databases defined, Django tests will not work unless they are told which database(s) to work with. For test writers this means any test must either: - instead of Django's TestCase or TransactionTestCase use the versions of those classes defined in tests/common_testing.py - when using pytest's `django_db` mark, define it like this: `@pytest.mark.django_db(databases=retrieve_current_databases())` In test code, anywhere the database is referenced the Django router needs to be used exactly like the package's code. .. code-block:: python token_database = router.db_for_write(AccessToken) with self.assertNumQueries(1, using=token_database): # call something using the database Without the 'using' option, this test fails in the multiple database scenario because 'default' will be used instead. 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 lint 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: - :file:`CHANGELOG.md` to show the release date. - :file:`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 built 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-3.0.1/docs/getting_started.rst000066400000000000000000000365151466704512300224510ustar00rootroot00000000000000Getting 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 structure:: . └── 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 initial 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 from oauth2_provider import urls as oauth2_urls urlpatterns = [ path('admin/', admin.site.urls), path('o/', include(oauth2_urls)), ] 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 below and before save take note of ``Client id`` and ``Client secret``, we will use it in a minute. If you want to use this application with OIDC and ``HS256`` (see :doc:`OpenID Connect `), uncheck ``Hash client secret`` to allow verifying tokens using JWT signatures. This means your client secret will be stored in cleartext but is the only way to successfully use signed JWT's with ``HS256``. .. note:: ``RS256`` is the more secure algorithm for signing your JWTs. Only use ``HS256`` if you must. Using ``RS256`` will allow you to keep your ``client_secret`` hashed. .. 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_challenge = hashlib.sha256(code_verifier.encode('utf-8')).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:: json { "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:: json { "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://rfc-editor.org/rfc/rfc6749.html#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-3.0.1/docs/glossary.rst000066400000000000000000000040741466704512300211200ustar00rootroot00000000000000Glossary ======== .. 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-3.0.1/docs/index.rst000066400000000000000000000026221466704512300203610ustar00rootroot00000000000000.. 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.8+ * Django 4.2, 5.0 or 5.1 * oauthlib 3.2.2+ 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-3.0.1/docs/install.rst000066400000000000000000000013301466704512300207130ustar00rootroot00000000000000Installation ============ 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 :file:`urls.py` .. code-block:: python from django.urls import include, path from oauth2_provider import urls as oauth2_urls urlpatterns = [ ... path('o/', include(oauth2_urls)), ] Sync your database ------------------ .. sourcecode:: sh python manage.py migrate oauth2_provider Next step is :doc:`getting started ` or :doc:`first tutorial `. django-oauth-toolkit-3.0.1/docs/management_commands.rst000066400000000000000000000112301466704512300232420ustar00rootroot00000000000000Management 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-3.0.1/docs/models.rst000066400000000000000000000001041466704512300205260ustar00rootroot00000000000000Models ====== .. automodule:: oauth2_provider.models :members: django-oauth-toolkit-3.0.1/docs/oidc.rst000066400000000000000000000431111466704512300201660ustar00rootroot00000000000000OpenID 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 preferable 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. To be able to verify the JWT's signature using the ``client_secret``, you must set the application's ``hash_client_secret`` to ``False``. 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:: ``RS256`` is the more secure algorithm for signing your JWTs. Only use ``HS256`` if you must. Using ``RS256`` will allow you to keep your ``client_secret`` hashed. 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_validators.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 reuse 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. Define where to store the profile ================================= .. py:function:: OAuth2Validator.get_or_create_user_from_content(content) An optional layer to define where to store the profile in ``UserModel`` or a separate model. For example ``UserOAuth``, where ``user = models.OneToOneField(UserModel)``. The function is called after checking that the username is present in the content. :return: An instance of the ``UserModel`` representing the user fetched or created. 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/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-3.0.1/docs/requirements.txt000066400000000000000000000001271466704512300220020ustar00rootroot00000000000000Django oauthlib>=3.2.2 m2r>=0.2.1 mistune<2 sphinx==7.2.6 sphinx-rtd-theme==1.3.0 -e . django-oauth-toolkit-3.0.1/docs/resource_server.rst000066400000000000000000000051741466704512300224740ustar00rootroot00000000000000Separate 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-3.0.1/docs/rest-framework/000077500000000000000000000000001466704512300214665ustar00rootroot00000000000000django-oauth-toolkit-3.0.1/docs/rest-framework/getting_started.rst000066400000000000000000000173041466704512300254140ustar00rootroot00000000000000Getting 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 :file:`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 :file:`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 import urls as oauth2_urls 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_urls)), path('users/', UserList.as_view()), path('users//', UserDetails.as_view()), path('groups/', GroupList.as_view()), # ... ] Also add the following to your :file:`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', ) } LOGIN_URL = '/admin/login/' ``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:: json { "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&scope=write" 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:: json { "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:: json { "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-3.0.1/docs/rest-framework/openapi.yaml000066400000000000000000000021631466704512300240070ustar00rootroot00000000000000openapi: "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-3.0.1/docs/rest-framework/permissions.rst000066400000000000000000000120341466704512300245730ustar00rootroot00000000000000Permissions =========== 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-3.0.1/docs/rest-framework/rest-framework.rst000066400000000000000000000001601466704512300251650ustar00rootroot00000000000000Django Rest Framework --------------------- .. toctree:: :maxdepth: 2 getting_started permissions django-oauth-toolkit-3.0.1/docs/rfc.py000066400000000000000000000021151466704512300176410ustar00rootroot00000000000000""" Custom Sphinx documentation module to link to parts of the OAuth2 RFC. """ from docutils import nodes base_url = "https://rfc-editor.org/rfc/rfc6749.html" 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-3.0.1/docs/settings.rst000066400000000000000000000414111466704512300211110ustar00rootroot00000000000000Settings ======== 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 `_ 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. ALLOWED_SCHEMES ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Default: ``["https"]`` A list of schemes that the ``allowed_origins`` field will be validated against. Setting this to ``["https"]`` only in production is strongly recommended. Adding ``"http"`` to the list is considered to be safe only for local development and testing. Note that `OAUTHLIB_INSECURE_TRANSPORT `_ environment variable should be also set to allow HTTP origins. 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 :doc:`oidc` 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``). REFRESH_TOKEN_REUSE_PROTECTION ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ When this is set to ``True`` (default ``False``), and ``ROTATE_REFRESH_TOKEN`` is used, the server will check if a previously, already revoked refresh token is used a second time. If it detects a reuse, it will automatically revoke all related refresh tokens. A reused refresh token indicates a breach. Since the server can't determine which request came from the legitimate user and which from an attacker, it will end the session for both. The user is required to perform a new login. Can be used in combination with ``REFRESH_TOKEN_GRACE_PERIOD_SECONDS`` More details at https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics-29#name-recommendations 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 ``ACCESS_TOKEN_GENERATOR`` 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 ``ACCESS_TOKEN_GENERATOR`` 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 ``ACCESS_TOKEN_GENERATOR`` is set to the SettingsScopes default. The name of the *read* scope. WRITE_SCOPE ~~~~~~~~~~~ .. note:: (0.12.0+) Only used if ``ACCESS_TOKEN_GENERATOR`` 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. AUTHENTICATION_SERVER_EXP_TIME_ZONE ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The exp (expiration date) of Access Tokens must be defined in UTC (Unix Timestamp). Although its wrong, sometimes a remote Authentication Server does not use UTC (eg. no timezone support and configured in local time other than UTC). Prior to fix #1292 this could be fixed by changing your own time zone. With the introduction of this fix, this workaround would not be possible anymore. This setting re-enables this workaround. 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_ENABLED ~~~~~~~~~~~~ Default: ``False`` Whether or not :doc:`oidc` support is enabled. 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-3.0.1/docs/signals.rst000066400000000000000000000012301466704512300207040ustar00rootroot00000000000000Signals ======= 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-3.0.1/docs/templates.rst000066400000000000000000000216531466704512300212550ustar00rootroot00000000000000Templates ========= 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`` - ``post_logout_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`` - ``post_logout_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-3.0.1/docs/tutorial/000077500000000000000000000000001466704512300203615ustar00rootroot00000000000000django-oauth-toolkit-3.0.1/docs/tutorial/admin+celery.png000066400000000000000000002030201466704512300234330ustar00rootroot00000000000000PNG  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-3.0.1/docs/tutorial/celery+add.png000066400000000000000000002162551466704512300231110ustar00rootroot00000000000000PNG  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 from oauth2_provider import urls as oauth2_urls urlpatterns = [ path("admin", admin.site.urls), path("o/", include(oauth2_urls)), # ... ] 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 information: * `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` * `Allowed origins`: Browser-based clients use Cross-Origin Resource Sharing (CORS) to request resources from origins other than their own. Provide space-separated list of allowed origins for the token endpoint. The origin must be in the form of `"://" [ ":" ]`, such as `https://login.mydomain.com` or `http://localhost:3000`. Query strings and hash information are not taken into account when validating these URLs. This does not include the 'Redirect URIs' or 'Post Logout Redirect URIs', if those domains will also use the token endpoint, they must be included in this list. * `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. * `Hash client secret`: checking this hashes the client secret on save so it cannot be retrieved later. This should be unchecked if you plan to use OIDC with ``HS256`` and want to check the tokens' signatures using JWT. Otherwise, Django OAuth Toolkit cannot use `Client Secret` to sign the tokens (as it cannot be retrieved later) and the hashed value will be used when signing. This may lead to incompatibilities with some OIDC Relying Party libraries. 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 `_. 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-3.0.1/docs/tutorial/tutorial_02.rst000066400000000000000000000107721466704512300232660ustar00rootroot00000000000000Part 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 :file:`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 :file:`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-3.0.1/docs/tutorial/tutorial_03.rst000066400000000000000000000120171466704512300232610ustar00rootroot00000000000000Part 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``. Note, ``OAuth2TokenMiddleware`` adds the user to the request object. There is also an optional ``OAuth2ExtraTokenMiddleware`` that adds the ``Token`` to the request. This makes it convenient to access the ``Application`` object within your views. To use it just add ``oauth2_provider.middleware.OAuth2ExtraTokenMiddleware`` to the ``MIDDLEWARE`` setting. 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 :file:`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-3.0.1/docs/tutorial/tutorial_04.rst000066400000000000000000000044331466704512300232650ustar00rootroot00000000000000Part 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 :file:`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://rfc-editor.org/rfc/rfc7009.html, 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 with 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-3.0.1/docs/tutorial/tutorial_05.rst000066400000000000000000000124501466704512300232640ustar00rootroot00000000000000Part 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: :file:`tutorial/celery.py`: .. code-block:: python import os from celery import Celery from django.conf import settings # 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 :file:`tasks.py` files in the list of installed apps. We'll add ours now in :file:`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 :file:`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-3.0.1/docs/views/000077500000000000000000000000001466704512300176535ustar00rootroot00000000000000django-oauth-toolkit-3.0.1/docs/views/application.rst000066400000000000000000000010461466704512300227110ustar00rootroot00000000000000Application 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 :file:`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-3.0.1/docs/views/class_based.rst000066400000000000000000000045461466704512300226610ustar00rootroot00000000000000Class-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-3.0.1/docs/views/details.rst000066400000000000000000000012201466704512300220250ustar00rootroot00000000000000Views 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-3.0.1/docs/views/function_based.rst000066400000000000000000000045061466704512300233750ustar00rootroot00000000000000Function-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-3.0.1/docs/views/mixins.rst000066400000000000000000000001651466704512300217160ustar00rootroot00000000000000Mixins for Class Based Views ============================ .. automodule:: oauth2_provider.views.mixins :members: django-oauth-toolkit-3.0.1/docs/views/token.rst000066400000000000000000000013041466704512300215230ustar00rootroot00000000000000Granted 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 :file:`authorized-tokens.html` for the list view and :file:`authorized-token-delete.html` for the delete view; they are located inside :file:`templates/oauth2_provider` folder. .. automodule:: oauth2_provider.views.token :members: django-oauth-toolkit-3.0.1/docs/views/views.rst000066400000000000000000000003241466704512300215410ustar00rootroot00000000000000Using 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-3.0.1/oauth2_provider/000077500000000000000000000000001466704512300207025ustar00rootroot00000000000000django-oauth-toolkit-3.0.1/oauth2_provider/__init__.py000066400000000000000000000000261466704512300230110ustar00rootroot00000000000000__version__ = "3.0.1" django-oauth-toolkit-3.0.1/oauth2_provider/admin.py000066400000000000000000000052441466704512300223510ustar00rootroot00000000000000from 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 = ("pk", "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-3.0.1/oauth2_provider/apps.py000066400000000000000000000003641466704512300222220ustar00rootroot00000000000000from django.apps import AppConfig class DOTConfig(AppConfig): name = "oauth2_provider" verbose_name = "Django OAuth Toolkit" def ready(self): # Import checks to ensure they run. from . import checks # noqa: F401 django-oauth-toolkit-3.0.1/oauth2_provider/backends.py000066400000000000000000000017161466704512300230330ustar00rootroot00000000000000from 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-3.0.1/oauth2_provider/checks.py000066400000000000000000000020261466704512300225140ustar00rootroot00000000000000from django.apps import apps from django.core import checks from django.db import router from .settings import oauth2_settings @checks.register(checks.Tags.database) def validate_token_configuration(app_configs, **kwargs): databases = set( router.db_for_write(apps.get_model(model)) for model in ( oauth2_settings.ACCESS_TOKEN_MODEL, oauth2_settings.ID_TOKEN_MODEL, oauth2_settings.REFRESH_TOKEN_MODEL, ) ) # This is highly unlikely, but let's warn people just in case it does. # If the tokens were allowed to be in different databases this would require all # writes to have a transaction around each database. Instead, let's enforce that # they all live together in one database. # The tokens are not required to live in the default database provided the Django # routers know the correct database for them. if len(databases) > 1: return [checks.Error("The token models are expected to be stored in the same database.")] return [] django-oauth-toolkit-3.0.1/oauth2_provider/compat.py000066400000000000000000000006111466704512300225350ustar00rootroot00000000000000""" The `compat` module provides support for backwards compatibility with older versions of Django and Python. """ try: # Django 5.1 introduced LoginRequiredMiddleware, and login_not_required decorator from django.contrib.auth.decorators import login_not_required except ImportError: def login_not_required(view_func): return view_func __all__ = ["login_not_required"] django-oauth-toolkit-3.0.1/oauth2_provider/contrib/000077500000000000000000000000001466704512300223425ustar00rootroot00000000000000django-oauth-toolkit-3.0.1/oauth2_provider/contrib/__init__.py000066400000000000000000000000001466704512300244410ustar00rootroot00000000000000django-oauth-toolkit-3.0.1/oauth2_provider/contrib/rest_framework/000077500000000000000000000000001466704512300253745ustar00rootroot00000000000000django-oauth-toolkit-3.0.1/oauth2_provider/contrib/rest_framework/__init__.py000066400000000000000000000003541466704512300275070ustar00rootroot00000000000000# flake8: noqa from .authentication import OAuth2Authentication from .permissions import ( IsAuthenticatedOrTokenHasScope, TokenHasReadWriteScope, TokenHasResourceScope, TokenHasScope, TokenMatchesOASRequirements, ) django-oauth-toolkit-3.0.1/oauth2_provider/contrib/rest_framework/authentication.py000066400000000000000000000033751466704512300307750ustar00rootroot00000000000000from collections import OrderedDict from django.core.exceptions import SuspiciousOperation 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. """ if request is None: return None oauthlib_core = get_oauthlib_core() try: valid, r = oauthlib_core.verify_request(request, scopes=[]) except ValueError as error: if str(error) == "Invalid hex encoding in query string.": raise SuspiciousOperation(error) raise else: 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-3.0.1/oauth2_provider/contrib/rest_framework/permissions.py000066400000000000000000000146721466704512300303330ustar00rootroot00000000000000import 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 useful 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-3.0.1/oauth2_provider/decorators.py000066400000000000000000000060301466704512300234200ustar00rootroot00000000000000from 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-3.0.1/oauth2_provider/exceptions.py000066400000000000000000000032141466704512300234350ustar00rootroot00000000000000class 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-3.0.1/oauth2_provider/forms.py000066400000000000000000000026521466704512300224070ustar00rootroot00000000000000from 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-3.0.1/oauth2_provider/generators.py000066400000000000000000000024361466704512300234320ustar00rootroot00000000000000from 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 https://rfc-editor.org/rfc/rfc2617.html#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-3.0.1/oauth2_provider/http.py000066400000000000000000000021541466704512300222350ustar00rootroot00000000000000from 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-3.0.1/oauth2_provider/locale/000077500000000000000000000000001466704512300221415ustar00rootroot00000000000000django-oauth-toolkit-3.0.1/oauth2_provider/locale/es/000077500000000000000000000000001466704512300225505ustar00rootroot00000000000000django-oauth-toolkit-3.0.1/oauth2_provider/locale/es/LC_MESSAGES/000077500000000000000000000000001466704512300243355ustar00rootroot00000000000000django-oauth-toolkit-3.0.1/oauth2_provider/locale/es/LC_MESSAGES/django.po000066400000000000000000000144371466704512300261500ustar00rootroot00000000000000# 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-3.0.1/oauth2_provider/locale/fa/000077500000000000000000000000001466704512300225275ustar00rootroot00000000000000django-oauth-toolkit-3.0.1/oauth2_provider/locale/fa/LC_MESSAGES/000077500000000000000000000000001466704512300243145ustar00rootroot00000000000000django-oauth-toolkit-3.0.1/oauth2_provider/locale/fa/LC_MESSAGES/django.po000066400000000000000000000137731466704512300261310ustar00rootroot00000000000000# 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-3.0.1/oauth2_provider/locale/fr/000077500000000000000000000000001466704512300225505ustar00rootroot00000000000000django-oauth-toolkit-3.0.1/oauth2_provider/locale/fr/LC_MESSAGES/000077500000000000000000000000001466704512300243355ustar00rootroot00000000000000django-oauth-toolkit-3.0.1/oauth2_provider/locale/fr/LC_MESSAGES/django.po000066400000000000000000000126731466704512300261500ustar00rootroot00000000000000#, 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-3.0.1/oauth2_provider/locale/ja/000077500000000000000000000000001466704512300225335ustar00rootroot00000000000000django-oauth-toolkit-3.0.1/oauth2_provider/locale/ja/LC_MESSAGES/000077500000000000000000000000001466704512300243205ustar00rootroot00000000000000django-oauth-toolkit-3.0.1/oauth2_provider/locale/ja/LC_MESSAGES/django.po000066400000000000000000000136661466704512300261360ustar00rootroot00000000000000# 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-3.0.1/oauth2_provider/locale/pt/000077500000000000000000000000001466704512300225645ustar00rootroot00000000000000django-oauth-toolkit-3.0.1/oauth2_provider/locale/pt/LC_MESSAGES/000077500000000000000000000000001466704512300243515ustar00rootroot00000000000000django-oauth-toolkit-3.0.1/oauth2_provider/locale/pt/LC_MESSAGES/django.po000066400000000000000000000125101466704512300261520ustar00rootroot00000000000000#, 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-3.0.1/oauth2_provider/locale/pt_BR/000077500000000000000000000000001466704512300231475ustar00rootroot00000000000000django-oauth-toolkit-3.0.1/oauth2_provider/locale/pt_BR/LC_MESSAGES/000077500000000000000000000000001466704512300247345ustar00rootroot00000000000000django-oauth-toolkit-3.0.1/oauth2_provider/locale/pt_BR/LC_MESSAGES/django.po000066400000000000000000000132751466704512300265460ustar00rootroot00000000000000# 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-3.0.1/oauth2_provider/management/000077500000000000000000000000001466704512300230165ustar00rootroot00000000000000django-oauth-toolkit-3.0.1/oauth2_provider/management/__init__.py000066400000000000000000000000001466704512300251150ustar00rootroot00000000000000django-oauth-toolkit-3.0.1/oauth2_provider/management/commands/000077500000000000000000000000001466704512300246175ustar00rootroot00000000000000django-oauth-toolkit-3.0.1/oauth2_provider/management/commands/__init__.py000066400000000000000000000000001466704512300267160ustar00rootroot00000000000000django-oauth-toolkit-3.0.1/oauth2_provider/management/commands/cleartokens.py000066400000000000000000000004311466704512300275010ustar00rootroot00000000000000from 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-3.0.1/oauth2_provider/management/commands/createapplication.py000066400000000000000000000104241466704512300306610ustar00rootroot00000000000000from 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( "--no-hash-client-secret", dest="hash_client_secret", action="store_false", help="Don't hash the client secret", ) parser.set_defaults(hash_client_secret=True) 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 (isinstance(value, bool) or 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-3.0.1/oauth2_provider/middleware.py000066400000000000000000000047011466704512300233730ustar00rootroot00000000000000import hashlib import logging from django.contrib.auth import authenticate from django.utils.cache import patch_vary_headers from oauth2_provider.models import get_access_token_model log = logging.getLogger(__name__) 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 class OAuth2ExtraTokenMiddleware: def __init__(self, get_response): self.get_response = get_response def __call__(self, request): authheader = request.META.get("HTTP_AUTHORIZATION", "") if authheader.startswith("Bearer"): tokenstring = authheader.split()[1] AccessToken = get_access_token_model() try: token_checksum = hashlib.sha256(tokenstring.encode("utf-8")).hexdigest() token = AccessToken.objects.get(token_checksum=token_checksum) request.access_token = token except AccessToken.DoesNotExist as e: log.exception(e) response = self.get_response(request) return response django-oauth-toolkit-3.0.1/oauth2_provider/migrations/000077500000000000000000000000001466704512300230565ustar00rootroot00000000000000django-oauth-toolkit-3.0.1/oauth2_provider/migrations/0001_initial.py000066400000000000000000000133601466704512300255240ustar00rootroot00000000000000from 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-3.0.1/oauth2_provider/migrations/0002_auto_20190406_1805.py000066400000000000000000000012101466704512300264750ustar00rootroot00000000000000# 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-3.0.1/oauth2_provider/migrations/0003_auto_20201211_1314.py000066400000000000000000000006021466704512300264600ustar00rootroot00000000000000# 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-3.0.1/oauth2_provider/migrations/0004_auto_20200902_2022.py000066400000000000000000000052141466704512300264700ustar00rootroot00000000000000import 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-3.0.1/oauth2_provider/migrations/0005_auto_20211222_2352.py000066400000000000000000000032011466704512300264660ustar00rootroot00000000000000import 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-3.0.1/oauth2_provider/migrations/0006_alter_application_client_secret.py000066400000000000000000000025471466704512300325020ustar00rootroot00000000000000import logging from django.db import migrations import oauth2_provider.generators import oauth2_provider.models from oauth2_provider import settings logger = logging.getLogger() 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']) def reverse_func(apps, schema_editor): warning_color_code = "\033[93m" end_color_code = "\033[0m" msg = f"\n{warning_color_code}The previously hashed client_secret cannot be reverted, and it remains hashed{end_color_code}" logger.warning(msg) 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, reverse_func), ] django-oauth-toolkit-3.0.1/oauth2_provider/migrations/0007_application_post_logout_redirect_uris.py000066400000000000000000000007621466704512300337670ustar00rootroot00000000000000# 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", default=""), ), ] django-oauth-toolkit-3.0.1/oauth2_provider/migrations/0008_alter_accesstoken_token.py000066400000000000000000000006751466704512300310000ustar00rootroot00000000000000# Generated by Django 4.2.4 on 2023-09-11 07:03 from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ ("oauth2_provider", "0007_application_post_logout_redirect_uris"), ] operations = [ migrations.AlterField( model_name="accesstoken", name="token", field=models.CharField(db_index=True, max_length=255, unique=True), ), ] django-oauth-toolkit-3.0.1/oauth2_provider/migrations/0009_add_hash_client_secret.py000066400000000000000000000006401466704512300305360ustar00rootroot00000000000000# Generated by Django 4.2.5 on 2023-09-07 19:26 from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ ('oauth2_provider', '0008_alter_accesstoken_token'), ] operations = [ migrations.AddField( model_name='application', name='hash_client_secret', field=models.BooleanField(default=True), ), ] django-oauth-toolkit-3.0.1/oauth2_provider/migrations/0010_application_allowed_origins.py000066400000000000000000000010441466704512300316330ustar00rootroot00000000000000# Generated by Django 4.1.5 on 2023-09-27 20:15 from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ ("oauth2_provider", "0009_add_hash_client_secret"), ] operations = [ migrations.AddField( model_name="application", name="allowed_origins", field=models.TextField( blank=True, help_text="Allowed origins list to enable CORS, space separated", default="", ), ), ] django-oauth-toolkit-3.0.1/oauth2_provider/migrations/0011_refreshtoken_token_family.py000066400000000000000000000010641466704512300313320ustar00rootroot00000000000000# Generated by Django 5.2 on 2024-08-09 16:40 from django.db import migrations, models from oauth2_provider.settings import oauth2_settings class Migration(migrations.Migration): dependencies = [ ('oauth2_provider', '0010_application_allowed_origins'), migrations.swappable_dependency(oauth2_settings.REFRESH_TOKEN_MODEL) ] operations = [ migrations.AddField( model_name='refreshtoken', name='token_family', field=models.UUIDField(blank=True, editable=False, null=True), ), ] django-oauth-toolkit-3.0.1/oauth2_provider/migrations/0012_add_token_checksum.py000066400000000000000000000026521466704512300277110ustar00rootroot00000000000000# Generated by Django 5.0.7 on 2024-07-29 23:13 import oauth2_provider.models from django.db import migrations, models from oauth2_provider.settings import oauth2_settings def forwards_func(apps, schema_editor): """ Forward migration touches every "old" accesstoken.token which will cause the checksum to be computed. """ AccessToken = apps.get_model(oauth2_settings.ACCESS_TOKEN_MODEL) accesstokens = AccessToken._default_manager.all() for accesstoken in accesstokens: accesstoken.save(update_fields=['token_checksum']) class Migration(migrations.Migration): dependencies = [ ("oauth2_provider", "0011_refreshtoken_token_family"), migrations.swappable_dependency(oauth2_settings.ACCESS_TOKEN_MODEL), ] operations = [ migrations.AddField( model_name="accesstoken", name="token_checksum", field=oauth2_provider.models.TokenChecksumField(blank=True, null=True, max_length=64), ), migrations.AlterField( model_name="accesstoken", name="token", field=models.TextField(), ), migrations.RunPython(forwards_func, migrations.RunPython.noop), migrations.AlterField( model_name='accesstoken', name='token_checksum', field=oauth2_provider.models.TokenChecksumField(blank=False, max_length=64, db_index=True, unique=True), ), ] django-oauth-toolkit-3.0.1/oauth2_provider/migrations/__init__.py000066400000000000000000000000001466704512300251550ustar00rootroot00000000000000django-oauth-toolkit-3.0.1/oauth2_provider/models.py000066400000000000000000000714151466704512300225470ustar00rootroot00000000000000import hashlib import logging import time import uuid from contextlib import suppress 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, router, 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 .utils import jwk_from_pem from .validators import AllowedURIValidator logger = logging.getLogger(__name__) class ClientSecretField(models.CharField): def pre_save(self, model_instance, add): secret = getattr(model_instance, self.attname) should_be_hashed = getattr(model_instance, "hash_client_secret", True) if not should_be_hashed: return super().pre_save(model_instance, add) 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 TokenChecksumField(models.CharField): def pre_save(self, model_instance, add): token = getattr(model_instance, "token") checksum = hashlib.sha256(token.encode("utf-8")).hexdigest() setattr(model_instance, self.attname, checksum) 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"), default="", ) 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."), ) hash_client_secret = models.BooleanField(default=True) 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) allowed_origins = models.TextField( blank=True, help_text=_("Allowed origins list to enable CORS, space separated"), default="", ) 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 origin_allowed(self, origin): """ Checks if given origin is one of the items in :attr:`allowed_origins` string :param origin: Origin to check """ return self.allowed_origins and is_origin_allowed(origin, self.allowed_origins.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 = AllowedURIValidator( allowed_schemes, name="redirect uri", allow_path=True, allow_query=True ) for uri in redirect_uris: validator(uri) 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 ) ) allowed_origins = self.allowed_origins.strip().split() if allowed_origins: # oauthlib allows only https scheme for CORS validator = AllowedURIValidator(oauth2_settings.ALLOWED_SCHEMES, "allowed origin") for uri in allowed_origins: validator(uri) 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.pk)]) 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_from_pem(oauth2_settings.OIDC_RSA_PRIVATE_KEY) 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.TextField() token_checksum = TokenChecksumField( max_length=64, blank=False, unique=True, db_index=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", ) token_family = models.UUIDField(null=True, blank=True, editable=False) 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() access_token_database = router.db_for_write(access_token_model) refresh_token_model = get_refresh_token_model() # Use the access_token_database instead of making the assumption it is in 'default'. with transaction.atomic(using=access_token_database): token = refresh_token_model.objects.select_for_update().filter(pk=self.pk, revoked__isnull=True) if not token: return self = list(token)[0] with suppress(access_token_model.DoesNotExist): access_token_model.objects.get(id=self.access_token_id).revoke() 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 IDToken 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 def is_origin_allowed(origin, allowed_origins): """ Checks if a given origin uri is allowed based on the provided allowed_origins configuration. :param origin: Origin URI to check :param allowed_origins: A list of Origin URIs that are allowed """ parsed_origin = urlparse(origin) if parsed_origin.scheme not in oauth2_settings.ALLOWED_SCHEMES: return False for allowed_origin in allowed_origins: parsed_allowed_origin = urlparse(allowed_origin) if ( parsed_allowed_origin.scheme == parsed_origin.scheme and parsed_allowed_origin.netloc == parsed_origin.netloc ): return True return False django-oauth-toolkit-3.0.1/oauth2_provider/oauth2_backends.py000066400000000000000000000231001466704512300243040ustar00rootroot00000000000000import 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"] # Add Access-Control-Allow-Origin header to the token endpoint response for authentication code grant, # if the origin is allowed by RequestValidator.is_origin_allowed. # https://github.com/oauthlib/oauthlib/pull/791 if "HTTP_ORIGIN" in headers: headers["Origin"] = headers["HTTP_ORIGIN"] 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-3.0.1/oauth2_provider/oauth2_validators.py000066400000000000000000001207341466704512300247150ustar00rootroot00000000000000import base64 import binascii import hashlib 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, identify_hasher from django.core.exceptions import ObjectDoesNotExist from django.db import router, transaction from django.http import HttpRequest from django.utils import dateformat, timezone from django.utils.crypto import constant_time_compare 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 errors, 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 from .utils import get_timezone 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 split = auth.split(" ", 1) if len(split) != 2: return None auth_type, auth_string = split if auth_type != "Basic": return None return auth_string def _check_secret(self, provided_secret, stored_secret): """ Checks whether the provided client secret is valid. Supports both hashed and unhashed secrets. """ try: identify_hasher(stored_secret) return check_password(provided_secret, stored_secret) except ValueError: # Raised if the stored_secret is not hashed. return constant_time_compare(provided_secret, stored_secret) 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 self._check_secret(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", "") or "" 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 self._check_secret(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: 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. :raises: InvalidGrantError if the grant does not exist. """ try: grant = Grant.objects.get(code=code, application=request.client) grant.delete() except Grant.DoesNotExist: raise errors.InvalidGrantError(request=request) 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_or_create_user_from_content(self, content): """ An optional layer to define where to store the profile in `UserModel` or a separate model. For example `UserOAuth`, where `user = models.OneToOneField(UserModel)` . The function is called after checking that username is in the content. Returns an UserModel instance; """ user, _ = UserModel.objects.get_or_create(**{UserModel.USERNAME_FIELD: content["username"]}) return user 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 = self.get_or_create_user_from_content(content) 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", "") if settings.USE_TZ: expires = make_aware( expires, timezone=get_timezone(oauth2_settings.AUTHENTICATION_SERVER_EXP_TIME_ZONE) ) 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): token_checksum = hashlib.sha256(token.encode("utf-8")).hexdigest() return ( AccessToken.objects.select_related("application", "user") .filter(token_checksum=token_checksum) .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 def save_bearer_token(self, token, request, *args, **kwargs): """ Save access and refresh token. Override _save_bearer_token and not this function when adding custom logic for the storing of these token. This allows the transaction logic to be separate from the token handling. """ # Use the AccessToken's database instead of making the assumption it is in 'default'. with transaction.atomic(using=router.db_for_write(AccessToken)): return self._save_bearer_token(token, request, *args, **kwargs) 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://rfc-editor.org/rfc/rfc6749.html#section-6 """ 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( pk=refresh_token_instance.pk ) 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, refresh_token_instance ) 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, previous_refresh_token): if previous_refresh_token: token_family = previous_refresh_token.token_family else: token_family = uuid.uuid4() return RefreshToken.objects.create( user=request.user, token=refresh_token_code, application=request.client, access_token=access_token, token_family=token_family, ) 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: try: return AccessToken.objects.get(source_refresh_token_id=rt.pk).scope except AccessToken.DoesNotExist: return [] 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 """ rt = RefreshToken.objects.filter(token=refresh_token).select_related("access_token").first() if not rt: return False if rt.revoked is not None and rt.revoked <= timezone.now() - timedelta( seconds=oauth2_settings.REFRESH_TOKEN_GRACE_PERIOD_SECONDS ): if oauth2_settings.REFRESH_TOKEN_REUSE_PROTECTION and rt.token_family: rt_token_family = RefreshToken.objects.filter(token_family=rt.token_family) for related_rt in rt_token_family.all(): related_rt.revoke() 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 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.pk)} else: claims = {"sub": str(request.user.pk)} # 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) # Use the IDToken's database instead of making the assumption it is in 'default'. with transaction.atomic(using=router.db_for_write(IDToken)): 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 according # 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 {} def is_origin_allowed(self, client_id, origin, request, *args, **kwargs): """Indicate if the given origin is allowed to access the token endpoint via Cross-Origin Resource Sharing (CORS). CORS is used by browser-based clients, such as Single-Page Applications, to perform the Authorization Code Grant. Verifies if request's origin is within Application's allowed origins list. """ return request.client.origin_allowed(origin) django-oauth-toolkit-3.0.1/oauth2_provider/scopes.py000066400000000000000000000030401466704512300225450ustar00rootroot00000000000000from .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-3.0.1/oauth2_provider/settings.py000066400000000000000000000271751466704512300231300ustar00rootroot00000000000000""" 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.core.signals import setting_changed from django.http import HttpRequest 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, "REFRESH_TOKEN_REUSE_PROTECTION": False, "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"], "ALLOWED_SCHEMES": ["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, # Authentication Server Exp Timezone: the time zone use dby Auth Server for generate EXP "AUTHENTICATION_SERVER_EXP_TIME_ZONE": "UTC", # 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-3.0.1/oauth2_provider/signals.py000066400000000000000000000001461466704512300227150ustar00rootroot00000000000000from django.dispatch import Signal app_authorized = Signal() # providing_args=["request", "token"] django-oauth-toolkit-3.0.1/oauth2_provider/templates/000077500000000000000000000000001466704512300227005ustar00rootroot00000000000000django-oauth-toolkit-3.0.1/oauth2_provider/templates/oauth2_provider/000077500000000000000000000000001466704512300260145ustar00rootroot00000000000000django-oauth-toolkit-3.0.1/oauth2_provider/templates/oauth2_provider/application_confirm_delete.html000066400000000000000000000013561466704512300342510ustar00rootroot00000000000000{% 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-3.0.1/oauth2_provider/templates/oauth2_provider/application_detail.html000066400000000000000000000041541466704512300325330ustar00rootroot00000000000000{% extends "oauth2_provider/base.html" %} {% load i18n %} {% block content %}

{{ application.name }}

  • {% trans "Client id" %}

  • {% trans "Client secret" %}

  • {% trans "Hash client secret" %}

    {{ application.hash_client_secret|yesno:_("yes,no") }}

  • {% trans "Client type" %}

    {{ application.client_type }}

  • {% trans "Authorization Grant Type" %}

    {{ application.authorization_grant_type }}

  • {% trans "Redirect Uris" %}

  • {% trans "Post Logout Redirect Uris" %}

  • {% trans "Allowed Origins" %}

{% endblock content %} django-oauth-toolkit-3.0.1/oauth2_provider/templates/oauth2_provider/application_form.html000066400000000000000000000034451466704512300322360ustar00rootroot00000000000000{% 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-3.0.1/oauth2_provider/templates/oauth2_provider/application_list.html000066400000000000000000000014721466704512300322440ustar00rootroot00000000000000{% 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.html000066400000000000000000000005571466704512300347520ustar00rootroot00000000000000django-oauth-toolkit-3.0.1/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-3.0.1/oauth2_provider/templates/oauth2_provider/authorize.html000066400000000000000000000025601466704512300307170ustar00rootroot00000000000000{% 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-3.0.1/oauth2_provider/templates/oauth2_provider/authorized-token-delete.html000066400000000000000000000004431466704512300334370ustar00rootroot00000000000000{% 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-3.0.1/oauth2_provider/templates/oauth2_provider/authorized-tokens.html000066400000000000000000000014241466704512300323620ustar00rootroot00000000000000{% 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-3.0.1/oauth2_provider/templates/oauth2_provider/base.html000066400000000000000000000024161466704512300276170ustar00rootroot00000000000000 {% block title %}{% endblock title %} {% block css %} {% endblock css %}
{% block content %} {% endblock content %}
django-oauth-toolkit-3.0.1/oauth2_provider/templates/oauth2_provider/logout_confirm.html000066400000000000000000000023641466704512300317350ustar00rootroot00000000000000{% 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-3.0.1/oauth2_provider/urls.py000066400000000000000000000036531466704512300222500ustar00rootroot00000000000000from django.urls import path, re_path from . import views app_name = "oauth2_provider" base_urlpatterns = [ path("authorize/", views.AuthorizationView.as_view(), name="authorize"), path("token/", views.TokenView.as_view(), name="token"), path("revoke_token/", views.RevokeTokenView.as_view(), name="revoke-token"), path("introspect/", views.IntrospectTokenView.as_view(), name="introspect"), ] management_urlpatterns = [ # Application management views path("applications/", views.ApplicationList.as_view(), name="list"), path("applications/register/", views.ApplicationRegistration.as_view(), name="register"), path("applications//", views.ApplicationDetail.as_view(), name="detail"), path("applications//delete/", views.ApplicationDelete.as_view(), name="delete"), path("applications//update/", views.ApplicationUpdate.as_view(), name="update"), # Token management views path("authorized_tokens/", views.AuthorizedTokensListView.as_view(), name="authorized-token-list"), path( "authorized_tokens//delete/", views.AuthorizedTokenDeleteView.as_view(), name="authorized-token-delete", ), ] oidc_urlpatterns = [ # .well-known/openid-configuration/ is deprecated # https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig # does not specify a trailing slash # Support for trailing slash shall be removed in a future release. re_path( r"^\.well-known/openid-configuration/?$", views.ConnectDiscoveryInfoView.as_view(), name="oidc-connect-discovery-info", ), path(".well-known/jwks.json", views.JwksInfoView.as_view(), name="jwks-info"), path("userinfo/", views.UserInfoView.as_view(), name="user-info"), path("logout/", views.RPInitiatedLogoutView.as_view(), name="rp-initiated-logout"), ] urlpatterns = base_urlpatterns + management_urlpatterns + oidc_urlpatterns django-oauth-toolkit-3.0.1/oauth2_provider/utils.py000066400000000000000000000014531466704512300224170ustar00rootroot00000000000000import functools from django.conf import settings from jwcrypto import jwk @functools.lru_cache() def jwk_from_pem(pem_string): """ A cached version of jwcrypto.JWK.from_pem. Converting from PEM is expensive for large keys such as those using RSA. """ return jwk.JWK.from_pem(pem_string.encode("utf-8")) # @functools.lru_cache def get_timezone(time_zone): """ Return the default time zone as a tzinfo instance. This is the time zone defined by settings.TIME_ZONE. """ try: import zoneinfo except ImportError: import pytz return pytz.timezone(time_zone) else: if getattr(settings, "USE_DEPRECATED_PYTZ", False): import pytz return pytz.timezone(time_zone) return zoneinfo.ZoneInfo(time_zone) django-oauth-toolkit-3.0.1/oauth2_provider/validators.py000066400000000000000000000062071466704512300234310ustar00rootroot00000000000000import 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(), ) def handle_no_permission(self): """ Generate response for unauthorized users. If prompt is set to none, then we redirect with an error code as defined by OIDC 3.1.2.6 Some code copied from OAuthLibMixin.error_response, but that is designed to operated on OAuth1Error from oauthlib wrapped in a OAuthToolkitError """ prompt = self.request.GET.get("prompt") redirect_uri = self.request.GET.get("redirect_uri") if prompt == "none" and redirect_uri: response_parameters = {"error": "login_required"} # REQUIRED if the Authorization Request included the state parameter. # Set to the value received from the Client state = self.request.GET.get("state") if state: response_parameters["state"] = state separator = "&" if "?" in redirect_uri else "?" redirect_to = redirect_uri + separator + urlencode(response_parameters) return self.redirect(redirect_to, application=None) else: return super().handle_no_permission() @method_decorator(csrf_exempt, name="dispatch") @method_decorator(login_not_required, 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_checksum = hashlib.sha256(access_token.encode("utf-8")).hexdigest() token = get_access_token_model().objects.get(token_checksum=token_checksum) 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") @method_decorator(login_not_required, 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-3.0.1/oauth2_provider/views/generic.py000066400000000000000000000024451466704512300240320ustar00rootroot00000000000000from django.views.generic import View from .mixins import ( ClientProtectedResourceMixin, ProtectedResourceMixin, ReadWriteScopedResourceMixin, ScopedResourceMixin, ) class ProtectedResourceView(ProtectedResourceMixin, 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, 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-3.0.1/oauth2_provider/views/introspect.py000066400000000000000000000047341466704512300246130ustar00rootroot00000000000000import calendar import hashlib 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 ..compat import login_not_required from ..models import get_access_token_model from ..views.generic import ClientProtectedScopedResourceView @method_decorator(csrf_exempt, name="dispatch") @method_decorator(login_not_required, name="dispatch") class IntrospectTokenView(ClientProtectedScopedResourceView): """ Implements an endpoint for token introspection based on RFC 7662 https://rfc-editor.org/rfc/rfc7662.html 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_checksum = hashlib.sha256(token_value.encode("utf-8")).hexdigest() token = ( get_access_token_model() .objects.select_related("user", "application") .get(token_checksum=token_checksum) ) 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-3.0.1/oauth2_provider/views/mixins.py000066400000000000000000000277311466704512300237320ustar00rootroot00000000000000import 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-3.0.1/oauth2_provider/views/oidc.py000066400000000000000000000426211466704512300233340ustar00rootroot00000000000000import json from urllib.parse import urlparse from django.contrib.auth import logout from django.contrib.auth.models import AnonymousUser 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 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 ..compat import login_not_required from ..exceptions import ( ClientIdMissmatch, InvalidIDTokenError, InvalidOIDCClientError, InvalidOIDCRedirectURIError, LogoutDenied, OIDCError, ) from ..forms import ConfirmLogoutForm from ..http import OAuth2ResponseRedirect from ..models import ( AbstractGrant, get_access_token_model, get_application_model, get_id_token_model, get_refresh_token_model, ) from ..settings import oauth2_settings from ..utils import jwk_from_pem from .mixins import OAuthLibMixin, OIDCLogoutOnlyMixin, OIDCOnlyMixin Application = get_application_model() @method_decorator(login_not_required, name="dispatch") 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 ), "code_challenge_methods_supported": [key for key, _ in AbstractGrant.CODE_CHALLENGE_METHODS], "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 @method_decorator(login_not_required, name="dispatch") 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_from_pem(pem) 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") @method_decorator(login_not_required, 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 @method_decorator(login_not_required, name="dispatch") 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: application, token_user = self.validate_logout_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 self.must_prompt(token_user): return self.do_logout(application, post_logout_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: application, token_user = self.validate_logout_request( id_token_hint=id_token_hint, client_id=client_id, post_logout_redirect_uri=post_logout_redirect_uri, ) if not self.must_prompt(token_user) or form.cleaned_data.get("allow"): return self.do_logout(application, post_logout_redirect_uri, state, token_user) else: raise LogoutDenied() except OIDCError as error: return self.error_response(error) def validate_post_logout_redirect_uri(self, application, post_logout_redirect_uri): """ Validate the OIDC RP-Initiated Logout Request post_logout_redirect_uri parameter """ if not post_logout_redirect_uri: return 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.") def validate_logout_request_user(self, id_token_hint, client_id): """ Validate the an OIDC RP-Initiated Logout Request user """ if not id_token_hint: return # 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(self.request, claims): raise InvalidIDTokenError() # 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() return id_token def get_request_application(self, id_token, client_id): if client_id: return get_application_model().objects.get(client_id=client_id) if id_token: return id_token.application def validate_logout_request(self, id_token_hint, client_id, post_logout_redirect_uri): """ Validate an OIDC RP-Initiated Logout Request. `(application, token_user)` is returned. If it is set, `application` is 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 = self.validate_logout_request_user(id_token_hint, client_id) application = self.get_request_application(id_token, client_id) self.validate_post_logout_redirect_uri(application, post_logout_redirect_uri) return application, id_token.user if id_token else None def must_prompt(self, token_user): """Indicate 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`. A logout without user interaction (i.e. no prompt) is only allowed if an ID Token is provided that matches the current user. """ return ( oauth2_settings.OIDC_RP_INITIATED_LOGOUT_ALWAYS_PROMPT or token_user is None or token_user != self.request.user ) def do_logout(self, application=None, post_logout_redirect_uri=None, state=None, token_user=None): user = token_user or self.request.user # Delete Access Tokens if a user was found if oauth2_settings.OIDC_RP_INITIATED_LOGOUT_DELETE_TOKENS and not isinstance(user, AnonymousUser): AccessToken = get_access_token_model() RefreshToken = get_refresh_token_model() access_tokens_to_delete = AccessToken.objects.filter( user=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-3.0.1/oauth2_provider/views/token.py000066400000000000000000000021041466704512300235260ustar00rootroot00000000000000from 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-3.0.1/pyproject.toml000066400000000000000000000044121466704512300205030ustar00rootroot00000000000000[build-system] requires = ["setuptools >= 61.0"] build-backend = "setuptools.build_meta" [project] name = "django-oauth-toolkit" dynamic = ["version"] requires-python = ">= 3.8" authors = [ {name = "Federico Frenguelli"}, {name = "Massimiliano Pippi"}, {email = "synasius@gmail.com"}, ] description = "OAuth2 Provider for Django" keywords = ["django", "oauth", "oauth2", "oauthlib"] license = {file = "LICENSE"} readme = "README.rst" classifiers = [ "Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "Framework :: Django", "Framework :: Django :: 4.2", "Framework :: Django :: 5.0", "Framework :: Django :: 5.1", "Intended Audience :: Developers", "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Topic :: Internet :: WWW/HTTP", ] dependencies = [ "django >= 4.2", "requests >= 2.13.0", "oauthlib >= 3.2.2", "jwcrypto >= 1.5.0", ] [project.urls] Homepage = "https://django-oauth-toolkit.readthedocs.io/" Repository = "https://github.com/jazzband/django-oauth-toolkit" [tool.setuptools.dynamic] version = {attr = "oauth2_provider.__version__"} # Ref: https://github.com/codespell-project/codespell#using-a-config-file [tool.codespell] skip = '.git,package-lock.json,locale,AUTHORS,tox.ini' check-hidden = true ignore-regex = '.*pragma: codespell-ignore.*' ignore-words-list = 'assertIn' [tool.coverage.run] source = ["oauth2_provider"] omit = ["*/migrations/*"] [tool.coverage.report] show_missing = true [tool.pytest.ini_options] 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", "nologinrequiredmiddleware", ] [tool.ruff] line-length = 110 exclude = [".tox", "build/", "dist/", "docs/", "oauth2_provider/migrations/", "tests/migrations/", "manage.py"] [tool.ruff.lint] select = ["E", "F", "I", "Q", "W"] [tool.ruff.lint.isort] lines-after-imports = 2 known-first-party = ["oauth2_provider"] django-oauth-toolkit-3.0.1/tests/000077500000000000000000000000001466704512300167305ustar00rootroot00000000000000django-oauth-toolkit-3.0.1/tests/__init__.py000066400000000000000000000000001466704512300210270ustar00rootroot00000000000000django-oauth-toolkit-3.0.1/tests/admin.py000066400000000000000000000006311466704512300203720ustar00rootroot00000000000000from 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-3.0.1/tests/app/000077500000000000000000000000001466704512300175105ustar00rootroot00000000000000django-oauth-toolkit-3.0.1/tests/app/README.md000066400000000000000000000025431466704512300207730ustar00rootroot00000000000000# Test Apps These apps are for local end to end testing of DOT features. They were implemented to save maintainers the trouble of setting up local test environments. You should be able to start both and instance of the IDP and RP using the directions below, then test the functionality of the IDP using the RP. ## /tests/app/idp This is an example IDP implementation for end to end testing. There are pre-configured fixtures which will work with the sample RP. username: superuser password: password ### Development Tasks * starting up the idp ```bash cd tests/app/idp # create a virtual env if that is something you do python manage.py migrate python manage.py loaddata fixtures/seed.json python manage.py runserver # open http://localhost:8000/admin ``` * update fixtures You can update data in the IDP and then dump the data to a new seed file as follows. ``` python -Xutf8 ./manage.py dumpdata -e sessions -e admin.logentry -e auth.permission -e contenttypes.contenttype -e oauth2_provider.accesstoken -e oauth2_provider.refreshtoken -e oauth2_provider.idtoken --natural-foreign --natural-primary --indent 2 > fixtures/seed.json ``` ## /test/app/rp This is an example RP. It is a SPA built with Svelte. ### Development Tasks * starting the RP ```bash cd test/apps/rp npm install npm run dev # open http://localhost:5173 ```django-oauth-toolkit-3.0.1/tests/app/idp/000077500000000000000000000000001466704512300202645ustar00rootroot00000000000000django-oauth-toolkit-3.0.1/tests/app/idp/README.md000066400000000000000000000000351466704512300215410ustar00rootroot00000000000000# TEST IDP see ../README.md django-oauth-toolkit-3.0.1/tests/app/idp/fixtures/000077500000000000000000000000001466704512300221355ustar00rootroot00000000000000django-oauth-toolkit-3.0.1/tests/app/idp/fixtures/seed.json000066400000000000000000000023271466704512300237540ustar00rootroot00000000000000[ { "model": "auth.user", "fields": { "password": "pbkdf2_sha256$390000$29LoVHfFRlvEOJ9clv73Wx$fx5ejfUJ+nYsnBXFf21jZvDsq4o3p5io3TrAGKAVTq4=", "last_login": "2023-11-11T17:24:19.359Z", "is_superuser": true, "username": "superuser", "first_name": "", "last_name": "", "email": "", "is_staff": true, "is_active": true, "date_joined": "2023-05-01T19:53:59.622Z", "groups": [], "user_permissions": [] } }, { "model": "oauth2_provider.application", "fields": { "client_id": "2EIxgjlyy5VgCp2fjhEpKLyRtSMMPK0hZ0gBpNdm", "user": null, "redirect_uris": "http://localhost:5173\r\nhttp://127.0.0.1:5173", "post_logout_redirect_uris": "http://localhost:5173\r\nhttp://127.0.0.1:5173", "client_type": "public", "authorization_grant_type": "authorization-code", "client_secret": "pbkdf2_sha256$600000$HEYByn6WXiQUI1D6ezTnAf$qPLekt0t3ZssnzEOvQkeOSfxx7tbs/gcC3O0CthtP2A=", "hash_client_secret": true, "name": "OIDC - Authorization Code", "skip_authorization": true, "created": "2023-05-01T20:27:46.167Z", "updated": "2023-11-11T17:23:44.643Z", "algorithm": "RS256", "allowed_origins": "http://localhost:5173\r\nhttp://127.0.0.1:5173" } } ] django-oauth-toolkit-3.0.1/tests/app/idp/idp/000077500000000000000000000000001466704512300210405ustar00rootroot00000000000000django-oauth-toolkit-3.0.1/tests/app/idp/idp/__init__.py000066400000000000000000000000001466704512300231370ustar00rootroot00000000000000django-oauth-toolkit-3.0.1/tests/app/idp/idp/apps.py000066400000000000000000000010061466704512300223520ustar00rootroot00000000000000from corsheaders.signals import check_request_enabled from django.apps import AppConfig def cors_allow_origin(sender, request, **kwargs): return ( request.path == "/o/userinfo/" or request.path == "/o/userinfo" or request.path == "/o/.well-known/openid-configuration" or request.path == "/o/.well-known/openid-configuration/" ) class IDPAppConfig(AppConfig): name = "idp" default = True def ready(self): check_request_enabled.connect(cors_allow_origin) django-oauth-toolkit-3.0.1/tests/app/idp/idp/asgi.py000066400000000000000000000006001466704512300223310ustar00rootroot00000000000000""" ASGI config for idp project. It exposes the ASGI callable as a module-level variable named ``application``. For more information on this file, see https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/ """ import os from django.core.asgi import get_asgi_application os.environ.setdefault("DJANGO_SETTINGS_MODULE", "idp.settings") application = get_asgi_application() django-oauth-toolkit-3.0.1/tests/app/idp/idp/oauth.py000066400000000000000000000032471466704512300225400ustar00rootroot00000000000000from django.conf import settings from django.contrib.auth.middleware import AuthenticationMiddleware from django.contrib.sessions.middleware import SessionMiddleware from oauth2_provider.oauth2_validators import OAuth2Validator # get_response is required for middleware, it doesn't need to do anything # the way we're using it, so we just use a lambda that returns None def get_response(): None class CustomOAuth2Validator(OAuth2Validator): def validate_silent_login(self, request) -> None: # request is an OAuthLib.common.Request and doesn't have the session # or user of the django request. We will emulate the session and auth # middleware here, since that is what the idp is using for auth. You # may need to modify this if you are using a different session # middleware or auth backend. session_cookie_name = settings.SESSION_COOKIE_NAME HTTP_COOKIE = request.headers.get("HTTP_COOKIE") COOKIES = HTTP_COOKIE.split("; ") for cookie in COOKIES: cookie_name, cookie_value = cookie.split("=") if cookie.startswith(session_cookie_name): break session_middleware = SessionMiddleware(get_response) session = session_middleware.SessionStore(cookie_value) # add session to request for compatibility with django.contrib.auth request.session = session # call the auth middleware to set request.user auth_middleware = AuthenticationMiddleware(get_response) auth_middleware.process_request(request) return request.user.is_authenticated def validate_silent_authorization(self, request) -> None: return True django-oauth-toolkit-3.0.1/tests/app/idp/idp/settings.py000066400000000000000000000210051466704512300232500ustar00rootroot00000000000000""" Django settings for idp project. Generated by 'django-admin startproject' using Django 4.2. For more information on this file, see https://docs.djangoproject.com/en/4.2/topics/settings/ For the full list of settings and their values, see https://docs.djangoproject.com/en/4.2/ref/settings/ """ import os from pathlib import Path import environ # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent env = environ.FileAwareEnv( DEBUG=(bool, True), ALLOWED_HOSTS=(list, []), DATABASE_URL=(str, "sqlite:///db.sqlite3"), SECRET_KEY=(str, "django-insecure-vri27@j_q62e2it4$xiy9ca!7@qgjkhhan(*zs&lz0k@yukbb3"), OAUTH2_PROVIDER_OIDC_ENABLED=(bool, True), OAUTH2_PROVIDER_OIDC_RP_INITIATED_LOGOUT_ENABLED=(bool, True), OAUTH2_PROVIDER_OIDC_RSA_PRIVATE_KEY=( str, """ -----BEGIN RSA PRIVATE KEY----- MIIJKAIBAAKCAgEAtd8X/v8pddKt+opMJZrhV4FH86gBTMPjTGXeAfKkQVf7KDUZ Ty90n+JMe2rvCUn+Nws9yy5vmtbkomQbj8Xs1kHJOVdCnH1L2HTkvM7BjTBmJ5vc bA94IBmSf9jJIzfIJkepshRLcGllMvHPOYQiR+lJsj58FFDLZN4/182S21C8Ri0w +63rT64SxiQkqt6h+E1w7V+tHQJKDZq3du1QctZVXiIr6Zs5BgTjTyRURoiqUVH0 WJ4dT2t4+Rg9mp3PBlVwTOqzw9xTcO8ke+ZdrIWP4euZuPIr/Dya5R7S2Ki8Nwag ANGV+LghJilucuWzJlOBO8TlIVUwgUaGOqaDxMHx9P/nRLQ6vTKP81FUJ7gNv6oj W+6No6nMhsESQ+thizvBYOgintZZoeBwpB8lebKvGJUeqRo6qhc5BeUEjAjsAgtP sJrRNQ4t8PT8mP+2dw4sU7J5PBAtx+ZdZ9bcH/sNuohBj77+6WhyvjmeYIKgCgjO TdZH9O+kUIMaX9mlB+WvoVsk32qensZG/CgXXa3rWyXPvOdA9aOE4V0GCv1JfWKK OXA8aY5aUGy0VvOWXHWpft5begr8onCjNs9UR6fCdCvcrSuiHTvNpM37E6Xh4kV4 uMzjGaj5ZLBOAY3cYzFI6LNrK4/YJvzLi9jxI1sJG1ZMz8kCywuJISEq4LcCAwEA AQKCAgBcnbV8l7gnVhhfA9pvNAYZJ67ad+3hh8fSefWqjEP1Orad7RxsZMBBQ16r YvNDibi5kzHurEENWu2nfM9EUgifu3SbjMJRKsVa/3wUYj3ShpkfBpIjPWVxA1TF YkJbeuakB8507zzTi/iLDvT2V0GV2Uk8SfGp7tMFFODyJq/om56lJhJRuGmidAT/ fhxmH2XgKp+dYiGoKihH8UgIeiWDtX5Xp5MxLWjGleqjvN5l5ObG7rM+BZbrgNFk GGIWwNJSaWP853CQBz0+v6mWpuOBHar945quwjSACOTgVOgOiS7/3pHQmOqEdE/9 PRAP1sV6eP/Qzh3Y8ab3zlBAwddLmZi+8sVV/sJadEMciU6AR8ZInf2zWtmxh6Ft TNXUrSmDjKId84wyYT+pDg8Vv04X8xMNLWAIYeBawOPasEiBiFVUqDGHciPMBbhb XxZK7Noi8akzCLWouPkrW4pjpsd5xrllakGFAFPktLvc8ZRyz2InaQKqhaaU+is5 ykAeHpJHVxg1xFY0hX06i8pkjXQROhc7+GUuifxKvVcouCwlUiSxcHGQLqzGKnYE fpCs9uGI8+XolEq637LyYaZ7zpWd8Ehiw4AEfE3oOVIQd4xAQ8YDJxUG1fUYQfF8 iD5VO2+WO7a9QfScFZK+UebHEEXQGq4+JNUlP0KSnSsp3J0XkQKCAQEA3Y0sE9sE l8VTTW3oxKChmq18UKJchyXU3BMLFnvDAPweUTdtS0QUIsDQD2pCU7wQonWOpqUj vMwlTZjyNo+9N0l2fqleha1phzgYFCfTsgJ6gcl82y/JUvsGqMglKOUKoCFW5UtM kUO+P5S25GqiDc0qsO6FGKSOvJ5aJLYEpEK5ez2q9uyzSYbp5aUuKwLb11rX0HW9 JjkB7hL4OtHpJ9E9uAsOj4VIWpysmX3d8UIv1Uez8f+bilhCMShKk4U9xz8ZY2K4 YXdfFr83b1kQybIDzeXeOQ5NQ6myS5HiqBSYx9Iy7Y54605KVM0CzLCPS5fAAcbW 5wq1H32OtxRS4wKCAQEA0iZ24W30BIYIx65YseVbBNs4cJr9ppqCAqUGqAhW8xfe q7Atd6KG+lXWVDj2tZzuoYeb0PLjQRsmOs8CVFUZT0ntH6YAUOpPW8l8tkrWTugp 7fCx2pR4r8aFAVb7Jkc41ojSvaYMbUClKf+JVtFPsY1ug7gNxizGjVnpAq66XX+X 76BVIpMEUivZcXos6/BrVM3seFYQg1pMZkjjO3q8lETnlT3LIYpPtRjaFSvcMaMy 1Cb4dGUz+xj8BM73bLDEJtHZEsyF6nEnurlE9rSbMui9XhckcC267e1qvIbAnKB9 JK5oJAM4L+xOylmvk71gdrul9Q9aT+QJGUXkPxwfHQKCAQBkMIQ/UmtISyb5u/to eA+8yDmQqWvYfiY9g6se9sbfuiPnrH4TbG0Crlkor2/hOAn5vdnNyJ5ZsaQo7EKU o/n4d5NLgkJJh3tSd+6DpuMX/AD0km6RHJIZoYWIbEJJtRJSCeGm/Z9Zjd4KGLGA qCwyu5ZTvvmXhEs8RwwSz/FXawlAD0oyMiZ92LILdOBk+Pz77YvtLGFmWJ9jz1ZM G0MqC3iysuVZx/dJatKu8vmcMcc51xwsEuB+9pywaD0Za0bdxM4xYKJrCTWKLtzd 0NRDseoAgbQ17x7Hu4Tyob1zLyVML+VyAlzyZEw+/xsF/849bBmbdBUZFIGGBRy1 9E3rAoIBAQCDs3dtb+stqpJ2Ed2kH4kbUgfdCkVM1CgGYEX7qL5VOvBhyNe10jWl TYY04j47M06aDNKp8I5bjxg2YuWi1HI4Lqxc2Tv5ed6iN3PhCqWkbftZEy9jPQkl n9RbMpfTNW95g+YO1LGVBp5745m+vw6ix3ArPH3lZMpKa76L39UMI5qkoma4dEqQ 9MohQ+BDPTkGvMcl40oWB9E5iRRfglwMz+IStddH/dZWOGz0N7iXox+HtaSfzYz2 IIJQwSRvCZjkez7/eQ20D5ZGfzWpJybckN+cyAQeCYrM8a2i2RB9GFdVVbgOWbYs 0nvOdMaEYHrD7nXjTuvahZ7uJ88TfhxBAoIBAG3ClX40pxUXs6kEOGZYUXHFaYDz Upuvj8X2h6SaepTAAokkJxGOdeg5t3ohsaXDeV2WcNb8KRFmDuVtcGSo0mUWtrtT RXgJT9SBEMl1rEPbEh0i9uXOaI8DWdBO62Ei0efeL0Wac7kxwBbObKDn8mQCmlWK 4nvzevqUB8frm9abjRGTOZX8QlNZcPs065vHubNJ8SAqr+uoe1GTb0qL7YkWT6vb dBCCnF8FP1yPW8UgGVGSeozmIMaJwSpl2srZUMkN1KlqHwzehrOn9Tn2grA9ue/i ipUMvb4Se0LDJnmFuv8v6gM6V4vyXkP855mNOiRHUOHOSKdQ3SeKrLlnR6I= -----END RSA PRIVATE KEY----- """, ), OAUTH2_PROVIDER_SCOPES=(dict, {"openid": "OpenID Connect scope"}), OAUTH2_PROVIDER_ALLOWED_SCHEMES=(list, ["https", "http"]), OAUTHLIB_INSECURE_TRANSPORT=(bool, "1"), STATIC_ROOT=(str, BASE_DIR / "static"), STATIC_URL=(str, "static/"), TEMPLATES_DIRS=(list, [BASE_DIR / "templates"]), ) # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! SECRET_KEY = env("SECRET_KEY") # SECURITY WARNING: don't run with debug turned on in production! DEBUG = env("DEBUG") ALLOWED_HOSTS = env("ALLOWED_HOSTS") # Application definition INSTALLED_APPS = [ "idp.apps.IDPAppConfig", "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", "oauth2_provider", "corsheaders", ] MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "corsheaders.middleware.CorsMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", ] ROOT_URLCONF = "idp.urls" TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", "DIRS": env("TEMPLATES_DIRS"), "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 = "idp.wsgi.application" # Database # https://docs.djangoproject.com/en/4.2/ref/settings/#databases DATABASES = { "default": env.db(), } # Password validation # https://docs.djangoproject.com/en/4.2/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.2/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.2/howto/static-files/ STATIC_ROOT = env("STATIC_ROOT") STATIC_URL = env("STATIC_URL") # Default primary key field type # https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" OAUTH2_PROVIDER = { "OAUTH2_VALIDATOR_CLASS": "idp.oauth.CustomOAuth2Validator", "OIDC_ENABLED": env("OAUTH2_PROVIDER_OIDC_ENABLED"), "OIDC_RP_INITIATED_LOGOUT_ENABLED": env("OAUTH2_PROVIDER_OIDC_RP_INITIATED_LOGOUT_ENABLED"), # this key is just for out test app, you should never store a key like this in a production environment. "OIDC_RSA_PRIVATE_KEY": env("OAUTH2_PROVIDER_OIDC_RSA_PRIVATE_KEY"), "SCOPES": { "openid": "OpenID Connect scope", }, "ALLOWED_SCHEMES": env("OAUTH2_PROVIDER_ALLOWED_SCHEMES"), } # needs to be set to allow cors requests from the test app, along with ALLOWED_SCHEMES=["http"] os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = env("OAUTHLIB_INSECURE_TRANSPORT") LOGGING = { "version": 1, "disable_existing_loggers": False, "handlers": { "console": { "class": "logging.StreamHandler", }, }, "root": { "handlers": ["console"], "level": "WARNING", }, "loggers": { # log oauth2_provider issues to facilitate troubleshooting "oauth2_provider": { "handlers": ["console"], "level": "DEBUG", "propagate": False, }, # occasionally you may want to see what's going on in upstream in oauthlib # "oauthlib": { # "handlers": ["console"], # "level": "DEBUG", # "propagate": False, # }, }, } django-oauth-toolkit-3.0.1/tests/app/idp/idp/urls.py000066400000000000000000000016141466704512300224010ustar00rootroot00000000000000""" URL configuration for idp project. The `urlpatterns` list routes URLs to views. For more information please see: https://docs.djangoproject.com/en/4.2/topics/http/urls/ Examples: Function views 1. Add an import: from my_app import views 2. Add a URL to urlpatterns: path('', views.home, name='home') Class-based views 1. Add an import: from other_app.views import Home 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') Including another URLconf 1. Import the include() function: from django.urls import include, path 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ 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")), path("accounts/", include("django.contrib.auth.urls")), ] django-oauth-toolkit-3.0.1/tests/app/idp/idp/wsgi.py000066400000000000000000000006001466704512300223570ustar00rootroot00000000000000""" WSGI config for idp project. It exposes the WSGI callable as a module-level variable named ``application``. For more information on this file, see https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/ """ import os from django.core.wsgi import get_wsgi_application os.environ.setdefault("DJANGO_SETTINGS_MODULE", "idp.settings") application = get_wsgi_application() django-oauth-toolkit-3.0.1/tests/app/idp/manage.py000066400000000000000000000012231466704512300220640ustar00rootroot00000000000000#!/usr/bin/env python """Django's command-line utility for administrative tasks.""" import os import sys def main(): """Run administrative tasks.""" os.environ.setdefault("DJANGO_SETTINGS_MODULE", "idp.settings") try: from django.core.management import execute_from_command_line except ImportError as exc: raise ImportError( "Couldn't import Django. Are you sure it's installed and " "available on your PYTHONPATH environment variable? Did you " "forget to activate a virtual environment?" ) from exc execute_from_command_line(sys.argv) if __name__ == "__main__": main() django-oauth-toolkit-3.0.1/tests/app/idp/requirements.txt000066400000000000000000000001211466704512300235420ustar00rootroot00000000000000Django>=3.2,<4.2 django-cors-headers==3.14.0 django-environ==0.11.2 -e ../../../django-oauth-toolkit-3.0.1/tests/app/idp/templates/000077500000000000000000000000001466704512300222625ustar00rootroot00000000000000django-oauth-toolkit-3.0.1/tests/app/idp/templates/registration/000077500000000000000000000000001466704512300247745ustar00rootroot00000000000000django-oauth-toolkit-3.0.1/tests/app/idp/templates/registration/login.html000066400000000000000000000002441466704512300267720ustar00rootroot00000000000000

Log In

{% csrf_token %} {{ form.as_p }}
django-oauth-toolkit-3.0.1/tests/app/rp/000077500000000000000000000000001466704512300201315ustar00rootroot00000000000000django-oauth-toolkit-3.0.1/tests/app/rp/.gitignore000066400000000000000000000002041466704512300221150ustar00rootroot00000000000000.DS_Store node_modules /build /.svelte-kit /package .env .env.* !.env.example vite.config.js.timestamp-* vite.config.ts.timestamp-* django-oauth-toolkit-3.0.1/tests/app/rp/.npmrc000066400000000000000000000000531466704512300212470ustar00rootroot00000000000000engine-strict=true resolution-mode=highest django-oauth-toolkit-3.0.1/tests/app/rp/.prettierignore000066400000000000000000000002401466704512300231700ustar00rootroot00000000000000.DS_Store node_modules /build /.svelte-kit /package .env .env.* !.env.example # Ignore files for PNPM, NPM and YARN pnpm-lock.yaml package-lock.json yarn.lock django-oauth-toolkit-3.0.1/tests/app/rp/.prettierrc000066400000000000000000000003511466704512300223140ustar00rootroot00000000000000{ "useTabs": true, "singleQuote": true, "trailingComma": "none", "printWidth": 100, "plugins": ["prettier-plugin-svelte"], "pluginSearchDirs": ["."], "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] } django-oauth-toolkit-3.0.1/tests/app/rp/Dockerfile000066400000000000000000000005151466704512300221240ustar00rootroot00000000000000FROM node:18-alpine AS builder WORKDIR /app COPY package*.json . RUN npm ci COPY . . RUN npm run build RUN npm prune --production FROM node:18-alpine WORKDIR /app COPY --from=builder /app/build build/ COPY --from=builder /app/node_modules node_modules/ COPY package.json . EXPOSE 3000 ENV NODE_ENV=production CMD [ "node", "build" ]django-oauth-toolkit-3.0.1/tests/app/rp/README.md000066400000000000000000000017241466704512300214140ustar00rootroot00000000000000# create-svelte **Please Read ../README.md First** Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/master/packages/create-svelte). ## Creating a project If you're seeing this, you've probably already done this step. Congrats! ```bash # create a new project in the current directory npm create svelte@latest # create a new project in my-app npm create svelte@latest my-app ``` ## Developing Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: ```bash npm run dev # or start the server and open the app in a new browser tab npm run dev -- --open ``` ## Building To create a production version of your app: ```bash npm run build ``` You can preview the production build with `npm run preview`. > To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment. django-oauth-toolkit-3.0.1/tests/app/rp/package-lock.json000066400000000000000000002222761466704512300233600ustar00rootroot00000000000000{ "name": "rp", "version": "0.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rp", "version": "0.0.1", "dependencies": { "@dopry/svelte-oidc": "^1.1.0" }, "devDependencies": { "@sveltejs/adapter-auto": "^3.1.1", "@sveltejs/adapter-node": "^5.0.1", "@sveltejs/kit": "^2.5.10", "prettier": "^3.3.2", "prettier-plugin-svelte": "^3.2.4", "svelte": "^4.2.19", "svelte-check": "^3.8.0", "tslib": "^2.4.1", "typescript": "^5.0.0", "vite": "^5.0.3" } }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", "dev": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" }, "engines": { "node": ">=6.0.0" } }, "node_modules/@dopry/svelte-oidc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@dopry/svelte-oidc/-/svelte-oidc-1.1.0.tgz", "integrity": "sha512-FfXm/f2vRNxFsYxKs8hal1Huf94dqKrRIppDzjDIH9cNy683b9sN9NUY0mZtrHc1yJL+jyfNNsB+bY9/9fCErA==", "dependencies": { "oidc-client": "1.11.5" } }, "node_modules/@esbuild/aix-ppc64": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==", "cpu": [ "ppc64" ], "dev": true, "optional": true, "os": [ "aix" ], "engines": { "node": ">=12" } }, "node_modules/@esbuild/android-arm": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz", "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==", "cpu": [ "arm" ], "dev": true, "optional": true, "os": [ "android" ], "engines": { "node": ">=12" } }, "node_modules/@esbuild/android-arm64": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz", "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==", "cpu": [ "arm64" ], "dev": true, "optional": true, "os": [ "android" ], "engines": { "node": ">=12" } }, "node_modules/@esbuild/android-x64": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz", "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==", "cpu": [ "x64" ], "dev": true, "optional": true, "os": [ "android" ], "engines": { "node": ">=12" } }, "node_modules/@esbuild/darwin-arm64": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz", "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==", "cpu": [ "arm64" ], "dev": true, "optional": true, "os": [ "darwin" ], "engines": { "node": ">=12" } }, "node_modules/@esbuild/darwin-x64": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz", "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==", "cpu": [ "x64" ], "dev": true, "optional": true, "os": [ "darwin" ], "engines": { "node": ">=12" } }, "node_modules/@esbuild/freebsd-arm64": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz", "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==", "cpu": [ "arm64" ], "dev": true, "optional": true, "os": [ "freebsd" ], "engines": { "node": ">=12" } }, "node_modules/@esbuild/freebsd-x64": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz", "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==", "cpu": [ "x64" ], "dev": true, "optional": true, "os": [ "freebsd" ], "engines": { "node": ">=12" } }, "node_modules/@esbuild/linux-arm": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz", "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==", "cpu": [ "arm" ], "dev": true, "optional": true, "os": [ "linux" ], "engines": { "node": ">=12" } }, "node_modules/@esbuild/linux-arm64": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz", "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==", "cpu": [ "arm64" ], "dev": true, "optional": true, "os": [ "linux" ], "engines": { "node": ">=12" } }, "node_modules/@esbuild/linux-ia32": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz", "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==", "cpu": [ "ia32" ], "dev": true, "optional": true, "os": [ "linux" ], "engines": { "node": ">=12" } }, "node_modules/@esbuild/linux-loong64": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz", "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==", "cpu": [ "loong64" ], "dev": true, "optional": true, "os": [ "linux" ], "engines": { "node": ">=12" } }, "node_modules/@esbuild/linux-mips64el": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz", "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==", "cpu": [ "mips64el" ], "dev": true, "optional": true, "os": [ "linux" ], "engines": { "node": ">=12" } }, "node_modules/@esbuild/linux-ppc64": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz", "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==", "cpu": [ "ppc64" ], "dev": true, "optional": true, "os": [ "linux" ], "engines": { "node": ">=12" } }, "node_modules/@esbuild/linux-riscv64": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz", "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==", "cpu": [ "riscv64" ], "dev": true, "optional": true, "os": [ "linux" ], "engines": { "node": ">=12" } }, "node_modules/@esbuild/linux-s390x": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz", "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==", "cpu": [ "s390x" ], "dev": true, "optional": true, "os": [ "linux" ], "engines": { "node": ">=12" } }, "node_modules/@esbuild/linux-x64": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==", "cpu": [ "x64" ], "dev": true, "optional": true, "os": [ "linux" ], "engines": { "node": ">=12" } }, "node_modules/@esbuild/netbsd-x64": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz", "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==", "cpu": [ "x64" ], "dev": true, "optional": true, "os": [ "netbsd" ], "engines": { "node": ">=12" } }, "node_modules/@esbuild/openbsd-x64": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz", "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==", "cpu": [ "x64" ], "dev": true, "optional": true, "os": [ "openbsd" ], "engines": { "node": ">=12" } }, "node_modules/@esbuild/sunos-x64": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz", "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==", "cpu": [ "x64" ], "dev": true, "optional": true, "os": [ "sunos" ], "engines": { "node": ">=12" } }, "node_modules/@esbuild/win32-arm64": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz", "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==", "cpu": [ "arm64" ], "dev": true, "optional": true, "os": [ "win32" ], "engines": { "node": ">=12" } }, "node_modules/@esbuild/win32-ia32": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz", "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==", "cpu": [ "ia32" ], "dev": true, "optional": true, "os": [ "win32" ], "engines": { "node": ">=12" } }, "node_modules/@esbuild/win32-x64": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz", "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==", "cpu": [ "x64" ], "dev": true, "optional": true, "os": [ "win32" ], "engines": { "node": ">=12" } }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", "dev": true, "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.24" }, "engines": { "node": ">=6.0.0" } }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz", "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==", "dev": true, "engines": { "node": ">=6.0.0" } }, "node_modules/@jridgewell/set-array": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", "dev": true, "engines": { "node": ">=6.0.0" } }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.4.15", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", "dev": true }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.25", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", "dev": true, "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", "dev": true, "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" }, "engines": { "node": ">= 8" } }, "node_modules/@nodelib/fs.stat": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", "dev": true, "engines": { "node": ">= 8" } }, "node_modules/@nodelib/fs.walk": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", "dev": true, "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" }, "engines": { "node": ">= 8" } }, "node_modules/@polka/url": { "version": "1.0.0-next.24", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.24.tgz", "integrity": "sha512-2LuNTFBIO0m7kKIQvvPHN6UE63VjpmL9rnEEaOOaiSPbZK+zUOYIzBAWcED+3XYzhYsd/0mD57VdxAEqqV52CQ==", "dev": true }, "node_modules/@rollup/plugin-commonjs": { "version": "25.0.8", "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-25.0.8.tgz", "integrity": "sha512-ZEZWTK5n6Qde0to4vS9Mr5x/0UZoqCxPVR9KRUjU4kA2sO7GEUn1fop0DAwpO6z0Nw/kJON9bDmSxdWxO/TT1A==", "dev": true, "dependencies": { "@rollup/pluginutils": "^5.0.1", "commondir": "^1.0.1", "estree-walker": "^2.0.2", "glob": "^8.0.3", "is-reference": "1.2.1", "magic-string": "^0.30.3" }, "engines": { "node": ">=14.0.0" }, "peerDependencies": { "rollup": "^2.68.0||^3.0.0||^4.0.0" }, "peerDependenciesMeta": { "rollup": { "optional": true } } }, "node_modules/@rollup/plugin-commonjs/node_modules/brace-expansion": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", "dev": true, "dependencies": { "balanced-match": "^1.0.0" } }, "node_modules/@rollup/plugin-commonjs/node_modules/estree-walker": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "dev": true }, "node_modules/@rollup/plugin-commonjs/node_modules/glob": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", "deprecated": "Glob versions prior to v9 are no longer supported", "dev": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^5.0.1", "once": "^1.3.0" }, "engines": { "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/@rollup/plugin-commonjs/node_modules/is-reference": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", "dev": true, "dependencies": { "@types/estree": "*" } }, "node_modules/@rollup/plugin-commonjs/node_modules/minimatch": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", "dev": true, "dependencies": { "brace-expansion": "^2.0.1" }, "engines": { "node": ">=10" } }, "node_modules/@rollup/plugin-json": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz", "integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==", "dev": true, "dependencies": { "@rollup/pluginutils": "^5.1.0" }, "engines": { "node": ">=14.0.0" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "peerDependenciesMeta": { "rollup": { "optional": true } } }, "node_modules/@rollup/plugin-node-resolve": { "version": "15.2.3", "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.2.3.tgz", "integrity": "sha512-j/lym8nf5E21LwBT4Df1VD6hRO2L2iwUeUmP7litikRsVp1H6NWx20NEp0Y7su+7XGc476GnXXc4kFeZNGmaSQ==", "dev": true, "dependencies": { "@rollup/pluginutils": "^5.0.1", "@types/resolve": "1.20.2", "deepmerge": "^4.2.2", "is-builtin-module": "^3.2.1", "is-module": "^1.0.0", "resolve": "^1.22.1" }, "engines": { "node": ">=14.0.0" }, "peerDependencies": { "rollup": "^2.78.0||^3.0.0||^4.0.0" }, "peerDependenciesMeta": { "rollup": { "optional": true } } }, "node_modules/@rollup/pluginutils": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.0.tgz", "integrity": "sha512-XTIWOPPcpvyKI6L1NHo0lFlCyznUEyPmPY1mc3KpPVDYulHSTvyeLNVW00QTLIAFNhR3kYnJTQHeGqU4M3n09g==", "dev": true, "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^2.3.1" }, "engines": { "node": ">=14.0.0" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "peerDependenciesMeta": { "rollup": { "optional": true } } }, "node_modules/@rollup/pluginutils/node_modules/estree-walker": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "dev": true }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.18.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.18.0.tgz", "integrity": "sha512-Tya6xypR10giZV1XzxmH5wr25VcZSncG0pZIjfePT0OVBvqNEurzValetGNarVrGiq66EBVAFn15iYX4w6FKgQ==", "cpu": [ "arm" ], "dev": true, "optional": true, "os": [ "android" ] }, "node_modules/@rollup/rollup-android-arm64": { "version": "4.18.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.18.0.tgz", "integrity": "sha512-avCea0RAP03lTsDhEyfy+hpfr85KfyTctMADqHVhLAF3MlIkq83CP8UfAHUssgXTYd+6er6PaAhx/QGv4L1EiA==", "cpu": [ "arm64" ], "dev": true, "optional": true, "os": [ "android" ] }, "node_modules/@rollup/rollup-darwin-arm64": { "version": "4.18.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.18.0.tgz", "integrity": "sha512-IWfdwU7KDSm07Ty0PuA/W2JYoZ4iTj3TUQjkVsO/6U+4I1jN5lcR71ZEvRh52sDOERdnNhhHU57UITXz5jC1/w==", "cpu": [ "arm64" ], "dev": true, "optional": true, "os": [ "darwin" ] }, "node_modules/@rollup/rollup-darwin-x64": { "version": "4.18.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.18.0.tgz", "integrity": "sha512-n2LMsUz7Ynu7DoQrSQkBf8iNrjOGyPLrdSg802vk6XT3FtsgX6JbE8IHRvposskFm9SNxzkLYGSq9QdpLYpRNA==", "cpu": [ "x64" ], "dev": true, "optional": true, "os": [ "darwin" ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { "version": "4.18.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.18.0.tgz", "integrity": "sha512-C/zbRYRXFjWvz9Z4haRxcTdnkPt1BtCkz+7RtBSuNmKzMzp3ZxdM28Mpccn6pt28/UWUCTXa+b0Mx1k3g6NOMA==", "cpu": [ "arm" ], "dev": true, "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { "version": "4.18.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.18.0.tgz", "integrity": "sha512-l3m9ewPgjQSXrUMHg93vt0hYCGnrMOcUpTz6FLtbwljo2HluS4zTXFy2571YQbisTnfTKPZ01u/ukJdQTLGh9A==", "cpu": [ "arm" ], "dev": true, "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { "version": "4.18.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.18.0.tgz", "integrity": "sha512-rJ5D47d8WD7J+7STKdCUAgmQk49xuFrRi9pZkWoRD1UeSMakbcepWXPF8ycChBoAqs1pb2wzvbY6Q33WmN2ftw==", "cpu": [ "arm64" ], "dev": true, "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { "version": "4.18.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.18.0.tgz", "integrity": "sha512-be6Yx37b24ZwxQ+wOQXXLZqpq4jTckJhtGlWGZs68TgdKXJgw54lUUoFYrg6Zs/kjzAQwEwYbp8JxZVzZLRepQ==", "cpu": [ "arm64" ], "dev": true, "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { "version": "4.18.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.18.0.tgz", "integrity": "sha512-hNVMQK+qrA9Todu9+wqrXOHxFiD5YmdEi3paj6vP02Kx1hjd2LLYR2eaN7DsEshg09+9uzWi2W18MJDlG0cxJA==", "cpu": [ "ppc64" ], "dev": true, "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { "version": "4.18.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.18.0.tgz", "integrity": "sha512-ROCM7i+m1NfdrsmvwSzoxp9HFtmKGHEqu5NNDiZWQtXLA8S5HBCkVvKAxJ8U+CVctHwV2Gb5VUaK7UAkzhDjlg==", "cpu": [ "riscv64" ], "dev": true, "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { "version": "4.18.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.18.0.tgz", "integrity": "sha512-0UyyRHyDN42QL+NbqevXIIUnKA47A+45WyasO+y2bGJ1mhQrfrtXUpTxCOrfxCR4esV3/RLYyucGVPiUsO8xjg==", "cpu": [ "s390x" ], "dev": true, "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.18.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.18.0.tgz", "integrity": "sha512-xuglR2rBVHA5UsI8h8UbX4VJ470PtGCf5Vpswh7p2ukaqBGFTnsfzxUBetoWBWymHMxbIG0Cmx7Y9qDZzr648w==", "cpu": [ "x64" ], "dev": true, "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-x64-musl": { "version": "4.18.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.18.0.tgz", "integrity": "sha512-LKaqQL9osY/ir2geuLVvRRs+utWUNilzdE90TpyoX0eNqPzWjRm14oMEE+YLve4k/NAqCdPkGYDaDF5Sw+xBfg==", "cpu": [ "x64" ], "dev": true, "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { "version": "4.18.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.18.0.tgz", "integrity": "sha512-7J6TkZQFGo9qBKH0pk2cEVSRhJbL6MtfWxth7Y5YmZs57Pi+4x6c2dStAUvaQkHQLnEQv1jzBUW43GvZW8OFqA==", "cpu": [ "arm64" ], "dev": true, "optional": true, "os": [ "win32" ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { "version": "4.18.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.18.0.tgz", "integrity": "sha512-Txjh+IxBPbkUB9+SXZMpv+b/vnTEtFyfWZgJ6iyCmt2tdx0OF5WhFowLmnh8ENGNpfUlUZkdI//4IEmhwPieNg==", "cpu": [ "ia32" ], "dev": true, "optional": true, "os": [ "win32" ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { "version": "4.18.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.18.0.tgz", "integrity": "sha512-UOo5FdvOL0+eIVTgS4tIdbW+TtnBLWg1YBCcU2KWM7nuNwRz9bksDX1bekJJCpu25N1DVWaCwnT39dVQxzqS8g==", "cpu": [ "x64" ], "dev": true, "optional": true, "os": [ "win32" ] }, "node_modules/@sveltejs/adapter-auto": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-3.1.1.tgz", "integrity": "sha512-6LeZft2Fo/4HfmLBi5CucMYmgRxgcETweQl/yQoZo/895K3S9YWYN4Sfm/IhwlIpbJp3QNvhKmwCHbsqQNYQpw==", "dev": true, "dependencies": { "import-meta-resolve": "^4.0.0" }, "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "node_modules/@sveltejs/adapter-node": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-5.0.1.tgz", "integrity": "sha512-eYdmxdUWMW+dad1JfMsWBPY2vjXz9eE+52A2AQnXPScPJlIxIVk5mmbaEEzrZivLfO2wEcLTZ5vdC03W69x+iA==", "dev": true, "dependencies": { "@rollup/plugin-commonjs": "^25.0.7", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^15.2.3", "rollup": "^4.9.5" }, "peerDependencies": { "@sveltejs/kit": "^2.4.0" } }, "node_modules/@sveltejs/kit": { "version": "2.5.10", "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.5.10.tgz", "integrity": "sha512-OqoyTmFG2cYmCFAdBfW+Qxbg8m23H4dv6KqwEt7ofr/ROcfcIl3Z/VT56L22H9f0uNZyr+9Bs1eh2gedOCK9kA==", "dev": true, "hasInstallScript": true, "dependencies": { "@types/cookie": "^0.6.0", "cookie": "^0.6.0", "devalue": "^5.0.0", "esm-env": "^1.0.0", "import-meta-resolve": "^4.1.0", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "sade": "^1.8.1", "set-cookie-parser": "^2.6.0", "sirv": "^2.0.4", "tiny-glob": "^0.2.9" }, "bin": { "svelte-kit": "svelte-kit.js" }, "engines": { "node": ">=18.13" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^3.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "vite": "^5.0.3" } }, "node_modules/@sveltejs/vite-plugin-svelte": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-3.0.2.tgz", "integrity": "sha512-MpmF/cju2HqUls50WyTHQBZUV3ovV/Uk8k66AN2gwHogNAG8wnW8xtZDhzNBsFJJuvmq1qnzA5kE7YfMJNFv2Q==", "dev": true, "peer": true, "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^2.0.0", "debug": "^4.3.4", "deepmerge": "^4.3.1", "kleur": "^4.1.5", "magic-string": "^0.30.5", "svelte-hmr": "^0.15.3", "vitefu": "^0.2.5" }, "engines": { "node": "^18.0.0 || >=20" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "vite": "^5.0.0" } }, "node_modules/@sveltejs/vite-plugin-svelte/node_modules/@sveltejs/vite-plugin-svelte-inspector": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-2.0.0.tgz", "integrity": "sha512-gjr9ZFg1BSlIpfZ4PRewigrvYmHWbDrq2uvvPB1AmTWKuM+dI1JXQSUu2pIrYLb/QncyiIGkFDFKTwJ0XqQZZg==", "dev": true, "peer": true, "dependencies": { "debug": "^4.3.4" }, "engines": { "node": "^18.0.0 || >=20" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^3.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "vite": "^5.0.0" } }, "node_modules/@types/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", "dev": true }, "node_modules/@types/estree": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", "dev": true }, "node_modules/@types/pug": { "version": "2.0.10", "resolved": "https://registry.npmjs.org/@types/pug/-/pug-2.0.10.tgz", "integrity": "sha512-Sk/uYFOBAB7mb74XcpizmH0KOR2Pv3D2Hmrh1Dmy5BmK3MpdSa5kqZcg6EKBdklU0bFXX9gCfzvpnyUehrPIuA==", "dev": true }, "node_modules/@types/resolve": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", "dev": true }, "node_modules/acorn": { "version": "7.4.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", "bin": { "acorn": "bin/acorn" }, "engines": { "node": ">=0.4.0" } }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", "dev": true, "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" }, "engines": { "node": ">= 8" } }, "node_modules/aria-query": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, "dependencies": { "dequal": "^2.0.3" } }, "node_modules/axobject-query": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.0.0.tgz", "integrity": "sha512-+60uv1hiVFhHZeO+Lz0RYzsVHy5Wr1ayX0mwda9KPDVLNJgZ1T9Ny7VmFbLDzxsH0D87I86vgj3gFrjTJUYznw==", "dev": true, "dependencies": { "dequal": "^2.0.3" } }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/feross" }, { "type": "patreon", "url": "https://www.patreon.com/feross" }, { "type": "consulting", "url": "https://feross.org/support" } ] }, "node_modules/binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", "dev": true, "engines": { "node": ">=8" } }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "dev": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "dependencies": { "fill-range": "^7.1.1" }, "engines": { "node": ">=8" } }, "node_modules/buffer-crc32": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", "dev": true, "engines": { "node": ">=8.0.0" } }, "node_modules/builtin-modules": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", "integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==", "dev": true, "engines": { "node": ">=6" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "dev": true, "engines": { "node": ">=6" } }, "node_modules/chokidar": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", "dev": true, "funding": [ { "type": "individual", "url": "https://paulmillr.com/funding/" } ], "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "engines": { "node": ">= 8.10.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "node_modules/code-red": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/code-red/-/code-red-1.0.4.tgz", "integrity": "sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw==", "dev": true, "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15", "@types/estree": "^1.0.1", "acorn": "^8.10.0", "estree-walker": "^3.0.3", "periscopic": "^3.1.0" } }, "node_modules/code-red/node_modules/acorn": { "version": "8.11.3", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", "dev": true, "bin": { "acorn": "bin/acorn" }, "engines": { "node": ">=0.4.0" } }, "node_modules/commondir": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", "dev": true }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true }, "node_modules/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", "dev": true, "engines": { "node": ">= 0.6" } }, "node_modules/core-js": { "version": "3.30.1", "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.30.1.tgz", "integrity": "sha512-ZNS5nbiSwDTq4hFosEDqm65izl2CWmLz0hARJMyNQBgkUZMIF51cQiMvIQKA6hvuaeWxQDP3hEedM1JZIgTldQ==", "hasInstallScript": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/core-js" } }, "node_modules/crypto-js": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==" }, "node_modules/css-tree": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz", "integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==", "dev": true, "dependencies": { "mdn-data": "2.0.30", "source-map-js": "^1.0.1" }, "engines": { "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" } }, "node_modules/debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", "dev": true, "peer": true, "dependencies": { "ms": "2.1.2" }, "engines": { "node": ">=6.0" }, "peerDependenciesMeta": { "supports-color": { "optional": true } } }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", "dev": true, "engines": { "node": ">=0.10.0" } }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "dev": true, "engines": { "node": ">=6" } }, "node_modules/detect-indent": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-6.1.0.tgz", "integrity": "sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==", "dev": true, "engines": { "node": ">=8" } }, "node_modules/devalue": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.0.0.tgz", "integrity": "sha512-gO+/OMXF7488D+u3ue+G7Y4AA3ZmUnB3eHJXmBTgNHvr4ZNzl36A0ZtG+XCRNYCkYx/bFmw4qtkoFLa+wSrwAA==", "dev": true }, "node_modules/es6-promise": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-3.3.1.tgz", "integrity": "sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg==", "dev": true }, "node_modules/esbuild": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==", "dev": true, "hasInstallScript": true, "bin": { "esbuild": "bin/esbuild" }, "engines": { "node": ">=12" }, "optionalDependencies": { "@esbuild/aix-ppc64": "0.20.2", "@esbuild/android-arm": "0.20.2", "@esbuild/android-arm64": "0.20.2", "@esbuild/android-x64": "0.20.2", "@esbuild/darwin-arm64": "0.20.2", "@esbuild/darwin-x64": "0.20.2", "@esbuild/freebsd-arm64": "0.20.2", "@esbuild/freebsd-x64": "0.20.2", "@esbuild/linux-arm": "0.20.2", "@esbuild/linux-arm64": "0.20.2", "@esbuild/linux-ia32": "0.20.2", "@esbuild/linux-loong64": "0.20.2", "@esbuild/linux-mips64el": "0.20.2", "@esbuild/linux-ppc64": "0.20.2", "@esbuild/linux-riscv64": "0.20.2", "@esbuild/linux-s390x": "0.20.2", "@esbuild/linux-x64": "0.20.2", "@esbuild/netbsd-x64": "0.20.2", "@esbuild/openbsd-x64": "0.20.2", "@esbuild/sunos-x64": "0.20.2", "@esbuild/win32-arm64": "0.20.2", "@esbuild/win32-ia32": "0.20.2", "@esbuild/win32-x64": "0.20.2" } }, "node_modules/esm-env": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.0.0.tgz", "integrity": "sha512-Cf6VksWPsTuW01vU9Mk/3vRue91Zevka5SjyNf3nEpokFRuqt/KjUQoGAwq9qMmhpLTHmXzSIrFRw8zxWzmFBA==", "dev": true }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, "dependencies": { "@types/estree": "^1.0.0" } }, "node_modules/fast-glob": { "version": "3.2.12", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz", "integrity": "sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==", "dev": true, "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" }, "engines": { "node": ">=8.6.0" } }, "node_modules/fastq": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", "integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==", "dev": true, "dependencies": { "reusify": "^1.0.4" } }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, "dependencies": { "to-regex-range": "^5.0.1" }, "engines": { "node": ">=8" } }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "dev": true }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, "hasInstallScript": true, "optional": true, "os": [ "darwin" ], "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Glob versions prior to v9 are no longer supported", "dev": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" }, "engines": { "node": "*" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/glob-parent": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "dev": true, "dependencies": { "is-glob": "^4.0.1" }, "engines": { "node": ">= 6" } }, "node_modules/globalyzer": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/globalyzer/-/globalyzer-0.1.0.tgz", "integrity": "sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q==", "dev": true }, "node_modules/globrex": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz", "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==", "dev": true }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "dev": true }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "dev": true, "dependencies": { "function-bind": "^1.1.2" }, "engines": { "node": ">= 0.4" } }, "node_modules/import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", "dev": true, "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" }, "engines": { "node": ">=6" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/import-meta-resolve": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz", "integrity": "sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw==", "dev": true, "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" } }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", "dev": true, "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "dev": true }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", "dev": true, "dependencies": { "binary-extensions": "^2.0.0" }, "engines": { "node": ">=8" } }, "node_modules/is-builtin-module": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-3.2.1.tgz", "integrity": "sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A==", "dev": true, "dependencies": { "builtin-modules": "^3.3.0" }, "engines": { "node": ">=6" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/is-core-module": { "version": "2.13.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", "dev": true, "dependencies": { "hasown": "^2.0.0" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true, "engines": { "node": ">=0.10.0" } }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, "dependencies": { "is-extglob": "^2.1.1" }, "engines": { "node": ">=0.10.0" } }, "node_modules/is-module": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", "dev": true }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "dev": true, "engines": { "node": ">=0.12.0" } }, "node_modules/is-reference": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.2.tgz", "integrity": "sha512-v3rht/LgVcsdZa3O2Nqs+NMowLOxeOm7Ay9+/ARQ2F+qEoANRcqrjAZKGN0v8ymUetZGgkp26LTnGT7H0Qo9Pg==", "dev": true, "dependencies": { "@types/estree": "*" } }, "node_modules/kleur": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", "dev": true, "engines": { "node": ">=6" } }, "node_modules/locate-character": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", "dev": true }, "node_modules/magic-string": { "version": "0.30.7", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.7.tgz", "integrity": "sha512-8vBuFF/I/+OSLRmdf2wwFCJCz+nSn0m6DPvGH1fS/KiQoSaR+sETbov0eIk9KhEKy8CYqIkIAnbohxT/4H0kuA==", "dev": true, "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "engines": { "node": ">=12" } }, "node_modules/mdn-data": { "version": "2.0.30", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==", "dev": true }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", "dev": true, "engines": { "node": ">= 8" } }, "node_modules/micromatch": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", "dev": true, "dependencies": { "braces": "^3.0.2", "picomatch": "^2.3.1" }, "engines": { "node": ">=8.6" } }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", "dev": true, "engines": { "node": ">=4" } }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, "dependencies": { "brace-expansion": "^1.1.7" }, "engines": { "node": "*" } }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/mkdirp": { "version": "0.5.6", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", "dev": true, "engines": { "node": ">=4" } }, "node_modules/mrmime": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.0.tgz", "integrity": "sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==", "dev": true, "engines": { "node": ">=10" } }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "dev": true, "peer": true }, "node_modules/nanoid": { "version": "3.3.7", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", "dev": true, "funding": [ { "type": "github", "url": "https://github.com/sponsors/ai" } ], "bin": { "nanoid": "bin/nanoid.cjs" }, "engines": { "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", "dev": true, "engines": { "node": ">=0.10.0" } }, "node_modules/oidc-client": { "version": "1.11.5", "resolved": "https://registry.npmjs.org/oidc-client/-/oidc-client-1.11.5.tgz", "integrity": "sha512-LcKrKC8Av0m/KD/4EFmo9Sg8fSQ+WFJWBrmtWd+tZkNn3WT/sQG3REmPANE9tzzhbjW6VkTNy4xhAXCfPApAOg==", "dependencies": { "acorn": "^7.4.1", "base64-js": "^1.5.1", "core-js": "^3.8.3", "crypto-js": "^4.0.0", "serialize-javascript": "^4.0.0" } }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "dev": true, "dependencies": { "wrappy": "1" } }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "dev": true, "dependencies": { "callsites": "^3.0.0" }, "engines": { "node": ">=6" } }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", "dev": true, "engines": { "node": ">=0.10.0" } }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "dev": true }, "node_modules/periscopic": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/periscopic/-/periscopic-3.1.0.tgz", "integrity": "sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==", "dev": true, "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^3.0.0", "is-reference": "^3.0.0" } }, "node_modules/picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", "dev": true }, "node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "dev": true, "engines": { "node": ">=8.6" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" } }, "node_modules/postcss": { "version": "8.4.38", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==", "dev": true, "funding": [ { "type": "opencollective", "url": "https://opencollective.com/postcss/" }, { "type": "tidelift", "url": "https://tidelift.com/funding/github/npm/postcss" }, { "type": "github", "url": "https://github.com/sponsors/ai" } ], "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.0.0", "source-map-js": "^1.2.0" }, "engines": { "node": "^10 || ^12 || >=14" } }, "node_modules/prettier": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.2.tgz", "integrity": "sha512-rAVeHYMcv8ATV5d508CFdn+8/pHPpXeIid1DdrPwXnaAdH7cqjVbpJaT5eq4yRAFU/lsbwYwSF/n5iNrdJHPQA==", "dev": true, "bin": { "prettier": "bin/prettier.cjs" }, "engines": { "node": ">=14" }, "funding": { "url": "https://github.com/prettier/prettier?sponsor=1" } }, "node_modules/prettier-plugin-svelte": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.2.4.tgz", "integrity": "sha512-tZv+ADfeOWFNQkXkRh6zUXE16w3Vla8x2Ug0B/EnSmjR4EnwdwZbGgL/liSwR1kcEALU5mAAyua98HBxheCxgg==", "dev": true, "peerDependencies": { "prettier": "^3.0.0", "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" } }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", "dev": true, "funding": [ { "type": "github", "url": "https://github.com/sponsors/feross" }, { "type": "patreon", "url": "https://www.patreon.com/feross" }, { "type": "consulting", "url": "https://feross.org/support" } ] }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", "dependencies": { "safe-buffer": "^5.1.0" } }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", "dev": true, "dependencies": { "picomatch": "^2.2.1" }, "engines": { "node": ">=8.10.0" } }, "node_modules/resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", "dev": true, "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true, "engines": { "node": ">=4" } }, "node_modules/reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", "dev": true, "engines": { "iojs": ">=1.0.0", "node": ">=0.10.0" } }, "node_modules/rimraf": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "node_modules/rollup": { "version": "4.18.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.18.0.tgz", "integrity": "sha512-QmJz14PX3rzbJCN1SG4Xe/bAAX2a6NpCP8ab2vfu2GiUr8AQcr2nCV/oEO3yneFarB67zk8ShlIyWb2LGTb3Sg==", "dev": true, "dependencies": { "@types/estree": "1.0.5" }, "bin": { "rollup": "dist/bin/rollup" }, "engines": { "node": ">=18.0.0", "npm": ">=8.0.0" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.18.0", "@rollup/rollup-android-arm64": "4.18.0", "@rollup/rollup-darwin-arm64": "4.18.0", "@rollup/rollup-darwin-x64": "4.18.0", "@rollup/rollup-linux-arm-gnueabihf": "4.18.0", "@rollup/rollup-linux-arm-musleabihf": "4.18.0", "@rollup/rollup-linux-arm64-gnu": "4.18.0", "@rollup/rollup-linux-arm64-musl": "4.18.0", "@rollup/rollup-linux-powerpc64le-gnu": "4.18.0", "@rollup/rollup-linux-riscv64-gnu": "4.18.0", "@rollup/rollup-linux-s390x-gnu": "4.18.0", "@rollup/rollup-linux-x64-gnu": "4.18.0", "@rollup/rollup-linux-x64-musl": "4.18.0", "@rollup/rollup-win32-arm64-msvc": "4.18.0", "@rollup/rollup-win32-ia32-msvc": "4.18.0", "@rollup/rollup-win32-x64-msvc": "4.18.0", "fsevents": "~2.3.2" } }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", "dev": true, "funding": [ { "type": "github", "url": "https://github.com/sponsors/feross" }, { "type": "patreon", "url": "https://www.patreon.com/feross" }, { "type": "consulting", "url": "https://feross.org/support" } ], "dependencies": { "queue-microtask": "^1.2.2" } }, "node_modules/sade": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", "dev": true, "dependencies": { "mri": "^1.1.0" }, "engines": { "node": ">=6" } }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/feross" }, { "type": "patreon", "url": "https://www.patreon.com/feross" }, { "type": "consulting", "url": "https://feross.org/support" } ] }, "node_modules/sander": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/sander/-/sander-0.5.1.tgz", "integrity": "sha512-3lVqBir7WuKDHGrKRDn/1Ye3kwpXaDOMsiRP1wd6wpZW56gJhsbp5RqQpA6JG/P+pkXizygnr1dKR8vzWaVsfA==", "dev": true, "dependencies": { "es6-promise": "^3.1.2", "graceful-fs": "^4.1.3", "mkdirp": "^0.5.1", "rimraf": "^2.5.2" } }, "node_modules/serialize-javascript": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz", "integrity": "sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==", "dependencies": { "randombytes": "^2.1.0" } }, "node_modules/set-cookie-parser": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.6.0.tgz", "integrity": "sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ==", "dev": true }, "node_modules/sirv": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz", "integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==", "dev": true, "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" }, "engines": { "node": ">= 10" } }, "node_modules/sorcery": { "version": "0.11.1", "resolved": "https://registry.npmjs.org/sorcery/-/sorcery-0.11.1.tgz", "integrity": "sha512-o7npfeJE6wi6J9l0/5LKshFzZ2rMatRiCDwYeDQaOzqdzRJwALhX7mk/A/ecg6wjMu7wdZbmXfD2S/vpOg0bdQ==", "dev": true, "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.14", "buffer-crc32": "^1.0.0", "minimist": "^1.2.0", "sander": "^0.5.0" }, "bin": { "sorcery": "bin/sorcery" } }, "node_modules/source-map-js": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", "dev": true, "engines": { "node": ">=0.10.0" } }, "node_modules/strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", "dev": true, "dependencies": { "min-indent": "^1.0.0" }, "engines": { "node": ">=8" } }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", "dev": true, "engines": { "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/svelte": { "version": "4.2.19", "resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.19.tgz", "integrity": "sha512-IY1rnGr6izd10B0A8LqsBfmlT5OILVuZ7XsI0vdGPEvuonFV7NYEUK4dAkm9Zg2q0Um92kYjTpS1CAP3Nh/KWw==", "dev": true, "dependencies": { "@ampproject/remapping": "^2.2.1", "@jridgewell/sourcemap-codec": "^1.4.15", "@jridgewell/trace-mapping": "^0.3.18", "@types/estree": "^1.0.1", "acorn": "^8.9.0", "aria-query": "^5.3.0", "axobject-query": "^4.0.0", "code-red": "^1.0.3", "css-tree": "^2.3.1", "estree-walker": "^3.0.3", "is-reference": "^3.0.1", "locate-character": "^3.0.0", "magic-string": "^0.30.4", "periscopic": "^3.1.0" }, "engines": { "node": ">=16" } }, "node_modules/svelte-check": { "version": "3.8.0", "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-3.8.0.tgz", "integrity": "sha512-7Nxn+3X97oIvMzYJ7t27w00qUf1Y52irE2RU2dQAd5PyvfGp4E7NLhFKVhb6PV2fx7dCRMpNKDIuazmGthjpSQ==", "dev": true, "dependencies": { "@jridgewell/trace-mapping": "^0.3.17", "chokidar": "^3.4.1", "fast-glob": "^3.2.7", "import-fresh": "^3.2.1", "picocolors": "^1.0.0", "sade": "^1.7.4", "svelte-preprocess": "^5.1.3", "typescript": "^5.0.3" }, "bin": { "svelte-check": "bin/svelte-check" }, "peerDependencies": { "svelte": "^3.55.0 || ^4.0.0-next.0 || ^4.0.0 || ^5.0.0-next.0" } }, "node_modules/svelte-hmr": { "version": "0.15.3", "resolved": "https://registry.npmjs.org/svelte-hmr/-/svelte-hmr-0.15.3.tgz", "integrity": "sha512-41snaPswvSf8TJUhlkoJBekRrABDXDMdpNpT2tfHIv4JuhgvHqLMhEPGtaQn0BmbNSTkuz2Ed20DF2eHw0SmBQ==", "dev": true, "peer": true, "engines": { "node": "^12.20 || ^14.13.1 || >= 16" }, "peerDependencies": { "svelte": "^3.19.0 || ^4.0.0" } }, "node_modules/svelte-preprocess": { "version": "5.1.4", "resolved": "https://registry.npmjs.org/svelte-preprocess/-/svelte-preprocess-5.1.4.tgz", "integrity": "sha512-IvnbQ6D6Ao3Gg6ftiM5tdbR6aAETwjhHV+UKGf5bHGYR69RQvF1ho0JKPcbUON4vy4R7zom13jPjgdOWCQ5hDA==", "dev": true, "hasInstallScript": true, "dependencies": { "@types/pug": "^2.0.6", "detect-indent": "^6.1.0", "magic-string": "^0.30.5", "sorcery": "^0.11.0", "strip-indent": "^3.0.0" }, "engines": { "node": ">= 16.0.0" }, "peerDependencies": { "@babel/core": "^7.10.2", "coffeescript": "^2.5.1", "less": "^3.11.3 || ^4.0.0", "postcss": "^7 || ^8", "postcss-load-config": "^2.1.0 || ^3.0.0 || ^4.0.0 || ^5.0.0", "pug": "^3.0.0", "sass": "^1.26.8", "stylus": "^0.55.0", "sugarss": "^2.0.0 || ^3.0.0 || ^4.0.0", "svelte": "^3.23.0 || ^4.0.0-next.0 || ^4.0.0 || ^5.0.0-next.0", "typescript": ">=3.9.5 || ^4.0.0 || ^5.0.0" }, "peerDependenciesMeta": { "@babel/core": { "optional": true }, "coffeescript": { "optional": true }, "less": { "optional": true }, "postcss": { "optional": true }, "postcss-load-config": { "optional": true }, "pug": { "optional": true }, "sass": { "optional": true }, "stylus": { "optional": true }, "sugarss": { "optional": true }, "typescript": { "optional": true } } }, "node_modules/svelte/node_modules/acorn": { "version": "8.11.3", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", "dev": true, "bin": { "acorn": "bin/acorn" }, "engines": { "node": ">=0.4.0" } }, "node_modules/tiny-glob": { "version": "0.2.9", "resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz", "integrity": "sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==", "dev": true, "dependencies": { "globalyzer": "0.1.0", "globrex": "^0.1.2" } }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "dev": true, "dependencies": { "is-number": "^7.0.0" }, "engines": { "node": ">=8.0" } }, "node_modules/totalist": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", "dev": true, "engines": { "node": ">=6" } }, "node_modules/tslib": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz", "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==", "dev": true }, "node_modules/typescript": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.0.4.tgz", "integrity": "sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==", "dev": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" }, "engines": { "node": ">=12.20" } }, "node_modules/vite": { "version": "5.2.13", "resolved": "https://registry.npmjs.org/vite/-/vite-5.2.13.tgz", "integrity": "sha512-SSq1noJfY9pR3I1TUENL3rQYDQCFqgD+lM6fTRAM8Nv6Lsg5hDLaXkjETVeBt+7vZBCMoibD+6IWnT2mJ+Zb/A==", "dev": true, "dependencies": { "esbuild": "^0.20.1", "postcss": "^8.4.38", "rollup": "^4.13.0" }, "bin": { "vite": "bin/vite.js" }, "engines": { "node": "^18.0.0 || >=20.0.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "peerDependenciesMeta": { "@types/node": { "optional": true }, "less": { "optional": true }, "lightningcss": { "optional": true }, "sass": { "optional": true }, "stylus": { "optional": true }, "sugarss": { "optional": true }, "terser": { "optional": true } } }, "node_modules/vitefu": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-0.2.5.tgz", "integrity": "sha512-SgHtMLoqaeeGnd2evZ849ZbACbnwQCIwRH57t18FxcXoZop0uQu0uzlIhJBlF/eWVzuce0sHeqPcDo+evVcg8Q==", "dev": true, "peer": true, "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0" }, "peerDependenciesMeta": { "vite": { "optional": true } } }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "dev": true } } } django-oauth-toolkit-3.0.1/tests/app/rp/package.json000066400000000000000000000014451466704512300224230ustar00rootroot00000000000000{ "name": "rp", "version": "0.0.1", "private": true, "scripts": { "dev": "vite dev", "build": "vite build", "preview": "vite preview", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "lint": "prettier --plugin-search-dir . --check .", "format": "prettier --plugin-search-dir . --write ." }, "devDependencies": { "@sveltejs/adapter-auto": "^3.1.1", "@sveltejs/adapter-node": "^5.0.1", "@sveltejs/kit": "^2.5.10", "prettier": "^3.3.2", "prettier-plugin-svelte": "^3.2.4", "svelte": "^4.2.19", "svelte-check": "^3.8.0", "tslib": "^2.4.1", "typescript": "^5.0.0", "vite": "^5.0.3" }, "type": "module", "dependencies": { "@dopry/svelte-oidc": "^1.1.0" } } django-oauth-toolkit-3.0.1/tests/app/rp/src/000077500000000000000000000000001466704512300207205ustar00rootroot00000000000000django-oauth-toolkit-3.0.1/tests/app/rp/src/app.d.ts000066400000000000000000000003611466704512300222720ustar00rootroot00000000000000// See https://kit.svelte.dev/docs/types#app // for information about these interfaces declare global { namespace App { // interface Error {} // interface Locals {} // interface PageData {} // interface Platform {} } } export {}; django-oauth-toolkit-3.0.1/tests/app/rp/src/app.html000066400000000000000000000005111466704512300223630ustar00rootroot00000000000000 %sveltekit.head%
%sveltekit.body%
django-oauth-toolkit-3.0.1/tests/app/rp/src/routes/000077500000000000000000000000001466704512300222415ustar00rootroot00000000000000django-oauth-toolkit-3.0.1/tests/app/rp/src/routes/+page.svelte000066400000000000000000000017421466704512300244600ustar00rootroot00000000000000 {#if browser} Login Logout RefreshToken
isLoading: {$isLoading}
isAuthenticated: {$isAuthenticated}
authToken: {$accessToken}
idToken: {$idToken}
userInfo: {JSON.stringify($userInfo, null, 2)}
authError: {$authError}
{/if} django-oauth-toolkit-3.0.1/tests/app/rp/static/000077500000000000000000000000001466704512300214205ustar00rootroot00000000000000django-oauth-toolkit-3.0.1/tests/app/rp/static/favicon.png000066400000000000000000000030431466704512300235530ustar00rootroot00000000000000PNG  IHDRi7@sRGBIDATx]m8U .%\BJp .!%b !A8ܛuƤ&gCr<;H`l6.fk6.]lg(.ۙSDNA"|؅و. ? 8N.V `qf\l f\ ҕy3P$~E3В9rf0zj ߤ ݤ ~M`*Y+5 0sK<%pq Z0";Gq^0< ɗrtŀ 漼q^a|6"$zLCl ,UwIxpq E GIx%F̅VV2:;b^3\ G@Nk6:KO}6 GK0-`@%th,M0qeMHx]WٗQ|!-$ 6!ht:nԏ?OxGC  kO#j"00+;L )/f0HA_NT\VWf+H;;b8 %vɇa y ƈ^7-f%P愲gA^tIP*vY /)/AS*{%<]P=lktO (!" =6,T@B;{` TBPު*PD(*rv1S%`* d@FQ |6h8Z"/ H}mP_Üa9+ålҵ~E~wMK?s3Wӕg# s(r kq=׸֍y2uD(Б {["@R ŗr݌P(%Kj6|Q4C5=+''GLm'r8t ء$5! `?\UPOgȽl]qKgYXCh30>;r G]͟<^ݜE.NcT!E'+cr@&^ I|'ϧsn:/_=v dw̞A}: FY\gv&5˛.x}>]jwq o6v֗IENDB`django-oauth-toolkit-3.0.1/tests/app/rp/svelte.config.js000066400000000000000000000006571466704512300232450ustar00rootroot00000000000000import adapter from '@sveltejs/adapter-node'; import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; /** @type {import('@sveltejs/kit').Config} */ const config = { // Consult https://kit.svelte.dev/docs/integrations#preprocessors // for more information about preprocessors preprocess: vitePreprocess(), kit: { // build to run in containerized node.js environment adapter: adapter() } }; export default config; django-oauth-toolkit-3.0.1/tests/app/rp/tsconfig.jsonc000066400000000000000000000010241466704512300230000ustar00rootroot00000000000000{ "extends": "./.svelte-kit/tsconfig.json", "compilerOptions": { "allowJs": true, "checkJs": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, "skipLibCheck": true, "sourceMap": true, "strict": true } // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias // // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes // from the referenced tsconfig.json - TypeScript does not merge them in } django-oauth-toolkit-3.0.1/tests/app/rp/vite.config.ts000066400000000000000000000002201466704512300227060ustar00rootroot00000000000000import { sveltekit } from '@sveltejs/kit/vite'; import { defineConfig } from 'vite'; export default defineConfig({ plugins: [sveltekit()] }); django-oauth-toolkit-3.0.1/tests/common_testing.py000066400000000000000000000022061466704512300223270ustar00rootroot00000000000000from django.conf import settings from django.test import TestCase as DjangoTestCase from django.test import TransactionTestCase as DjangoTransactionTestCase # The multiple database scenario setup for these tests purposefully defines 'default' as # an empty database in order to catch any assumptions in this package about database names # and in particular to ensure there is no assumption that 'default' is a valid database. # # When there are multiple databases defined, Django tests will not work unless they are # told which database(s) to work with. def retrieve_current_databases(): if len(settings.DATABASES) > 1: return [name for name in settings.DATABASES if name != "default"] else: return ["default"] class OAuth2ProviderBase: @classmethod def setUpClass(cls): cls.databases = retrieve_current_databases() super().setUpClass() class OAuth2ProviderTestCase(OAuth2ProviderBase, DjangoTestCase): """Place holder to allow overriding behaviors.""" class OAuth2ProviderTransactionTestCase(OAuth2ProviderBase, DjangoTransactionTestCase): """Place holder to allow overriding behaviors.""" django-oauth-toolkit-3.0.1/tests/conftest.py000066400000000000000000000232471466704512300211370ustar00rootroot00000000000000import uuid from datetime import timedelta from types import SimpleNamespace from urllib.parse import parse_qs, urlparse import pytest from django import VERSION 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 cors_application(): return Application.objects.create( name="Test CORS Application", client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, algorithm=Application.RS256_ALGORITHM, client_secret=CLEARTEXT_SECRET, allowed_origins="https://example.com http://example.com", ) @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", ) @pytest.fixture(autouse=True) def django_login_required_middleware(settings, request): if "nologinrequiredmiddleware" in request.keywords: return # Django 5.1 introduced LoginRequiredMiddleware if VERSION[0] >= 5 and VERSION[1] >= 1: settings.MIDDLEWARE = [*settings.MIDDLEWARE, "django.contrib.auth.middleware.LoginRequiredMiddleware"] django-oauth-toolkit-3.0.1/tests/db_router.py000066400000000000000000000046661466704512300213030ustar00rootroot00000000000000apps_in_beta = {"some_other_app", "this_one_too"} # These are bare minimum routers to fake the scenario where there is actually a # decision around where an application's models might live. class AlphaRouter: # alpha is where the core Django models are stored including user. To keep things # simple this is where the oauth2 provider models are stored as well because they # have a foreign key to User. def db_for_read(self, model, **hints): if model._meta.app_label not in apps_in_beta: return "alpha" return None def db_for_write(self, model, **hints): if model._meta.app_label not in apps_in_beta: return "alpha" return None def allow_relation(self, obj1, obj2, **hints): if obj1._state.db == "alpha" and obj2._state.db == "alpha": return True return None def allow_migrate(self, db, app_label, model_name=None, **hints): if app_label not in apps_in_beta: return db == "alpha" return None class BetaRouter: def db_for_read(self, model, **hints): if model._meta.app_label in apps_in_beta: return "beta" return None def db_for_write(self, model, **hints): if model._meta.app_label in apps_in_beta: return "beta" return None def allow_relation(self, obj1, obj2, **hints): if obj1._state.db == "beta" and obj2._state.db == "beta": return True return None def allow_migrate(self, db, app_label, model_name=None, **hints): if app_label in apps_in_beta: return db == "beta" class CrossDatabaseRouter: # alpha is where the core Django models are stored including user. To keep things # simple this is where the oauth2 provider models are stored as well because they # have a foreign key to User. def db_for_read(self, model, **hints): if model._meta.model_name == "accesstoken": return "beta" return None def db_for_write(self, model, **hints): if model._meta.model_name == "accesstoken": return "beta" return None def allow_relation(self, obj1, obj2, **hints): if obj1._state.db == "beta" and obj2._state.db == "beta": return True return None def allow_migrate(self, db, app_label, model_name=None, **hints): if model_name == "accesstoken": return db == "beta" return None django-oauth-toolkit-3.0.1/tests/mig_settings.py000066400000000000000000000063431466704512300220040ustar00rootroot00000000000000""" 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" # pragma: codespell-ignore # 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-3.0.1/tests/migrations/000077500000000000000000000000001466704512300211045ustar00rootroot00000000000000django-oauth-toolkit-3.0.1/tests/migrations/0001_initial.py000066400000000000000000000026501466704512300235520ustar00rootroot00000000000000# 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-3.0.1/tests/migrations/0002_swapped_models.py000066400000000000000000000342061466704512300251320ustar00rootroot00000000000000# 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.TextField(), ), migrations.AddField( model_name="sampleaccesstoken", name="token_checksum", field=models.CharField(max_length=64, unique=True, db_index=True), ), 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.py000066400000000000000000000015251466704512300353270ustar00rootroot00000000000000django-oauth-toolkit-3.0.1/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-3.0.1/tests/migrations/0004_basetestapplication_hash_client_secret_and_more.py000066400000000000000000000020531466704512300337310ustar00rootroot00000000000000# Generated by Django 4.2.5 on 2023-09-07 19:28 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', '0003_basetestapplication_post_logout_redirect_uris_and_more'), ] operations = [ migrations.AddField( model_name='basetestapplication', name='hash_client_secret', field=models.BooleanField(default=True), ), migrations.AddField( model_name='sampleapplication', name='hash_client_secret', field=models.BooleanField(default=True), ), migrations.AlterField( model_name='sampleaccesstoken', name='id_token', field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='s_access_token', to=settings.OAUTH2_PROVIDER_ID_TOKEN_MODEL), ), ] django-oauth-toolkit-3.0.1/tests/migrations/0005_basetestapplication_allowed_origins_and_more.py000066400000000000000000000015561466704512300332740ustar00rootroot00000000000000# Generated by Django 4.1.5 on 2023-09-27 22:25 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", "0004_basetestapplication_hash_client_secret_and_more"), ] operations = [ migrations.AddField( model_name="basetestapplication", name="allowed_origins", field=models.TextField(blank=True, help_text="Allowed origins list to enable CORS, space separated"), ), migrations.AddField( model_name="sampleapplication", name="allowed_origins", field=models.TextField(blank=True, help_text="Allowed origins list to enable CORS, space separated"), ), ] django-oauth-toolkit-3.0.1/tests/migrations/0006_basetestapplication_token_family.py000066400000000000000000000011021466704512300307140ustar00rootroot00000000000000# Generated by Django 5.2 on 2024-08-09 16:40 from django.db import migrations, models from oauth2_provider.settings import oauth2_settings class Migration(migrations.Migration): dependencies = [ ('tests', '0005_basetestapplication_allowed_origins_and_more'), migrations.swappable_dependency(oauth2_settings.REFRESH_TOKEN_MODEL) ] operations = [ migrations.AddField( model_name='samplerefreshtoken', name='token_family', field=models.UUIDField(blank=True, editable=False, null=True), ), ] django-oauth-toolkit-3.0.1/tests/migrations/0007_add_localidtoken.py000066400000000000000000000026531466704512300254120ustar00rootroot00000000000000# Generated by Django 3.2.25 on 2024-08-08 22:47 from django.conf import settings from django.db import migrations, models import django.db.models.deletion import uuid class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.OAUTH2_PROVIDER_APPLICATION_MODEL), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ('tests', '0006_basetestapplication_token_family'), ] operations = [ migrations.CreateModel( name='LocalIDToken', fields=[ ('id', models.BigAutoField(primary_key=True, serialize=False)), ('jti', models.UUIDField(default=uuid.uuid4, editable=False, unique=True, 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=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)), ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='tests_localidtoken', to=settings.AUTH_USER_MODEL)), ], options={ 'abstract': False, }, ), ] django-oauth-toolkit-3.0.1/tests/migrations/__init__.py000066400000000000000000000000001466704512300232030ustar00rootroot00000000000000django-oauth-toolkit-3.0.1/tests/models.py000066400000000000000000000033031466704512300205640ustar00rootroot00000000000000from django.db import models from oauth2_provider.models import ( AbstractAccessToken, AbstractApplication, AbstractGrant, AbstractIDToken, 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) class LocalIDToken(AbstractIDToken): """Exists to be improperly configured for multiple databases.""" # The other token types will be in 'alpha' database. django-oauth-toolkit-3.0.1/tests/multi_db_settings.py000066400000000000000000000010601466704512300230160ustar00rootroot00000000000000# Import the test settings and then override DATABASES. from .settings import * # noqa: F401, F403 DATABASES = { "alpha": { "ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:", }, "beta": { "ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:", }, # As https://docs.djangoproject.com/en/4.2/topics/db/multi-db/#defining-your-databases # indicates, it is ok to have no default database. "default": {}, } DATABASE_ROUTERS = ["tests.db_router.AlphaRouter", "tests.db_router.BetaRouter"] django-oauth-toolkit-3.0.1/tests/multi_db_settings_invalid_token_configuration.py000066400000000000000000000003551466704512300306610ustar00rootroot00000000000000from .multi_db_settings import * # noqa: F401, F403 OAUTH2_PROVIDER = { # The other two tokens will be in alpha. This will cause a failure when the # app's ready method is called. "ID_TOKEN_MODEL": "tests.LocalIDToken", } django-oauth-toolkit-3.0.1/tests/presets.py000066400000000000000000000046551466704512300210010ustar00rootroot00000000000000from 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", } ALLOWED_SCHEMES_DEFAULT = { "ALLOWED_SCHEMES": ["https"], } ALLOWED_SCHEMES_HTTP = { "ALLOWED_SCHEMES": ["https", "http"], } django-oauth-toolkit-3.0.1/tests/settings.py000066400000000000000000000125671466704512300211550ustar00rootroot00000000000000import 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-3.0.1/tests/settings_swapped.py000066400000000000000000000003401466704512300226620ustar00rootroot00000000000000from .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-3.0.1/tests/test_application_views.py000066400000000000000000000135471466704512300240730ustar00rootroot00000000000000import pytest from django.contrib.auth import get_user_model from django.urls import reverse from oauth2_provider.models import get_application_model from oauth2_provider.views.application import ApplicationRegistration from .common_testing import OAuth2ProviderTestCase as TestCase from .models import SampleApplication Application = get_application_model() UserModel = get_user_model() class BaseTest(TestCase): @classmethod def setUpTestData(cls): cls.foo_user = UserModel.objects.create_user("foo_user", "test@example.com", "123456") cls.bar_user = UserModel.objects.create_user("bar_user", "dev@example.com", "123456") @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", "post_logout_redirect_uris": "http://other_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") app = Application.objects.get() self.assertEqual(app.name, form_data["name"]) self.assertEqual(app.client_id, form_data["client_id"]) self.assertEqual(app.redirect_uris, form_data["redirect_uris"]) self.assertEqual(app.post_logout_redirect_uris, form_data["post_logout_redirect_uris"]) self.assertEqual(app.client_type, form_data["client_type"]) self.assertEqual(app.authorization_grant_type, form_data["authorization_grant_type"]) self.assertEqual(app.algorithm, form_data["algorithm"]) class TestApplicationViews(BaseTest): @classmethod def _create_application(cls, name, user): return Application.objects.create( name=name, redirect_uris="http://example.com", post_logout_redirect_uris="http://other_example.com", client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, user=user, ) @classmethod def setUpTestData(cls): super().setUpTestData() cls.app_foo_1 = cls._create_application("app foo_user 1", cls.foo_user) cls.app_foo_2 = cls._create_application("app foo_user 2", cls.foo_user) cls.app_foo_3 = cls._create_application("app foo_user 3", cls.foo_user) cls.app_bar_1 = cls._create_application("app bar_user 1", cls.bar_user) cls.app_bar_2 = cls._create_application("app bar_user 2", cls.bar_user) 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) self.assertContains(response, self.app_foo_1.name) self.assertContains(response, self.app_foo_1.redirect_uris) self.assertContains(response, self.app_foo_1.post_logout_redirect_uris) self.assertContains(response, self.app_foo_1.client_type) self.assertContains(response, self.app_foo_1.authorization_grant_type) 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) def test_application_update(self): self.client.login(username="foo_user", password="123456") form_data = { "client_id": "new_client_id", "redirect_uris": "http://new_example.com", "post_logout_redirect_uris": "http://new_other_example.com", "client_type": Application.CLIENT_PUBLIC, "authorization_grant_type": Application.GRANT_OPENID_HYBRID, } response = self.client.post( reverse("oauth2_provider:update", args=(self.app_foo_1.pk,)), data=form_data, ) self.assertRedirects(response, reverse("oauth2_provider:detail", args=(self.app_foo_1.pk,))) self.app_foo_1.refresh_from_db() self.assertEqual(self.app_foo_1.client_id, form_data["client_id"]) self.assertEqual(self.app_foo_1.redirect_uris, form_data["redirect_uris"]) self.assertEqual(self.app_foo_1.post_logout_redirect_uris, form_data["post_logout_redirect_uris"]) self.assertEqual(self.app_foo_1.client_type, form_data["client_type"]) self.assertEqual(self.app_foo_1.authorization_grant_type, form_data["authorization_grant_type"]) django-oauth-toolkit-3.0.1/tests/test_auth_backends.py000066400000000000000000000165671466704512300231530ustar00rootroot00000000000000from 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 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 OAuth2ExtraTokenMiddleware, OAuth2TokenMiddleware from oauth2_provider.models import get_access_token_model, get_application_model from .common_testing import OAuth2ProviderTestCase as TestCase UserModel = get_user_model() ApplicationModel = get_application_model() AccessTokenModel = get_access_token_model() class BaseTest(TestCase): """ Base class for cases in this module """ factory = RequestFactory() @classmethod def setUpTestData(cls): cls.user = UserModel.objects.create_user("user", "test@example.com", "123456") cls.app = ApplicationModel.objects.create( name="app", client_type=ApplicationModel.CLIENT_CONFIDENTIAL, authorization_grant_type=ApplicationModel.GRANT_CLIENT_CREDENTIALS, user=cls.user, ) cls.token = AccessTokenModel.objects.create( user=cls.user, token="tokstr", application=cls.app, expires=now() + timedelta(days=365) ) 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 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 = AnonymousUser() 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"]) @override_settings( AUTHENTICATION_BACKENDS=( "oauth2_provider.backends.OAuth2Backend", "django.contrib.auth.backends.ModelBackend", ), ) @modify_settings( MIDDLEWARE={ "append": "oauth2_provider.middleware.OAuth2TokenMiddleware", } ) class TestOAuth2ExtraTokenMiddleware(BaseTest): def dummy_get_response(self, request): return HttpResponse() def test_middleware_wrong_headers(self): m = OAuth2ExtraTokenMiddleware(self.dummy_get_response) request = self.factory.get("/a-resource") m(request) self.assertFalse(hasattr(request, "access_token")) 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, "access_token")) def test_middleware_token_does_not_exist(self): m = OAuth2ExtraTokenMiddleware(self.dummy_get_response) auth_headers = { "HTTP_AUTHORIZATION": "Bearer " + "badtokstr", } request = self.factory.get("/a-resource", **auth_headers) m(request) self.assertFalse(hasattr(request, "access_token")) def test_middleware_success(self): m = OAuth2ExtraTokenMiddleware(self.dummy_get_response) auth_headers = { "HTTP_AUTHORIZATION": "Bearer " + "tokstr", } request = self.factory.get("/a-resource", **auth_headers) m(request) self.assertEqual(request.access_token, self.token) def test_middleware_response(self): m = OAuth2ExtraTokenMiddleware(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) django-oauth-toolkit-3.0.1/tests/test_authorization_code.py000066400000000000000000002372071466704512300242460ustar00rootroot00000000000000import 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 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 .common_testing import OAuth2ProviderTestCase as TestCase 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): factory = RequestFactory() @classmethod def setUpTestData(cls): cls.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") cls.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") cls.application = Application.objects.create( name="Test Application", redirect_uris=( "http://localhost http://example.com http://example.org custom-scheme://example.com" ), user=cls.dev_user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, client_secret=CLEARTEXT_SECRET, ) def setUp(self): self.oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES = ["http", "custom-scheme"] self.oauth2_settings.PKCE_REQUIRED = False 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 https://rfc-editor.org/rfc/rfc6749.html#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_login(self): """ Test login page is rendered if user is not authenticated """ self.oauth2_settings.PKCE_REQUIRED = False query_data = { "client_id": self.application.client_id, "response_type": "code", "state": "random_state_string", "scope": "openid", "redirect_uri": "http://example.org", } path = reverse("oauth2_provider:authorize") response = self.client.get(path, data=query_data) # The authorization view redirects to the login page with the 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(f"client_id={self.application.client_id}", next) self.assertIn("response_type=code", next) self.assertIn("state=random_state_string", next) self.assertIn("scope=openid", next) self.assertIn("redirect_uri=http%3A%2F%2Fexample.org", next) 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) def test_prompt_none_unauthorized(self): """ Test response for redirect when supplied with prompt: none Should redirect to redirect_uri with an error of login_required """ self.oauth2_settings.PKCE_REQUIRED = False query_data = { "client_id": self.application.client_id, "response_type": "code", "state": "random_state_string", "scope": "read write", "redirect_uri": "http://example.org", "prompt": "none", } 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"]) parsed_query = parse_qs(query) self.assertIn("login_required", parsed_query["error"]) self.assertIn("random_state_string", parsed_query["state"]) 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_revokes_old_token(self): """ If a refresh token is reused, the server should invalidate *all* access tokens that have a relation to the re-used token. This forces a malicious actor to be logged out. The server can't determine whether the first or the second client was legitimate, so it needs to revoke both. See https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics-29#name-recommendations """ self.oauth2_settings.REFRESH_TOKEN_REUSE_PROTECTION = True 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"], } # First response works as usual response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) new_tokens = json.loads(response.content.decode("utf-8")) # Second request fails response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 400) # Previously returned tokens are now invalid as well new_token_request_data = { "grant_type": "refresh_token", "refresh_token": new_tokens["refresh_token"], "scope": new_tokens["scope"], } response = self.client.post( reverse("oauth2_provider:token"), data=new_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_grace_period_with_reuse_protection(self): """ Trying to refresh an access token with the same refresh token more than once succeeds. Should work within the grace period, but should revoke previous tokens """ self.oauth2_settings.REFRESH_TOKEN_GRACE_PERIOD_SECONDS = 120 self.oauth2_settings.REFRESH_TOKEN_REUSE_PROTECTION = True 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) refresh_token_1 = content["refresh_token"] token_request_data = { "grant_type": "refresh_token", "refresh_token": refresh_token_1, "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_2 = json.loads(response.content.decode("utf-8"))["refresh_token"] response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) refresh_token_3 = json.loads(response.content.decode("utf-8"))["refresh_token"] self.assertEqual(refresh_token_2, refresh_token_3) # Let the first refresh token expire rt = RefreshToken.objects.get(token=refresh_token_1) rt.revoked = timezone.now() - datetime.timedelta(minutes=10) rt.save() # Using the expired token fails response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 400) # Because we used the expired token, the recently issued token is also revoked new_token_request_data = { "grant_type": "refresh_token", "refresh_token": refresh_token_2, "scope": content["scope"], } response = self.client.post( reverse("oauth2_provider:token"), data=new_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_refresh_with_deleted_token(self): """ Ensure that using a deleted refresh token returns 400 """ self.client.login(username="test_user", password="123456") authorization_code = self.get_auth() token_request_data = { "grant_type": "authorization_code", "scope": "read write", "code": authorization_code, "redirect_uri": "http://example.org", } auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) # get a refresh token 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"] token_request_data = { "grant_type": "refresh_token", "refresh_token": rt, "scope": "read write", } # delete the access token AccessToken.objects.filter(token=content["access_token"]).delete() 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_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): @classmethod def setUpTestData(cls): super().setUpTestData() cls.application.algorithm = Application.RS256_ALGORITHM cls.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): @classmethod def setUpTestData(cls): super().setUpTestData() cls.application.algorithm = Application.HS256_ALGORITHM cls.application.save() def setUp(self): super().setUp() self.oauth2_settings.OIDC_RSA_PRIVATE_KEY = None 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): @classmethod def setUpTestData(cls): super().setUpTestData() cls.application.algorithm = Application.RS256_ALGORITHM cls.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-3.0.1/tests/test_client_credential.py000066400000000000000000000162161466704512300240170ustar00rootroot00000000000000import 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 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 .common_testing import OAuth2ProviderTestCase as TestCase 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): factory = RequestFactory() @classmethod def setUpTestData(cls): cls.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") cls.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") cls.application = Application.objects.create( name="test_client_credentials_app", user=cls.dev_user, client_type=Application.CLIENT_PUBLIC, authorization_grant_type=Application.GRANT_CLIENT_CREDENTIALS, client_secret=CLEARTEXT_SECRET, ) 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 ExampleView(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 = ExampleView() 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): ExampleView().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): ExampleView().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-3.0.1/tests/test_commands.py000066400000000000000000000115201466704512300221410ustar00rootroot00000000000000from 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 oauth2_provider.models import get_application_model from . import presets from .common_testing import OAuth2ProviderTestCase as TestCase 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): import django output = StringIO() call_command( "createapplication", "confidential", "authorization-code", "--redirect-uris=http://example.com http://example2.com", "--user=783", stdout=output, ) output_str = output.getvalue() self.assertIn("user", output_str) self.assertIn("783", output_str) if django.VERSION < (5, 2): self.assertIn("does not exist", output_str) else: self.assertIn("is not a valid choice", output_str) django-oauth-toolkit-3.0.1/tests/test_decorators.py000066400000000000000000000062651466704512300225170ustar00rootroot00000000000000from datetime import timedelta from django.contrib.auth import get_user_model from django.test import RequestFactory 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 from .common_testing import OAuth2ProviderTestCase as TestCase Application = get_application_model() AccessToken = get_access_token_model() UserModel = get_user_model() class TestProtectedResourceDecorator(TestCase): request_factory = RequestFactory() @classmethod def setUpTestData(cls): cls.user = UserModel.objects.create_user("test_user", "test@example.com", "123456") cls.application = Application.objects.create( name="test_client_credentials_app", user=cls.user, client_type=Application.CLIENT_PUBLIC, authorization_grant_type=Application.GRANT_CLIENT_CREDENTIALS, ) cls.access_token = AccessToken.objects.create( user=cls.user, scope="read write", expires=timezone.now() + timedelta(seconds=300), token="secret-access-token-key", application=cls.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-3.0.1/tests/test_django_checks.py000066400000000000000000000014751466704512300231320ustar00rootroot00000000000000from django.core.management import call_command from django.core.management.base import SystemCheckError from django.test import override_settings from .common_testing import OAuth2ProviderTestCase as TestCase class DjangoChecksTestCase(TestCase): def test_checks_pass(self): call_command("check") # CrossDatabaseRouter claims AccessToken is in beta while everything else is in alpha. # This will cause the database checks to fail. @override_settings( DATABASE_ROUTERS=["tests.db_router.CrossDatabaseRouter", "tests.db_router.AlphaRouter"] ) def test_checks_fail_when_router_crosses_databases(self): message = "The token models are expected to be stored in the same database." with self.assertRaisesMessage(SystemCheckError, message): call_command("check") django-oauth-toolkit-3.0.1/tests/test_generator.py000066400000000000000000000017751466704512300223410ustar00rootroot00000000000000import pytest from oauth2_provider.generators import BaseHashGenerator, generate_client_id, generate_client_secret from .common_testing import OAuth2ProviderTestCase as TestCase 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-3.0.1/tests/test_hybrid.py000066400000000000000000001611271466704512300216320ustar00rootroot00000000000000import 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 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 .common_testing import OAuth2ProviderTestCase as TestCase from .common_testing import retrieve_current_databases 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): factory = RequestFactory() @classmethod def setUpTestData(cls): cls.hy_test_user = UserModel.objects.create_user("hy_test_user", "test_hy@example.com", "123456") cls.hy_dev_user = UserModel.objects.create_user("hy_dev_user", "dev_hy@example.com", "123456") cls.application = Application( name="Hybrid Test Application", redirect_uris=( "http://localhost http://example.com http://example.org custom-scheme://example.com" ), user=cls.hy_dev_user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_OPENID_HYBRID, algorithm=Application.RS256_ALGORITHM, client_secret=CLEARTEXT_SECRET, ) cls.application.save() def setUp(self): self.oauth2_settings.PKCE_REQUIRED = False self.oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES = ["http", "custom-scheme"] @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 https://rfc-editor.org/rfc/rfc6749.html#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 https://rfc-editor.org/rfc/rfc6749.html#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 https://rfc-editor.org/rfc/rfc6749.html#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(databases=retrieve_current_databases()) @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(databases=retrieve_current_databases()) @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-3.0.1/tests/test_implicit.py000066400000000000000000000437141466704512300221640ustar00rootroot00000000000000import json from urllib.parse import parse_qs, urlparse import pytest from django.contrib.auth import get_user_model from django.test import RequestFactory 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 from .common_testing import OAuth2ProviderTestCase as TestCase 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): factory = RequestFactory() @classmethod def setUpTestData(cls): cls.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") cls.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") cls.application = Application.objects.create( name="Test Implicit Application", redirect_uris="http://localhost http://example.com http://example.org", user=cls.dev_user, client_type=Application.CLIENT_PUBLIC, authorization_grant_type=Application.GRANT_IMPLICIT, ) @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 https://rfc-editor.org/rfc/rfc6749.html#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): @classmethod def setUpTestData(cls): super().setUpTestData() cls.application.algorithm = Application.RS256_ALGORITHM cls.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+parameter", 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-3.0.1/tests/test_introspection_auth.py000066400000000000000000000257541466704512300242770ustar00rootroot00000000000000import 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 override_settings from django.urls import path from django.utils import timezone from oauthlib.common import Request from oauth2_provider.compat import login_not_required 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 from .common_testing import OAuth2ProviderTestCase as TestCase try: from unittest import mock except ImportError: import mock Application = get_application_model() AccessToken = get_access_token_model() UserModel = get_user_model() default_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) 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 def mocked_requests_post(url, data, *args, **kwargs): """ Mock the response from the authentication server """ 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(default_exp.timetuple())), }, 200, ) return MockResponse( { "active": False, }, 200, ) def mocked_introspect_request_short_living_token(url, data, *args, **kwargs): exp = datetime.datetime.now() + datetime.timedelta(minutes=30) 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, ) urlpatterns = [ path("oauth2/", include("oauth2_provider.urls")), path("oauth2-test-resource/", login_not_required(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 """ @classmethod def setUpTestData(cls): cls.validator = OAuth2Validator() cls.request = mock.MagicMock(wraps=Request) cls.resource_server_user = UserModel.objects.create_user( "resource_server", "test@example.com", "123456" ) cls.application = Application.objects.create( name="Test Application", redirect_uris="http://localhost http://example.com http://example.org", user=cls.resource_server_user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, ) cls.resource_server_token = AccessToken.objects.create( user=cls.resource_server_user, token="12345678900", application=cls.application, expires=timezone.now() + datetime.timedelta(days=1), scope="introspection", ) cls.invalid_token = AccessToken.objects.create( user=cls.resource_server_user, token="12345678901", application=cls.application, expires=timezone.now() + datetime.timedelta(days=-1), scope="read write dolphin", ) def setUp(self): self.oauth2_settings.RESOURCE_SERVER_AUTH_TOKEN = self.resource_server_token.token @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_introspect_request_short_living_token) def test_get_token_from_authentication_server_expires_no_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: access_token = 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, ) self.assertFalse(access_token.is_expired()) except ValueError as exception: self.fail(str(exception)) finally: settings.USE_TZ = settings_use_tz_backup @mock.patch("requests.post", side_effect=mocked_introspect_request_short_living_token) def test_get_token_from_authentication_server_expires_utc_timezone(self, mock_get): """ Test method _get_token_from_authentication_server for projects with USE_TZ True and a UTC Timezone """ settings_use_tz_backup = settings.USE_TZ settings_time_zone_backup = settings.TIME_ZONE settings.USE_TZ = True settings.TIME_ZONE = "UTC" try: access_token = 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, ) self.assertFalse(access_token.is_expired()) except ValueError as exception: self.fail(str(exception)) finally: settings.USE_TZ = settings_use_tz_backup settings.TIME_ZONE = settings_time_zone_backup @mock.patch("requests.post", side_effect=mocked_introspect_request_short_living_token) def test_get_token_from_authentication_server_expires_non_utc_timezone(self, mock_get): """ Test method _get_token_from_authentication_server for projects with USE_TZ True and a non UTC Timezone This test is important to check if the UTC Exp. date gets converted correctly """ settings_use_tz_backup = settings.USE_TZ settings_time_zone_backup = settings.TIME_ZONE settings.USE_TZ = True settings.TIME_ZONE = "Europe/Amsterdam" try: access_token = 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, ) self.assertFalse(access_token.is_expired()) except ValueError as exception: self.fail(str(exception)) finally: settings.USE_TZ = settings_use_tz_backup settings.TIME_ZONE = settings_time_zone_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-3.0.1/tests/test_introspection_view.py000066400000000000000000000305461466704512300243030ustar00rootroot00000000000000import calendar import datetime import pytest from django.contrib.auth import get_user_model from django.db import router 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 .common_testing import OAuth2ProviderTestCase as TestCase 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 """ @classmethod def setUpTestData(cls): cls.resource_server_user = UserModel.objects.create_user("resource_server", "test@example.com") cls.test_user = UserModel.objects.create_user("bar_user", "dev@example.com") cls.application = Application.objects.create( name="Test Application", redirect_uris="http://localhost http://example.com http://example.org", user=cls.test_user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, client_secret=CLEARTEXT_SECRET, ) cls.resource_server_token = AccessToken.objects.create( user=cls.resource_server_user, token="12345678900", application=cls.application, expires=timezone.now() + datetime.timedelta(days=1), scope="introspection", ) cls.valid_token = AccessToken.objects.create( user=cls.test_user, token="12345678901", application=cls.application, expires=timezone.now() + datetime.timedelta(days=1), scope="read write dolphin", ) cls.invalid_token = AccessToken.objects.create( user=cls.test_user, token="12345678902", application=cls.application, expires=timezone.now() + datetime.timedelta(days=-1), scope="read write dolphin", ) cls.token_without_user = AccessToken.objects.create( user=None, token="12345678903", application=cls.application, expires=timezone.now() + datetime.timedelta(days=1), scope="read write dolphin", ) cls.token_without_app = AccessToken.objects.create( user=cls.test_user, token="12345678904", application=None, expires=timezone.now() + datetime.timedelta(days=1), scope="read write dolphin", ) 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): token_database = router.db_for_write(AccessToken) with self.assertNumQueries(1, using=token_database): self.client.post(reverse("oauth2_provider:introspect")) django-oauth-toolkit-3.0.1/tests/test_mixins.py000066400000000000000000000154551466704512300216620ustar00rootroot00000000000000import logging import pytest from django.core.exceptions import ImproperlyConfigured from django.http import HttpResponse from django.test import RequestFactory 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 from .common_testing import OAuth2ProviderTestCase as TestCase @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-3.0.1/tests/test_models.py000066400000000000000000000605661466704512300216410ustar00rootroot00000000000000import hashlib import secrets from datetime import timedelta import pytest from django.contrib.auth import get_user_model from django.contrib.auth.hashers import check_password from django.core.exceptions import ImproperlyConfigured, ValidationError 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 from .common_testing import OAuth2ProviderTestCase as TestCase from .common_testing import retrieve_current_databases CLEARTEXT_SECRET = "1234567890abcdefghijklmnopqrstuvwxyz" 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): @classmethod def setUpTestData(cls): cls.user = UserModel.objects.create_user("test_user", "test@example.com", "123456") 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_hashed_secret(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, client_secret=CLEARTEXT_SECRET, hash_client_secret=True, ) self.assertNotEqual(app.client_secret, CLEARTEXT_SECRET) self.assertTrue(check_password(CLEARTEXT_SECRET, app.client_secret)) def test_unhashed_secret(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, client_secret=CLEARTEXT_SECRET, hash_client_secret=False, ) self.assertEqual(app.client_secret, CLEARTEXT_SECRET) 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): @classmethod def setUpTestData(cls): super().setUpTestData() cls.application = Application.objects.create( name="Test Application", redirect_uris="", user=cls.user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, ) 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()) def test_token_checksum_field(self): token = secrets.token_urlsafe(32) access_token = AccessToken.objects.create( user=self.user, token=token, expires=timezone.now() + timedelta(hours=1), ) expected_checksum = hashlib.sha256(token.encode()).hexdigest() self.assertEqual(access_token.token_checksum, expected_checksum) 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): @classmethod def setUpTestData(cls): super().setUpTestData() # Insert many tokens, both expired and not, and grants. cls.num_tokens = 100 cls.delta_secs = 1000 cls.now = timezone.now() cls.earlier = cls.now - timedelta(seconds=cls.delta_secs) cls.later = cls.now + timedelta(seconds=cls.delta_secs) app = Application.objects.create( name="test_app", redirect_uris="http://localhost http://example.com http://example.org", user=cls.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=cls.earlier) for i in range(cls.num_tokens) ] for a in expired_access_tokens: a.save() current_access_tokens = [ AccessToken(token=f"current AccessToken {i}", expires=cls.later) for i in range(cls.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=cls.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=cls.user, ).save() # Make some grants, half of which are expired. for i in range(cls.num_tokens): Grant( user=cls.user, code=f"old grant code {i}", application=app, expires=cls.earlier, redirect_uri="https://localhost/redirect", ).save() for i in range(cls.num_tokens): Grant( user=cls.user, code=f"new grant code {i}", application=app, expires=cls.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(databases=retrieve_current_databases()) @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(databases=retrieve_current_databases()) @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(databases=retrieve_current_databases()) @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(databases=retrieve_current_databases()) @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) application.authorization_grant_type = Application.GRANT_AUTHORIZATION_CODE # allowed_origins can be only https:// application.allowed_origins = "http://example.com" with pytest.raises(ValidationError) as exc: application.clean() assert "allowed origin URI Validation error. invalid_scheme: http://example.com" in str(exc.value) application.allowed_origins = "https://example.com" application.clean() @pytest.mark.django_db(databases=retrieve_current_databases()) @pytest.mark.oauth2_settings(presets.ALLOWED_SCHEMES_DEFAULT) def test_application_origin_allowed_default_https(oauth2_settings, cors_application): """Test that http schemes are not allowed because ALLOWED_SCHEMES allows only https""" assert cors_application.origin_allowed("https://example.com") assert not cors_application.origin_allowed("http://example.com") @pytest.mark.django_db(databases=retrieve_current_databases()) @pytest.mark.oauth2_settings(presets.ALLOWED_SCHEMES_HTTP) def test_application_origin_allowed_http(oauth2_settings, cors_application): """Test that http schemes are allowed because http was added to ALLOWED_SCHEMES""" assert cors_application.origin_allowed("https://example.com") assert cors_application.origin_allowed("http://example.com") django-oauth-toolkit-3.0.1/tests/test_oauth2_backends.py000066400000000000000000000202231466704512300233740ustar00rootroot00000000000000import base64 import json import pytest from django.contrib.auth import get_user_model from django.test import RequestFactory 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 from tests.common_testing import OAuth2ProviderTestCase as TestCase try: from unittest import mock except ImportError: import mock @pytest.mark.usefixtures("oauth2_settings") class TestOAuthLibCoreBackend(TestCase): factory = RequestFactory() @classmethod def setUpTestData(cls): cls.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): factory = RequestFactory() @classmethod def setUpTestData(cls): cls.oauthlib_core = OAuthLibCore() cls.user = UserModel.objects.create_user("john", "test@example.com", "123456") cls.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=cls.user, ) 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 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): factory = RequestFactory() 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") oauthlib_core = JSONOAuthLibCore() uri, http_method, body, headers = oauthlib_core._extract_params(request) self.assertIn("grant_type=password", body) self.assertIn("username=john", body) self.assertIn("password=123456", body) class TestOAuthLibCore(TestCase): 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-3.0.1/tests/test_oauth2_validators.py000066400000000000000000000577041466704512300240100ustar00rootroot00000000000000import contextlib import datetime import json import pytest from django.contrib.auth import get_user_model from django.contrib.auth.hashers import make_password from django.utils import timezone from jwcrypto import jwt from oauthlib.common import Request from oauthlib.oauth2.rfc6749 import errors as rfc6749_errors from oauth2_provider.exceptions import FatalClientError from oauth2_provider.models import ( get_access_token_model, get_application_model, get_grant_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 .common_testing import OAuth2ProviderTestCase as TestCase from .common_testing import OAuth2ProviderTransactionTestCase as TransactionTestCase from .common_testing import retrieve_current_databases 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() Grant = get_grant_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_authenticate_request_body_unhashed_secret(self): self.application.client_secret = CLEARTEXT_SECRET self.application.hash_client_secret = False self.application.save() self.request.client_id = "client_id" self.request.client_secret = CLEARTEXT_SECRET self.assertTrue(self.validator._authenticate_request_body(self.request)) self.application.hash_client_secret = True self.application.save() 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_hashed_secret(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_unhashed_secret(self): self.application.client_secret = CLEARTEXT_SECRET self.application.hash_client_secret = False self.application.save() 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_check_secret(self): hashed = make_password(CLEARTEXT_SECRET) self.assertTrue(self.validator._check_secret(CLEARTEXT_SECRET, CLEARTEXT_SECRET)) self.assertTrue(self.validator._check_secret(CLEARTEXT_SECRET, hashed)) self.assertFalse(self.validator._check_secret(hashed, hashed)) self.assertFalse(self.validator._check_secret(hashed, CLEARTEXT_SECRET)) 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 def test_get_or_create_user_from_content(self): content = {"username": "test_user"} UserModel.objects.filter(username=content["username"]).delete() user = self.validator.get_or_create_user_from_content(content) self.assertIsNotNone(user) self.assertEqual(content["username"], user.username) class TestOAuth2ValidatorProvidesErrorData(TransactionTestCase): """These test cases check that the recommended error codes are returned when token authentication fails. RFC-6750: https://rfc-editor.org/rfc/rfc6750.html > 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://rfc-editor.org/rfc/rfc6750.html#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. """ @classmethod def setUpTestData(cls): cls.token = "test_token" cls.introspection_url = "http://example.com/token/introspection/" cls.introspection_token = "test_introspection_token" cls.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(databases=retrieve_current_databases()) @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(databases=retrieve_current_databases()) @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(databases=retrieve_current_databases()) @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 @pytest.mark.django_db def test_invalidate_authorization_token_returns_invalid_grant_error_when_grant_does_not_exist(): client_id = "123" code = "12345" request = Request("/") assert Grant.objects.all().count() == 0 with pytest.raises(rfc6749_errors.InvalidGrantError): validator = OAuth2Validator() validator.invalidate_authorization_code(client_id=client_id, code=code, request=request) django-oauth-toolkit-3.0.1/tests/test_oidc_views.py000066400000000000000000000734201466704512300225020ustar00rootroot00000000000000import pytest from django.contrib.auth import get_user from django.contrib.auth.models import AnonymousUser from django.test import RequestFactory from django.urls import reverse from django.utils import timezone from pytest_django.asserts import assertRedirects from oauth2_provider.exceptions import ( ClientIdMissmatch, InvalidIDTokenError, 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 RPInitiatedLogoutView, _load_id_token, _validate_claims from . import presets from .common_testing import OAuth2ProviderTestCase as TestCase from .common_testing import retrieve_current_databases @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"], "code_challenge_methods_supported": ["plain", "S256"], "claims_supported": ["sub"], } response = self.client.get("/o/.well-known/openid-configuration") self.assertEqual(response.status_code, 200) assert response.json() == expected_response def test_get_connect_discovery_info_deprecated(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"], "code_challenge_methods_supported": ["plain", "S256"], "claims_supported": ["sub"], } response = self.client.get("/o/.well-known/openid-configuration/") self.assertEqual(response.status_code, 200) assert response.json() == expected_response def expect_json_response_with_rp_logout(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"], "code_challenge_methods_supported": ["plain", "S256"], "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_logout(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"], "code_challenge_methods_supported": ["plain", "S256"], "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_logout("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(databases=retrieve_current_databases()) def test_validate_logout_request(oidc_tokens, public_application, rp_settings): oidc_tokens = oidc_tokens application = oidc_tokens.application client_id = application.client_id id_token = oidc_tokens.id_token view = RPInitiatedLogoutView() view.request = mock_request_for(oidc_tokens.user) assert view.validate_logout_request( id_token_hint=None, client_id=None, post_logout_redirect_uri=None, ) == (None, None) assert view.validate_logout_request( id_token_hint=None, client_id=client_id, post_logout_redirect_uri=None, ) == (application, None) assert view.validate_logout_request( id_token_hint=None, client_id=client_id, post_logout_redirect_uri="http://example.org", ) == (application, None) assert view.validate_logout_request( id_token_hint=id_token, client_id=None, post_logout_redirect_uri="http://example.org", ) == (application, oidc_tokens.user) assert view.validate_logout_request( id_token_hint=id_token, client_id=client_id, post_logout_redirect_uri="http://example.org", ) == (application, oidc_tokens.user) with pytest.raises(InvalidIDTokenError): view.validate_logout_request( id_token_hint="111", client_id=public_application.client_id, post_logout_redirect_uri="http://other.org", ) with pytest.raises(ClientIdMissmatch): view.validate_logout_request( id_token_hint=id_token, client_id=public_application.client_id, post_logout_redirect_uri="http://other.org", ) with pytest.raises(InvalidOIDCClientError): view.validate_logout_request( id_token_hint=None, client_id=None, post_logout_redirect_uri="http://example.org", ) with pytest.raises(InvalidOIDCRedirectURIError): view.validate_logout_request( id_token_hint=None, client_id=client_id, post_logout_redirect_uri="example.org", ) with pytest.raises(InvalidOIDCRedirectURIError): view.validate_logout_request( id_token_hint=None, client_id=client_id, post_logout_redirect_uri="imap://example.org", ) with pytest.raises(InvalidOIDCRedirectURIError): view.validate_logout_request( id_token_hint=None, client_id=client_id, post_logout_redirect_uri="http://other.org", ) with pytest.raises(InvalidOIDCRedirectURIError): rp_settings.OIDC_RP_INITIATED_LOGOUT_STRICT_REDIRECT_URIS = True view.validate_logout_request( id_token_hint=None, client_id=public_application.client_id, post_logout_redirect_uri="http://other.org", ) @pytest.mark.django_db(databases=retrieve_current_databases()) @pytest.mark.parametrize("ALWAYS_PROMPT", [True, False]) def test_must_prompt(oidc_tokens, other_user, rp_settings, ALWAYS_PROMPT): rp_settings.OIDC_RP_INITIATED_LOGOUT_ALWAYS_PROMPT = ALWAYS_PROMPT oidc_tokens = oidc_tokens assert RPInitiatedLogoutView(request=mock_request_for(oidc_tokens.user)).must_prompt(None) is True assert ( RPInitiatedLogoutView(request=mock_request_for(oidc_tokens.user)).must_prompt(oidc_tokens.user) == ALWAYS_PROMPT ) assert RPInitiatedLogoutView(request=mock_request_for(other_user)).must_prompt(oidc_tokens.user) is True 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(databases=retrieve_current_databases()) 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(databases=retrieve_current_databases()) 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(databases=retrieve_current_databases()) 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(databases=retrieve_current_databases()) 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(databases=retrieve_current_databases()) 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(databases=retrieve_current_databases()) 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(databases=retrieve_current_databases()) 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(databases=retrieve_current_databases()) 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(databases=retrieve_current_databases()) def test_rp_initiated_logout_get_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(databases=retrieve_current_databases()) 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(databases=retrieve_current_databases()) 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(databases=retrieve_current_databases()) def test_rp_initiated_logout_post_no_session(client, oidc_tokens, rp_settings): form_data = {"client_id": oidc_tokens.application.client_id, "allow": True} rsp = 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(client) @pytest.mark.django_db(databases=retrieve_current_databases()) @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(databases=retrieve_current_databases()) @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(databases=retrieve_current_databases()) @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(databases=retrieve_current_databases()) @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(databases=retrieve_current_databases()) @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(databases=retrieve_current_databases()) @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(databases=retrieve_current_databases()) @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(databases=retrieve_current_databases()) @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(databases=retrieve_current_databases()) 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(databases=retrieve_current_databases()) 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(databases=retrieve_current_databases()) 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(databases=retrieve_current_databases()) @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(databases=retrieve_current_databases()) 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(databases=retrieve_current_databases()) 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(databases=retrieve_current_databases()) 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(databases=retrieve_current_databases()) 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-3.0.1/tests/test_password.py000066400000000000000000000071101466704512300222020ustar00rootroot00000000000000import json import pytest from django.contrib.auth import get_user_model from django.test import RequestFactory from django.urls import reverse from oauth2_provider.models import get_application_model from oauth2_provider.views import ProtectedResourceView from .common_testing import OAuth2ProviderTestCase as TestCase 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): factory = RequestFactory() @classmethod def setUpTestData(cls): cls.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") cls.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") cls.application = Application.objects.create( name="Test Password Application", user=cls.dev_user, client_type=Application.CLIENT_PUBLIC, authorization_grant_type=Application.GRANT_PASSWORD, client_secret=CLEARTEXT_SECRET, ) 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-3.0.1/tests/test_rest_framework.py000066400000000000000000000415761466704512300234100ustar00rootroot00000000000000from 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.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 from .common_testing import OAuth2ProviderTestCase as TestCase 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.nologinrequiredmiddleware @pytest.mark.usefixtures("oauth2_settings") @pytest.mark.oauth2_settings(presets.REST_FRAMEWORK_SCOPES) class TestOAuth2Authentication(TestCase): @classmethod def setUpTestData(cls): cls.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") cls.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") cls.application = Application.objects.create( name="Test Application", redirect_uris="http://localhost http://example.com http://example.org", user=cls.dev_user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, ) cls.access_token = AccessToken.objects.create( user=cls.test_user, scope="read write", expires=timezone.now() + timedelta(seconds=300), token="secret-access-token-key", application=cls.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) def test_invalid_hex_string_in_query(self): auth = self._create_authorization_header(self.access_token.token) response = self.client.get("/oauth2-test/?q=73%%20of%20Arkansans", HTTP_AUTHORIZATION=auth) # Should respond with a 400 rather than raise a ValueError self.assertEqual(response.status_code, 400) django-oauth-toolkit-3.0.1/tests/test_scopes.py000066400000000000000000000372531466704512300216470ustar00rootroot00000000000000import 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 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 .common_testing import OAuth2ProviderTestCase as TestCase 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): factory = RequestFactory() @classmethod def setUpTestData(cls): cls.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") cls.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") cls.application = Application.objects.create( name="Test Application", redirect_uris="http://localhost http://example.com http://example.org", user=cls.dev_user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, client_secret=CLEARTEXT_SECRET, ) 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-3.0.1/tests/test_scopes_backend.py000066400000000000000000000005261466704512300233070ustar00rootroot00000000000000from 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-3.0.1/tests/test_settings.py000066400000000000000000000152541466704512300222100ustar00rootroot00000000000000import pytest from django.core.exceptions import ImproperlyConfigured 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 tests.common_testing import OAuth2ProviderTestCase as TestCase 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-3.0.1/tests/test_token_endpoint_cors.py000066400000000000000000000140651466704512300244150ustar00rootroot00000000000000import json from urllib.parse import parse_qs, urlparse import pytest from django.contrib.auth import get_user_model from django.test import RequestFactory from django.urls import reverse from oauth2_provider.models import get_application_model from . import presets from .common_testing import OAuth2ProviderTestCase as TestCase from .utils import get_basic_auth_header Application = get_application_model() UserModel = get_user_model() CLEARTEXT_SECRET = "1234567890abcdefghijklmnopqrstuvwxyz" # CORS is allowed for https only CLIENT_URI = "https://example.org" CLIENT_URI_HTTP = "http://example.org" @pytest.mark.usefixtures("oauth2_settings") @pytest.mark.oauth2_settings(presets.DEFAULT_SCOPES_RW) class TestTokenEndpointCors(TestCase): """ Test that CORS headers can be managed by OAuthLib. The objective is: http request 'Origin' header should be passed to OAuthLib """ factory = RequestFactory() @classmethod def setUpTestData(cls): cls.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") cls.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") cls.application = Application.objects.create( name="Test Application", redirect_uris=CLIENT_URI, user=cls.dev_user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, client_secret=CLEARTEXT_SECRET, allowed_origins=CLIENT_URI, ) def setUp(self): self.oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES = ["https"] self.oauth2_settings.PKCE_REQUIRED = False def test_valid_origin_with_https(self): """ Test that /token endpoint has Access-Control-Allow-Origin """ authorization_code = self._get_authorization_code() # exchange authorization code for a valid access token token_request_data = { "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": CLIENT_URI, } auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) auth_headers["HTTP_ORIGIN"] = CLIENT_URI response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) self.assertEqual(response.status_code, 200) self.assertEqual(response["Access-Control-Allow-Origin"], CLIENT_URI) 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) self.assertEqual(response["Access-Control-Allow-Origin"], CLIENT_URI) def test_valid_origin_no_https(self): """ Test that CORS is not allowed if origin uri does not have https:// schema """ authorization_code = self._get_authorization_code() # exchange authorization code for a valid access token token_request_data = { "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": CLIENT_URI, } auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) auth_headers["HTTP_ORIGIN"] = CLIENT_URI_HTTP response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) self.assertFalse(response.has_header("Access-Control-Allow-Origin")) def test_origin_not_from_allowed_origins(self): """ Test that /token endpoint does not have Access-Control-Allow-Origin when request origin is not in Application.allowed_origins """ authorization_code = self._get_authorization_code() # exchange authorization code for a valid access token token_request_data = { "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": CLIENT_URI, } auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) auth_headers["HTTP_ORIGIN"] = "https://another_example.org" response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) self.assertFalse(response.has_header("Access-Control-Allow-Origin")) def test_no_origin(self): """ Test that /token endpoint does not have Access-Control-Allow-Origin """ authorization_code = self._get_authorization_code() # exchange authorization code for a valid access token token_request_data = { "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": CLIENT_URI, } 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) # No CORS headers, because request did not have Origin self.assertFalse(response.has_header("Access-Control-Allow-Origin")) def _get_authorization_code(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": "https://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() django-oauth-toolkit-3.0.1/tests/test_token_revocation.py000066400000000000000000000166421466704512300237230ustar00rootroot00000000000000import datetime from django.contrib.auth import get_user_model from django.test import RequestFactory 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 from .common_testing import OAuth2ProviderTestCase as TestCase Application = get_application_model() AccessToken = get_access_token_model() RefreshToken = get_refresh_token_model() UserModel = get_user_model() CLEARTEXT_SECRET = "1234567890abcdefghijklmnopqrstuvwxyz" class BaseTest(TestCase): factory = RequestFactory() @classmethod def setUpTestData(cls): cls.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") cls.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") cls.application = Application.objects.create( name="Test Application", redirect_uris="http://localhost http://example.com http://example.org", user=cls.dev_user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, client_secret=CLEARTEXT_SECRET, ) 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(pk=tok.pk).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(pk=tok.pk).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(pk=tok.pk).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(pk=rtok.pk).first() self.assertIsNotNone(refresh_token.revoked) self.assertFalse(AccessToken.objects.filter(pk=rtok.access_token.pk).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(pk=tok.pk).exists()) refresh_token = RefreshToken.objects.filter(pk=rtok.pk).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(pk=tok.pk).exists()) django-oauth-toolkit-3.0.1/tests/test_token_view.py000066400000000000000000000175211466704512300225210ustar00rootroot00000000000000import datetime from django.contrib.auth import get_user_model from django.urls import reverse from django.utils import timezone from oauth2_provider.models import get_access_token_model, get_application_model from .common_testing import OAuth2ProviderTestCase as TestCase Application = get_application_model() AccessToken = get_access_token_model() UserModel = get_user_model() class TestAuthorizedTokenViews(TestCase): """ TestCase superclass for Authorized Token Views" Test Cases """ @classmethod def setUpTestData(cls): cls.foo_user = UserModel.objects.create_user("foo_user", "test@example.com", "123456") cls.bar_user = UserModel.objects.create_user("bar_user", "dev@example.com", "123456") cls.application = Application.objects.create( name="Test Application", redirect_uris="http://localhost http://example.com http://example.org", user=cls.bar_user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, ) 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-3.0.1/tests/test_ui_locales.py000066400000000000000000000037531466704512300224700ustar00rootroot00000000000000from django.contrib.auth import get_user_model from django.test import TestCase, override_settings from django.urls import reverse from oauth2_provider.models import get_application_model UserModel = get_user_model() Application = get_application_model() @override_settings( OAUTH2_PROVIDER={ "OIDC_ENABLED": True, "PKCE_REQUIRED": False, "SCOPES": { "openid": "OpenID connect", }, } ) class TestUILocalesParam(TestCase): @classmethod def setUpTestData(cls): cls.application = Application.objects.create( name="Test Application", client_id="test", redirect_uris="https://www.example.com/", client_type=Application.CLIENT_PUBLIC, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, ) cls.trusted_application = Application.objects.create( name="Trusted Application", client_id="trusted", redirect_uris="https://www.example.com/", client_type=Application.CLIENT_PUBLIC, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, skip_authorization=True, ) cls.user = UserModel.objects.create_user("test_user") cls.url = reverse("oauth2_provider:authorize") def setUp(self): self.client.force_login(self.user) def test_application_ui_locales_param(self): response = self.client.get( f"{self.url}?response_type=code&client_id=test&scope=openid&ui_locales=de", ) self.assertEqual(response.status_code, 200) self.assertTemplateUsed(response, "oauth2_provider/authorize.html") def test_trusted_application_ui_locales_param(self): response = self.client.get( f"{self.url}?response_type=code&client_id=trusted&scope=openid&ui_locales=de", ) self.assertEqual(response.status_code, 302) self.assertRegex(response.url, r"https://www\.example\.com/\?code=[a-zA-Z0-9]+") django-oauth-toolkit-3.0.1/tests/test_utils.py000066400000000000000000000015421466704512300215030ustar00rootroot00000000000000from oauth2_provider import utils def test_jwk_from_pem_caches_jwk(): a_tiny_rsa_key = """-----BEGIN RSA PRIVATE KEY----- MGQCAQACEQCxqYaL6GtPooVMhVwcZrCfAgMBAAECECyNmdsuHvMqIEl9/Fex27kC CQDlc0deuSVrtQIJAMY4MTw2eCeDAgkA5VzfMykQ5yECCQCgkF4Zl0nHPwIJALPv +IAFUPv3 -----END RSA PRIVATE KEY-----""" # For the same private key we expect the same object to be returned jwk1 = utils.jwk_from_pem(a_tiny_rsa_key) jwk2 = utils.jwk_from_pem(a_tiny_rsa_key) assert jwk1 is jwk2 a_different_tiny_rsa_key = """-----BEGIN RSA PRIVATE KEY----- MGMCAQACEQCvyNNNw4J201yzFVogcfgnAgMBAAECEE3oXe5bNlle+xU4EVHTUIEC CQDpSvwIvDMSIQIJAMDk47DzG9FHAghtvg1TWpy3oQIJAL6NHlS+RBufAgkA6QLA 2GK4aDc= -----END RSA PRIVATE KEY-----""" # But for a different key, a different object jwk3 = utils.jwk_from_pem(a_different_tiny_rsa_key) assert jwk3 is not jwk1 django-oauth-toolkit-3.0.1/tests/test_validators.py000066400000000000000000000147661466704512300225270ustar00rootroot00000000000000import pytest from django.core.validators import ValidationError from oauth2_provider.validators import AllowedURIValidator from .common_testing import OAuth2ProviderTestCase as TestCase @pytest.mark.usefixtures("oauth2_settings") class TestAllowedURIValidator(TestCase): # TODO: verify the specifics of the ValidationErrors def test_valid_schemes(self): validator = AllowedURIValidator(["my-scheme", "https", "git+ssh"], "test") 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_invalid_schemes(self): validator = AllowedURIValidator(["https"], "test") bad_uris = [ "http:/example.com", "HTTP://localhost", "HTTP://example.com", "https://-exa", # triggers an exception in the upstream validators "HTTP://example.com/path", "HTTP://example.com/path?query=string", "HTTP://example.com/path?query=string#fragmemt", "HTTP://example.com.", "http://example.com/path/#fragment", "http://example.com?query=string#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) def test_allow_paths_valid_urls(self): validator = AllowedURIValidator(["https", "myapp"], "test", allow_path=True) good_uris = [ "https://example.com", "https://example.com:8080", "https://example", "https://example.com/path", "https://example.com:8080/path", "https://example/path", "https://localhost/path", "myapp://host/path", ] for uri in good_uris: # Check ValidationError not thrown validator(uri) def test_allow_paths_invalid_urls(self): validator = AllowedURIValidator(["https", "myapp"], "test", allow_path=True) bad_uris = [ "https://example.com?query=string", "https://example.com#fragment", "https://example.com/path?query=string", "https://example.com/path#fragment", "https://example.com/path?query=string#fragment", "myapp://example.com/path?query=string", "myapp://example.com/path#fragment", "myapp://example.com/path?query=string#fragment", "bad://example.com/path", ] for uri in bad_uris: with self.assertRaises(ValidationError): validator(uri) def test_allow_query_valid_urls(self): validator = AllowedURIValidator(["https", "myapp"], "test", allow_query=True) good_uris = [ "https://example.com", "https://example.com:8080", "https://example.com?query=string", "https://example", "myapp://example.com?query=string", "myapp://example?query=string", ] for uri in good_uris: # Check ValidationError not thrown validator(uri) def test_allow_query_invalid_urls(self): validator = AllowedURIValidator(["https", "myapp"], "test", allow_query=True) bad_uris = [ "https://example.com/path", "https://example.com#fragment", "https://example.com/path?query=string", "https://example.com/path#fragment", "https://example.com/path?query=string#fragment", "https://example.com:8080/path", "https://example/path", "https://localhost/path", "myapp://example.com/path?query=string", "myapp://example.com/path#fragment", "myapp://example.com/path?query=string#fragment", "bad://example.com/path", ] for uri in bad_uris: with self.assertRaises(ValidationError): validator(uri) def test_allow_fragment_valid_urls(self): validator = AllowedURIValidator(["https", "myapp"], "test", allow_fragments=True) good_uris = [ "https://example.com", "https://example.com#fragment", "https://example.com:8080", "https://example.com:8080#fragment", "https://example", "https://example#fragment", "myapp://example", "myapp://example#fragment", "myapp://example.com", "myapp://example.com#fragment", ] for uri in good_uris: # Check ValidationError not thrown validator(uri) def test_allow_fragment_invalid_urls(self): validator = AllowedURIValidator(["https", "myapp"], "test", allow_fragments=True) bad_uris = [ "https://example.com?query=string", "https://example.com?query=string#fragment", "https://example.com/path", "https://example.com/path?query=string", "https://example.com/path#fragment", "https://example.com/path?query=string#fragment", "https://example.com:8080/path", "https://example?query=string", "https://example?query=string#fragment", "https://example/path", "https://example/path?query=string", "https://example/path#fragment", "https://example/path?query=string#fragment", "myapp://example?query=string", "myapp://example?query=string#fragment", "myapp://example/path", "myapp://example/path?query=string", "myapp://example/path#fragment", "myapp://example.com/path?query=string", "myapp://example.com/path#fragment", "myapp://example.com/path?query=string#fragment", "myapp://example.com?query=string", "bad://example.com", ] for uri in bad_uris: with self.assertRaises(ValidationError): validator(uri) django-oauth-toolkit-3.0.1/tests/urls.py000066400000000000000000000003561466704512300202730ustar00rootroot00000000000000from django.contrib import admin from django.urls import include, path from oauth2_provider import urls as oauth2_urls admin.autodiscover() urlpatterns = [ path("o/", include(oauth2_urls)), path("admin/", admin.site.urls), ] django-oauth-toolkit-3.0.1/tests/utils.py000066400000000000000000000014131466704512300204410ustar00rootroot00000000000000import 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-3.0.1/tox.ini000066400000000000000000000043601466704512300171040ustar00rootroot00000000000000[tox] envlist = migrations, migrate_swapped, docs, lint, sphinxlint, py{38,39,310,311,312}-dj42, py{310,311,312}-dj50, py{310,311,312}-dj51, py{310,311,312}-djmain, py39-multi-db-dj-42 [gh-actions] python = 3.8: py38, docs, lint, migrations, migrate_swapped, sphinxlint 3.9: py39 3.10: py310 3.11: py311 3.12: py312 [gh-actions:env] DJANGO = 4.2: dj42 5.0: dj50 5.1: dj51 main: djmain [testenv] commands = pytest {posargs} coverage report coverage xml setenv = DJANGO_SETTINGS_MODULE = tests.settings PYTHONPATH = {toxinidir} PYTHONWARNINGS = all deps = dj42: Django>=4.2,<4.3 dj50: Django>=5.0,<5.1 dj51: Django>=5.1,<5.2 djmain: https://github.com/django/django/archive/main.tar.gz djangorestframework oauthlib>=3.2.2 jwcrypto coverage pytest pytest-cov pytest-django pytest-xdist pytest-mock requests pytz; python_version < '3.9' passenv = PYTEST_ADDOPTS [testenv:py{310,311,312}-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.2.2 m2r>=0.2.1 mistune<2 sphinx-rtd-theme livedocs: sphinx-autobuild jwcrypto django [testenv:lint] basepython = python3.8 deps = ruff>=0.6 skip_install = True commands = ruff format --check ruff check [testenv:migrations] setenv = DJANGO_SETTINGS_MODULE = tests.mig_settings PYTHONPATH = {toxinidir} PYTHONWARNINGS = all commands = django-admin makemigrations --dry-run --check [testenv:py39-multi-db-dj42] setenv = DJANGO_SETTINGS_MODULE = tests.multi_db_settings PYTHONPATH = {toxinidir} PYTHONWARNINGS = all [testenv:migrate_swapped] setenv = DJANGO_SETTINGS_MODULE = tests.settings_swapped PYTHONPATH = {toxinidir} PYTHONWARNINGS = all commands = django-admin migrate [testenv:build] deps = build twine allowlist_externals = rm commands = rm -rf dist python -m build twine check dist/*