diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..1c1551eb3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,73 @@ +venv +__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/ diff --git a/.env b/.env new file mode 100644 index 000000000..dc223bf0b --- /dev/null +++ b/.env @@ -0,0 +1,2 @@ +# required for vscode testing activity to discover tests +DJANGO_SETTINGS_MODULE=tests.settings \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 25051eaff..64302e819 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,24 +11,22 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: - python-version: 3.8 + python-version: '3.12' - name: Install dependencies run: | - python -m pip install -U pip - python -m pip install -U setuptools twine wheel + python -m pip install -U pip build twine - name: Build package run: | - python setup.py --version - python setup.py sdist --format=gztar bdist_wheel + python -m build twine check dist/* - name: Upload packages to Jazzband diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6409b6861..0b453d269 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,19 +3,35 @@ name: Test on: [push, pull_request] jobs: - build: + test-package: + name: Test Package (Python ${{ matrix.python-version }}, Django ${{ matrix.django-version }}) runs-on: ubuntu-latest + permissions: + id-token: write # Required for Codecov OIDC token strategy: fail-fast: false - max-parallel: 5 matrix: - python-version: ['3.7', '3.8', '3.9', '3.10'] + 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@v2 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} @@ -26,11 +42,11 @@ jobs: echo "::set-output name=dir::$(pip cache dir)" - name: Cache - uses: actions/cache@v2 + uses: actions/cache@v4 with: path: ${{ steps.pip-cache.outputs.dir }} key: - ${{ matrix.python-version }}-v1-${{ hashFiles('**/setup.py') }}-${{ hashFiles('**/tox.ini') }} + ${{ matrix.python-version }}-v1-${{ hashFiles('**/pyproject.toml') }}-${{ hashFiles('**/tox.ini') }} restore-keys: | ${{ matrix.python-version }}-v1- @@ -42,8 +58,51 @@ jobs: - name: Tox tests run: | tox -v + env: + DJANGO: ${{ matrix.django-version }} - name: Upload coverage - uses: codecov/codecov-action@v1 + uses: codecov/codecov-action@v5 with: name: Python ${{ matrix.python-version }} + use_oidc: true + + 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@v4 + 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 diff --git a/.gitignore b/.gitignore index 4d15af97f..d64e1776b 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ pip-log.txt .coverage .tox .pytest_cache/ +.ruff_cache/ nosetests.xml # Translations @@ -51,3 +52,8 @@ _build /venv/ /coverage.xml + +db.sqlite3 +venv/ + +/tests/app/idp/static diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 386d28c9c..f4eb471cf 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,11 +1,12 @@ repos: - - repo: https://github.com/psf/black - rev: 22.3.0 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.12.7 hooks: - - id: black - exclude: ^(oauth2_provider/migrations/|tests/migrations/) + - id: ruff + args: [ --fix ] + - id: ruff-format - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.2.0 + rev: v5.0.0 hooks: - id: check-ast - id: trailing-whitespace @@ -15,17 +16,15 @@ repos: - id: check-yaml - id: mixed-line-ending args: ['--fix=lf'] - - repo: https://github.com/PyCQA/isort - rev: 5.10.1 - hooks: - - id: isort - exclude: ^(oauth2_provider/migrations/|tests/migrations/) - - repo: https://github.com/PyCQA/flake8 - rev: 4.0.1 - hooks: - - id: flake8 - exclude: ^(oauth2_provider/migrations/|tests/migrations/) - repo: https://github.com/sphinx-contrib/sphinx-lint - rev: v0.3 + rev: v1.0.0 hooks: - id: sphinx-lint +# Configuration for codespell is in pyproject.toml + - repo: https://github.com/codespell-project/codespell + rev: v2.4.1 + hooks: + - id: codespell + exclude: (package-lock.json|/locale/) + additional_dependencies: + - tomli diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..fee847fe4 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + "python.testing.pytestArgs": [ + "tests", + "--no-cov" + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true +} \ No newline at end of file diff --git a/AUTHORS b/AUTHORS index a5f652ea0..2d3f80527 100644 --- a/AUTHORS +++ b/AUTHORS @@ -8,70 +8,121 @@ 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 +Cihad GUNDOGDU +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 -Hossein Shakiba 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 +Matej Spiller Muys +Matias Seniquiel Michael Howitz +Owen Gong +Patrick Palacin Paul Dekkers Paul Oswald Pavel Tvrdík -Patrick Palacin 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 +Thales Barbosa Bento Tom Evans +Vinay Karanam +Víðir Valberg Guðmundsson Will Beaufoy -Rustem Saiargaliev -Jadiel Teófilo pySilver Łukasz Skarżyński -Shaheed Haque -Peter Karman -Vinay Karanam -Eduardo Oliveira -Andrea Greco -Dominik George -David Hill -Darrel O'Pry +Wouter Klein Heerenbrink +Yaroslav Halchenko +Yuri Savin +Miriam Forner +Alex Kerkum +q0w diff --git a/CHANGELOG.md b/CHANGELOG.md index 7819fe616..32dd1734c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,17 +4,162 @@ 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). - ### Fixed +* #1517 OP prompts for logout when no OP session +* #1512 client_secret not marked sensitive +* #1521 Fix 0012 migration loading access token table into memory +* #1584 Fix IDP container in docker compose environment could not find templates and static files. +* #1562 Fix: Handle AttributeError in IntrospectTokenView +* #1583 Fix: Missing pt_BR translations + +--> -## [unreleased] + +## [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 @@ -63,7 +208,7 @@ This is a major release with **BREAKING** changes. Please make sure to review th ### Added * #969 Add batching of expired token deletions in `cleartokens` management command and `models.clear_expired()` - to improve performance for removal of large numers of expired tokens. Configure with + 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). @@ -103,7 +248,7 @@ This is a major release with **BREAKING** changes. Please make sure to review th ## [1.6.0] 2021-12-19 ### Added -* #949 Provide django.contrib.auth.authenticate() with a `request` for compatibiity with more backends (like django-axes). +* #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. @@ -148,7 +293,7 @@ This is a major release with **BREAKING** changes. Please make sure to review th ### Added * #917 Documentation improvement for Access Token expiration. -* #916 (for DOT contributors) Added `tox -e livedocs` which launches a local web server on `locahost:8000` +* #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. @@ -271,7 +416,7 @@ This is a major release with **BREAKING** changes. Please make sure to review th * **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. + refresh tokens may be reused. * An `app_authorized` signal is fired when a token is generated. ## 1.0.0 [2017-06-07] @@ -353,7 +498,7 @@ This is a major release with **BREAKING** changes. Please make sure to review th * #185: fixed vulnerabilities on Basic authentication * #173: ProtectResourceMixin now allows OPTIONS requests * Fixed `client_id` and `client_secret` characters set -* #169: hide sensitive informations in error emails +* #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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..4565df5ff --- /dev/null +++ b/Dockerfile @@ -0,0 +1,70 @@ +# 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 /data/static /data/templates +COPY --from=builder /code/tests/app/idp/static /data/static +COPY --from=builder /code/tests/app/idp/templates /data/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 '-'"] diff --git a/README.rst b/README.rst index 3acf459d8..e8b49d2a6 100644 --- a/README.rst +++ b/README.rst @@ -33,12 +33,7 @@ If you are facing one or more of the following: 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 `_. - -Note: If you have issues installing Django 4.0.0, it is because we only support -Django 4.0.1+ due to a regression in Django 4.0.0. Besides 4.0.0, Django 2.2+ is supported. -`Explanation `_. - +`rfc-compliant `_. Reporting security issues ------------------------- @@ -48,9 +43,9 @@ Please report any security issues to the JazzBand security team at =4.0.1 -* oauthlib 3.1+ +* Python 3.8+ +* Django 4.2, 5.0 or 5.1 +* oauthlib 3.2.2+ Installation ------------ @@ -59,7 +54,7 @@ Install with pip:: pip install django-oauth-toolkit -Add `oauth2_provider` to your `INSTALLED_APPS` +Add ``oauth2_provider`` to your ``INSTALLED_APPS`` .. code-block:: python @@ -69,14 +64,15 @@ Add `oauth2_provider` to your `INSTALLED_APPS` ) -If you need an OAuth2 provider you'll want to add the following to your urls.py. -Notice that `oauth2_provider` namespace is mandatory. +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_provider.urls', namespace='oauth2_provider')), + path('o/', include(oauth2_urls)), ] Changelog @@ -118,6 +114,12 @@ info and the open especially those labeled `help-wanted `__. +Discussions +~~~~~~~~~~~ +Have questions or want to discuss the project? +See `the discussions `__. + + Submit PRs and Perform Reviews ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -138,6 +140,5 @@ release for the leads to deal with “unexpected” merged PRs. Become a Project Lead ~~~~~~~~~~~~~~~~~~~~~ -If you are interested in stepping up to be a Project Lead, please join -the -`discussion `__. +If you are interested in stepping up to be a Project Lead, please take a look at +the `discussion about this `__. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..3a3459fde --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,40 @@ +volumes: + 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: + - idp \ No newline at end of file diff --git a/docs/_images/application-register-auth-code.png b/docs/_images/application-register-auth-code.png index d4ef8bd5a..86bfb402b 100644 Binary files a/docs/_images/application-register-auth-code.png and b/docs/_images/application-register-auth-code.png differ diff --git a/docs/_images/application-register-client-credential.png b/docs/_images/application-register-client-credential.png index 7f2d1cb60..510ed4f84 100644 Binary files a/docs/_images/application-register-client-credential.png and b/docs/_images/application-register-client-credential.png differ diff --git a/docs/advanced_topics.rst b/docs/advanced_topics.rst index 3fa1519b1..204e3f860 100644 --- a/docs/advanced_topics.rst +++ b/docs/advanced_topics.rst @@ -20,6 +20,8 @@ logo, acceptance of some user agreement and so on. * :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:`allowed_origins` The list of origin URIs to enable CORS for token endpoint. 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` @@ -29,7 +31,7 @@ Django OAuth Toolkit lets you extend the AbstractApplication model in a fashion custom user models. If you need, let's say, application logo and user agreement acceptance field, you can do this in -your Django app (provided that your app is in the list of the INSTALLED_APPS in your settings +your Django app (provided that your app is in the list of the ``INSTALLED_APPS`` in your settings module):: from django.db import models @@ -42,11 +44,11 @@ module):: Then you need to tell Django OAuth Toolkit which model you want to use to represent applications. Write something like this in your settings module:: - OAUTH2_PROVIDER_APPLICATION_MODEL='your_app_name.MyApplication' + OAUTH2_PROVIDER_APPLICATION_MODEL = 'your_app_name.MyApplication' Be aware that, when you intend to swap the application model, you should create and run the -migration defining the swapped application model prior to setting OAUTH2_PROVIDER_APPLICATION_MODEL. -You'll run into models.E022 in Core system checks if you don't get the order right. +migration defining the swapped application model prior to setting ``OAUTH2_PROVIDER_APPLICATION_MODEL``. +You'll run into ``models.E022`` in Core system checks if you don't get the order right. You can force your migration providing the custom model to run in the right order by adding:: @@ -59,15 +61,26 @@ to the migration class. That's all, now Django OAuth Toolkit will use your model wherever an Application instance is needed. - **Notice:** `OAUTH2_PROVIDER_APPLICATION_MODEL` is the only setting variable that is not namespaced, this +.. note:: ``OAUTH2_PROVIDER_APPLICATION_MODEL`` is the only setting variable that is not namespaced, this is because of the way Django currently implements swappable models. - See issue #90 (https://github.com/jazzband/django-oauth-toolkit/issues/90) for details + See `issue #90 `_ 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 +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 @@ -84,12 +97,12 @@ 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. +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. +* ``force`` - users are always prompted for authorization. -* `auto` - users are prompted only the first time, subsequent authorizations for the same application +* ``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 @@ -97,5 +110,49 @@ 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 programmaticaly or within the +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. diff --git a/docs/contributing.rst b/docs/contributing.rst index 00b4dbedc..569f5eab2 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -12,7 +12,7 @@ This is a `Jazzband `_ project. By contributing you agree t Setup ===== -Fork `django-oauth-toolkit` repository on `GitHub `_ and follow these steps: +Fork ``django-oauth-toolkit`` repository on `GitHub `_ and follow these steps: * Create a virtualenv and activate it * Clone your repository locally @@ -27,14 +27,11 @@ add a comment stating you're working on it. Code Style ========== -The project uses `flake8 `_ for linting, -`black `_ for formatting the code, -`isort `_ for formatting and sorting imports, -and `pre-commit `_ for checking/fixing commits for -correctness before they are made. +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 ``flake8``, ``black`` and ``isort``. +take care of installing ``ruff``. After cloning your repository, go into it and run:: @@ -42,27 +39,27 @@ After cloning your repository, go into it and run:: to install the hooks. On the next commit that you make, ``pre-commit`` will download and install the necessary hooks (a one off task). If anything in the -commit would fail the hooks, the commit will be abandoned. For ``black`` and -``isort``, any necessary changes will be made automatically, but not staged. +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 ``black`` has formatted it, and think if there +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 ``docs/``. This project +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 ``docs/_build/html``. +This will build the docs, and place the result in :file:`docs/_build/html`. Alternatively, you can run:: tox -e livedocs @@ -89,7 +86,7 @@ For example, to add Deutsch:: cd oauth2_provider django-admin makemessages --locale de -Then edit ``locale/de/LC_MESSAGES/django.po`` to add your translations. +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:: @@ -108,8 +105,8 @@ 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:: +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 @@ -117,7 +114,7 @@ name "better" by adding the `-n name` option:: Pull requests ============= -Please avoid providing a pull request from your `master` and use **topic branches** instead; you can add as many commits +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:: @@ -129,7 +126,7 @@ When you're ready to submit your pull request, first push the topic branch to yo 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 +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: @@ -150,29 +147,29 @@ When you begin your PR, you'll be asked to provide the following: * 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 `_ +* Update ``CHANGELOG.md`` (only for user relevant changes). We use `Keep A Changelog `_ format which categorizes the changes as: - * `Added` for new features. + * ``Added`` for new features. - * `Changed` for changes in existing functionality. + * ``Changed`` for changes in existing functionality. - * `Deprecated` for soon-to-be removed features. + * ``Deprecated`` for soon-to-be removed features. - * `Removed` for now removed features. + * ``Removed`` for now removed features. - * `Fixed` for any bug fixes. + * ``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 + * ``Security`` in case of vulnerabilities. (Please report any security issues to the + JazzBand security team ````. Do not file an issue on the tracker or submit a PR until directed to do so.) -* Make sure your name is in `AUTHORS`. We want to give credit to all contrbutors! +* 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. +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`. +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 @@ -194,7 +191,7 @@ Then merge the changes that you fetched:: git merge upstream/master -For more info, see http://help.github.com/fork-a-repo/ +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. @@ -209,7 +206,7 @@ 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: +what you'll see when creating your PR:: Fixes # @@ -251,10 +248,51 @@ You can check your coverage locally with the `coverage `_ @@ -286,6 +325,28 @@ Reviewing and Merging PRs PRs that are incorrectly merged may (reluctantly) be reverted by the Project Leads. +End to End Testing +------------------ + +There is a demonstration Identity Provider (IDP) and Relying Party (RP) to allow for +end to end testing. They can be launched directly by following the instructions in +/test/apps/README.md or via docker compose. To launch via docker compose + +.. code-block:: bash + + # build the images with the current code + docker compose build + # wipe any existing services and volumes + docker compose rm -v + # start the services + docker compose up -d + +Please verify the RP behaves as expected by logging in, reloading, and logging out. + +open http://localhost:5173 in your browser and login with the following credentials: + +username: superuser +password: password Publishing a Release -------------------- @@ -301,14 +362,14 @@ and rtfd.io. This checklist is a reminder of the required steps. to make them meaningful to users. - Make a final PR for the release that updates: - - CHANGELOG to show the release date. - - `oauth2_provider/__init__.py` to set `__version__ = "..."` + - :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 bullt wheel zip and tgz files into - temp directories and do a `diff -r` to make sure they have the same content. +- 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. diff --git a/docs/getting_started.rst b/docs/getting_started.rst index 3ea4f7e58..d2ce14ca1 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -42,7 +42,7 @@ Create a Django project:: django-admin startproject iam -This will create a mysite directory in your current directory. With the following estructure:: +This will create a mysite directory in your current directory. With the following structure:: . └── iam @@ -109,9 +109,9 @@ Configure ``users.User`` to be the model used for the ``auth`` application by ad .. code-block:: python - AUTH_USER_MODEL='users.User' + AUTH_USER_MODEL = 'users.User' -Create inital migration for ``users`` application ``User`` model:: +Create initial migration for ``users`` application ``User`` model:: python manage.py makemigrations @@ -191,10 +191,11 @@ Include ``oauth2_provider.urls`` to :file:`iam/urls.py` as follows: 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_provider.urls', namespace='oauth2_provider')), + path('o/', include(oauth2_urls)), ] This will make available endpoints to authorize, generate token and create OAuth applications. @@ -203,7 +204,7 @@ Last change, add ``LOGIN_URL`` to :file:`iam/settings.py`: .. code-block:: python - LOGIN_URL='/admin/login/' + LOGIN_URL = '/admin/login/' We will use Django Admin login to make our life easy. @@ -242,9 +243,16 @@ 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. +Point your browser to http://127.0.0.1:8000/o/applications/register/ and let's create an application. + +Fill the form as shown in the screenshot below and before clicking on 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. -Fill the form as show in the screenshot bellow and before save take note of ``Client id`` and ``Client secret`` we will use it in a minute. .. image:: _images/application-register-auth-code.png :alt: Authorization code application registration @@ -256,13 +264,39 @@ Export ``Client id`` and ``Client secret`` values as environment variable: 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&client_id=vW1RcAl7Mb0d5gyHNQIAcH110lWoOW2BmWJIero8&redirect_uri=http://127.0.0.1:8000/noexist/callback + 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`` @@ -287,7 +321,7 @@ Export it as an environment variable: 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 "redirect_uri=http://127.0.0.1:8000/noexist/callback" -d "grant_type=authorization_code" + 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:: @@ -298,12 +332,13 @@ To be more easy to visualize:: -d "client_id=${ID}" \ -d "client_secret=${SECRET}" \ -d "code=${CODE}" \ + -d "code_verifier=${CODE_VERIFIER}" \ -d "redirect_uri=http://127.0.0.1:8000/noexist/callback" \ -d "grant_type=authorization_code" The OAuth2 provider will return the follow response: -.. code-block:: javascript +.. code-block:: json { "access_token": "jooqrnOrNa0BrNWlg68u9sl6SkdFZg", @@ -373,7 +408,7 @@ To be easier to visualize:: The OAuth2 provider will return the following response: -.. code-block:: javascript +.. code-block:: json { "access_token": "PaZDOD5UwzbGOFsQr34LQ7JUYOj3yK", @@ -388,7 +423,7 @@ Next step is :doc:`first tutorial `. .. _Whitson Gordon: https://en.wikipedia.org/wiki/OAuth#cite_note-1 .. _User: https://docs.djangoproject.com/en/3.0/ref/contrib/auth/#django.contrib.auth.models.User .. _Django documentation: https://docs.djangoproject.com/en/3.0/topics/auth/customizing/#using-a-custom-user-model-when-starting-a-project -.. _RFC6749: https://tools.ietf.org/html/rfc6749#section-1.3 +.. _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 diff --git a/docs/index.rst b/docs/index.rst index fdd8131b7..07ed24314 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -9,7 +9,7 @@ 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 `_. +`rfc-compliant `_. See our :doc:`Changelog ` for information on updates. @@ -21,9 +21,9 @@ If you need help please submit a `question ` or :doc:`first tutorial `. - diff --git a/docs/management_commands.rst b/docs/management_commands.rst index 085b130ec..0a3f1bda0 100644 --- a/docs/management_commands.rst +++ b/docs/management_commands.rst @@ -5,8 +5,6 @@ Django OAuth Toolkit exposes some useful management commands that can be run via or :doc:`Celery `. .. _cleartokens: -.. _createapplication: - cleartokens ~~~~~~~~~~~ @@ -22,10 +20,12 @@ 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: createapplication ~~~~~~~~~~~~~~~~~ @@ -34,18 +34,26 @@ The ``createapplication`` management command provides a shortcut to create a new .. code-block:: sh - usage: manage.py createapplication [-h] [--client-id CLIENT_ID] [--user USER] [--redirect-uris REDIRECT_URIS] - [--client-secret CLIENT_SECRET] [--name NAME] [--skip-authorization] [--version] [-v {0,1,2,3}] - [--settings SETTINGS] [--pythonpath PYTHONPATH] [--traceback] [--no-color] [--force-color] + 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, can be confidential or public + client_type The client type, one of: confidential, public authorization_grant_type - The type of authorization grant to be used + 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 @@ -53,9 +61,34 @@ The ``createapplication`` management command provides a shortcut to create a new 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' + 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 The ID of the new 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. + diff --git a/docs/oidc.rst b/docs/oidc.rst index 4b427ba86..1669a00d4 100644 --- a/docs/oidc.rst +++ b/docs/oidc.rst @@ -4,8 +4,8 @@ OpenID Connect OpenID Connect support ====================== -``django-oauth-toolkit`` supports OpenID Connect (OIDC), which standardizes -authentication flows and provides a plug and play integration with other +``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 @@ -23,6 +23,8 @@ We support: * OpenID Connect Implicit Flow * OpenID Connect Hybrid Flow +Furthermore ``django-oauth-toolkit`` also supports `OpenID Connect RP-Initiated Logout `_. + Configuration ============= @@ -32,7 +34,7 @@ that must be provided. ``django-oauth-toolkit`` supports two different algorithms for signing JWT tokens, ``RS256``, which uses asymmetric RSA keys (a public key and a private key), and ``HS256``, which uses a symmetric key. -It is preferrable to use ``RS256``, because this produces a token that can be +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 @@ -131,6 +133,9 @@ 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``:: @@ -144,8 +149,25 @@ scopes in your ``settings.py``:: } .. note:: - If you want to enable ``RS256`` at a later date, you can do so - just add - the private key as described above. + ``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 =============================== @@ -217,7 +239,7 @@ just return the same claims as the ID token. To configure all of these things we need to customize the ``OAUTH2_VALIDATOR_CLASS`` in ``django-oauth-toolkit``. Create a new file in -our project, eg ``my_project/oauth_validator.py``:: +our project, eg ``my_project/oauth_validators.py``:: from oauth2_provider.oauth2_validators import OAuth2Validator @@ -317,7 +339,7 @@ The following example adds instructions to return the ``foo`` claim when the ``b Set ``oidc_claim_scope = None`` to return all claims irrespective of the granted scopes. -You have to make sure you've added addtional claims via ``get_additional_claims`` +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:: @@ -350,7 +372,7 @@ for a POST request. Again, to modify the content delivered, we need to add a function to our custom validator. The default implementation adds the claims from the ID -token, so you will probably want to re-use that:: +token, so you will probably want to reuse that:: class CustomOAuth2Validator(OAuth2Validator): @@ -359,6 +381,15 @@ token, so you will probably want to re-use that:: 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 ========== @@ -373,10 +404,21 @@ In the docs below, it assumes that you have mounted the 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 +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. @@ -394,3 +436,10 @@ 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). diff --git a/docs/requirements.txt b/docs/requirements.txt index 4f5593f9b..aa59757a1 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,5 +1,7 @@ Django -oauthlib>=3.1.0 +oauthlib>=3.2.2 m2r>=0.2.1 mistune<2 +sphinx==7.2.6 +sphinx-rtd-theme==1.3.0 -e . diff --git a/docs/resource_server.rst b/docs/resource_server.rst index 4e623b118..eeb0cd3ae 100644 --- a/docs/resource_server.rst +++ b/docs/resource_server.rst @@ -1,7 +1,7 @@ Separate 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 +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. diff --git a/docs/rest-framework/getting_started.rst b/docs/rest-framework/getting_started.rst index 8028a412f..8e019c44e 100644 --- a/docs/rest-framework/getting_started.rst +++ b/docs/rest-framework/getting_started.rst @@ -4,20 +4,16 @@ Getting 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 +.. 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`... - -:: +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. +Start a new Django project and add ``'rest_framework'`` and ``'oauth2_provider'`` to your ``INSTALLED_APPS`` setting. .. code-block:: python @@ -29,7 +25,7 @@ Start a new Django project and add `'rest_framework'` and `'oauth2_provider'` to ) Now we need to tell Django REST Framework to use the new authentication backend. -To do so add the following lines at the end of your `settings.py` module: +To do so add the following lines at the end of your :file:`settings.py` module: .. code-block:: python @@ -44,7 +40,7 @@ Step 2: Create a simple API Let's create a simple API for accessing users and groups. -Here's our project's root `urls.py` module: +Here's our project's root :file:`urls.py` module: .. code-block:: python @@ -55,6 +51,7 @@ Here's our project's root `urls.py` module: 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 @@ -88,14 +85,14 @@ Here's our project's root `urls.py` module: # Setup the URLs and include login URLs for the browsable API. urlpatterns = [ path('admin/', admin.site.urls), - path('o/', include('oauth2_provider.urls', namespace='oauth2_provider')), + path('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 `settings.py` module: +Also add the following to your :file:`settings.py` module: .. code-block:: python @@ -112,7 +109,9 @@ Also add the following to your `settings.py` module: ) } -`OAUTH2_PROVIDER.SCOPES` setting parameter contains the scopes that the application will be aware of, + 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: @@ -147,25 +146,23 @@ views you can use to CRUD application instances, just point your browser at: 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* +* **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 - -:: +At this point we're ready to request an access_token. Open your shell:: curl -X POST -d "grant_type=password&username=&password=" -u":" http://localhost:8000/o/token/ The *user_name* and *password* are the credential of the users registered in your :term:`Authorization Server`, like any user created in Step 2. Response should be something like: -.. code-block:: javascript +.. code-block:: json { "access_token": "", @@ -175,9 +172,7 @@ Response should be something like: "scope": "read write groups" } -Grab your access_token and start using your new OAuth2 API: - -:: +Grab your access_token and start using your new OAuth2 API:: # Retrieve users curl -H "Authorization: Bearer " http://localhost:8000/users/ @@ -187,17 +182,15 @@ Grab your access_token and start using your new OAuth2 API: curl -H "Authorization: Bearer " http://localhost:8000/groups/ # Insert a new user - curl -H "Authorization: Bearer " -X POST -d"username=foo&password=bar" http://localhost:8000/users/ - -Some time has passed and your access token is about to expire, you can get renew the access token issued using the `refresh token`: + curl -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: +Your response should be similar to your first ``access_token`` request, containing a new access_token and refresh_token: -.. code-block:: javascript +.. code-block:: json { "access_token": "", @@ -212,15 +205,13 @@ Your response should be similar to your first access_token request, containing a 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 - -:: +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`: +As you can see the only scope provided is ``read``: -.. code-block:: javascript +.. code-block:: json { "access_token": "", @@ -230,15 +221,13 @@ As you can see the only scope provided is `read`: "scope": "read" } -We now try to access our resources: - -:: +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. +OK, this one works since users read only requires ``read`` scope. :: @@ -248,5 +237,5 @@ Ok, this one works since users read only requires `read` scope. # '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`. +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``. diff --git a/docs/rest-framework/permissions.rst b/docs/rest-framework/permissions.rst index ee398d9fc..31e00ff2b 100644 --- a/docs/rest-framework/permissions.rst +++ b/docs/rest-framework/permissions.rst @@ -70,8 +70,8 @@ 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 browseable API. -To restrict users to only browse the parts of the browseable API they should be allowed to see, you can combine this with the DjangoModelPermission or the DjangoObjectPermission. +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: diff --git a/docs/rfc.py b/docs/rfc.py index e5af5f476..da5e6ecde 100644 --- a/docs/rfc.py +++ b/docs/rfc.py @@ -1,10 +1,11 @@ """ Custom Sphinx documentation module to link to parts of the OAuth2 RFC. """ + from docutils import nodes -base_url = "http://tools.ietf.org/html/rfc6749" +base_url = "https://rfc-editor.org/rfc/rfc6749.html" def rfclink(name, rawtext, text, lineno, inliner, options={}, content=[]): diff --git a/docs/settings.rst b/docs/settings.rst index 2ac31ccda..985ca5d2c 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -1,10 +1,8 @@ Settings ======== -Our configurations are all namespaced under the `OAUTH2_PROVIDER` settings with the exception of -`OAUTH2_PROVIDER_APPLICATION_MODEL, OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL, OAUTH2_PROVIDER_GRANT_MODEL, -OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL`: this is because of the way Django currently implements -swappable models. See issue #90 (https://github.com/jazzband/django-oauth-toolkit/issues/90) for details. +Our configurations are all namespaced under the ``OAUTH2_PROVIDER`` settings, with the exception +of the `List of non-namespaced settings`_. For example: @@ -24,29 +22,24 @@ For example: A big *thank you* to the guys from Django REST Framework for inspiring this. -List of available settings --------------------------- +List of available settings within OAUTH2_PROVIDER +------------------------------------------------- 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. (default: 36000) - -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``). +can cache the token for a reasonable amount of time. ACCESS_TOKEN_GENERATOR ~~~~~~~~~~~~~~~~~~~~~~ Import path of a callable used to generate access tokens. -oauthlib.oauth2.tokens.random_token_generator is (normally) used if not provided. +``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. @@ -60,18 +53,53 @@ assigned ports. Note that you may override ``Application.get_allowed_schemes()`` to set this on a per-application basis. +ALLOW_URI_WILDCARDS +~~~~~~~~~~~~~~~~~~~ +Default: ``False`` -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``). +SECURITY WARNING: Enabling this setting can introduce security vulnerabilities. Only enable +this setting if you understand the risks. https://datatracker.ietf.org/doc/html/rfc6749#section-3.1.2 +states "The redirection endpoint URI MUST be an absolute URI as defined by [RFC3986] Section 4.3." The +intent of the URI restrictions is to prevent open redirects and phishing attacks. If you do enable this +ensure that the wildcards restrict URIs to resources under your control. You are strongly encouragd not +to use this feature in production. + +When set to ``True``, the server will allow wildcard characters in the domains for allowed_origins and +redirect_uris. + +``*`` is the only wildcard character allowed. + +``*`` can only be used as a prefix to a domain, must be the first character in +the domain, and cannot be in the top or second level domain. Matching is done using an +endsWith check. + +For example, +``https://*.example.com`` is allowed, +``https://*-myproject.example.com`` is allowed, +``https://*.sub.example.com`` is not allowed, +``https://*.com`` is not allowed, and +``https://example.*.com`` is not allowed. + +This feature is useful for working with CI service such as cloudflare, netlify, and vercel that offer branch +deployments for development previews and user acceptance testing. + +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. 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 a -10 minutes (600 seconds) duration. +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 ~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -88,6 +116,10 @@ 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. +CLIENT_SECRET_HASHER +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +The hasher for storing generated secrets. By default library will use the first hasher in PASSWORD_HASHERS. + EXTRA_SERVER_KWARGS ~~~~~~~~~~~~~~~~~~~ A dictionary to be passed to oauthlib's Server class. Three options @@ -130,7 +162,7 @@ OAUTH2_SERVER_CLASS ~~~~~~~~~~~~~~~~~~~ The import string for the ``server_class`` (or ``oauthlib.oauth2.Server`` subclass) used in the ``OAuthLibMixin`` that implements OAuth2 grant types. It defaults -to ``oauthlib.oauth2.Server``, except when OIDC support is enabled, when the +to ``oauthlib.oauth2.Server``, except when :doc:`oidc` is enabled, when the default is ``oauthlib.openid.Server``. OAUTH2_VALIDATOR_CLASS @@ -163,23 +195,29 @@ 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. -Known bugs: `False` currently has a side effect of immediately revoking both access and refresh token on refreshing. +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. +See `ACCESS_TOKEN_GENERATOR`_. This is the same but for refresh tokens. Defaults to access token generator if not provided. REQUEST_APPROVAL_PROMPT @@ -194,7 +232,7 @@ Defaults to ``oauth2_provider.scopes.SettingsScopes``, which reads scopes throug SCOPES ~~~~~~ -.. note:: (0.12.0+) Only used if `SCOPES_BACKEND_CLASS` is set to the SettingsScopes default. +.. note:: (0.12.0+) Only used if ``SCOPES_BACKEND_CLASS`` is set to the SettingsScopes default. A dictionary mapping each scope name to its human description. @@ -202,11 +240,11 @@ A dictionary mapping each scope name to its human description. DEFAULT_SCOPES ~~~~~~~~~~~~~~ -.. note:: (0.12.0+) Only used if `SCOPES_BACKEND_CLASS` is set to the SettingsScopes default. +.. note:: (0.12.0+) Only used if ``SCOPES_BACKEND_CLASS`` is set to the SettingsScopes default. A list of scopes that should be returned by default. -This is a subset of the keys of the SCOPES setting. -By default this is set to '__all__' meaning that the whole set of SCOPES will be returned. +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 @@ -214,13 +252,13 @@ By default this is set to '__all__' meaning that the whole set of SCOPES will be READ_SCOPE ~~~~~~~~~~ -.. note:: (0.12.0+) Only used if `SCOPES_BACKEND_CLASS` is set to the SettingsScopes default. +.. note:: (0.12.0+) Only used if ``SCOPES_BACKEND_CLASS`` is set to the SettingsScopes default. The name of the *read* scope. WRITE_SCOPE ~~~~~~~~~~~ -.. note:: (0.12.0+) Only used if `SCOPES_BACKEND_CLASS` is set to the SettingsScopes default. +.. note:: (0.12.0+) Only used if ``SCOPES_BACKEND_CLASS`` is set to the SettingsScopes default. The name of the *write* scope. @@ -232,8 +270,8 @@ Only applicable when used with `Django REST Framework `_ - 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 ~~~~~~~~~~~~~~~~~~~~ @@ -308,6 +353,41 @@ 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: ``""`` @@ -315,7 +395,7 @@ 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/``. +``/.well-known/openid-configuration``. If unset, the default location is used, eg if ``django-oauth-toolkit`` is mounted at ``/o``, it will be ``/o``. @@ -361,14 +441,50 @@ 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 +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. +List of non-namespaced settings +------------------------------- +.. note:: + These settings must be set as top-level Django settings (outside of ``OAUTH2_PROVIDER``), + because of the way Django currently implements swappable models. + See `issue #90 `_ for details. + + +OAUTH2_PROVIDER_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``). + +OAUTH2_PROVIDER_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``). + +OAUTH2_PROVIDER_ID_TOKEN_MODEL +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +The import string of the class (model) representing your OpenID Connect ID Token. +Overwrite this value if you wrote your own implementation (subclass of +``oauth2_provider.models.IDToken``). + +OAUTH2_PROVIDER_GRANT_MODEL +~~~~~~~~~~~~~~~~~~~~~~~~~~~ +The import string of the class (model) representing your OAuth2 grants. +Overwrite this value if you wrote your own implementation (subclass of +``oauth2_provider.models.Grant``). + +OAUTH2_PROVIDER_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``). Settings imported from Django project ------------------------------------- USE_TZ ~~~~~~ - Used to determine whether or not to make token expire dates timezone aware. diff --git a/docs/signals.rst b/docs/signals.rst index fe696ae2c..f35832af5 100644 --- a/docs/signals.rst +++ b/docs/signals.rst @@ -4,7 +4,7 @@ Signals 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 +You can easily import signals from ``oauth2_provider.signals`` and attach your own listeners. For example: @@ -20,5 +20,5 @@ For example: Currently supported signals are: -* `oauth2_provider.signals.app_authorized` - fired once an oauth code has been +* ``oauth2_provider.signals.app_authorized`` - fired once an oauth code has been authorized and an access token has been granted diff --git a/docs/templates.rst b/docs/templates.rst index 8ebcd4127..7f23ae3d1 100644 --- a/docs/templates.rst +++ b/docs/templates.rst @@ -165,10 +165,11 @@ This template gets passed the following template context variables: - ``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 overiding this template. + Be sure to provide the same blocks if you are only overriding this template. application_registration_form.html ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -184,6 +185,7 @@ This template gets passed the following template context variable: - ``client_type`` - ``authorization_grant_type`` - ``redirect_uris`` + - ``post_logout_redirect_uris`` .. note:: In the default implementation this template extends `application_form.html`_. diff --git a/docs/tutorial/tutorial_01.rst b/docs/tutorial/tutorial_01.rst index f0b8cb3ed..0d0e6b45c 100644 --- a/docs/tutorial/tutorial_01.rst +++ b/docs/tutorial/tutorial_01.rst @@ -34,10 +34,11 @@ Include the Django OAuth Toolkit urls in your `urls.py`, choosing the urlspace y .. 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_provider.urls', namespace='oauth2_provider')), + path("o/", include(oauth2_urls)), # ... ] @@ -82,14 +83,21 @@ Let's register your application. You need to be logged in before registration. So, go to http://localhost:8000/admin and log in. After that point your browser to http://localhost:8000/o/applications/ and add an Application instance. -`Client id` and `Client Secret` are automatically generated; you have to provide the rest of the informations: +`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 - `http://django-oauth-toolkit.herokuapp.com/consumer/exchange/` + `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*. @@ -99,23 +107,39 @@ point your browser to http://localhost:8000/o/applications/ and add an Applicati * `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 the rest -of us, there is a `consumer service `_ deployed on Heroku to test -your provider. +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. Go to the -`Consumer `_ page and complete the form by filling in your -application's details obtained from the steps in this tutorial. Submit the form, and you'll receive a link your users can -use to access the authorization page. +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 +++++++++++++++++++++++++ @@ -125,18 +149,19 @@ page is login protected by django-oauth-toolkit. Login, then you should see the her authorization to the client application. Flag the *Allow* checkbox and click *Authorize*, you will be redirected again to the consumer service. -__ loginTemplate_ +Possible errors: -If you are not redirected to the correct page after logging in successfully, -you probably need to `setup your login template correctly`__. +* 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. -This operation is usually done automatically by the client application during the request/response cycle, but we cannot -make a POST request from Heroku to your localhost, so we proceed manually with this step. Fill the form with the -missing data and click *Submit*. + 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`. diff --git a/docs/tutorial/tutorial_02.rst b/docs/tutorial/tutorial_02.rst index cdc94540c..556eb6356 100644 --- a/docs/tutorial/tutorial_02.rst +++ b/docs/tutorial/tutorial_02.rst @@ -14,7 +14,7 @@ to provide an API to access some kind of resources. We don't need an actual reso endpoint protected with OAuth2: let's do it in a *class based view* fashion! Django OAuth Toolkit provides a set of generic class based view you can use to add OAuth behaviour to your views. Open -your `views.py` module and import the view: +your :file:`views.py` module and import the view: .. code-block:: python @@ -29,7 +29,7 @@ Then create the view which will respond to the API endpoint: def get(self, request, *args, **kwargs): return HttpResponse('Hello, OAuth2!') -That's it, our API will expose only one method, responding to `GET` requests. Now open your `urls.py` and specify the +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 @@ -73,15 +73,15 @@ URL this view will respond to: 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 +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). +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 diff --git a/docs/tutorial/tutorial_03.rst b/docs/tutorial/tutorial_03.rst index 30c8317e6..a9e063785 100644 --- a/docs/tutorial/tutorial_03.rst +++ b/docs/tutorial/tutorial_03.rst @@ -24,33 +24,35 @@ which takes care of token verification. In your settings.py: MIDDLEWARE = [ '...', - # If you use SessionAuthenticationMiddleware, be sure it appears before OAuth2TokenMiddleware. - # SessionAuthenticationMiddleware is NOT required for using django-oauth-toolkit. - 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', + # 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 +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 +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 +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) +``request.user`` and ``request._cached_user`` fields so that AuthenticationMiddleware (when active) will not try to get user from the session. -If you use SessionAuthenticationMiddleware, be sure it appears before OAuth2TokenMiddleware. -However SessionAuthenticationMiddleware is NOT required for using django-oauth-toolkit. +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 `views.py` module: +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 @@ -73,7 +75,7 @@ To check everything works properly, mount the view above to some url: 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: +Now supposing your access token value is ``123456`` you can try to access your authenticated view: :: @@ -90,7 +92,7 @@ It would be nice to reuse those views **and** support token handling. Instead of 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. +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 @@ -105,8 +107,8 @@ The key is setting a class attribute to override the default *permissions_classe 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 +ways this can be solved. Overriding the class function ``get_permission_classes`` is another way to solve the problem. -A detailed dive into the `Dango REST framework permissions is here. `_ +A detailed dive into the `Django REST framework permissions is here. `_ diff --git a/docs/tutorial/tutorial_04.rst b/docs/tutorial/tutorial_04.rst index c13974e18..9585582bb 100644 --- a/docs/tutorial/tutorial_04.rst +++ b/docs/tutorial/tutorial_04.rst @@ -7,12 +7,12 @@ You've granted a user an :term:`Access Token`, following :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`. +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://tools.ietf.org/html/rfc7009, so as specified, the revocation request requires: +`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'. +- ``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. @@ -36,7 +36,7 @@ obtained in :doc:`part 1 `. If your application type is `Confidenti token=XXXX&client_id=XXXX&client_secret=XXXX -The server will respond wih a `200` status code on successful revocation. You can use `curl` to make a revoke request on your server. If you have access to a local installation of your authorization server, you can test revoking a token with a request like that shown below, for a `Confidential` client. +The server will respond 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. :: diff --git a/docs/tutorial/tutorial_05.rst b/docs/tutorial/tutorial_05.rst index 1be656b88..74feec4d2 100644 --- a/docs/tutorial/tutorial_05.rst +++ b/docs/tutorial/tutorial_05.rst @@ -38,7 +38,7 @@ See the `RabbitMQ Installing on Windows `_. :: @@ -58,13 +58,14 @@ in the database and adds a Django Admin interface for configuring them. } -Now add a new file to your app to add Celery: ``tutorial/celery.py``: +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') @@ -74,8 +75,8 @@ Now add a new file to your app to add Celery: ``tutorial/celery.py``: # Load task modules from all registered Django apps. app.autodiscover_tasks() -This will autodiscover any ``tasks.py`` files in the list of installed apps. -We'll add ours now in ``tutorial/tasks.py``: +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 @@ -87,7 +88,7 @@ We'll add ours now in ``tutorial/tasks.py``: clear_expired() -Finally, update ``tutorial/__init__.py`` to make sure Celery gets loaded when the app starts up: +Finally, update :file:`tutorial/__init__.py` to make sure Celery gets loaded when the app starts up: .. code-block:: python @@ -162,8 +163,6 @@ 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 +* https://docs.celeryq.dev/en/stable/django/first-steps-with-django.html +* https://docs.celeryq.dev/en/stable/userguide/periodic-tasks.html#beat-custom-schedulers +* https://django-celery-beat.readthedocs.io/en/latest/index.html diff --git a/docs/views/application.rst b/docs/views/application.rst index a9f04bcd3..c5ec70d3b 100644 --- a/docs/views/application.rst +++ b/docs/views/application.rst @@ -2,9 +2,9 @@ Application Views ================= A set of views is provided to let users handle application instances without accessing Django Admin -Site. Application views are listed at the url `applications/` and you can register a new one at the -url `applications/register`. You can override default templates located in -`templates/oauth2_provider` folder and provide a custom layout. Every view provides access only to +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. diff --git a/docs/views/class_based.rst b/docs/views/class_based.rst index 543ed58bb..d5573a600 100644 --- a/docs/views/class_based.rst +++ b/docs/views/class_based.rst @@ -38,7 +38,7 @@ using the *Class Based View* approach. .. 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 + ``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:: diff --git a/docs/views/function_based.rst b/docs/views/function_based.rst index cc0650bd9..57884b2b9 100644 --- a/docs/views/function_based.rst +++ b/docs/views/function_based.rst @@ -43,8 +43,8 @@ Django OAuth Toolkit provides decorators to help you in protecting your function .. 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:: + box. ``GET``, ``HEAD``, ``OPTIONS`` HTTP methods require ``'read'`` scope. + Otherwise ``'write'`` scope is required:: from oauth2_provider.decorators import rw_protected_resource @@ -54,7 +54,7 @@ Django OAuth Toolkit provides decorators to help you in protecting your function # ... pass - If you need, you can ask for other scopes over "read" and "write":: + If you need, you can ask for other scopes over ``'read'`` and ``'write'``:: from oauth2_provider.decorators import rw_protected_resource diff --git a/docs/views/token.rst b/docs/views/token.rst index ead0d023d..6c6d2b6ae 100644 --- a/docs/views/token.rst +++ b/docs/views/token.rst @@ -5,10 +5,10 @@ A set of views is provided to let users handle tokens that have been granted to 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/`. +Granted Token views are listed at the url ``authorized_tokens/``. -For each granted token there is a delete view that allows you to delete such token. You can override default templates `authorized-tokens.html` for the list view and `authorized-token-delete.html` for the delete view; they are located inside `templates/oauth2_provider` folder. +For each granted token there is a delete view that allows you to delete such token. You can override default templates :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 diff --git a/oauth2_provider/__init__.py b/oauth2_provider/__init__.py index 49a4433da..055276878 100644 --- a/oauth2_provider/__init__.py +++ b/oauth2_provider/__init__.py @@ -1,7 +1 @@ -import django - - -__version__ = "2.0.0" - -if django.VERSION < (3, 2): - default_app_config = "oauth2_provider.apps.DOTConfig" +__version__ = "3.0.1" diff --git a/oauth2_provider/admin.py b/oauth2_provider/admin.py index cf41ec5b2..dd636184c 100644 --- a/oauth2_provider/admin.py +++ b/oauth2_provider/admin.py @@ -19,7 +19,7 @@ class ApplicationAdmin(admin.ModelAdmin): - list_display = ("id", "name", "user", "client_type", "authorization_grant_type") + 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, @@ -48,6 +48,7 @@ class IDTokenAdmin(admin.ModelAdmin): raw_id_fields = ("user",) search_fields = ("user__email",) if has_email else () list_filter = ("application",) + list_select_related = ("application", "user") class RefreshTokenAdmin(admin.ModelAdmin): diff --git a/oauth2_provider/apps.py b/oauth2_provider/apps.py index 887e4e3fb..3ad08b715 100644 --- a/oauth2_provider/apps.py +++ b/oauth2_provider/apps.py @@ -4,3 +4,7 @@ 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 diff --git a/oauth2_provider/checks.py b/oauth2_provider/checks.py new file mode 100644 index 000000000..848ba1af7 --- /dev/null +++ b/oauth2_provider/checks.py @@ -0,0 +1,28 @@ +from 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 [] diff --git a/oauth2_provider/compat.py b/oauth2_provider/compat.py index 0c83cb37a..846e32d0e 100644 --- a/oauth2_provider/compat.py +++ b/oauth2_provider/compat.py @@ -2,3 +2,14 @@ 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"] diff --git a/oauth2_provider/contrib/rest_framework/authentication.py b/oauth2_provider/contrib/rest_framework/authentication.py index 53087f756..afa75d845 100644 --- a/oauth2_provider/contrib/rest_framework/authentication.py +++ b/oauth2_provider/contrib/rest_framework/authentication.py @@ -1,5 +1,6 @@ from collections import OrderedDict +from django.core.exceptions import SuspiciousOperation from rest_framework.authentication import BaseAuthentication from ...oauth2_backends import get_oauthlib_core @@ -23,10 +24,18 @@ 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() - valid, r = oauthlib_core.verify_request(request, scopes=[]) - if valid: - return r.user, r.access_token + 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 diff --git a/oauth2_provider/contrib/rest_framework/permissions.py b/oauth2_provider/contrib/rest_framework/permissions.py index 1050bf751..bab3c776d 100644 --- a/oauth2_provider/contrib/rest_framework/permissions.py +++ b/oauth2_provider/contrib/rest_framework/permissions.py @@ -107,7 +107,7 @@ class IsAuthenticatedOrTokenHasScope(BasePermission): This only returns True if the user is authenticated, but not using a token or using a token, and the token has the correct scope. - This is usefull when combined with the DjangoModelPermissions to allow people browse + 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 """ diff --git a/oauth2_provider/exceptions.py b/oauth2_provider/exceptions.py index c4208488d..f68a651b6 100644 --- a/oauth2_provider/exceptions.py +++ b/oauth2_provider/exceptions.py @@ -17,3 +17,49 @@ class FatalClientError(OAuthToolkitError): """ 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." diff --git a/oauth2_provider/forms.py b/oauth2_provider/forms.py index 876213626..113ab3f53 100644 --- a/oauth2_provider/forms.py +++ b/oauth2_provider/forms.py @@ -12,3 +12,17 @@ class AllowForm(forms.Form): 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) diff --git a/oauth2_provider/generators.py b/oauth2_provider/generators.py index f72bc6e7a..436a303aa 100644 --- a/oauth2_provider/generators.py +++ b/oauth2_provider/generators.py @@ -17,7 +17,7 @@ class ClientIdGenerator(BaseHashGenerator): def hash(self): """ Generate a client_id for Basic Authentication scheme without colon char - as in http://tools.ietf.org/html/rfc2617#section-2 + as in https://rfc-editor.org/rfc/rfc2617.html#section-2 """ return oauthlib_generate_client_id(length=40, chars=UNICODE_ASCII_CHARACTER_SET) diff --git a/oauth2_provider/locale/es/LC_MESSAGES/django.po b/oauth2_provider/locale/es/LC_MESSAGES/django.po new file mode 100644 index 000000000..f4223386b --- /dev/null +++ b/oauth2_provider/locale/es/LC_MESSAGES/django.po @@ -0,0 +1,197 @@ +# 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." diff --git a/oauth2_provider/locale/fr/LC_MESSAGES/django.po b/oauth2_provider/locale/fr/LC_MESSAGES/django.po new file mode 100644 index 000000000..2d796cab6 --- /dev/null +++ b/oauth2_provider/locale/fr/LC_MESSAGES/django.po @@ -0,0 +1,193 @@ +#, 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." diff --git a/oauth2_provider/locale/ja/LC_MESSAGES/django.po b/oauth2_provider/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 000000000..75e3be247 --- /dev/null +++ b/oauth2_provider/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,197 @@ +# 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 "認証されたトークンはありません" diff --git a/oauth2_provider/locale/pt_BR/LC_MESSAGES/django.po b/oauth2_provider/locale/pt_BR/LC_MESSAGES/django.po index 48d673e33..e852622e3 100644 --- a/oauth2_provider/locale/pt_BR/LC_MESSAGES/django.po +++ b/oauth2_provider/locale/pt_BR/LC_MESSAGES/django.po @@ -9,10 +9,10 @@ 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" +"PO-Revision-Date: 2025-07-01 12:35-0300\n" +"Last-Translator: Thales Barbosa Bento \n" "Language-Team: LANGUAGE \n" -"Language: \n" +"Language: pt_BR\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" @@ -101,7 +101,7 @@ msgstr "Tem certeza que deseja remover a aplicação?" msgid "Cancel" msgstr "Cancelar" -#: templates/oauth2_provider/application_confirm_delete.html:13 +#: templates/oauth2_provider/application_confirm_delete.html:13F #: templates/oauth2_provider/application_detail.html:38 #: templates/oauth2_provider/authorized-token-delete.html:7 msgid "Delete" @@ -113,7 +113,7 @@ msgstr "ID do Cliente" #: templates/oauth2_provider/application_detail.html:15 msgid "Client secret" -msgstr "Palavra-Chave Secreta do Cliente" +msgstr "Chave Secreta do Cliente" #: templates/oauth2_provider/application_detail.html:20 msgid "Client type" @@ -200,3 +200,258 @@ msgstr "Revogar" #: templates/oauth2_provider/authorized-tokens.html:19 msgid "There are no authorized tokens yet." msgstr "Não existem tokens autorizados ainda." + +msgid "Allowed origins list to enable CORS, space separated" +msgstr "Lista de origens permitidas para habilitar CORS, separadas por espaços" + +msgid "Access tokens" +msgstr "Tokens de acesso" + +msgid "Applications" +msgstr "Aplicações" + +msgid "Grants" +msgstr "Concessões" + +msgid "Id tokens" +msgstr "Tokens de ID" + +msgid "Refresh tokens" +msgstr "Tokens de atualização" + +msgid "Access token" +msgstr "Token de acesso" + +msgid "Application" +msgstr "Aplicação" + +msgid "Grant" +msgstr "Concessão" + +msgid "Id token" +msgstr "Token de ID" + +msgid "Refresh token" +msgstr "Token de atualização" + +msgid "Skip authorization" +msgstr "Pular autorização" + +msgid "Algorithm" +msgstr "Algoritmo" + +msgid "Created" +msgstr "Criado" + +msgid "Updated" +msgstr "Atualizado" + +msgid "Scope" +msgstr "Escopo" + +msgid "Scopes" +msgstr "Escopos" + +msgid "Expires" +msgstr "Expira" + +msgid "Token" +msgstr "Token" + +msgid "User" +msgstr "Usuário" + +msgid "Code" +msgstr "Código" + +msgid "Redirect URI" +msgstr "URI de Redirecionamento" + +msgid "State" +msgstr "Estado" + +msgid "Nonce" +msgstr "Nonce (Number Used Once)" + +msgid "Code Challenge" +msgstr "Desafio do Código" + +msgid "Code Challenge Method" +msgstr "Método do Desafio do Código" + +msgid "PKCE required" +msgstr "PKCE obrigatório" + +msgid "Post logout redirect URI" +msgstr "URI de redirecionamento pós-logout" + +msgid "Hash client secret" +msgstr "Hash da chave secreta do cliente" + +msgid "The client secret will be hashed" +msgstr "A chave secreta do cliente será transformada em hash" + +msgid "Skip authorization completely" +msgstr "Pular autorização completamente" + +msgid "Post logout redirect URIs" +msgstr "URIs de redirecionamento pós-logout" + +msgid "Allowed Post Logout Redirect URIs list, space separated" +msgstr "Lista de URIs de redirecionamento pós-logout permitidas, separadas por espaços" + +msgid "Authorization Grant" +msgstr "Concessão de Autorização" + +msgid "Authorization Grants" +msgstr "Concessões de Autorização" + +msgid "Invalid client or client credentials" +msgstr "Cliente inválido ou credenciais do cliente inválidas" + +msgid "Invalid authorization code" +msgstr "Código de autorização inválido" + +msgid "Invalid redirect URI" +msgstr "URI de redirecionamento inválida" + +msgid "Invalid grant type" +msgstr "Tipo de concessão inválido" + +msgid "Invalid scope" +msgstr "Escopo inválido" + +msgid "Invalid request" +msgstr "Solicitação inválida" + +msgid "Unsupported grant type" +msgstr "Tipo de concessão não suportado" + +msgid "Unsupported response type" +msgstr "Tipo de resposta não suportado" + +msgid "Authorization server error" +msgstr "Erro do servidor de autorização" + +msgid "Temporarily unavailable" +msgstr "Temporariamente indisponível" + +msgid "Access denied" +msgstr "Acesso negado" + +msgid "Invalid client" +msgstr "Cliente inválido" + +msgid "The client identifier provided is invalid" +msgstr "O identificador do cliente fornecido é inválido" + +msgid "The client authentication failed" +msgstr "A autenticação do cliente falhou" + +msgid "The authorization grant is invalid, expired, or revoked" +msgstr "A concessão de autorização é inválida, expirada ou revogada" + +msgid "The authenticated client is not authorized to use this authorization grant type" +msgstr "O cliente autenticado não está autorizado a usar este tipo de concessão de autorização" + +msgid "The request is missing a required parameter" +msgstr "A solicitação está perdendo um parâmetro obrigatório" + +msgid "The authorization server encountered an unexpected condition" +msgstr "O servidor de autorização encontrou uma condição inesperada" + +msgid "The authorization server is currently unable to handle the request" +msgstr "O servidor de autorização atualmente não consegue processar a solicitação" + +msgid "The resource owner or authorization server denied the request" +msgstr "O proprietário do recurso ou servidor de autorização negou a solicitação" + +msgid "The authorization server does not support obtaining authorization codes" +msgstr "O servidor de autorização não suporta obter códigos de autorização" + +msgid "The authorization server does not support the revocation of access tokens" +msgstr "O servidor de autorização não suporta a revogação de tokens de acesso" + +msgid "The authorization server does not support the revocation of refresh tokens" +msgstr "O servidor de autorização não suporta a revogação de tokens de atualização" + +msgid "The authorization server does not support the use of the transformation parameter" +msgstr "O servidor de autorização não suporta o uso do parâmetro de transformação" + +msgid "The target resource is invalid, missing, malformed, or not supported" +msgstr "O recurso de destino é inválido, ausente, malformado ou não suportado" + +msgid "Rotate refresh token" +msgstr "Rotacionar token de atualização" + +msgid "Reuse refresh token" +msgstr "Reutilizar token de atualização" + +msgid "Application name" +msgstr "Nome da aplicação" + +msgid "Application description" +msgstr "Descrição da aplicação" + +msgid "Application logo" +msgstr "Logo da aplicação" + +msgid "Application website" +msgstr "Website da aplicação" + +msgid "Terms of service" +msgstr "Termos de serviço" + +msgid "Privacy policy" +msgstr "Política de privacidade" + +msgid "Support email" +msgstr "Email de suporte" + +msgid "Support URL" +msgstr "URL de suporte" + +msgid "Redirect URIs" +msgstr "URIs de Redirecionamento" + +msgid "Source refresh token" +msgstr "Token de atualização de origem" + +msgid "Token checksum" +msgstr "Checksum do token" + +msgid "Token family" +msgstr "Família do token" + +msgid "Code challenge" +msgstr "Desafio do código" + +msgid "Code challenge method" +msgstr "Método do desafio do código" + +msgid "Claims" +msgstr "Reivindicações" + +msgid "JWT Token ID" +msgstr "ID do Token JWT" + +msgid "Allowed origins" +msgstr "Origens permitidas" + +msgid "Client ID" +msgstr "ID do Cliente" + +msgid "Revoked" +msgstr "Revogado" + +msgid "Authorization grant type" +msgstr "Tipo de concessão de autorização" + +msgid "Hashed on Save. Copy it now if this is a new secret." +msgstr "Hashed em Salvar. Copie agora se este for um novo segredo." + +msgid "allowed origin URI Validation error" +msgstr "erro de validação de URI de origem permitido. invalid_scheme" + +msgid "Date" +msgstr "Data" diff --git a/oauth2_provider/locale/tr/LC_MESSAGES/django.po b/oauth2_provider/locale/tr/LC_MESSAGES/django.po new file mode 100644 index 000000000..cf49b1ccc --- /dev/null +++ b/oauth2_provider/locale/tr/LC_MESSAGES/django.po @@ -0,0 +1,215 @@ +# 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. +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-07-11 14:11+0300\n" +"PO-Revision-Date: 2025-07-11 14:15+0300\n" +"Last-Translator: Cihad GUNDOGDU \n" +"Language-Team: LANGUAGE \n" +"Language: tr\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:84 +msgid "Confidential" +msgstr "Gizli" + +#: oauth2_provider/models.py:85 +msgid "Public" +msgstr "Herkese açık" + +#: oauth2_provider/models.py:94 +msgid "Authorization code" +msgstr "Yetkilendirme kodu" + +#: oauth2_provider/models.py:95 +msgid "Implicit" +msgstr "Açık" + +#: oauth2_provider/models.py:96 +msgid "Resource owner password-based" +msgstr "Kaynak sahibi şifre tabanlı" + +#: oauth2_provider/models.py:97 +msgid "Client credentials" +msgstr "İstemci kimlik bilgileri" + +#: oauth2_provider/models.py:98 +msgid "OpenID connect hybrid" +msgstr "OpenID connect hibrit" + +#: oauth2_provider/models.py:105 +msgid "No OIDC support" +msgstr "OpenID Connect desteği yok" + +#: oauth2_provider/models.py:106 +msgid "RSA with SHA-2 256" +msgstr "RSA ile SHA-2 256" + +#: oauth2_provider/models.py:107 +msgid "HMAC with SHA-2 256" +msgstr "HMAC ile SHA-2 256" + +#: oauth2_provider/models.py:122 +msgid "Allowed URIs list, space separated" +msgstr "İzin verilen URI'ler listesi, boşlukla ayrılmış" + +#: oauth2_provider/models.py:126 +msgid "Allowed Post Logout URIs list, space separated" +msgstr "İzin verilen Oturum Kapatma URI'leri listesi, boşlukla ayrılmış" + +#: oauth2_provider/models.py:136 +msgid "Hashed on Save. Copy it now if this is a new secret." +msgstr "Kaydedildiğinde Hashlendi. Bu yeni bir gizli anahtar ise şimdi kopyalayın." + +#: oauth2_provider/models.py:147 +msgid "Allowed origins list to enable CORS, space separated" +msgstr "CORS'u etkinleştirmek için izin verilen kökenler listesi, boşlukla ayrılmış" + +#: oauth2_provider/models.py:227 +#, python-brace-format +msgid "redirect_uris cannot be empty with grant_type {grant_type}" +msgstr "redirect_uris {grant_type} ile boş olamaz" + +#: oauth2_provider/models.py:244 +msgid "You must set OIDC_RSA_PRIVATE_KEY to use RSA algorithm" +msgstr "RSA algoritmasını kullanmak için OIDC_RSA_PRIVATE_KEY ayarlanmalıdır" + +#: oauth2_provider/models.py:253 +msgid "You cannot use HS256 with public grants or clients" +msgstr "HS256'yı herkese açık izinler veya istemcilerle kullanamazsınız" + +#: oauth2_provider/oauth2_validators.py:225 +msgid "The access token is invalid." +msgstr "Geçersiz erişim belirteci." + +#: oauth2_provider/oauth2_validators.py:232 +msgid "The access token has expired." +msgstr "Erişim belirteci süresi dolmuş." + +#: oauth2_provider/oauth2_validators.py:239 +msgid "The access token is valid but does not have enough scope." +msgstr "Erişim belirteci geçerli ancak yeterli kapsamı yok." + +#: oauth2_provider/templates/oauth2_provider/application_confirm_delete.html:6 +msgid "Are you sure to delete the application" +msgstr "Uygulamayı silmek istediğinize emin misiniz" + +#: oauth2_provider/templates/oauth2_provider/application_confirm_delete.html:12 +#: oauth2_provider/templates/oauth2_provider/authorize.html:29 +msgid "Cancel" +msgstr "İptal" + +#: oauth2_provider/templates/oauth2_provider/application_confirm_delete.html:13 +#: oauth2_provider/templates/oauth2_provider/application_detail.html:53 +#: oauth2_provider/templates/oauth2_provider/authorized-token-delete.html:7 +msgid "Delete" +msgstr "Sil" + +#: oauth2_provider/templates/oauth2_provider/application_detail.html:10 +msgid "Client id" +msgstr "İstemci kimliği" + +#: oauth2_provider/templates/oauth2_provider/application_detail.html:15 +msgid "Client secret" +msgstr "İstemci gizli anahtarı" + +#: oauth2_provider/templates/oauth2_provider/application_detail.html:20 +msgid "Hash client secret" +msgstr "İstemci gizli anahtarını hashle" + +#: oauth2_provider/templates/oauth2_provider/application_detail.html:21 +msgid "yes,no" +msgstr "evet,hayır" + +#: oauth2_provider/templates/oauth2_provider/application_detail.html:25 +msgid "Client type" +msgstr "İstemci türü" + +#: oauth2_provider/templates/oauth2_provider/application_detail.html:30 +msgid "Authorization Grant Type" +msgstr "Yetkilendirme İzni Türü" + +#: oauth2_provider/templates/oauth2_provider/application_detail.html:35 +msgid "Redirect Uris" +msgstr "Yönlendirme URI'leri" + +#: oauth2_provider/templates/oauth2_provider/application_detail.html:40 +msgid "Post Logout Redirect Uris" +msgstr "Oturum Kapatma Yönlendirme URI'leri" + +#: oauth2_provider/templates/oauth2_provider/application_detail.html:45 +msgid "Allowed Origins" +msgstr "İzin Verilen Orijinler" + +#: oauth2_provider/templates/oauth2_provider/application_detail.html:51 +#: oauth2_provider/templates/oauth2_provider/application_form.html:35 +msgid "Go Back" +msgstr "Geri Dön" + +#: oauth2_provider/templates/oauth2_provider/application_detail.html:52 +msgid "Edit" +msgstr "Düzenle" + +#: oauth2_provider/templates/oauth2_provider/application_form.html:9 +msgid "Edit application" +msgstr "Uygulamayı düzenle" + +#: oauth2_provider/templates/oauth2_provider/application_form.html:37 +msgid "Save" +msgstr "Kaydet" + +#: oauth2_provider/templates/oauth2_provider/application_list.html:6 +msgid "Your applications" +msgstr "Uygulamalarınız" + +#: oauth2_provider/templates/oauth2_provider/application_list.html:14 +msgid "New Application" +msgstr "Yeni Uygulama" + +#: oauth2_provider/templates/oauth2_provider/application_list.html:17 +msgid "No applications defined" +msgstr "Tanımlı uygulama yok" + +#: oauth2_provider/templates/oauth2_provider/application_list.html:17 +msgid "Click here" +msgstr "Buraya tıklayın" + +#: oauth2_provider/templates/oauth2_provider/application_list.html:17 +msgid "if you want to register a new one" +msgstr "eğer yeni bir tane kaydetmek istiyorsanız" + +#: oauth2_provider/templates/oauth2_provider/application_registration_form.html:5 +msgid "Register a new application" +msgstr "Yeni bir uygulama kaydet" + +#: oauth2_provider/templates/oauth2_provider/authorize.html:8 +#: oauth2_provider/templates/oauth2_provider/authorize.html:30 +msgid "Authorize" +msgstr "Yetkilendir" + +#: oauth2_provider/templates/oauth2_provider/authorize.html:17 +msgid "Application requires the following permissions" +msgstr "Uygulama aşağıdaki izinleri gerektirir" + +#: oauth2_provider/templates/oauth2_provider/authorized-token-delete.html:6 +msgid "Are you sure you want to delete this token?" +msgstr "Bu tokeni silmek istediğinize emin misiniz?" + +#: oauth2_provider/templates/oauth2_provider/authorized-tokens.html:6 +msgid "Tokens" +msgstr "Tokenler" + +#: oauth2_provider/templates/oauth2_provider/authorized-tokens.html:11 +msgid "revoke" +msgstr "iptal et" + +#: oauth2_provider/templates/oauth2_provider/authorized-tokens.html:19 +msgid "There are no authorized tokens yet." +msgstr "Henüz yetkilendirilmiş token yok." diff --git a/oauth2_provider/management/commands/createapplication.py b/oauth2_provider/management/commands/createapplication.py index f8575a8b0..ad95a11b7 100644 --- a/oauth2_provider/management/commands/createapplication.py +++ b/oauth2_provider/management/commands/createapplication.py @@ -14,12 +14,13 @@ def add_arguments(self, parser): parser.add_argument( "client_type", type=str, - help="The client type, can be confidential or public", + 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", + 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", @@ -36,11 +37,24 @@ def add_arguments(self, parser): 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, @@ -54,7 +68,8 @@ def add_arguments(self, parser): parser.add_argument( "--algorithm", type=str, - help="The OIDC token signing algorithm for this application (e.g., 'RS256' or 'HS256')", + 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): @@ -63,10 +78,10 @@ def handle(self, *args, **options): 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 unneded key-value like + # Data in options must be cleaned because there are unneeded key-value like # verbosity and others. Also do not pass any None to the Application # instance so default values will be generated for those fields - if key in application_fields and value: + if key in application_fields and (isinstance(value, bool) or value): if key == "user": application_data.update({"user_id": value}) else: @@ -82,5 +97,13 @@ def handle(self, *args, **options): ) self.stdout.write(self.style.ERROR("Please correct the following errors:\n %s" % errors)) else: + cleartext_secret = new_application.client_secret new_application.save() - self.stdout.write(self.style.SUCCESS("New application created successfully")) + # 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)) diff --git a/oauth2_provider/middleware.py b/oauth2_provider/middleware.py index 17ba6c35f..65c9cf03c 100644 --- a/oauth2_provider/middleware.py +++ b/oauth2_provider/middleware.py @@ -1,6 +1,14 @@ +import 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: """ @@ -36,3 +44,22 @@ def __call__(self, request): 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 diff --git a/oauth2_provider/migrations/0006_alter_application_client_secret.py b/oauth2_provider/migrations/0006_alter_application_client_secret.py index 88e148274..a940c22c9 100644 --- a/oauth2_provider/migrations/0006_alter_application_client_secret.py +++ b/oauth2_provider/migrations/0006_alter_application_client_secret.py @@ -1,20 +1,32 @@ -from django.db import migrations -from django.contrib.auth.hashers import identify_hasher, make_password import 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('oauth2_provider', 'application') - applications = Application.objects.all() + 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 = [ @@ -27,5 +39,5 @@ class Migration(migrations.Migration): 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), + migrations.RunPython(forwards_func, reverse_func), ] diff --git a/oauth2_provider/migrations/0007_application_post_logout_redirect_uris.py b/oauth2_provider/migrations/0007_application_post_logout_redirect_uris.py new file mode 100644 index 000000000..f4ca37187 --- /dev/null +++ b/oauth2_provider/migrations/0007_application_post_logout_redirect_uris.py @@ -0,0 +1,18 @@ +# 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=""), + ), + ] diff --git a/oauth2_provider/migrations/0008_alter_accesstoken_token.py b/oauth2_provider/migrations/0008_alter_accesstoken_token.py new file mode 100644 index 000000000..5d3a9ebc8 --- /dev/null +++ b/oauth2_provider/migrations/0008_alter_accesstoken_token.py @@ -0,0 +1,17 @@ +# 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), + ), + ] diff --git a/oauth2_provider/migrations/0009_add_hash_client_secret.py b/oauth2_provider/migrations/0009_add_hash_client_secret.py new file mode 100644 index 000000000..9452bce98 --- /dev/null +++ b/oauth2_provider/migrations/0009_add_hash_client_secret.py @@ -0,0 +1,18 @@ +# 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), + ), + ] diff --git a/oauth2_provider/migrations/0010_application_allowed_origins.py b/oauth2_provider/migrations/0010_application_allowed_origins.py new file mode 100644 index 000000000..a22a8f7c0 --- /dev/null +++ b/oauth2_provider/migrations/0010_application_allowed_origins.py @@ -0,0 +1,22 @@ +# 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="", + ), + ), + ] diff --git a/oauth2_provider/migrations/0011_refreshtoken_token_family.py b/oauth2_provider/migrations/0011_refreshtoken_token_family.py new file mode 100644 index 000000000..94fb4e171 --- /dev/null +++ b/oauth2_provider/migrations/0011_refreshtoken_token_family.py @@ -0,0 +1,19 @@ +# 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), + ), + ] diff --git a/oauth2_provider/migrations/0012_add_token_checksum.py b/oauth2_provider/migrations/0012_add_token_checksum.py new file mode 100644 index 000000000..d27c65e54 --- /dev/null +++ b/oauth2_provider/migrations/0012_add_token_checksum.py @@ -0,0 +1,40 @@ +# 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.iterator() + 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), + ), + ] diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index 1ded7a4e2..a76db37c0 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -1,6 +1,8 @@ +import hashlib import logging import time import uuid +from contextlib import suppress from datetime import timedelta from urllib.parse import parse_qsl, urlparse @@ -8,7 +10,7 @@ from django.conf import settings from django.contrib.auth.hashers import identify_hasher, make_password from django.core.exceptions import ImproperlyConfigured -from django.db import models, transaction +from django.db import models, router, transaction from django.urls import reverse from django.utils import timezone from django.utils.translation import gettext_lazy as _ @@ -19,7 +21,8 @@ from .generators import generate_client_id, generate_client_secret from .scopes import get_scopes_backend from .settings import oauth2_settings -from .validators import RedirectURIValidator, WildcardSet +from .utils import jwk_from_pem +from .validators import AllowedURIValidator logger = logging.getLogger(__name__) @@ -28,17 +31,29 @@ 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) + hashed_secret = make_password(secret, hasher=oauth2_settings.CLIENT_SECRET_HASHER) 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. @@ -52,6 +67,9 @@ class AbstractApplication(models.Model): * :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 @@ -103,6 +121,11 @@ class AbstractApplication(models.Model): 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( @@ -112,12 +135,18 @@ class AbstractApplication(models.Model): 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 @@ -150,6 +179,22 @@ def redirect_uri_allowed(self, uri): """ 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 @@ -167,12 +212,15 @@ def clean(self): allowed_schemes = set(s.lower() for s in self.get_allowed_schemes()) if redirect_uris: - validator = RedirectURIValidator(WildcardSet()) + validator = AllowedURIValidator( + allowed_schemes, + name="redirect uri", + allow_path=True, + allow_query=True, + allow_hostname_wildcard=oauth2_settings.ALLOW_URI_WILDCARDS, + ) for uri in redirect_uris: validator(uri) - scheme = urlparse(uri).scheme - if scheme not in allowed_schemes: - raise ValidationError(_("Unauthorized redirect scheme: {scheme}").format(scheme=scheme)) elif self.authorization_grant_type in grant_types: raise ValidationError( @@ -180,6 +228,17 @@ def clean(self): 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", + allow_hostname_wildcard=oauth2_settings.ALLOW_URI_WILDCARDS, + ) + 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")) @@ -194,7 +253,7 @@ def clean(self): raise ValidationError(_("You cannot use HS256 with public grants or clients")) def get_absolute_url(self): - return reverse("oauth2_provider:detail", args=[str(self.id)]) + return reverse("oauth2_provider:detail", args=[str(self.pk)]) def get_allowed_schemes(self): """ @@ -219,7 +278,7 @@ def jwk_key(self): if self.algorithm == AbstractApplication.RS256_ALGORITHM: if not oauth2_settings.OIDC_RSA_PRIVATE_KEY: raise ImproperlyConfigured("You must set OIDC_RSA_PRIVATE_KEY to use RSA algorithm") - return jwk.JWK.from_pem(oauth2_settings.OIDC_RSA_PRIVATE_KEY.encode("utf8")) + 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") @@ -338,9 +397,12 @@ class AbstractAccessToken(models.Model): null=True, related_name="refreshed_access_token", ) - token = models.CharField( - max_length=255, + 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, @@ -448,6 +510,7 @@ class AbstractRefreshToken(models.Model): 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) @@ -458,17 +521,19 @@ 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() - with transaction.atomic(): + + # 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] - try: + with suppress(access_token_model.DoesNotExist): access_token_model.objects.get(id=self.access_token_id).revoke() - except access_token_model.DoesNotExist: - pass + self.access_token = None self.revoked = timezone.now() self.save() @@ -601,7 +666,7 @@ def get_access_token_model(): def get_id_token_model(): - """Return the AccessToken model that is active in this project.""" + """Return the IDToken model that is active in this project.""" return apps.get_model(oauth2_settings.ID_TOKEN_MODEL) @@ -650,7 +715,7 @@ def batch_delete(queryset, query): 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") + 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() @@ -663,6 +728,7 @@ def batch_delete(queryset, query): 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 @@ -696,6 +762,12 @@ def batch_delete(queryset, 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) @@ -713,12 +785,28 @@ def redirect_to_uri_allowed(uri, allowed_uris): :param allowed_uris: A list of URIs that are allowed """ + if not isinstance(allowed_uris, list): + raise ValueError("allowed_uris must be a list") + parsed_uri = urlparse(uri) uqs_set = set(parse_qsl(parsed_uri.query)) for allowed_uri in allowed_uris: parsed_allowed_uri = urlparse(allowed_uri) + if parsed_allowed_uri.scheme != parsed_uri.scheme: + # match failed, continue + continue + + """ check hostname """ + if oauth2_settings.ALLOW_URI_WILDCARDS and parsed_allowed_uri.hostname.startswith("*"): + """ wildcard hostname """ + if not parsed_uri.hostname.endswith(parsed_allowed_uri.hostname[1:]): + continue + elif parsed_allowed_uri.hostname != parsed_uri.hostname: + continue + # From RFC 8252 (Section 7.3) + # https://datatracker.ietf.org/doc/html/rfc8252#section-7.3 # # Loopback redirect URIs use the "http" scheme # [...] @@ -726,25 +814,48 @@ def redirect_to_uri_allowed(uri, allowed_uris): # 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", + ] + """ check port """ + if not allowed_uri_is_loopback and parsed_allowed_uri.port != parsed_uri.port: + continue + + """ check path """ + if parsed_allowed_uri.path != parsed_uri.path: + continue + + """ check querystring """ + aqs_set = set(parse_qsl(parsed_allowed_uri.query)) + if not aqs_set.issubset(uqs_set): + continue # circuit break - 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 - ) + return True + + # if uris matched then it's not allowed + 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 ( - 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 + parsed_allowed_origin.scheme == parsed_origin.scheme + and parsed_allowed_origin.netloc == parsed_origin.netloc ): - - aqs_set = set(parse_qsl(parsed_allowed_uri.query)) - if aqs_set.issubset(uqs_set): - return True + return True return False diff --git a/oauth2_provider/oauth2_backends.py b/oauth2_provider/oauth2_backends.py index dbebd3a8e..3ddb9c90b 100644 --- a/oauth2_provider/oauth2_backends.py +++ b/oauth2_provider/oauth2_backends.py @@ -75,6 +75,11 @@ def extract_headers(self, request): 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: @@ -152,12 +157,14 @@ def create_token_response(self, request): uri, http_method, body, headers = self._extract_params(request) extra_credentials = self._get_extra_credentials(request) - 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 + 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): """ diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index b33c80f39..db459a446 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -1,5 +1,6 @@ import base64 import binascii +import hashlib import http.client import inspect import json @@ -12,18 +13,18 @@ import requests from django.conf import settings from django.contrib.auth import authenticate, get_user_model -from django.contrib.auth.hashers import check_password +from django.contrib.auth.hashers import check_password, identify_hasher from django.core.exceptions import ObjectDoesNotExist -from django.db import transaction -from django.db.models import Q +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 utils +from oauthlib.oauth2.rfc6749 import errors, utils from oauthlib.openid import RequestValidator from .exceptions import FatalClientError @@ -37,6 +38,7 @@ ) from .scopes import get_scopes_backend from .settings import oauth2_settings +from .utils import get_timezone log = logging.getLogger("oauth2_provider") @@ -102,16 +104,28 @@ def _extract_basic_auth(self, request): if not auth: return None - splitted = auth.split(" ", 1) - if len(splitted) != 2: + split = auth.split(" ", 1) + if len(split) != 2: return None - auth_type, auth_string = splitted + 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. @@ -152,7 +166,7 @@ def _authenticate_basic_auth(self, request): elif request.client.client_id != client_id: log.debug("Failed basic auth: wrong client id %s" % client_id) return False - elif not check_password(client_secret, request.client.client_secret): + 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: @@ -170,14 +184,14 @@ def _authenticate_request_body(self, request): # TODO: check if oauthlib has already unquoted client_id and client_secret try: client_id = request.client_id - client_secret = request.client_secret + 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 check_password(client_secret, request.client.client_secret): + 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: @@ -292,7 +306,6 @@ def authenticate_client_id(self, client_id, request, *args, **kwargs): proceed only if the client exists and is not of type "Confidential". """ if self._load_application(client_id, request) is not None: - log.debug("Application %r has type %r" % (client_id, request.client.client_type)) return request.client.client_type != AbstractApplication.CLIENT_CONFIDENTIAL return False @@ -305,10 +318,13 @@ def confirm_redirect_uri(self, client_id, code, redirect_uri, client, *args, **k def invalidate_authorization_code(self, client_id, code, request, *args, **kwargs): """ - Remove the temporary grant used to swap the authorization token + Remove the temporary grant used to swap the authorization token. + + :raises: InvalidGrantError if the grant does not exist. """ - grant = Grant.objects.get(code=code, application=request.client) - grant.delete() + deleted_grant_count, _ = Grant.objects.filter(code=code, application=request.client).delete() + if not deleted_grant_count: + raise errors.InvalidGrantError(request=request) def validate_client_id(self, client_id, request, *args, **kwargs): """ @@ -320,6 +336,18 @@ def validate_client_id(self, client_id, request, *args, **kwargs): 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 ): @@ -370,9 +398,7 @@ def _get_token_from_authentication_server( if "active" in content and content["active"] is True: if "username" in content: - user, _created = UserModel.objects.get_or_create( - **{UserModel.USERNAME_FIELD: content["username"]} - ) + user = self.get_or_create_user_from_content(content) else: user = None @@ -388,7 +414,11 @@ def _get_token_from_authentication_server( expires = max_caching_time scope = content.get("scope", "") - expires = make_aware(expires) if settings.USE_TZ else expires + + 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, @@ -435,7 +465,12 @@ def validate_bearer_token(self, token, scopes, request): return False def _load_access_token(self, token): - return AccessToken.objects.select_related("application", "user").filter(token=token).first() + 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: @@ -530,13 +565,25 @@ def rotate_refresh_token(self, request): """ return oauth2_settings.ROTATE_REFRESH_TOKEN - @transaction.atomic def save_bearer_token(self, token, request, *args, **kwargs): """ - Save access and refresh token, If refresh token is issued, remove or - reuse old refresh token as in rfc:`6` + 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) - @see: https://tools.ietf.org/html/draft-ietf-oauth-v2-31#page-43 + 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: @@ -572,7 +619,6 @@ def save_bearer_token(self, token, request, *args, **kwargs): 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 ) @@ -591,7 +637,7 @@ def save_bearer_token(self, token, request, *args, **kwargs): # from the db while acquiring a lock on it # We also put it in the "request cache" refresh_token_instance = RefreshToken.objects.select_for_update().get( - id=refresh_token_instance.id + pk=refresh_token_instance.pk ) request.refresh_token_instance = refresh_token_instance @@ -618,7 +664,9 @@ def save_bearer_token(self, token, request, *args, **kwargs): source_refresh_token=refresh_token_instance, ) - self._create_refresh_token(request, refresh_token_code, access_token) + 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 @@ -662,9 +710,17 @@ def _create_authorization_code(self, request, code, expires=None): claims=json.dumps(request.claims or {}), ) - def _create_refresh_token(self, request, refresh_token_code, access_token): + 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 + 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): @@ -714,8 +770,10 @@ def get_original_scopes(self, refresh_token, request, *args, **kwargs): # validate_refresh_token. rt = request.refresh_token_instance if not rt.access_token_id: - return AccessToken.objects.get(source_refresh_token_id=rt.id).scope - + 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): @@ -724,25 +782,27 @@ def validate_refresh_token(self, refresh_token, client, request, *args, **kwargs Also attach User instance to the request object """ - null_or_recent = Q(revoked__isnull=True) | Q( - revoked__gt=timezone.now() - timedelta(seconds=oauth2_settings.REFRESH_TOKEN_GRACE_PERIOD_SECONDS) - ) - rt = ( - RefreshToken.objects.filter(null_or_recent, token=refresh_token) - .select_related("access_token") - .first() - ) + 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 - @transaction.atomic def _save_id_token(self, jti, request, expires, *args, **kwargs): scopes = request.scope or " ".join(request.scopes) @@ -764,9 +824,9 @@ def get_jwt_bearer_token(self, token, token_handler, request): def get_claim_dict(self, request): if self._get_additional_claims_is_request_agnostic(): - claims = {"sub": lambda r: str(r.user.id)} + claims = {"sub": lambda r: str(r.user.pk)} else: - claims = {"sub": str(request.user.id)} + 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(): @@ -843,7 +903,9 @@ def finalize_id_token(self, id_token, token, token_handler, request): claims=json.dumps(id_token, default=str), ) jwt_token.make_signed_token(request.client.jwk_key) - id_token = self._save_id_token(id_token["jti"], request, expiration_time) + # 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 @@ -909,7 +971,7 @@ def _get_client_by_audience(self, audience): return Application.objects.filter(client_id__in=audience).first() def validate_user_match(self, id_token_hint, scopes, claims, request): - # TODO: Fix to validate when necessary acording + # 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 @@ -946,3 +1008,13 @@ def get_userinfo_claims(self, 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) diff --git a/oauth2_provider/settings.py b/oauth2_provider/settings.py index 00a4e631c..9771aa4e7 100644 --- a/oauth2_provider/settings.py +++ b/oauth2_provider/settings.py @@ -18,8 +18,8 @@ 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.test.signals import setting_changed from django.urls import reverse from django.utils.module_loading import import_string from oauthlib.common import Request @@ -37,6 +37,7 @@ "CLIENT_ID_GENERATOR_CLASS": "oauth2_provider.generators.ClientIdGenerator", "CLIENT_SECRET_GENERATOR_CLASS": "oauth2_provider.generators.ClientSecretGenerator", "CLIENT_SECRET_GENERATOR_LENGTH": 128, + "CLIENT_SECRET_HASHER": "default", "ACCESS_TOKEN_GENERATOR": None, "REFRESH_TOKEN_GENERATOR": None, "EXTRA_SERVER_KWARGS": {}, @@ -54,6 +55,7 @@ "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, @@ -68,6 +70,8 @@ "REFRESH_TOKEN_ADMIN_CLASS": "oauth2_provider.admin.RefreshTokenAdmin", "REQUEST_APPROVAL_PROMPT": "force", "ALLOWED_REDIRECT_URI_SCHEMES": ["http", "https"], + "ALLOWED_SCHEMES": ["https"], + "ALLOW_URI_WILDCARDS": False, "OIDC_ENABLED": False, "OIDC_ISS_ENDPOINT": "", "OIDC_USERINFO_ENDPOINT": "", @@ -88,6 +92,11 @@ "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": [], @@ -96,6 +105,8 @@ "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. @@ -289,7 +300,7 @@ def oidc_issuer(self, request): 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/")] + return abs_url[: -len("/.well-known/openid-configuration")] oauth2_settings = OAuth2ProviderSettings(USER_SETTINGS, DEFAULTS, IMPORT_STRINGS, MANDATORY) diff --git a/oauth2_provider/templates/oauth2_provider/application_detail.html b/oauth2_provider/templates/oauth2_provider/application_detail.html index 736dc4605..74b71ee74 100644 --- a/oauth2_provider/templates/oauth2_provider/application_detail.html +++ b/oauth2_provider/templates/oauth2_provider/application_detail.html @@ -16,6 +16,11 @@

{{ application.name }}

+
  • +

    {% trans "Hash client secret" %}

    +

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

    +
  • +
  • {% trans "Client type" %}

    {{ application.client_type }}

    @@ -30,12 +35,22 @@

    {{ application.name }}

    {% trans "Redirect Uris" %}

  • + +
  • +

    {% trans "Post Logout Redirect Uris" %}

    + +
  • + +
  • +

    {% trans "Allowed Origins" %}

    + +
  • {% endblock content %} diff --git a/oauth2_provider/templates/oauth2_provider/application_form.html b/oauth2_provider/templates/oauth2_provider/application_form.html index dd8a644e8..7d8c07989 100644 --- a/oauth2_provider/templates/oauth2_provider/application_form.html +++ b/oauth2_provider/templates/oauth2_provider/application_form.html @@ -3,7 +3,7 @@ {% load i18n %} {% block content %}
    -
    +

    {% block app-form-title %} {% trans "Edit application" %} {{ application.name }} @@ -31,7 +31,7 @@

    - + {% trans "Go Back" %} diff --git a/oauth2_provider/templates/oauth2_provider/application_list.html b/oauth2_provider/templates/oauth2_provider/application_list.html index 807c050d3..509ccfc94 100644 --- a/oauth2_provider/templates/oauth2_provider/application_list.html +++ b/oauth2_provider/templates/oauth2_provider/application_list.html @@ -7,7 +7,7 @@

    {% trans "Your applications" %}

    {% if applications %} diff --git a/oauth2_provider/templates/oauth2_provider/logout_confirm.html b/oauth2_provider/templates/oauth2_provider/logout_confirm.html new file mode 100644 index 000000000..8b64f8314 --- /dev/null +++ b/oauth2_provider/templates/oauth2_provider/logout_confirm.html @@ -0,0 +1,37 @@ +{% 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 %} diff --git a/oauth2_provider/urls.py b/oauth2_provider/urls.py index 508f97c96..155822f45 100644 --- a/oauth2_provider/urls.py +++ b/oauth2_provider/urls.py @@ -1,4 +1,4 @@ -from django.urls import re_path +from django.urls import path, re_path from . import views @@ -7,37 +7,42 @@ base_urlpatterns = [ - re_path(r"^authorize/$", views.AuthorizationView.as_view(), name="authorize"), - re_path(r"^token/$", views.TokenView.as_view(), name="token"), - re_path(r"^revoke_token/$", views.RevokeTokenView.as_view(), name="revoke-token"), - re_path(r"^introspect/$", views.IntrospectTokenView.as_view(), name="introspect"), + 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 - re_path(r"^applications/$", views.ApplicationList.as_view(), name="list"), - re_path(r"^applications/register/$", views.ApplicationRegistration.as_view(), name="register"), - re_path(r"^applications/(?P[\w-]+)/$", views.ApplicationDetail.as_view(), name="detail"), - re_path(r"^applications/(?P[\w-]+)/delete/$", views.ApplicationDelete.as_view(), name="delete"), - re_path(r"^applications/(?P[\w-]+)/update/$", views.ApplicationUpdate.as_view(), name="update"), + 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 - re_path(r"^authorized_tokens/$", views.AuthorizedTokensListView.as_view(), name="authorized-token-list"), - re_path( - r"^authorized_tokens/(?P[\w-]+)/delete/$", + 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/$", + r"^\.well-known/openid-configuration/?$", views.ConnectDiscoveryInfoView.as_view(), name="oidc-connect-discovery-info", ), - re_path(r"^\.well-known/jwks.json$", views.JwksInfoView.as_view(), name="jwks-info"), - re_path(r"^userinfo/$", views.UserInfoView.as_view(), name="user-info"), + 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"), ] diff --git a/oauth2_provider/utils.py b/oauth2_provider/utils.py new file mode 100644 index 000000000..3f48723c5 --- /dev/null +++ b/oauth2_provider/utils.py @@ -0,0 +1,34 @@ +import 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) diff --git a/oauth2_provider/validators.py b/oauth2_provider/validators.py index 6c8fa3839..b2370cfd0 100644 --- a/oauth2_provider/validators.py +++ b/oauth2_provider/validators.py @@ -18,29 +18,118 @@ class URIValidator(URLValidator): regex = re.compile(scheme_re + host_re + port_re + path_re, re.IGNORECASE) -class RedirectURIValidator(URIValidator): - def __init__(self, allowed_schemes, allow_fragments=False): - super().__init__(schemes=allowed_schemes) +class AllowedURIValidator(URIValidator): + # TODO: find a way to get these associated with their form fields in place of passing name + # TODO: submit PR to get `cause` included in the parent class ValidationError params` + def __init__( + self, + schemes, + name, + allow_path=False, + allow_query=False, + allow_fragments=False, + allow_hostname_wildcard=False, + ): + """ + :param schemes: List of allowed schemes. E.g.: ["https"] + :param name: Name of the validated URI. It is required for validation message. E.g.: "Origin" + :param allow_path: If URI can contain path part + :param allow_query: If URI can contain query part + :param allow_fragments: If URI can contain fragments part + """ + super().__init__(schemes=schemes) + self.name = name + self.allow_path = allow_path + self.allow_query = allow_query self.allow_fragments = allow_fragments + self.allow_hostname_wildcard = allow_hostname_wildcard def __call__(self, value): - super().__call__(value) value = force_str(value) - scheme, netloc, path, query, fragment = urlsplit(value) + try: + scheme, netloc, path, query, fragment = urlsplit(value) + except ValueError as e: + raise ValidationError( + "%(name)s URI validation error. %(cause)s: %(value)s", + params={"name": self.name, "value": value, "cause": e}, + ) + + # send better validation errors + if scheme not in self.schemes: + raise ValidationError( + "%(name)s URI Validation error. %(cause)s: %(value)s", + params={"name": self.name, "value": value, "cause": "invalid_scheme"}, + ) + + if query and not self.allow_query: + raise ValidationError( + "%(name)s URI validation error. %(cause)s: %(value)s", + params={"name": self.name, "value": value, "cause": "query string not allowed"}, + ) if fragment and not self.allow_fragments: - raise ValidationError("Redirect URIs must not contain fragments") + raise ValidationError( + "%(name)s URI validation error. %(cause)s: %(value)s", + params={"name": self.name, "value": value, "cause": "fragment not allowed"}, + ) + if path and not self.allow_path: + raise ValidationError( + "%(name)s URI validation error. %(cause)s: %(value)s", + params={"name": self.name, "value": value, "cause": "path not allowed"}, + ) + if self.allow_hostname_wildcard and "*" in netloc: + domain_parts = netloc.split(".") + if netloc.count("*") > 1: + raise ValidationError( + "%(name)s URI validation error. %(cause)s: %(value)s", + params={ + "name": self.name, + "value": value, + "cause": "only one wildcard is allowed in the hostname", + }, + ) + if not netloc.startswith("*"): + raise ValidationError( + "%(name)s URI validation error. %(cause)s: %(value)s", + params={ + "name": self.name, + "value": value, + "cause": "wildcards must be at the beginning of the hostname", + }, + ) + if len(domain_parts) < 3: + raise ValidationError( + "%(name)s URI validation error. %(cause)s: %(value)s", + params={ + "name": self.name, + "value": value, + "cause": "wildcards cannot be in the top level or second level domain", + }, + ) -## -# WildcardSet is a special set that contains everything. -# This is required in order to move validation of the scheme from -# URLValidator (the base class of URIValidator), to OAuth2Application.clean(). + # strip the wildcard from the netloc, we'll reassamble the value later to pass to URI Validator + if netloc.startswith("*."): + netloc = netloc[2:] + else: + netloc = netloc[1:] + # domains cannot start with a hyphen, but can have them in the middle, so we strip hyphens + # after the wildcard so the final domain is valid and will succeed in URIVAlidator + if netloc.startswith("-"): + netloc = netloc[1:] -class WildcardSet(set): - """ - A set that always returns True on `in`. - """ + # we stripped the wildcard from the netloc and path if they were allowed and present since they would + # fail validation we'll reassamble the URI to pass to the URIValidator + reassambled_uri = f"{scheme}://{netloc}{path}" + if query: + reassambled_uri += f"?{query}" + if fragment: + reassambled_uri += f"#{fragment}" - def __contains__(self, item): - return True + try: + super().__call__(reassambled_uri) + except ValidationError as e: + raise ValidationError( + "%(name)s URI validation error. %(cause)s: %(value)s", + params={"name": self.name, "value": value, "cause": e}, + ) diff --git a/oauth2_provider/views/__init__.py b/oauth2_provider/views/__init__.py index 0720c1aa2..9e32e17d8 100644 --- a/oauth2_provider/views/__init__.py +++ b/oauth2_provider/views/__init__.py @@ -15,5 +15,5 @@ ScopedProtectedResourceView, ) from .introspect import IntrospectTokenView -from .oidc import ConnectDiscoveryInfoView, JwksInfoView, UserInfoView +from .oidc import ConnectDiscoveryInfoView, JwksInfoView, RPInitiatedLogoutView, UserInfoView from .token import AuthorizedTokenDeleteView, AuthorizedTokensListView diff --git a/oauth2_provider/views/application.py b/oauth2_provider/views/application.py index e9a21a99f..b896c45e3 100644 --- a/oauth2_provider/views/application.py +++ b/oauth2_provider/views/application.py @@ -34,9 +34,12 @@ def get_form_class(self): "name", "client_id", "client_secret", + "hash_client_secret", "client_type", "authorization_grant_type", "redirect_uris", + "post_logout_redirect_uris", + "allowed_origins", "algorithm", ), ) @@ -92,9 +95,12 @@ def get_form_class(self): "name", "client_id", "client_secret", + "hash_client_secret", "client_type", "authorization_grant_type", "redirect_uris", + "post_logout_redirect_uris", + "allowed_origins", "algorithm", ), ) diff --git a/oauth2_provider/views/base.py b/oauth2_provider/views/base.py index 211da45ed..c5c904b14 100644 --- a/oauth2_provider/views/base.py +++ b/oauth2_provider/views/base.py @@ -1,14 +1,19 @@ +import hashlib import json import logging +from urllib.parse import parse_qsl, urlencode, urlparse from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib.auth.views import redirect_to_login from django.http import HttpResponse +from django.shortcuts import resolve_url from django.utils import timezone from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt from django.views.decorators.debug import sensitive_post_parameters from django.views.generic import FormView, View +from ..compat import login_not_required from ..exceptions import OAuthToolkitError from ..forms import AllowForm from ..http import OAuth2ResponseRedirect @@ -22,6 +27,8 @@ log = logging.getLogger("oauth2_provider") +# login_not_required decorator to bypass LoginRequiredMiddleware +@method_decorator(login_not_required, name="dispatch") class BaseAuthorizationView(LoginRequiredMixin, OAuthLibMixin, View): """ Implements a generic endpoint to handle *Authorization Requests* as in :rfc:`4.1.1`. The view @@ -74,10 +81,10 @@ class AuthorizationView(BaseAuthorizationView, FormView): * then receive a ``POST`` request possibly after user authorized the access - Some informations contained in the ``GET`` request and needed to create a Grant token during + Some information contained in the ``GET`` request and needed to create a Grant token during the ``POST`` request would be lost between the two steps above, so they are temporarily stored in hidden fields on the form. - A possible alternative could be keeping such informations in the session. + A possible alternative could be keeping such information in the session. The endpoint is used in the following flows: * Authorization code @@ -144,6 +151,10 @@ def get(self, request, *args, **kwargs): # Application is not available at this time. return self.error_response(error, application=None) + prompt = request.GET.get("prompt") + if prompt == "login": + return self.handle_prompt_login() + all_scopes = get_scopes_backend().get_all_scopes() kwargs["scopes_descriptions"] = [all_scopes[scope] for scope in scopes] kwargs["scopes"] = scopes @@ -175,6 +186,10 @@ def get(self, request, *args, **kwargs): # a successful response depending on "approval_prompt" url parameter require_approval = request.GET.get("approval_prompt", oauth2_settings.REQUEST_APPROVAL_PROMPT) + if "ui_locales" in credentials and isinstance(credentials["ui_locales"], list): + # Make sure ui_locales a space separated string for oauthlib to handle it correctly. + credentials["ui_locales"] = " ".join(credentials["ui_locales"]) + try: # If skip_authorization field is True, skip the authorization screen even # if this is the first use of the application and there was no previous authorization. @@ -211,8 +226,62 @@ def get(self, request, *args, **kwargs): 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 @@ -223,13 +292,14 @@ class TokenView(OAuthLibMixin, View): * Client credentials """ - @method_decorator(sensitive_post_parameters("password")) + @method_decorator(sensitive_post_parameters("password", "client_secret")) def post(self, request, *args, **kwargs): url, headers, body, status = self.create_token_response(request) if status == 200: access_token = json.loads(body).get("access_token") if access_token is not None: - token = get_access_token_model().objects.get(token=access_token) + 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) @@ -239,6 +309,7 @@ def post(self, request, *args, **kwargs): @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 diff --git a/oauth2_provider/views/generic.py b/oauth2_provider/views/generic.py index da675eac4..232afff76 100644 --- a/oauth2_provider/views/generic.py +++ b/oauth2_provider/views/generic.py @@ -2,14 +2,13 @@ from .mixins import ( ClientProtectedResourceMixin, - OAuthLibMixin, ProtectedResourceMixin, ReadWriteScopedResourceMixin, ScopedResourceMixin, ) -class ProtectedResourceView(ProtectedResourceMixin, OAuthLibMixin, View): +class ProtectedResourceView(ProtectedResourceMixin, View): """ Generic view protecting resources by providing OAuth2 authentication out of the box """ @@ -35,8 +34,7 @@ class ReadWriteScopedResourceView(ReadWriteScopedResourceMixin, ProtectedResourc pass -class ClientProtectedResourceView(ClientProtectedResourceMixin, OAuthLibMixin, View): - +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. """ @@ -45,7 +43,6 @@ class ClientProtectedResourceView(ClientProtectedResourceMixin, OAuthLibMixin, V class ClientProtectedScopedResourceView(ScopedResourceMixin, ClientProtectedResourceView): - """Impose scope restrictions if client protection fallsback to access token.""" pass diff --git a/oauth2_provider/views/introspect.py b/oauth2_provider/views/introspect.py index 26254da6b..5b9810c82 100644 --- a/oauth2_provider/views/introspect.py +++ b/oauth2_provider/views/introspect.py @@ -1,19 +1,22 @@ import 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 oauth2_provider.models import get_access_token_model -from oauth2_provider.views.generic import ClientProtectedScopedResourceView +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://tools.ietf.org/html/rfc7662 + 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`. @@ -23,9 +26,17 @@ class IntrospectTokenView(ClientProtectedScopedResourceView): @staticmethod def get_token_response(token_value=None): + if token_value is None: + return JsonResponse( + {"error": "invalid_request", "error_description": "Token parameter is missing."}, + status=400, + ) try: + token_checksum = hashlib.sha256(token_value.encode("utf-8")).hexdigest() token = ( - get_access_token_model().objects.select_related("user", "application").get(token=token_value) + get_access_token_model() + .objects.select_related("user", "application") + .get(token_checksum=token_checksum) ) except ObjectDoesNotExist: return JsonResponse({"active": False}, status=200) diff --git a/oauth2_provider/views/mixins.py b/oauth2_provider/views/mixins.py index ebb654216..203d0103b 100644 --- a/oauth2_provider/views/mixins.py +++ b/oauth2_provider/views/mixins.py @@ -279,7 +279,6 @@ def get_scopes(self, *args, **kwargs): 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. @@ -326,3 +325,27 @@ def dispatch(self, *args, **kwargs): 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) diff --git a/oauth2_provider/views/oidc.py b/oauth2_provider/views/oidc.py index bb47d4f43..a252f1be4 100644 --- a/oauth2_provider/views/oidc.py +++ b/oauth2_provider/views/oidc.py @@ -1,21 +1,46 @@ import 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 View -from jwcrypto import jwk +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 ..models import get_application_model +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 .mixins import OAuthLibMixin, OIDCOnlyMixin +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 @@ -33,6 +58,10 @@ def get(self, request, *args, **kwargs): 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 @@ -42,6 +71,8 @@ def get(self, request, *args, **kwargs): 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: @@ -67,13 +98,17 @@ def get(self, request, *args, **kwargs): "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 @@ -86,8 +121,7 @@ def get(self, request, *args, **kwargs): oauth2_settings.OIDC_RSA_PRIVATE_KEY, *oauth2_settings.OIDC_RSA_PRIVATE_KEYS_INACTIVE, ]: - - key = jwk.JWK.from_pem(pem.encode("utf8")) + key = jwk_from_pem(pem) data = {"alg": "RS256", "use": "sig", "kid": key.thumbprint()} data.update(json.loads(key.export_public())) keys.append(data) @@ -103,6 +137,7 @@ def get(self, request, *args, **kwargs): @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 @@ -121,3 +156,319 @@ def _create_userinfo_response(self, request): 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): + """ + per: https://openid.net/specs/openid-connect-rpinitiated-1_0.html + + > At the Logout Endpoint, the OP SHOULD ask the End-User whether to log + > out of the OP as well. Furthermore, the OP MUST ask the End-User this + > question if an id_token_hint was not provided or if the supplied ID + > Token does not belong to the current OP session with the RP and/or + > currently logged in End-User. + + """ + + if not self.request.user.is_authenticated: + """ + > the OP MUST ask ask the End-User whether to log out of the OP as + + If the user does not have an active session with the OP, they cannot + end their OP session, so there is nothing to prompt for. This occurs + in cases where the user has logged out of the OP via another channel + such as the OP's own logout page, session timeout or another RP's + logout page. + """ + return False + + if oauth2_settings.OIDC_RP_INITIATED_LOGOUT_ALWAYS_PROMPT: + """ + > At the Logout Endpoint, the OP SHOULD ask the End-User whether to + > log out of the OP as well + + The admin has configured the OP to always prompt the userfor logout + per the SHOULD recommendation. + """ + return True + + if token_user is None: + """ + > the OP MUST ask ask the End-User whether to log out of the OP as + > well if the supplied ID Token does not belong to the current OP + > session with the RP. + + token_user will only be populated if an ID token was found for the + RP (Application) that is requesting the logout. If token_user is not + then we must prompt the user. + """ + return True + + if token_user != self.request.user: + """ + > the OP MUST ask ask the End-User whether to log out of the OP as + > well if the supplied ID Token does not belong to the logged in + > End-User. + + is_authenticated indicates that there is a logged in user and was + tested in the first condition. + token_user != self.request.user indicates that the token does not + belong to the logged in user, Therefore we need to prompt the user. + """ + return True + + """ We didn't find a reason to prompt the user """ + return False + + 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) diff --git a/oauth2_provider/views/token.py b/oauth2_provider/views/token.py index 53fcf3544..91dd1a345 100644 --- a/oauth2_provider/views/token.py +++ b/oauth2_provider/views/token.py @@ -16,7 +16,7 @@ class AuthorizedTokensListView(LoginRequiredMixin, ListView): def get_queryset(self): """ - Show only user"s tokens + Show only user's tokens """ return super().get_queryset().select_related("application").filter(user=self.request.user) diff --git a/pyproject.toml b/pyproject.toml index a4b95794e..401d33cab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,94 @@ -[tool.black] +[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.optional-dependencies] +dev = [ + "pytest", + "pytest-cov", + "m2r", + "sphinx-rtd-theme", +] + +[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 -target-version = ['py38'] -exclude = ''' -^/( - oauth2_provider/migrations/ - | tests/migrations/ - | .tox -) -''' +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"] diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 7fc5a9243..000000000 --- a/setup.cfg +++ /dev/null @@ -1,43 +0,0 @@ -[metadata] -name = django-oauth-toolkit -version = attr: oauth2_provider.__version__ -description = OAuth2 Provider for Django -long_description = file: README.rst -long_description_content_type = text/x-rst -author = Federico Frenguelli, Massimiliano Pippi -author_email = synasius@gmail.com -url = https://github.com/jazzband/django-oauth-toolkit -keywords = django, oauth, oauth2, oauthlib -classifiers = - Development Status :: 5 - Production/Stable - Environment :: Web Environment - Framework :: Django - Framework :: Django :: 2.2 - Framework :: Django :: 3.2 - Framework :: Django :: 4.0 - Intended Audience :: Developers - License :: OSI Approved :: BSD License - Operating System :: OS Independent - Programming Language :: Python :: 3 - Programming Language :: Python :: 3.7 - Programming Language :: Python :: 3.8 - Programming Language :: Python :: 3.9 - Programming Language :: Python :: 3.10 - Topic :: Internet :: WWW/HTTP - -[options] -packages = find: -include_package_data = True -zip_safe = False -# jwcrypto has a direct dependency on six, but does not list it yet in a release -# Previously, cryptography also depended on six, so this was unnoticed -install_requires = - django >= 2.2, != 4.0.0 - requests >= 2.13.0 - oauthlib >= 3.1.0 - jwcrypto >= 0.8.0 - -[options.packages.find] -exclude = - tests - tests.* diff --git a/setup.py b/setup.py deleted file mode 100755 index dd4e63e40..000000000 --- a/setup.py +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env python - -from setuptools import setup - - -setup() diff --git a/tests/app/README.md b/tests/app/README.md new file mode 100644 index 000000000..a2632b262 --- /dev/null +++ b/tests/app/README.md @@ -0,0 +1,49 @@ +# 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 + ``` \ No newline at end of file diff --git a/tests/app/idp/README.md b/tests/app/idp/README.md new file mode 100644 index 000000000..54245073d --- /dev/null +++ b/tests/app/idp/README.md @@ -0,0 +1,3 @@ +# TEST IDP + +see ../README.md diff --git a/tests/app/idp/fixtures/seed.json b/tests/app/idp/fixtures/seed.json new file mode 100644 index 000000000..b77d1f4e2 --- /dev/null +++ b/tests/app/idp/fixtures/seed.json @@ -0,0 +1,38 @@ +[ +{ + "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" + } +} +] diff --git a/tests/app/idp/idp/__init__.py b/tests/app/idp/idp/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/app/idp/idp/apps.py b/tests/app/idp/idp/apps.py new file mode 100644 index 000000000..f40a9f644 --- /dev/null +++ b/tests/app/idp/idp/apps.py @@ -0,0 +1,19 @@ +from 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) diff --git a/tests/app/idp/idp/asgi.py b/tests/app/idp/idp/asgi.py new file mode 100644 index 000000000..00e738adf --- /dev/null +++ b/tests/app/idp/idp/asgi.py @@ -0,0 +1,17 @@ +""" +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() diff --git a/tests/app/idp/idp/oauth.py b/tests/app/idp/idp/oauth.py new file mode 100644 index 000000000..bfe44904a --- /dev/null +++ b/tests/app/idp/idp/oauth.py @@ -0,0 +1,40 @@ +from 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 diff --git a/tests/app/idp/idp/settings.py b/tests/app/idp/idp/settings.py new file mode 100644 index 000000000..eee20982e --- /dev/null +++ b/tests/app/idp/idp/settings.py @@ -0,0 +1,240 @@ +""" +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, + # }, + }, +} diff --git a/tests/app/idp/idp/urls.py b/tests/app/idp/idp/urls.py new file mode 100644 index 000000000..90e8abd48 --- /dev/null +++ b/tests/app/idp/idp/urls.py @@ -0,0 +1,26 @@ +""" +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")), +] diff --git a/tests/app/idp/idp/wsgi.py b/tests/app/idp/idp/wsgi.py new file mode 100644 index 000000000..41a8c45e1 --- /dev/null +++ b/tests/app/idp/idp/wsgi.py @@ -0,0 +1,17 @@ +""" +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() diff --git a/tests/app/idp/manage.py b/tests/app/idp/manage.py new file mode 100644 index 000000000..abee496ad --- /dev/null +++ b/tests/app/idp/manage.py @@ -0,0 +1,22 @@ +#!/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() diff --git a/tests/app/idp/requirements.txt b/tests/app/idp/requirements.txt new file mode 100644 index 000000000..ec77fcf9d --- /dev/null +++ b/tests/app/idp/requirements.txt @@ -0,0 +1,5 @@ +Django>=4.2,<=5.1 +django-cors-headers==3.14.0 +django-environ==0.11.2 + +-e ../../../ diff --git a/tests/app/idp/templates/registration/login.html b/tests/app/idp/templates/registration/login.html new file mode 100644 index 000000000..4622bdfbf --- /dev/null +++ b/tests/app/idp/templates/registration/login.html @@ -0,0 +1,7 @@ + +

    Log In

    +
    + {% csrf_token %} + {{ form.as_p }} + +
    \ No newline at end of file diff --git a/tests/app/rp/.gitignore b/tests/app/rp/.gitignore new file mode 100644 index 000000000..6635cf554 --- /dev/null +++ b/tests/app/rp/.gitignore @@ -0,0 +1,10 @@ +.DS_Store +node_modules +/build +/.svelte-kit +/package +.env +.env.* +!.env.example +vite.config.js.timestamp-* +vite.config.ts.timestamp-* diff --git a/tests/app/rp/.npmrc b/tests/app/rp/.npmrc new file mode 100644 index 000000000..0c05da457 --- /dev/null +++ b/tests/app/rp/.npmrc @@ -0,0 +1,2 @@ +engine-strict=true +resolution-mode=highest diff --git a/tests/app/rp/.prettierignore b/tests/app/rp/.prettierignore new file mode 100644 index 000000000..38972655f --- /dev/null +++ b/tests/app/rp/.prettierignore @@ -0,0 +1,13 @@ +.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 diff --git a/tests/app/rp/.prettierrc b/tests/app/rp/.prettierrc new file mode 100644 index 000000000..a77fddea9 --- /dev/null +++ b/tests/app/rp/.prettierrc @@ -0,0 +1,9 @@ +{ + "useTabs": true, + "singleQuote": true, + "trailingComma": "none", + "printWidth": 100, + "plugins": ["prettier-plugin-svelte"], + "pluginSearchDirs": ["."], + "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] +} diff --git a/tests/app/rp/Dockerfile b/tests/app/rp/Dockerfile new file mode 100644 index 000000000..a719a1eb4 --- /dev/null +++ b/tests/app/rp/Dockerfile @@ -0,0 +1,16 @@ +FROM 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" ] \ No newline at end of file diff --git a/tests/app/rp/README.md b/tests/app/rp/README.md new file mode 100644 index 000000000..4c6cfd3bc --- /dev/null +++ b/tests/app/rp/README.md @@ -0,0 +1,40 @@ +# 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. diff --git a/tests/app/rp/package-lock.json b/tests/app/rp/package-lock.json new file mode 100644 index 000000000..c8186b56d --- /dev/null +++ b/tests/app/rp/package-lock.json @@ -0,0 +1,2374 @@ +{ + "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.20.6", + "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.4.19" + } + }, + "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.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "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.28", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.28.tgz", + "integrity": "sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==", + "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.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.22.4.tgz", + "integrity": "sha512-Fxamp4aEZnfPOcGA8KSNEohV8hX7zVHOemC8jVBoBUHu5zpJK/Eu3uJwt6BMgy9fkvzxDaurgj96F/NiLukF2w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.22.4.tgz", + "integrity": "sha512-VXoK5UMrgECLYaMuGuVTOx5kcuap1Jm8g/M83RnCHBKOqvPPmROFJGQaZhGccnsFtfXQ3XYa4/jMCJvZnbJBdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.22.4.tgz", + "integrity": "sha512-xMM9ORBqu81jyMKCDP+SZDhnX2QEVQzTcC6G18KlTQEzWK8r/oNZtKuZaCcHhnsa6fEeOBionoyl5JsAbE/36Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.22.4.tgz", + "integrity": "sha512-aJJyYKQwbHuhTUrjWjxEvGnNNBCnmpHDvrb8JFDbeSH3m2XdHcxDd3jthAzvmoI8w/kSjd2y0udT+4okADsZIw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.22.4.tgz", + "integrity": "sha512-j63YtCIRAzbO+gC2L9dWXRh5BFetsv0j0va0Wi9epXDgU/XUi5dJKo4USTttVyK7fGw2nPWK0PbAvyliz50SCQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.22.4.tgz", + "integrity": "sha512-dJnWUgwWBX1YBRsuKKMOlXCzh2Wu1mlHzv20TpqEsfdZLb3WoJW2kIEsGwLkroYf24IrPAvOT/ZQ2OYMV6vlrg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.22.4.tgz", + "integrity": "sha512-AdPRoNi3NKVLolCN/Sp4F4N1d98c4SBnHMKoLuiG6RXgoZ4sllseuGioszumnPGmPM2O7qaAX/IJdeDU8f26Aw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.22.4.tgz", + "integrity": "sha512-Gl0AxBtDg8uoAn5CCqQDMqAx22Wx22pjDOjBdmG0VIWX3qUBHzYmOKh8KXHL4UpogfJ14G4wk16EQogF+v8hmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.22.4.tgz", + "integrity": "sha512-3aVCK9xfWW1oGQpTsYJJPF6bfpWfhbRnhdlyhak2ZiyFLDaayz0EP5j9V1RVLAAxlmWKTDfS9wyRyY3hvhPoOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.22.4.tgz", + "integrity": "sha512-ePYIir6VYnhgv2C5Xe9u+ico4t8sZWXschR6fMgoPUK31yQu7hTEJb7bCqivHECwIClJfKgE7zYsh1qTP3WHUA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.22.4.tgz", + "integrity": "sha512-GqFJ9wLlbB9daxhVlrTe61vJtEY99/xB3C8e4ULVsVfflcpmR6c8UZXjtkMA6FhNONhj2eA5Tk9uAVw5orEs4Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.22.4.tgz", + "integrity": "sha512-87v0ol2sH9GE3cLQLNEy0K/R0pz1nvg76o8M5nhMR0+Q+BBGLnb35P0fVz4CQxHYXaAOhE8HhlkaZfsdUOlHwg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.22.4.tgz", + "integrity": "sha512-UV6FZMUgePDZrFjrNGIWzDo/vABebuXBhJEqrHxrGiU6HikPy0Z3LfdtciIttEUQfuDdCn8fqh7wiFJjCNwO+g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.22.4.tgz", + "integrity": "sha512-BjI+NVVEGAXjGWYHz/vv0pBqfGoUH0IGZ0cICTn7kB9PyjrATSkX+8WkguNjWoj2qSr1im/+tTGRaY+4/PdcQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.22.4.tgz", + "integrity": "sha512-SiWG/1TuUdPvYmzmYnmd3IEifzR61Tragkbx9D3+R8mzQqDBz8v+BvZNDlkiTtI9T15KYZhP0ehn3Dld4n9J5g==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.22.4.tgz", + "integrity": "sha512-j8pPKp53/lq9lMXN57S8cFz0MynJk8OWNuUnXct/9KCpKU7DgU3bYMJhwWmcqC0UU29p8Lr0/7KEVcaM6bf47Q==", + "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.20.6", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.20.6.tgz", + "integrity": "sha512-ImUkSQ//Xf4N9r0HHAe5vRA7RyQ7U1Ue1YUT235Ig+IiIqbsixEulHTHrP5LtBiC8xOkJoPZQ1VZ/nWHNOaGGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookie": "^0.6.0", + "cookie": "^0.6.0", + "devalue": "^5.1.0", + "esm-env": "^1.2.2", + "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": "^3.0.0" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": ">=18.13" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "vite": "^5.0.3 || ^6.0.0" + } + }, + "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.1.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.1.1.tgz", + "integrity": "sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw==", + "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.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "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/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.1.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", + "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==", + "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.47", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", + "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", + "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.1.0", + "source-map-js": "^1.2.1" + }, + "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.22.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.22.4.tgz", + "integrity": "sha512-vD8HJ5raRcWOyymsR6Z3o6+RzfEPCnVLMFJ6vRslO1jt4LO6dUo5Qnpg7y4RkZFM2DMe3WUirkI5c16onjrc6A==", + "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.22.4", + "@rollup/rollup-android-arm64": "4.22.4", + "@rollup/rollup-darwin-arm64": "4.22.4", + "@rollup/rollup-darwin-x64": "4.22.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.22.4", + "@rollup/rollup-linux-arm-musleabihf": "4.22.4", + "@rollup/rollup-linux-arm64-gnu": "4.22.4", + "@rollup/rollup-linux-arm64-musl": "4.22.4", + "@rollup/rollup-linux-powerpc64le-gnu": "4.22.4", + "@rollup/rollup-linux-riscv64-gnu": "4.22.4", + "@rollup/rollup-linux-s390x-gnu": "4.22.4", + "@rollup/rollup-linux-x64-gnu": "4.22.4", + "@rollup/rollup-linux-x64-musl": "4.22.4", + "@rollup/rollup-win32-arm64-msvc": "4.22.4", + "@rollup/rollup-win32-ia32-msvc": "4.22.4", + "@rollup/rollup-win32-x64-msvc": "4.22.4", + "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": "3.0.0", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.0.tgz", + "integrity": "sha512-BPwJGUeDaDCHihkORDchNyyTvWFhcusy1XMmhEVTQTwGeybFbp8YEmB+njbPnth1FibULBSBVwCQni25XlCUDg==", + "dev": true, + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "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.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "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/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.4.19", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz", + "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.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": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "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 + } + } +} diff --git a/tests/app/rp/package.json b/tests/app/rp/package.json new file mode 100644 index 000000000..603114a1a --- /dev/null +++ b/tests/app/rp/package.json @@ -0,0 +1,30 @@ +{ + "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.20.6", + "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.4.19" + }, + "type": "module", + "dependencies": { + "@dopry/svelte-oidc": "^1.1.0" + } +} diff --git a/tests/app/rp/src/app.d.ts b/tests/app/rp/src/app.d.ts new file mode 100644 index 000000000..f59b884c5 --- /dev/null +++ b/tests/app/rp/src/app.d.ts @@ -0,0 +1,12 @@ +// 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 {}; diff --git a/tests/app/rp/src/app.html b/tests/app/rp/src/app.html new file mode 100644 index 000000000..77ec85d79 --- /dev/null +++ b/tests/app/rp/src/app.html @@ -0,0 +1,12 @@ + + + + + + + %sveltekit.head% + + +
    %sveltekit.body%
    + + diff --git a/tests/app/rp/src/routes/+page.svelte b/tests/app/rp/src/routes/+page.svelte new file mode 100644 index 000000000..1df1a226b --- /dev/null +++ b/tests/app/rp/src/routes/+page.svelte @@ -0,0 +1,43 @@ + + +{#if browser} + + Login + Logout + RefreshToken
    +
    isLoading: {$isLoading}
    +
    isAuthenticated: {$isAuthenticated}
    +
    authToken: {$accessToken}
    +
    idToken: {$idToken}
    +
    userInfo: {JSON.stringify($userInfo, null, 2)}
    +
    authError: {$authError}
    +
    +{/if} diff --git a/tests/app/rp/static/favicon.png b/tests/app/rp/static/favicon.png new file mode 100644 index 000000000..825b9e65a Binary files /dev/null and b/tests/app/rp/static/favicon.png differ diff --git a/tests/app/rp/svelte.config.js b/tests/app/rp/svelte.config.js new file mode 100644 index 000000000..1023568ae --- /dev/null +++ b/tests/app/rp/svelte.config.js @@ -0,0 +1,16 @@ +import 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; diff --git a/tests/app/rp/tsconfig.jsonc b/tests/app/rp/tsconfig.jsonc new file mode 100644 index 000000000..6ae0c8c44 --- /dev/null +++ b/tests/app/rp/tsconfig.jsonc @@ -0,0 +1,17 @@ +{ + "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 +} diff --git a/tests/app/rp/vite.config.ts b/tests/app/rp/vite.config.ts new file mode 100644 index 000000000..bbf8c7da4 --- /dev/null +++ b/tests/app/rp/vite.config.ts @@ -0,0 +1,6 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [sveltekit()] +}); diff --git a/tests/common_testing.py b/tests/common_testing.py new file mode 100644 index 000000000..6f6a5b745 --- /dev/null +++ b/tests/common_testing.py @@ -0,0 +1,33 @@ +from 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.""" diff --git a/tests/conftest.py b/tests/conftest.py index 14db54aa5..2510025ce 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,13 +1,17 @@ +import 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 jwcrypto import jwk +from django.utils import dateformat, timezone +from jwcrypto import jwk, jwt -from oauth2_provider.models import get_application_model +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 @@ -100,6 +104,7 @@ 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, @@ -107,6 +112,40 @@ def application(): ) +@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 @@ -121,16 +160,29 @@ def test_user(): @pytest.fixture -def oidc_tokens(oauth2_settings, application, test_user, client): - oauth2_settings.update(presets.OIDC_SETTINGS_RW) +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": "openid", - "redirect_uri": "http://example.org", + "scope": scope, + "redirect_uri": redirect_uri, "response_type": "code", "allow": True, }, @@ -143,10 +195,10 @@ def oidc_tokens(oauth2_settings, application, test_user, client): data={ "grant_type": "authorization_code", "code": code, - "redirect_uri": "http://example.org", + "redirect_uri": redirect_uri, "client_id": application.client_id, "client_secret": CLEARTEXT_SECRET, - "scope": "openid", + "scope": scope, }, ) assert token_rsp.status_code == 200 @@ -161,40 +213,95 @@ def oidc_tokens(oauth2_settings, application, test_user, client): @pytest.fixture -def oidc_email_scope_tokens(oauth2_settings, application, test_user, client): - oauth2_settings.update(presets.OIDC_SETTINGS_EMAIL_SCOPE) - client.force_login(test_user) - auth_rsp = client.post( - reverse("oauth2_provider:authorize"), - data={ - "client_id": application.client_id, - "state": "random_state_string", - "scope": "openid email", - "redirect_uri": "http://example.org", - "response_type": "code", - "allow": True, - }, +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", ) - assert auth_rsp.status_code == 302 - code = parse_qs(urlparse(auth_rsp["Location"]).query)["code"] - client.logout() - token_rsp = client.post( - reverse("oauth2_provider:token"), - data={ - "grant_type": "authorization_code", - "code": code, - "redirect_uri": "http://example.org", - "client_id": application.client_id, - "client_secret": CLEARTEXT_SECRET, - "scope": "openid email", - }, + + +@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", ) - 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 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"] diff --git a/tests/custom_hasher.py b/tests/custom_hasher.py new file mode 100644 index 000000000..5f7ceb89c --- /dev/null +++ b/tests/custom_hasher.py @@ -0,0 +1,10 @@ +from django.contrib.auth.hashers import PBKDF2PasswordHasher + + +class MyPBKDF2PasswordHasher(PBKDF2PasswordHasher): + """ + A subclass of PBKDF2PasswordHasher that uses less iterations. + """ + + algorithm = "fast_pbkdf2" + iterations = 10000 diff --git a/tests/db_router.py b/tests/db_router.py new file mode 100644 index 000000000..7aa354ed8 --- /dev/null +++ b/tests/db_router.py @@ -0,0 +1,76 @@ +apps_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 diff --git a/tests/mig_settings.py b/tests/mig_settings.py index 8f77d1190..a3462bcdc 100644 --- a/tests/mig_settings.py +++ b/tests/mig_settings.py @@ -21,7 +21,7 @@ # 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" +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 diff --git a/tests/migrations/0001_initial.py b/tests/migrations/0001_initial.py index 8903a5a96..4baa18a57 100644 --- a/tests/migrations/0001_initial.py +++ b/tests/migrations/0001_initial.py @@ -1,9 +1,6 @@ -# Generated by Django 2.2.6 on 2019-10-24 20:21 +# Generated by Django 4.0.4 on 2022-05-27 21:07 -from django.conf import settings from django.db import migrations, models -import django.db.models.deletion -import oauth2_provider.generators class Migration(migrations.Migration): @@ -11,112 +8,41 @@ class Migration(migrations.Migration): initial = True dependencies = [ - migrations.swappable_dependency(settings.OAUTH2_PROVIDER_APPLICATION_MODEL), - migrations.swappable_dependency(settings.OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - migrations.swappable_dependency(settings.OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL), + ] + + run_before = [ + ('oauth2_provider', '0001_initial'), ] operations = [ migrations.CreateModel( - name='SampleGrant', + name='BaseTestApplication', fields=[ - ('id', models.BigAutoField(primary_key=True, serialize=False)), - ('code', models.CharField(max_length=255, unique=True)), - ('expires', models.DateTimeField()), - ('redirect_uri', models.CharField(max_length=255)), - ('scope', models.TextField(blank=True)), - ('created', models.DateTimeField(auto_now_add=True)), - ('updated', models.DateTimeField(auto_now=True)), - ('code_challenge', models.CharField(blank=True, default='', max_length=128)), - ('code_challenge_method', models.CharField(blank=True, choices=[('plain', 'plain'), ('S256', 'S256')], default='', max_length=10)), - ('custom_field', models.CharField(max_length=255)), - ('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tests_samplegrant', to=settings.AUTH_USER_MODEL)), - ("nonce", models.CharField(blank=True, max_length=255, default="")), - ("claims", models.TextField(blank=True)), + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ], - options={ - 'abstract': False, - }, ), migrations.CreateModel( - name='SampleApplication', + name='SampleAccessToken', fields=[ - ('id', models.BigAutoField(primary_key=True, serialize=False)), - ('client_id', models.CharField(db_index=True, default=oauth2_provider.generators.generate_client_id, max_length=100, unique=True)), - ('redirect_uris', models.TextField(blank=True, help_text='Allowed URIs list, space separated')), - ('client_type', models.CharField(choices=[('confidential', 'Confidential'), ('public', 'Public')], max_length=32)), - ('authorization_grant_type', 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)), - ('client_secret', models.CharField(blank=True, db_index=True, default=oauth2_provider.generators.generate_client_secret, max_length=255)), - ('name', models.CharField(blank=True, max_length=255)), - ('skip_authorization', models.BooleanField(default=False)), - ('created', models.DateTimeField(auto_now_add=True)), - ('updated', models.DateTimeField(auto_now=True)), - ('custom_field', models.CharField(max_length=255)), - ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='tests_sampleapplication', to=settings.AUTH_USER_MODEL)), - ('algorithm', models.CharField(max_length=5, choices=[('RS256', 'RSA with SHA-2 256'), ('HS256', 'HMAC with SHA-2 256')], default='RS256')), + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ], - options={ - 'abstract': False, - }, ), migrations.CreateModel( - name='SampleAccessToken', + name='SampleApplication', fields=[ - ('id', models.BigAutoField(primary_key=True, serialize=False)), - ('token', models.CharField(max_length=255, unique=True)), - ('expires', models.DateTimeField()), - ('scope', models.TextField(blank=True)), - ('created', models.DateTimeField(auto_now_add=True)), - ('updated', models.DateTimeField(auto_now=True)), - ('custom_field', models.CharField(max_length=255)), - ('application', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)), - ('source_refresh_token', 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)), - ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='tests_sampleaccesstoken', to=settings.AUTH_USER_MODEL)), - ('id_token', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='access_token', to=settings.OAUTH2_PROVIDER_ID_TOKEN_MODEL)), + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ], - options={ - 'abstract': False, - }, ), migrations.CreateModel( - name='BaseTestApplication', + name='SampleGrant', fields=[ - ('id', models.BigAutoField(primary_key=True, serialize=False)), - ('client_id', models.CharField(db_index=True, default=oauth2_provider.generators.generate_client_id, max_length=100, unique=True)), - ('redirect_uris', models.TextField(blank=True, help_text='Allowed URIs list, space separated')), - ('client_type', models.CharField(choices=[('confidential', 'Confidential'), ('public', 'Public')], max_length=32)), - ('authorization_grant_type', 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)), - ('client_secret', models.CharField(blank=True, db_index=True, default=oauth2_provider.generators.generate_client_secret, max_length=255)), - ('name', models.CharField(blank=True, max_length=255)), - ('skip_authorization', models.BooleanField(default=False)), - ('created', models.DateTimeField(auto_now_add=True)), - ('updated', models.DateTimeField(auto_now=True)), - ('allowed_schemes', models.TextField(blank=True)), - ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='tests_basetestapplication', to=settings.AUTH_USER_MODEL)), - ('algorithm', models.CharField(max_length=5, choices=[('RS256', 'RSA with SHA-2 256'), ('HS256', 'HMAC with SHA-2 256')], default='RS256')), + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ], - options={ - 'abstract': False, - }, ), migrations.CreateModel( name='SampleRefreshToken', fields=[ - ('id', models.BigAutoField(primary_key=True, serialize=False)), - ('token', models.CharField(max_length=255)), - ('created', models.DateTimeField(auto_now_add=True)), - ('updated', models.DateTimeField(auto_now=True)), - ('revoked', models.DateTimeField(null=True)), - ('custom_field', models.CharField(max_length=255)), - ('access_token', 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)), - ('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tests_samplerefreshtoken', to=settings.AUTH_USER_MODEL)), + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ], - options={ - 'abstract': False, - 'unique_together': {('token', 'revoked')}, - }, ), ] diff --git a/tests/migrations/0002_swapped_models.py b/tests/migrations/0002_swapped_models.py new file mode 100644 index 000000000..e168a053d --- /dev/null +++ b/tests/migrations/0002_swapped_models.py @@ -0,0 +1,350 @@ +# 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')}, + ), + ] diff --git a/tests/migrations/0003_basetestapplication_post_logout_redirect_uris_and_more.py b/tests/migrations/0003_basetestapplication_post_logout_redirect_uris_and_more.py new file mode 100644 index 000000000..8ca59c84b --- /dev/null +++ b/tests/migrations/0003_basetestapplication_post_logout_redirect_uris_and_more.py @@ -0,0 +1,26 @@ +# 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"), + ), + ] diff --git a/tests/migrations/0004_basetestapplication_hash_client_secret_and_more.py b/tests/migrations/0004_basetestapplication_hash_client_secret_and_more.py new file mode 100644 index 000000000..80edd057e --- /dev/null +++ b/tests/migrations/0004_basetestapplication_hash_client_secret_and_more.py @@ -0,0 +1,31 @@ +# 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), + ), + ] diff --git a/tests/migrations/0005_basetestapplication_allowed_origins_and_more.py b/tests/migrations/0005_basetestapplication_allowed_origins_and_more.py new file mode 100644 index 000000000..fbc083a2b --- /dev/null +++ b/tests/migrations/0005_basetestapplication_allowed_origins_and_more.py @@ -0,0 +1,26 @@ +# 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"), + ), + ] diff --git a/tests/migrations/0006_basetestapplication_token_family.py b/tests/migrations/0006_basetestapplication_token_family.py new file mode 100644 index 000000000..6b065a242 --- /dev/null +++ b/tests/migrations/0006_basetestapplication_token_family.py @@ -0,0 +1,20 @@ +# 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), + ), + ] diff --git a/tests/migrations/0007_add_localidtoken.py b/tests/migrations/0007_add_localidtoken.py new file mode 100644 index 000000000..f74cce5b6 --- /dev/null +++ b/tests/migrations/0007_add_localidtoken.py @@ -0,0 +1,34 @@ +# 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, + }, + ), + ] diff --git a/tests/models.py b/tests/models.py index 32f9a1b7c..9f3643db8 100644 --- a/tests/models.py +++ b/tests/models.py @@ -4,6 +4,7 @@ AbstractAccessToken, AbstractApplication, AbstractGrant, + AbstractIDToken, AbstractRefreshToken, ) from oauth2_provider.settings import oauth2_settings @@ -32,6 +33,13 @@ class SampleAccessToken(AbstractAccessToken): 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): @@ -47,3 +55,9 @@ class SampleRefreshToken(AbstractRefreshToken): 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. diff --git a/tests/multi_db_settings.py b/tests/multi_db_settings.py new file mode 100644 index 000000000..a6daf04a3 --- /dev/null +++ b/tests/multi_db_settings.py @@ -0,0 +1,19 @@ +# 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"] diff --git a/tests/multi_db_settings_invalid_token_configuration.py b/tests/multi_db_settings_invalid_token_configuration.py new file mode 100644 index 000000000..ed2804f79 --- /dev/null +++ b/tests/multi_db_settings_invalid_token_configuration.py @@ -0,0 +1,8 @@ +from .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", +} diff --git a/tests/presets.py b/tests/presets.py index 6411687a4..4538c64eb 100644 --- a/tests/presets.py +++ b/tests/presets.py @@ -20,6 +20,7 @@ }, "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"] @@ -27,6 +28,15 @@ 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", @@ -47,3 +57,11 @@ "READ_SCOPE": "read", "WRITE_SCOPE": "write", } + +ALLOWED_SCHEMES_DEFAULT = { + "ALLOWED_SCHEMES": ["https"], +} + +ALLOWED_SCHEMES_HTTP = { + "ALLOWED_SCHEMES": ["https", "http"], +} diff --git a/tests/settings.py b/tests/settings.py index 27dcfe9a3..c4d9f59ad 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -1,3 +1,6 @@ +import django + + ADMINS = () MANAGERS = ADMINS @@ -23,7 +26,8 @@ SITE_ID = 1 USE_I18N = True -USE_L10N = True +if django.VERSION < (4, 0): + USE_L10N = True USE_TZ = True MEDIA_ROOT = "" @@ -54,6 +58,7 @@ "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", @@ -84,6 +89,8 @@ "tests", ) +PASSWORD_HASHERS = django.conf.settings.PASSWORD_HASHERS + ["tests.custom_hasher.MyPBKDF2PasswordHasher"] + LOGGING = { "version": 1, "disable_existing_loggers": False, diff --git a/tests/settings_swapped.py b/tests/settings_swapped.py new file mode 100644 index 000000000..cb3a37571 --- /dev/null +++ b/tests/settings_swapped.py @@ -0,0 +1,6 @@ +from .settings import * # noqa + + +OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL = "tests.SampleAccessToken" +OAUTH2_PROVIDER_APPLICATION_MODEL = "tests.SampleApplication" +OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL = "tests.SampleRefreshToken" diff --git a/tests/test_application_views.py b/tests/test_application_views.py index 42eb17fd0..d4c7e28a9 100644 --- a/tests/test_application_views.py +++ b/tests/test_application_views.py @@ -1,11 +1,11 @@ import pytest from django.contrib.auth import get_user_model -from django.test import TestCase from django.urls import reverse from oauth2_provider.models import get_application_model from oauth2_provider.views.application import ApplicationRegistration +from .common_testing import OAuth2ProviderTestCase as TestCase from .models import SampleApplication @@ -14,13 +14,10 @@ class BaseTest(TestCase): - def setUp(self): - self.foo_user = UserModel.objects.create_user("foo_user", "test@example.com", "123456") - self.bar_user = UserModel.objects.create_user("bar_user", "dev@example.com", "123456") - - def tearDown(self): - self.foo_user.delete() - self.bar_user.delete() + @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") @@ -46,6 +43,138 @@ def test_application_registration_user(self): "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"]) + + +@pytest.mark.usefixtures("oauth2_settings") +@pytest.mark.oauth2_settings({"ALLOW_URI_WILDCARDS": True}) +class TestApplicationRegistrationViewRedirectURIWithWildcard(BaseTest): + def _test_valid(self, redirect_uri): + 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": redirect_uri, + "post_logout_redirect_uris": "http://example.com", + "authorization_grant_type": Application.GRANT_AUTHORIZATION_CODE, + "algorithm": "", + } + + response = self.client.post(reverse("oauth2_provider:register"), form_data) + self.assertEqual(response.status_code, 302) + + app = get_application_model().objects.get(name="Foo app") + self.assertEqual(app.user.username, "foo_user") + 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"]) + + def _test_invalid(self, uri, error_message): + 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": uri, + "post_logout_redirect_uris": "http://example.com", + "authorization_grant_type": Application.GRANT_AUTHORIZATION_CODE, + "algorithm": "", + } + + response = self.client.post(reverse("oauth2_provider:register"), form_data) + self.assertEqual(response.status_code, 200) + self.assertContains(response, error_message) + + def test_application_registration_valid_3ld_wildcard(self): + self._test_valid("https://*.example.com") + + def test_application_registration_valid_3ld_partial_wildcard(self): + self._test_valid("https://*-partial.example.com") + + def test_application_registration_invalid_star(self): + self._test_invalid("*", "invalid_scheme: *") + + def test_application_registration_invalid_tld_wildcard(self): + self._test_invalid("https://*", "wildcards cannot be in the top level or second level domain") + + def test_application_registration_invalid_tld_partial_wildcard(self): + self._test_invalid("https://*-partial", "wildcards cannot be in the top level or second level domain") + + def test_application_registration_invalid_tld_not_startswith_wildcard_tld(self): + self._test_invalid("https://example.*", "wildcards must be at the beginning of the hostname") + + def test_application_registration_invalid_2ld_wildcard(self): + self._test_invalid("https://*.com", "wildcards cannot be in the top level or second level domain") + + def test_application_registration_invalid_2ld_partial_wildcard(self): + self._test_invalid( + "https://*-partial.com", "wildcards cannot be in the top level or second level domain" + ) + + def test_application_registration_invalid_2ld_not_startswith_wildcard_tld(self): + self._test_invalid("https://example.*.com", "wildcards must be at the beginning of the hostname") + + def test_application_registration_invalid_3ld_partial_not_startswith_wildcard_2ld(self): + self._test_invalid( + "https://invalid-*.example.com", "wildcards must be at the beginning of the hostname" + ) + + def test_application_registration_invalid_4ld_not_startswith_wildcard_3ld(self): + self._test_invalid( + "https://invalid.*.invalid.example.com", + "wildcards must be at the beginning of the hostname", + ) + + def test_application_registration_invalid_4ld_partial_not_startswith_wildcard_2ld(self): + self._test_invalid( + "https://invalid-*.invalid.example.com", + "wildcards must be at the beginning of the hostname", + ) + + +@pytest.mark.usefixtures("oauth2_settings") +@pytest.mark.oauth2_settings({"ALLOW_URI_WILDCARDS": True}) +class TestApplicationRegistrationViewAllowedOriginWithWildcard( + TestApplicationRegistrationViewRedirectURIWithWildcard +): + def _test_valid(self, uris): + 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, + "allowed_origins": uris, + "redirect_uris": "https://example.com", + "post_logout_redirect_uris": "http://example.com", "authorization_grant_type": Application.GRANT_AUTHORIZATION_CODE, "algorithm": "", } @@ -55,31 +184,56 @@ def test_application_registration_user(self): 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"]) + + def _test_invalid(self, uri, error_message): + 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, + "allowed_origins": uri, + "redirect_uris": "http://example.com", + "post_logout_redirect_uris": "http://example.com", + "authorization_grant_type": Application.GRANT_AUTHORIZATION_CODE, + "algorithm": "", + } + + response = self.client.post(reverse("oauth2_provider:register"), form_data) + self.assertEqual(response.status_code, 200) + self.assertContains(response, error_message) class TestApplicationViews(BaseTest): - def _create_application(self, name, user): - app = Application.objects.create( + @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, ) - return app - def setUp(self): - super().setUp() - self.app_foo_1 = self._create_application("app foo_user 1", self.foo_user) - self.app_foo_2 = self._create_application("app foo_user 2", self.foo_user) - self.app_foo_3 = self._create_application("app foo_user 3", self.foo_user) + @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) - self.app_bar_1 = self._create_application("app bar_user 1", self.bar_user) - self.app_bar_2 = self._create_application("app bar_user 2", self.bar_user) - - def tearDown(self): - super().tearDown() - get_application_model().objects.all().delete() + 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") @@ -93,9 +247,37 @@ def test_application_detail_owner(self): 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"]) diff --git a/tests/test_auth_backends.py b/tests/test_auth_backends.py index 8eeb8ef12..49729b1c4 100644 --- a/tests/test_auth_backends.py +++ b/tests/test_auth_backends.py @@ -5,14 +5,16 @@ from django.contrib.auth.models import AnonymousUser from django.core.exceptions import SuspiciousOperation from django.http import HttpResponse -from django.test import RequestFactory, TestCase +from django.test 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 OAuth2TokenMiddleware +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() @@ -24,23 +26,20 @@ class BaseTest(TestCase): Base class for cases in this module """ - def setUp(self): - self.user = UserModel.objects.create_user("user", "test@example.com", "123456") - self.app = ApplicationModel.objects.create( + 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=self.user, + user=cls.user, ) - self.token = AccessTokenModel.objects.create( - user=self.user, token="tokstr", application=self.app, expires=now() + timedelta(days=365) + cls.token = AccessTokenModel.objects.create( + user=cls.user, token="tokstr", application=cls.app, expires=now() + timedelta(days=365) ) - self.factory = RequestFactory() - - def tearDown(self): - self.user.delete() - self.app.delete() - self.token.delete() class TestOAuth2Backend(BaseTest): @@ -103,10 +102,6 @@ def test_get_user(self): } ) class TestOAuth2Middleware(BaseTest): - def setUp(self): - super().setUp() - self.anon_user = AnonymousUser() - def dummy_get_response(self, request): return HttpResponse() @@ -131,7 +126,7 @@ def test_middleware_user_is_set(self): request.user = self.user m(request) self.assertIs(request.user, self.user) - request.user = self.anon_user + request.user = AnonymousUser() m(request) self.assertEqual(request.user.pk, self.user.pk) @@ -162,3 +157,58 @@ def test_middleware_response_header(self): 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) diff --git a/tests/test_authorization_code.py b/tests/test_authorization_code.py index 8bface719..660e5e5d4 100644 --- a/tests/test_authorization_code.py +++ b/tests/test_authorization_code.py @@ -5,8 +5,9 @@ from urllib.parse import parse_qs, urlparse import pytest +from django.conf import settings from django.contrib.auth import get_user_model -from django.test import RequestFactory, TestCase +from django.test import RequestFactory from django.urls import reverse from django.utils import timezone from django.utils.crypto import get_random_string @@ -22,6 +23,7 @@ from oauth2_provider.views import ProtectedResourceView from . import presets +from .common_testing import OAuth2ProviderTestCase as TestCase from .utils import get_basic_auth_header @@ -42,29 +44,27 @@ def get(self, request, *args, **kwargs): @pytest.mark.usefixtures("oauth2_settings") class BaseTest(TestCase): - def setUp(self): - self.factory = RequestFactory() - self.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") - self.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") + factory = RequestFactory() - self.oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES = ["http", "custom-scheme"] - self.oauth2_settings.PKCE_REQUIRED = False + @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") - self.application = Application.objects.create( + cls.application = Application.objects.create( name="Test Application", redirect_uris=( "http://localhost http://example.com http://example.org custom-scheme://example.com" ), - user=self.dev_user, + user=cls.dev_user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, client_secret=CLEARTEXT_SECRET, ) - def tearDown(self): - self.application.delete() - self.test_user.delete() - self.dev_user.delete() + def setUp(self): + self.oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES = ["http", "custom-scheme"] + self.oauth2_settings.PKCE_REQUIRED = False class TestRegressionIssue315(BaseTest): @@ -482,7 +482,7 @@ def test_code_post_auth_redirection_uri_with_querystring(self): """ Tests that a redirection uri with query string is allowed and query string is retained on redirection. - See http://tools.ietf.org/html/rfc6749#section-3.1.2 + See https://rfc-editor.org/rfc/rfc6749.html#section-3.1.2 """ self.client.login(username="test_user", password="123456") @@ -546,6 +546,33 @@ def test_code_post_auth_fails_when_redirect_uri_path_is_invalid(self): @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. @@ -612,6 +639,67 @@ def test_id_token_code_post_auth_allow(self): 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"): @@ -898,6 +986,54 @@ def test_refresh_fail_repeating_requests(self): 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 reused 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 @@ -937,6 +1073,63 @@ def test_refresh_repeating_requests(self): 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. @@ -967,6 +1160,39 @@ def test_refresh_repeating_requests_non_rotating_tokens(self): 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 @@ -1110,7 +1336,7 @@ def test_public(self): 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 successfull + 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") @@ -1137,7 +1363,7 @@ def test_public_pkce_S256_authorize_get(self): 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 successfull + 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") @@ -1524,10 +1750,11 @@ def test_code_exchange_succeed_when_redirect_uri_match_with_multiple_query_param @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) class TestOIDCAuthorizationCodeTokenView(BaseAuthorizationCodeTokenView): - def setUp(self): - super().setUp() - self.application.algorithm = Application.RS256_ALGORITHM - self.application.save() + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.application.algorithm = Application.RS256_ALGORITHM + cls.application.save() def test_id_token_public(self): """ @@ -1601,11 +1828,15 @@ def test_id_token_code_exchange_succeed_when_redirect_uri_match_with_multiple_qu @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 - self.application.algorithm = Application.HS256_ALGORITHM - self.application.save() def test_id_token(self): """ @@ -1636,7 +1867,7 @@ def test_id_token(self): # Check decoding JWT using HS256 key = self.application.jwk_key - assert key.key_type == "oct" + assert key.kty == "oct" jwt_token = jwt.JWT(key=key, jwt=content["id_token"]) claims = json.loads(jwt_token.claims) assert claims["sub"] == "1" @@ -1697,10 +1928,11 @@ def test_resource_access_deny(self): @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) class TestOIDCAuthorizationCodeProtectedResource(BaseTest): - def setUp(self): - super().setUp() - self.application.algorithm = Application.RS256_ALGORITHM - self.application.save() + @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") diff --git a/tests/test_client_credential.py b/tests/test_client_credential.py index 38265c3d9..3572f432d 100644 --- a/tests/test_client_credential.py +++ b/tests/test_client_credential.py @@ -4,7 +4,7 @@ import pytest from django.contrib.auth import get_user_model from django.core.exceptions import SuspiciousOperation -from django.test import RequestFactory, TestCase +from django.test import RequestFactory from django.urls import reverse from django.views.generic import View from oauthlib.oauth2 import BackendApplicationServer @@ -16,6 +16,7 @@ from oauth2_provider.views.mixins import OAuthLibMixin from . import presets +from .common_testing import OAuth2ProviderTestCase as TestCase from .utils import get_basic_auth_header @@ -35,24 +36,21 @@ def get(self, request, *args, **kwargs): @pytest.mark.usefixtures("oauth2_settings") @pytest.mark.oauth2_settings(presets.DEFAULT_SCOPES_RW) class BaseTest(TestCase): - def setUp(self): - self.factory = RequestFactory() - self.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") - self.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") + factory = RequestFactory() - self.application = Application.objects.create( + @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=self.dev_user, + user=cls.dev_user, client_type=Application.CLIENT_PUBLIC, authorization_grant_type=Application.GRANT_CLIENT_CREDENTIALS, client_secret=CLEARTEXT_SECRET, ) - def tearDown(self): - self.application.delete() - self.test_user.delete() - self.dev_user.delete() - class TestClientCredential(BaseTest): def test_client_credential_access_allowed(self): @@ -98,7 +96,7 @@ def test_client_credential_user_is_none_on_access_token(self): self.assertIsNone(access_token.user) -class TestView(OAuthLibMixin, View): +class ExampleView(OAuthLibMixin, View): server_class = BackendApplicationServer validator_class = OAuth2Validator oauthlib_backend_class = OAuthLibCore @@ -132,7 +130,7 @@ def test_extended_request(self): request = self.request_factory.get("/fake-req", **auth_headers) request.user = "fake" - test_view = TestView() + test_view = ExampleView() self.assertIsInstance(test_view.get_server(), BackendApplicationServer) valid, r = test_view.verify_request(request) @@ -145,7 +143,7 @@ def test_raises_error_with_invalid_hex_in_query_params(self): request = self.request_factory.get("/fake-req?auth_token=%%7A") with pytest.raises(SuspiciousOperation): - TestView().verify_request(request) + ExampleView().verify_request(request) @patch("oauth2_provider.views.mixins.OAuthLibMixin.get_oauthlib_core") def test_reraises_value_errors_as_is(self, patched_core): @@ -154,7 +152,7 @@ def test_reraises_value_errors_as_is(self, patched_core): request = self.request_factory.get("/fake-req") with pytest.raises(ValueError): - TestView().verify_request(request) + ExampleView().verify_request(request) class TestClientResourcePasswordBased(BaseTest): diff --git a/tests/test_commands.py b/tests/test_commands.py index f9a9f5ade..c4d359ce5 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -5,11 +5,11 @@ from django.contrib.auth.hashers import check_password from django.core.management import call_command from django.core.management.base import CommandError -from django.test import TestCase from oauth2_provider.models import get_application_model from . import presets +from .common_testing import OAuth2ProviderTestCase as TestCase Application = get_application_model() @@ -27,7 +27,7 @@ def test_command_creates_application(self): stdout=output, ) self.assertEqual(Application.objects.count(), 1) - self.assertIn("New application created successfully", output.getvalue()) + self.assertIn("created successfully", output.getvalue()) def test_missing_required_args(self): self.assertEqual(Application.objects.count(), 0) @@ -130,6 +130,8 @@ def test_application_created_with_algorithm(self): self.assertEqual(app.algorithm, "RS256") def test_validation_failed_message(self): + import django + output = StringIO() call_command( "createapplication", @@ -140,6 +142,10 @@ def test_validation_failed_message(self): stdout=output, ) - self.assertIn("user", output.getvalue()) - self.assertIn("783", output.getvalue()) - self.assertIn("does not exist", output.getvalue()) + 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) diff --git a/tests/test_decorators.py b/tests/test_decorators.py index ce17a891a..f91ada2ac 100644 --- a/tests/test_decorators.py +++ b/tests/test_decorators.py @@ -1,12 +1,14 @@ from datetime import timedelta from django.contrib.auth import get_user_model -from django.test import RequestFactory, TestCase +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() @@ -14,26 +16,24 @@ class TestProtectedResourceDecorator(TestCase): - @classmethod - def setUpClass(cls): - cls.request_factory = RequestFactory() - super().setUpClass() + request_factory = RequestFactory() - def setUp(self): - self.user = UserModel.objects.create_user("test_user", "test@example.com", "123456") - self.application = Application.objects.create( + @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=self.user, + user=cls.user, client_type=Application.CLIENT_PUBLIC, authorization_grant_type=Application.GRANT_CLIENT_CREDENTIALS, ) - self.access_token = AccessToken.objects.create( - user=self.user, + cls.access_token = AccessToken.objects.create( + user=cls.user, scope="read write", expires=timezone.now() + timedelta(seconds=300), token="secret-access-token-key", - application=self.application, + application=cls.application, ) def test_access_denied(self): diff --git a/tests/test_django_checks.py b/tests/test_django_checks.py new file mode 100644 index 000000000..77025b115 --- /dev/null +++ b/tests/test_django_checks.py @@ -0,0 +1,20 @@ +from 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") diff --git a/tests/test_generator.py b/tests/test_generator.py index cc7928017..201200b00 100644 --- a/tests/test_generator.py +++ b/tests/test_generator.py @@ -1,8 +1,9 @@ import pytest -from django.test import TestCase 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): diff --git a/tests/test_hybrid.py b/tests/test_hybrid.py index 2e85b05b1..67c29a54e 100644 --- a/tests/test_hybrid.py +++ b/tests/test_hybrid.py @@ -5,7 +5,7 @@ import pytest from django.contrib.auth import get_user_model -from django.test import RequestFactory, TestCase +from django.test import RequestFactory from django.urls import reverse from django.utils import timezone from jwcrypto import jwt @@ -21,6 +21,8 @@ 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 @@ -48,30 +50,29 @@ def get(self, request, *args, **kwargs): @pytest.mark.usefixtures("oauth2_settings") class BaseTest(TestCase): - def setUp(self): - self.factory = RequestFactory() - self.hy_test_user = UserModel.objects.create_user("hy_test_user", "test_hy@example.com", "123456") - self.hy_dev_user = UserModel.objects.create_user("hy_dev_user", "dev_hy@example.com", "123456") - self.oauth2_settings.PKCE_REQUIRED = False - self.oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES = ["http", "custom-scheme"] + 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") - self.application = Application( + cls.application = Application( name="Hybrid Test Application", redirect_uris=( "http://localhost http://example.com http://example.org custom-scheme://example.com" ), - user=self.hy_dev_user, + 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, ) - self.application.save() + cls.application.save() - def tearDown(self): - self.application.delete() - self.hy_test_user.delete() - self.hy_dev_user.delete() + 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) @@ -690,7 +691,7 @@ def test_code_post_auth_redirection_uri_with_querystring_code_token(self): """ Tests that a redirection uri with query string is allowed and query string is retained on redirection. - See http://tools.ietf.org/html/rfc6749#section-3.1.2 + See https://rfc-editor.org/rfc/rfc6749.html#section-3.1.2 """ self.client.login(username="hy_test_user", password="123456") @@ -713,7 +714,7 @@ def test_code_post_auth_redirection_uri_with_querystring_code_id_token(self): """ Tests that a redirection uri with query string is allowed and query string is retained on redirection. - See http://tools.ietf.org/html/rfc6749#section-3.1.2 + See https://rfc-editor.org/rfc/rfc6749.html#section-3.1.2 """ self.client.login(username="hy_test_user", password="123456") @@ -737,7 +738,7 @@ def test_code_post_auth_redirection_uri_with_querystring_code_id_token_token(sel """ Tests that a redirection uri with query string is allowed and query string is retained on redirection. - See http://tools.ietf.org/html/rfc6749#section-3.1.2 + See https://rfc-editor.org/rfc/rfc6749.html#section-3.1.2 """ self.client.login(username="hy_test_user", password="123456") @@ -1319,7 +1320,7 @@ def test_pre_auth_default_scopes(self): self.assertEqual(form["client_id"].value(), self.application.client_id) -@pytest.mark.django_db +@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) @@ -1368,7 +1369,7 @@ def test_id_token_nonce_in_token_response(oauth2_settings, test_user, hybrid_app assert claims["nonce"] == "random_nonce_string" -@pytest.mark.django_db +@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 diff --git a/tests/test_implicit.py b/tests/test_implicit.py index 5fcad62b0..85e773d22 100644 --- a/tests/test_implicit.py +++ b/tests/test_implicit.py @@ -3,7 +3,7 @@ import pytest from django.contrib.auth import get_user_model -from django.test import RequestFactory, TestCase +from django.test import RequestFactory from django.urls import reverse from jwcrypto import jwt @@ -11,6 +11,7 @@ from oauth2_provider.views import ProtectedResourceView from . import presets +from .common_testing import OAuth2ProviderTestCase as TestCase Application = get_application_model() @@ -25,24 +26,21 @@ def get(self, request, *args, **kwargs): @pytest.mark.usefixtures("oauth2_settings") class BaseTest(TestCase): - def setUp(self): - self.factory = RequestFactory() - self.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") - self.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") + factory = RequestFactory() - self.application = Application.objects.create( + @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=self.dev_user, + user=cls.dev_user, client_type=Application.CLIENT_PUBLIC, authorization_grant_type=Application.GRANT_IMPLICIT, ) - def tearDown(self): - self.application.delete() - self.test_user.delete() - self.dev_user.delete() - @pytest.mark.oauth2_settings(presets.DEFAULT_SCOPES_RO) class TestImplicitAuthorizationCodeView(BaseTest): @@ -205,7 +203,7 @@ def test_implicit_redirection_uri_with_querystring(self): """ Tests that a redirection uri with query string is allowed and query string is retained on redirection. - See http://tools.ietf.org/html/rfc6749#section-3.1.2 + See https://rfc-editor.org/rfc/rfc6749.html#section-3.1.2 """ self.client.login(username="test_user", password="123456") @@ -276,10 +274,11 @@ def test_resource_access_allowed(self): @pytest.mark.usefixtures("oidc_key") @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) class TestOpenIDConnectImplicitFlow(BaseTest): - def setUp(self): - super().setUp() - self.application.algorithm = Application.RS256_ALGORITHM - self.application.save() + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.application.algorithm = Application.RS256_ALGORITHM + cls.application.save() def test_id_token_post_auth_allow(self): """ @@ -363,7 +362,7 @@ def test_id_token_skip_authorization_completely_missing_nonce(self): response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 302) self.assertIn("error=invalid_request", response["Location"]) - self.assertIn("error_description=Request+is+missing+mandatory+nonce+paramete", response["Location"]) + self.assertIn("error_description=Request+is+missing+mandatory+nonce+parameter", response["Location"]) def test_id_token_post_auth_deny(self): """ diff --git a/tests/test_introspection_auth.py b/tests/test_introspection_auth.py index 8b2a6daf0..e1a096428 100644 --- a/tests/test_introspection_auth.py +++ b/tests/test_introspection_auth.py @@ -6,17 +6,19 @@ from django.conf.urls import include from django.contrib.auth import get_user_model from django.http import HttpResponse -from django.test import TestCase, override_settings +from django.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: @@ -29,7 +31,7 @@ AccessToken = get_access_token_model() UserModel = get_user_model() -exp = datetime.datetime.now() + datetime.timedelta(days=1) +default_exp = datetime.datetime.now() + datetime.timedelta(days=1) class ScopeResourceView(ScopedProtectedResourceView): @@ -42,19 +44,20 @@ 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 """ - class MockResponse: - def __init__(self, json_data, status_code): - self.json_data = json_data - self.status_code = status_code - - def json(self): - return self.json_data - if "token" in data and data["token"] and data["token"] != "12345678900": return MockResponse( { @@ -62,7 +65,7 @@ def json(self): "scope": "read write dolphin", "client_id": "client_id_{}".format(data["token"]), "username": "{}_user".format(data["token"]), - "exp": int(calendar.timegm(exp.timetuple())), + "exp": int(calendar.timegm(default_exp.timetuple())), }, 200, ) @@ -75,9 +78,24 @@ def json(self): ) +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/", ScopeResourceView.as_view()), + path("oauth2-test-resource/", login_not_required(ScopeResourceView.as_view())), ] @@ -89,45 +107,41 @@ class TestTokenIntrospectionAuth(TestCase): Tests for Authorization through token introspection """ - def setUp(self): - self.validator = OAuth2Validator() - self.request = mock.MagicMock(wraps=Request) - self.resource_server_user = UserModel.objects.create_user( + @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" ) - self.application = Application.objects.create( + cls.application = Application.objects.create( name="Test Application", redirect_uris="http://localhost http://example.com http://example.org", - user=self.resource_server_user, + user=cls.resource_server_user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, ) - self.resource_server_token = AccessToken.objects.create( - user=self.resource_server_user, + cls.resource_server_token = AccessToken.objects.create( + user=cls.resource_server_user, token="12345678900", - application=self.application, + application=cls.application, expires=timezone.now() + datetime.timedelta(days=1), scope="introspection", ) - self.invalid_token = AccessToken.objects.create( - user=self.resource_server_user, + cls.invalid_token = AccessToken.objects.create( + user=cls.resource_server_user, token="12345678901", - application=self.application, + 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 - def tearDown(self): - self.resource_server_token.delete() - self.application.delete() - AccessToken.objects.all().delete() - UserModel.objects.all().delete() - @mock.patch("requests.post", side_effect=mocked_requests_post) def test_get_token_from_authentication_server_not_existing_token(self, mock_get): """ @@ -156,24 +170,76 @@ def test_get_token_from_authentication_server_existing_token(self, mock_get): self.assertEqual(token.user.username, "foo_user") self.assertEqual(token.scope, "read write dolphin") - @mock.patch("requests.post", side_effect=mocked_requests_post) - def test_get_token_from_authentication_server_expires_timezone(self, mock_get): + @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: - self.validator._get_token_from_authentication_server( + 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): diff --git a/tests/test_introspection_view.py b/tests/test_introspection_view.py index b19c521d5..ad7d8983d 100644 --- a/tests/test_introspection_view.py +++ b/tests/test_introspection_view.py @@ -3,13 +3,14 @@ import pytest from django.contrib.auth import get_user_model -from django.test import TestCase +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 @@ -27,64 +28,60 @@ class TestTokenIntrospectionViews(TestCase): Tests for Authorized Token Introspection Views """ - def setUp(self): - self.resource_server_user = UserModel.objects.create_user("resource_server", "test@example.com") - self.test_user = UserModel.objects.create_user("bar_user", "dev@example.com") + @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") - self.application = Application.objects.create( + cls.application = Application.objects.create( name="Test Application", redirect_uris="http://localhost http://example.com http://example.org", - user=self.test_user, + user=cls.test_user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, client_secret=CLEARTEXT_SECRET, ) - self.resource_server_token = AccessToken.objects.create( - user=self.resource_server_user, + cls.resource_server_token = AccessToken.objects.create( + user=cls.resource_server_user, token="12345678900", - application=self.application, + application=cls.application, expires=timezone.now() + datetime.timedelta(days=1), scope="introspection", ) - self.valid_token = AccessToken.objects.create( - user=self.test_user, + cls.valid_token = AccessToken.objects.create( + user=cls.test_user, token="12345678901", - application=self.application, + application=cls.application, expires=timezone.now() + datetime.timedelta(days=1), scope="read write dolphin", ) - self.invalid_token = AccessToken.objects.create( - user=self.test_user, + cls.invalid_token = AccessToken.objects.create( + user=cls.test_user, token="12345678902", - application=self.application, + application=cls.application, expires=timezone.now() + datetime.timedelta(days=-1), scope="read write dolphin", ) - self.token_without_user = AccessToken.objects.create( + cls.token_without_user = AccessToken.objects.create( user=None, token="12345678903", - application=self.application, + application=cls.application, expires=timezone.now() + datetime.timedelta(days=1), scope="read write dolphin", ) - self.token_without_app = AccessToken.objects.create( - user=self.test_user, + 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 tearDown(self): - AccessToken.objects.all().delete() - Application.objects.all().delete() - UserModel.objects.all().delete() - def test_view_forbidden(self): """ Test that the view is restricted for logged-in users. @@ -282,6 +279,20 @@ def test_view_post_notexisting_token(self): }, ) + def test_view_post_no_token(self): + """ + Test that when you pass no token HTTP 400 is returned + """ + auth_headers = { + "HTTP_AUTHORIZATION": "Bearer " + self.resource_server_token.token, + } + response = self.client.post(reverse("oauth2_provider:introspect"), **auth_headers) + + self.assertEqual(response.status_code, 400) + content = response.json() + self.assertIsInstance(content, dict) + self.assertEqual(content["error"], "invalid_request") + 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) @@ -347,5 +358,6 @@ def test_view_post_invalid_client_creds_plaintext(self): self.assertEqual(response.status_code, 403) def test_select_related_in_view_for_less_db_queries(self): - with self.assertNumQueries(1): + token_database = router.db_for_write(AccessToken) + with self.assertNumQueries(1, using=token_database): self.client.post(reverse("oauth2_provider:introspect")) diff --git a/tests/test_mixins.py b/tests/test_mixins.py index 1294b75cb..1cefa1334 100644 --- a/tests/test_mixins.py +++ b/tests/test_mixins.py @@ -3,7 +3,7 @@ import pytest from django.core.exceptions import ImproperlyConfigured from django.http import HttpResponse -from django.test import RequestFactory, TestCase +from django.test import RequestFactory from django.views.generic import View from oauthlib.oauth2 import Server @@ -11,12 +11,14 @@ 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") @@ -145,6 +147,15 @@ def get(self, *args, **kwargs): 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 @@ -153,6 +164,14 @@ def test_oidc_only_mixin_oidc_enabled(oauth2_settings, rf, oidc_only_view): 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 @@ -161,6 +180,14 @@ def test_oidc_only_mixin_oidc_disabled_debug(oauth2_settings, rf, settings, oidc 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 @@ -169,3 +196,15 @@ def test_oidc_only_mixin_oidc_disabled_no_debug(oauth2_settings, rf, settings, o 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 diff --git a/tests/test_models.py b/tests/test_models.py index 9ce1e5eb7..eb01aac8f 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,9 +1,11 @@ +import 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 import TestCase from django.test.utils import override_settings from django.utils import timezone @@ -14,11 +16,16 @@ get_grant_model, get_id_token_model, get_refresh_token_model, + redirect_to_uri_allowed, ) 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() @@ -28,11 +35,9 @@ class BaseTestModels(TestCase): - def setUp(self): - self.user = UserModel.objects.create_user("test_user", "test@example.com", "123456") - - def tearDown(self): - self.user.delete() + @classmethod + def setUpTestData(cls): + cls.user = UserModel.objects.create_user("test_user", "test@example.com", "123456") class TestModels(BaseTestModels): @@ -54,6 +59,49 @@ def test_allow_scopes(self): 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)) + + @override_settings(OAUTH2_PROVIDER={"CLIENT_SECRET_HASHER": "fast_pbkdf2"}) + def test_hashed_from_settings(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.assertIn("fast_pbkdf2", app.client_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", @@ -222,20 +270,17 @@ def test_custom_grant_model_not_installed(self): class TestGrantModel(BaseTestModels): - def setUp(self): - super().setUp() - self.application = Application.objects.create( + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.application = Application.objects.create( name="Test Application", redirect_uris="", - user=self.user, + user=cls.user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, ) - def tearDown(self): - self.application.delete() - super().tearDown() - def test_str(self): grant = Grant(code="test_code") self.assertEqual("%s" % grant, grant.code) @@ -285,6 +330,17 @@ def test_expires_can_be_none(self): 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): @@ -294,73 +350,72 @@ def test_str(self): @pytest.mark.usefixtures("oauth2_settings") class TestClearExpired(BaseTestModels): - def setUp(self): - super().setUp() + @classmethod + def setUpTestData(cls): + super().setUpTestData() # Insert many tokens, both expired and not, and grants. - self.num_tokens = 100 - now = timezone.now() - earlier = now - timedelta(seconds=100) - later = now + timedelta(seconds=100) + 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=self.user, + 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.objects.bulk_create( - AccessToken(token="expired AccessToken {}".format(i), expires=earlier) - for i in range(self.num_tokens) - ) - current_access_tokens = AccessToken.objects.bulk_create( - AccessToken(token=f"current AccessToken {i}", expires=later) for i in range(self.num_tokens) - ) + 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. - RefreshToken.objects.bulk_create( + 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].pk, - user=self.user, - ) - for i in range(0, len(expired_access_tokens) // 2, 2) - ) - RefreshToken.objects.bulk_create( + 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].pk, - user=self.user, - ) - for i in range(1, len(current_access_tokens) // 2, 2) - ) + access_token=current_access_tokens[i], + user=cls.user, + ).save() + # Make some grants, half of which are expired. - Grant.objects.bulk_create( + for i in range(cls.num_tokens): Grant( - user=self.user, + user=cls.user, code=f"old grant code {i}", application=app, - expires=earlier, + expires=cls.earlier, redirect_uri="https://localhost/redirect", - ) - for i in range(self.num_tokens) - ) - Grant.objects.bulk_create( + ).save() + for i in range(cls.num_tokens): Grant( - user=self.user, + user=cls.user, code=f"new grant code {i}", application=app, - expires=later, + expires=cls.later, redirect_uri="https://localhost/redirect", - ) - for i in range(self.num_tokens) - ) - - def test_clear_expired_tokens(self): - self.oauth2_settings.REFRESH_TOKEN_EXPIRE_SECONDS = 60 - assert clear_expired() is None + ).save() def test_clear_expired_tokens_incorect_timetype(self): self.oauth2_settings.REFRESH_TOKEN_EXPIRE_SECONDS = "A" @@ -372,22 +427,64 @@ def test_clear_expired_tokens_incorect_timetype(self): 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 - at_count = AccessToken.objects.count() - assert at_count == 2 * self.num_tokens, f"{2 * self.num_tokens} access tokens should exist." - rt_count = RefreshToken.objects.count() - assert rt_count == self.num_tokens // 2, f"{self.num_tokens // 2} refresh tokens should exist." - gt_count = Grant.objects.count() - assert gt_count == self.num_tokens * 2, f"{self.num_tokens * 2} grants should exist." + 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() - at_count = AccessToken.objects.count() - assert at_count == self.num_tokens, "Half the access tokens should not have been deleted." - rt_count = RefreshToken.objects.count() - assert rt_count == self.num_tokens // 2, "Half of the refresh tokens should have been deleted." - gt_count = Grant.objects.count() - assert gt_count == self.num_tokens, "Half the grants should have been deleted." + + # after clear_expired(): + remaining_at_count = AccessToken.objects.count() + assert remaining_at_count == initial_at_count // 2, ( + "half the initial access tokens should still exist." + ) + remaining_expired_at_count = AccessToken.objects.filter(expires__lte=self.now).count() + assert remaining_expired_at_count == 0, "no remaining expired access tokens should still exist." + remaining_current_at_count = AccessToken.objects.filter(expires__gt=self.now).count() + assert remaining_current_at_count == initial_current_at_count, ( + "all current access tokens should still exist." + ) + remaining_rt_count = RefreshToken.objects.count() + assert remaining_rt_count == initial_rt_count // 2, "half the refresh tokens should still exist." + remaining_rt_expired_at_count = RefreshToken.objects.filter( + access_token__expires__lte=self.now + ).count() + assert remaining_rt_expired_at_count == 0, "no refresh tokens for expired AT's should still exist." + remaining_rt_current_at_count = RefreshToken.objects.filter( + access_token__expires__gt=self.now + ).count() + assert remaining_rt_current_at_count == initial_rt_current_at_count, ( + "all the refresh tokens for current access tokens should still exist." + ) + remaining_gt_count = Grant.objects.count() + assert remaining_gt_count == initial_gt_count // 2, "half the remaining grants should still exist." -@pytest.mark.django_db +@pytest.mark.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() @@ -422,12 +519,51 @@ def test_id_token_methods(oidc_tokens, rf): assert IDToken.objects.filter(jti=id_token.jti).count() == 0 -@pytest.mark.django_db +@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" + assert key.kty == "RSA" # RS256 key, but not configured oauth2_settings.OIDC_RSA_PRIVATE_KEY = None @@ -438,7 +574,7 @@ def test_application_key(oauth2_settings, application): # HS256 key application.algorithm = Application.HS256_ALGORITHM key = application.jwk_key - assert key.key_type == "oct" + assert key.kty == "oct" # No algorithm application.algorithm = Application.NO_ALGORITHM @@ -447,7 +583,7 @@ def test_application_key(oauth2_settings, application): assert "This application does not support signed tokens" == str(exc.value) -@pytest.mark.django_db +@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 @@ -475,3 +611,134 @@ def test_application_clean(oauth2_settings, application): 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() + + +def _test_wildcard_redirect_uris_valid(oauth2_settings, application, uris): + oauth2_settings.ALLOW_URI_WILDCARDS = True + application.redirect_uris = uris + application.clean() + + +def _test_wildcard_redirect_uris_invalid(oauth2_settings, application, uris): + oauth2_settings.ALLOW_URI_WILDCARDS = True + application.redirect_uris = uris + with pytest.raises(ValidationError): + application.clean() + + +@pytest.mark.django_db(databases=retrieve_current_databases()) +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) +def test_application_clean_wildcard_redirect_uris_valid_3ld(oauth2_settings, application): + _test_wildcard_redirect_uris_valid(oauth2_settings, application, "https://*.example.com/path") + + +@pytest.mark.django_db(databases=retrieve_current_databases()) +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) +def test_application_clean_wildcard_redirect_uris_valid_partial_3ld(oauth2_settings, application): + _test_wildcard_redirect_uris_valid(oauth2_settings, application, "https://*-partial.example.com/path") + + +@pytest.mark.django_db(databases=retrieve_current_databases()) +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) +def test_application_clean_wildcard_redirect_uris_invalid_3ld_not_starting_with_wildcard( + oauth2_settings, application +): + _test_wildcard_redirect_uris_invalid(oauth2_settings, application, "https://invalid-*.example.com/path") + + +@pytest.mark.django_db(databases=retrieve_current_databases()) +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) +def test_application_clean_wildcard_redirect_uris_invalid_2ld(oauth2_settings, application): + _test_wildcard_redirect_uris_invalid(oauth2_settings, application, "https://*.com/path") + + +@pytest.mark.django_db(databases=retrieve_current_databases()) +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) +def test_application_clean_wildcard_redirect_uris_invalid_partial_2ld(oauth2_settings, application): + _test_wildcard_redirect_uris_invalid(oauth2_settings, application, "https://*-partial.com/path") + + +@pytest.mark.django_db(databases=retrieve_current_databases()) +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) +def test_application_clean_wildcard_redirect_uris_invalid_2ld_not_starting_with_wildcard( + oauth2_settings, application +): + _test_wildcard_redirect_uris_invalid(oauth2_settings, application, "https://invalid-*.com/path") + + +@pytest.mark.django_db(databases=retrieve_current_databases()) +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) +def test_application_clean_wildcard_redirect_uris_invalid_tld(oauth2_settings, application): + _test_wildcard_redirect_uris_invalid(oauth2_settings, application, "https://*/path") + + +@pytest.mark.django_db(databases=retrieve_current_databases()) +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) +def test_application_clean_wildcard_redirect_uris_invalid_tld_partial(oauth2_settings, application): + _test_wildcard_redirect_uris_invalid(oauth2_settings, application, "https://*-partial/path") + + +@pytest.mark.django_db(databases=retrieve_current_databases()) +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) +def test_application_clean_wildcard_redirect_uris_invalid_tld_not_starting_with_wildcard( + oauth2_settings, application +): + _test_wildcard_redirect_uris_invalid(oauth2_settings, application, "https://invalid-*/path") + + +@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") + + +def test_redirect_to_uri_allowed_expects_allowed_uri_list(): + with pytest.raises(ValueError): + redirect_to_uri_allowed("https://example.com", "https://example.com") + assert redirect_to_uri_allowed("https://example.com", ["https://example.com"]) + + +valid_wildcard_redirect_to_params = [ + ("https://valid.example.com", ["https://*.example.com"]), + ("https://valid.valid.example.com", ["https://*.example.com"]), + ("https://valid-partial.example.com", ["https://*-partial.example.com"]), + ("https://valid.valid-partial.example.com", ["https://*-partial.example.com"]), +] + + +@pytest.mark.parametrize("uri, allowed_uri", valid_wildcard_redirect_to_params) +def test_wildcard_redirect_to_uri_allowed_valid(uri, allowed_uri, oauth2_settings): + oauth2_settings.ALLOW_URI_WILDCARDS = True + assert redirect_to_uri_allowed(uri, allowed_uri) + + +invalid_wildcard_redirect_to_params = [ + ("https://invalid.com", ["https://*.example.com"]), + ("https://invalid.example.com", ["https://*-partial.example.com"]), +] + + +@pytest.mark.parametrize("uri, allowed_uri", invalid_wildcard_redirect_to_params) +def test_wildcard_redirect_to_uri_allowed_invalid(uri, allowed_uri, oauth2_settings): + oauth2_settings.ALLOW_URI_WILDCARDS = True + assert not redirect_to_uri_allowed(uri, allowed_uri) diff --git a/tests/test_oauth2_backends.py b/tests/test_oauth2_backends.py index acff2cae9..a4408f8e6 100644 --- a/tests/test_oauth2_backends.py +++ b/tests/test_oauth2_backends.py @@ -1,11 +1,15 @@ +import base64 import json import pytest -from django.test import RequestFactory, TestCase +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 redirect_to_uri_allowed +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: @@ -16,9 +20,11 @@ @pytest.mark.usefixtures("oauth2_settings") class TestOAuthLibCoreBackend(TestCase): - def setUp(self): - self.factory = RequestFactory() - self.oauthlib_core = OAuthLibCore() + factory = RequestFactory() + + @classmethod + def setUpTestData(cls): + cls.oauthlib_core = OAuthLibCore() def test_swappable_server_class(self): self.oauth2_settings.OAUTH2_SERVER_CLASS = mock.MagicMock @@ -50,6 +56,94 @@ def test_application_json_extract_params(self): 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 @@ -60,8 +154,7 @@ class MyOAuthLibCore(OAuthLibCore): def _get_extra_credentials(self, request): return 1 - def setUp(self): - self.factory = RequestFactory() + factory = RequestFactory() def test_create_token_response_gets_extra_credentials(self): """ @@ -79,9 +172,7 @@ def test_create_token_response_gets_extra_credentials(self): class TestJSONOAuthLibCoreBackend(TestCase): - def setUp(self): - self.factory = RequestFactory() - self.oauthlib_core = JSONOAuthLibCore() + factory = RequestFactory() def test_application_json_extract_params(self): payload = json.dumps( @@ -92,16 +183,16 @@ def test_application_json_extract_params(self): } ) request = self.factory.post("/o/token/", payload, content_type="application/json") + oauthlib_core = JSONOAuthLibCore() - uri, http_method, body, headers = self.oauthlib_core._extract_params(request) + 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): - def setUp(self): - self.factory = RequestFactory() + factory = RequestFactory() def test_validate_authorization_request_unsafe_query(self): auth_headers = { diff --git a/tests/test_oauth2_validators.py b/tests/test_oauth2_validators.py index fd06a1eda..14c74506e 100644 --- a/tests/test_oauth2_validators.py +++ b/tests/test_oauth2_validators.py @@ -3,18 +3,28 @@ import json import pytest +import requests from django.contrib.auth import get_user_model -from django.test import TestCase, TransactionTestCase +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_refresh_token_model +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 @@ -27,9 +37,11 @@ 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 @@ -61,11 +73,25 @@ def setUp(self): ) 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)) @@ -75,6 +101,27 @@ def test_authenticate_request_body(self): 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") @@ -87,7 +134,16 @@ def test_extract_basic_auth(self): self.request.headers = {"HTTP_AUTHORIZATION": "Basic 123456 789"} self.assertEqual(self.validator._extract_basic_auth(self.request), "123456 789") - def test_authenticate_basic_auth(self): + 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)) @@ -124,6 +180,13 @@ def test_authenticate_basic_auth_not_utf8(self): 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)) @@ -160,7 +223,6 @@ def test_save_bearer_token__without_user__raises_fatal_client(self): 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 @@ -190,7 +252,6 @@ def test_save_bearer_token__with_existing_tokens__does_not_create_new_tokens(sel 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 @@ -284,12 +345,20 @@ def test_save_bearer_token__with_new_token__calls_methods_to_create_access_and_r 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://tools.ietf.org/html/rfc6750 + 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 @@ -309,7 +378,7 @@ class TestOAuth2ValidatorProvidesErrorData(TransactionTestCase): > attribute to provide the client with the reason why the access > request was declined. - See https://tools.ietf.org/html/rfc6750#section-3.1 for the allowed error + See https://rfc-editor.org/rfc/rfc6750.html#section-3.1 for the allowed error codes. """ @@ -426,24 +495,33 @@ class TestOAuth2ValidatorErrorResourceToken(TestCase): is unsuccessful. """ - def setUp(self): - self.token = "test_token" - self.introspection_url = "http://example.com/token/introspection/" - self.introspection_token = "test_introspection_token" - self.validator = OAuth2Validator() - - def test_response_when_auth_server_response_return_404(self): - with self.assertLogs(logger="oauth2_provider") as mock_log: - self.validator._get_token_from_authentication_server( - self.token, self.introspection_url, self.introspection_token, None - ) - self.assertIn( - "ERROR:oauth2_provider:Introspection: Failed to " - "get a valid response from authentication server. " - "Status code: 404, Reason: " - "Not Found.\nNoneType: None", - mock_log.output, - ) + @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_not_200(self): + """ + Ensure we log the error when the authentication server returns a non-200 response. + """ + mock_response = requests.Response() + mock_response.status_code = 404 + mock_response.reason = "Not Found" + with mock.patch("requests.post") as mock_post: + mock_post.return_value = mock_response + 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) @@ -485,7 +563,7 @@ def test_get_jwt_bearer_token(oauth2_settings, mocker): assert mock_get_id_token.call_args[1] == {} -@pytest.mark.django_db +@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) @@ -501,7 +579,7 @@ def test_validate_id_token_no_token(oauth2_settings, mocker): assert status is False -@pytest.mark.django_db +@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() @@ -510,7 +588,7 @@ def test_validate_id_token_app_removed(oauth2_settings, mocker, oidc_tokens): assert status is False -@pytest.mark.django_db +@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"})) @@ -518,3 +596,14 @@ def test_validate_id_token_bad_token_no_aud(oauth2_settings, mocker, 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) diff --git a/tests/test_oidc_views.py b/tests/test_oidc_views.py index 7b379d1b3..65197cbd1 100644 --- a/tests/test_oidc_views.py +++ b/tests/test_oidc_views.py @@ -1,10 +1,25 @@ import pytest -from django.test import TestCase +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") @@ -30,12 +45,72 @@ def test_get_connect_discovery_info(self): "subject_types_supported": ["public"], "id_token_signing_alg_values_supported": ["RS256", "HS256"], "token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"], + "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 @@ -58,12 +133,19 @@ def test_get_connect_discovery_info_without_issuer_url(self): "subject_types_supported": ["public"], "id_token_signing_alg_values_supported": ["RS256", "HS256"], "token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"], + "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")) @@ -124,7 +206,332 @@ def test_get_jwks_info_multiple_rsa_keys(self): assert response.json() == expected_response -@pytest.mark.django_db +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 + assert ( + RPInitiatedLogoutView(request=mock_request_for(AnonymousUser())).must_prompt(oidc_tokens.user) + is False + ) + + +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 @@ -137,7 +544,7 @@ def test_userinfo_endpoint(oidc_tokens, client, method): assert data["sub"] == str(oidc_tokens.user.pk) -@pytest.mark.django_db +@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")) @@ -150,6 +557,116 @@ def test_userinfo_endpoint_bad_token(oidc_tokens, client): 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_without_op_session_get(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 == 302 + assert not is_logged_in(client) + # Check that all tokens are active. + assert AccessToken.objects.count() == 0 + assert IDToken.objects.count() == 0 + assert RefreshToken.objects.count() == 1 + + with pytest.raises(AccessToken.DoesNotExist): + AccessToken.objects.get() + + with pytest.raises(IDToken.DoesNotExist): + IDToken.objects.get() + + refresh_token = RefreshToken.objects.get() + assert refresh_token.revoked is not None + + +@pytest.mark.django_db(databases=retrieve_current_databases()) +def test_token_deletion_on_logout_without_op_session_post(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.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" @@ -157,7 +674,7 @@ def claim_user_email(request): return EXAMPLE_EMAIL -@pytest.mark.django_db +@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 @@ -185,7 +702,7 @@ def get_additional_claims(self): assert data["email"] == EXAMPLE_EMAIL -@pytest.mark.django_db +@pytest.mark.django_db(databases=retrieve_current_databases()) def test_userinfo_endpoint_custom_claims_email_scope_callable( oidc_email_scope_tokens, client, oauth2_settings ): @@ -212,7 +729,7 @@ def get_additional_claims(self): assert data["email"] == EXAMPLE_EMAIL -@pytest.mark.django_db +@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 @@ -240,7 +757,7 @@ def get_additional_claims(self, request): assert data["email"] == EXAMPLE_EMAIL -@pytest.mark.django_db +@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): diff --git a/tests/test_password.py b/tests/test_password.py index ab0f49228..65cf5a8b5 100644 --- a/tests/test_password.py +++ b/tests/test_password.py @@ -2,12 +2,13 @@ import pytest from django.contrib.auth import get_user_model -from django.test import RequestFactory, TestCase +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 @@ -25,24 +26,21 @@ def get(self, request, *args, **kwargs): @pytest.mark.usefixtures("oauth2_settings") class BaseTest(TestCase): - def setUp(self): - self.factory = RequestFactory() - self.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") - self.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") + factory = RequestFactory() - self.application = Application.objects.create( + @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=self.dev_user, + user=cls.dev_user, client_type=Application.CLIENT_PUBLIC, authorization_grant_type=Application.GRANT_PASSWORD, client_secret=CLEARTEXT_SECRET, ) - def tearDown(self): - self.application.delete() - self.test_user.delete() - self.dev_user.delete() - class TestPasswordTokenView(BaseTest): def test_get_token(self): diff --git a/tests/test_rest_framework.py b/tests/test_rest_framework.py index a25611b93..f8ff86f23 100644 --- a/tests/test_rest_framework.py +++ b/tests/test_rest_framework.py @@ -5,7 +5,6 @@ from django.contrib.auth import get_user_model from django.core.exceptions import ImproperlyConfigured from django.http import HttpResponse -from django.test import TestCase from django.test.utils import override_settings from django.urls import path, re_path from django.utils import timezone @@ -25,6 +24,7 @@ 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() @@ -127,27 +127,29 @@ class AuthenticationNoneOAuth2View(MockView): @override_settings(ROOT_URLCONF=__name__) +@pytest.mark.nologinrequiredmiddleware @pytest.mark.usefixtures("oauth2_settings") @pytest.mark.oauth2_settings(presets.REST_FRAMEWORK_SCOPES) class TestOAuth2Authentication(TestCase): - def setUp(self): - self.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") - self.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") + @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") - self.application = Application.objects.create( + cls.application = Application.objects.create( name="Test Application", redirect_uris="http://localhost http://example.com http://example.org", - user=self.dev_user, + user=cls.dev_user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, ) - self.access_token = AccessToken.objects.create( - user=self.test_user, + 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=self.application, + application=cls.application, ) def _create_authorization_header(self, token): @@ -414,3 +416,9 @@ 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) diff --git a/tests/test_scopes.py b/tests/test_scopes.py index 548cc060c..4dae0d3c4 100644 --- a/tests/test_scopes.py +++ b/tests/test_scopes.py @@ -4,12 +4,13 @@ import pytest from django.contrib.auth import get_user_model from django.core.exceptions import ImproperlyConfigured -from django.test import RequestFactory, TestCase +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 @@ -58,25 +59,22 @@ def post(self, request, *args, **kwargs): @pytest.mark.usefixtures("oauth2_settings") @pytest.mark.oauth2_settings(SCOPE_SETTINGS) class BaseTest(TestCase): - def setUp(self): - self.factory = RequestFactory() - self.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") - self.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") + factory = RequestFactory() - self.application = Application.objects.create( + @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=self.dev_user, + user=cls.dev_user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, client_secret=CLEARTEXT_SECRET, ) - def tearDown(self): - self.application.delete() - self.test_user.delete() - self.dev_user.delete() - class TestScopesSave(BaseTest): def test_scopes_saved_in_grant(self): diff --git a/tests/test_settings.py b/tests/test_settings.py index f9f540339..b64fc31db 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -1,6 +1,5 @@ import pytest from django.core.exceptions import ImproperlyConfigured -from django.test import TestCase from django.test.utils import override_settings from oauthlib.common import Request @@ -19,6 +18,7 @@ CustomIDTokenAdmin, CustomRefreshTokenAdmin, ) +from tests.common_testing import OAuth2ProviderTestCase as TestCase from . import presets diff --git a/tests/test_token_endpoint_cors.py b/tests/test_token_endpoint_cors.py new file mode 100644 index 000000000..6eaea6560 --- /dev/null +++ b/tests/test_token_endpoint_cors.py @@ -0,0 +1,161 @@ +import 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() diff --git a/tests/test_token_revocation.py b/tests/test_token_revocation.py index b4f5af7dd..fa836b6a2 100644 --- a/tests/test_token_revocation.py +++ b/tests/test_token_revocation.py @@ -1,12 +1,14 @@ import datetime from django.contrib.auth import get_user_model -from django.test import RequestFactory, TestCase +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() @@ -17,25 +19,22 @@ class BaseTest(TestCase): - def setUp(self): - self.factory = RequestFactory() - self.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") - self.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") + 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") - self.application = Application.objects.create( + cls.application = Application.objects.create( name="Test Application", redirect_uris="http://localhost http://example.com http://example.org", - user=self.dev_user, + user=cls.dev_user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, client_secret=CLEARTEXT_SECRET, ) - def tearDown(self): - self.application.delete() - self.test_user.delete() - self.dev_user.delete() - class TestRevocationView(BaseTest): def test_revoke_access_token(self): @@ -56,7 +55,7 @@ def test_revoke_access_token(self): response = self.client.post(url, data=data) self.assertEqual(response.status_code, 200) self.assertEqual(response.content, b"") - self.assertFalse(AccessToken.objects.filter(id=tok.id).exists()) + self.assertFalse(AccessToken.objects.filter(pk=tok.pk).exists()) def test_revoke_access_token_public(self): public_app = Application( @@ -104,7 +103,7 @@ def test_revoke_access_token_with_hint(self): url = reverse("oauth2_provider:revoke-token") response = self.client.post(url, data=data) self.assertEqual(response.status_code, 200) - self.assertFalse(AccessToken.objects.filter(id=tok.id).exists()) + self.assertFalse(AccessToken.objects.filter(pk=tok.pk).exists()) def test_revoke_access_token_with_invalid_hint(self): tok = AccessToken.objects.create( @@ -126,7 +125,7 @@ def test_revoke_access_token_with_invalid_hint(self): url = reverse("oauth2_provider:revoke-token") response = self.client.post(url, data=data) self.assertEqual(response.status_code, 200) - self.assertFalse(AccessToken.objects.filter(id=tok.id).exists()) + self.assertFalse(AccessToken.objects.filter(pk=tok.pk).exists()) def test_revoke_refresh_token(self): tok = AccessToken.objects.create( @@ -149,9 +148,9 @@ def test_revoke_refresh_token(self): url = reverse("oauth2_provider:revoke-token") response = self.client.post(url, data=data) self.assertEqual(response.status_code, 200) - refresh_token = RefreshToken.objects.filter(id=rtok.id).first() + refresh_token = RefreshToken.objects.filter(pk=rtok.pk).first() self.assertIsNotNone(refresh_token.revoked) - self.assertFalse(AccessToken.objects.filter(id=rtok.access_token.id).exists()) + 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( @@ -175,8 +174,8 @@ def test_revoke_refresh_token_with_revoked_access_token(self): response = self.client.post(url, data=data) self.assertEqual(response.status_code, 200) - self.assertFalse(AccessToken.objects.filter(id=tok.id).exists()) - refresh_token = RefreshToken.objects.filter(id=rtok.id).first() + self.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): @@ -205,4 +204,4 @@ def test_revoke_token_with_wrong_hint(self): url = reverse("oauth2_provider:revoke-token") response = self.client.post(url, data=data) self.assertEqual(response.status_code, 200) - self.assertFalse(AccessToken.objects.filter(id=tok.id).exists()) + self.assertFalse(AccessToken.objects.filter(pk=tok.pk).exists()) diff --git a/tests/test_token_view.py b/tests/test_token_view.py index 784ea3b84..63e76ed2f 100644 --- a/tests/test_token_view.py +++ b/tests/test_token_view.py @@ -1,12 +1,13 @@ import datetime from django.contrib.auth import get_user_model -from django.test import TestCase from django.urls import reverse from django.utils import timezone from oauth2_provider.models import get_access_token_model, get_application_model +from .common_testing import OAuth2ProviderTestCase as TestCase + Application = get_application_model() AccessToken = get_access_token_model() @@ -18,22 +19,19 @@ class TestAuthorizedTokenViews(TestCase): TestCase superclass for Authorized Token Views" Test Cases """ - def setUp(self): - self.foo_user = UserModel.objects.create_user("foo_user", "test@example.com", "123456") - self.bar_user = UserModel.objects.create_user("bar_user", "dev@example.com", "123456") + @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") - self.application = Application.objects.create( + cls.application = Application.objects.create( name="Test Application", redirect_uris="http://localhost http://example.com http://example.org", - user=self.bar_user, + user=cls.bar_user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, ) - def tearDown(self): - self.foo_user.delete() - self.bar_user.delete() - class TestAuthorizedTokenListView(TestAuthorizedTokenViews): """ diff --git a/tests/test_ui_locales.py b/tests/test_ui_locales.py new file mode 100644 index 000000000..d375dc55c --- /dev/null +++ b/tests/test_ui_locales.py @@ -0,0 +1,57 @@ +from 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]+") diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 000000000..2c319b6ea --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,27 @@ +from 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 diff --git a/tests/test_validators.py b/tests/test_validators.py index 0760e0290..a77a1e16a 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -1,29 +1,16 @@ import pytest from django.core.validators import ValidationError -from django.test import TestCase -from oauth2_provider.validators import RedirectURIValidator +from oauth2_provider.validators import AllowedURIValidator +from .common_testing import OAuth2ProviderTestCase as TestCase -@pytest.mark.usefixtures("oauth2_settings") -class TestValidators(TestCase): - def test_validate_good_uris(self): - validator = RedirectURIValidator(allowed_schemes=["https"]) - good_uris = [ - "https://example.com/", - "https://example.org/?key=val", - "https://example", - "https://localhost", - "https://1.1.1.1", - "https://127.0.0.1", - "https://255.255.255.255", - ] - for uri in good_uris: - # Check ValidationError not thrown - validator(uri) - def test_validate_custom_uri_scheme(self): - validator = RedirectURIValidator(allowed_schemes=["my-scheme", "https", "git+ssh"]) +@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", @@ -36,15 +23,19 @@ def test_validate_custom_uri_scheme(self): # Check ValidationError not thrown validator(uri) - def test_validate_bad_uris(self): - validator = RedirectURIValidator(allowed_schemes=["https"]) - self.oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES = ["https", "good"] + 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/#fragment", + "http://example.com/path/#fragment", + "http://example.com?query=string#fragment", "123://example.com", "http://fe80::1", "git+ssh://example.com", @@ -61,3 +52,146 @@ def test_validate_bad_uris(self): 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) + + def test_allow_hostname_wildcard(self): + validator = AllowedURIValidator(["https"], "test", allow_hostname_wildcard=True) + good_uris = [ + "https://*.example.com", + "https://*-partial.example.com", + "https://*.partial.example.com", + "https://*-partial.valid.example.com", + ] + for uri in good_uris: + # Check ValidationError not thrown + validator(uri) + + bad_uris = [ + "https://*/", + "https://*-partial", + "https://*.com", + "https://*-partial.com", + "https://*.*.example.com", + "https://invalid.*.example.com", + ] + for uri in bad_uris: + with self.assertRaises(ValidationError): + validator(uri) diff --git a/tests/urls.py b/tests/urls.py index 0661a9336..6f8f56832 100644 --- a/tests/urls.py +++ b/tests/urls.py @@ -1,11 +1,13 @@ from 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_provider.urls", namespace="oauth2_provider")), + path("o/", include(oauth2_urls)), path("admin/", admin.site.urls), ] diff --git a/tox.ini b/tox.ini index 117a5e901..d5cf8d2dc 100644 --- a/tox.ini +++ b/tox.ini @@ -1,30 +1,31 @@ [tox] envlist = - flake8, migrations, + migrate_swapped, docs, + lint, sphinxlint, - py{37,38,39}-dj22, - py{37,38,39,310}-dj32, - py{38,39,310}-dj40, - py{38,39,310}-djmain, + py{38,39,310,311,312,313}-dj42, + py{310,311,312,313}-dj50, + py{310,311,312,313}-dj51, + py{310,311,312,313}-djmain, + py39-multi-db-dj-42 [gh-actions] python = - 3.7: py37 - 3.8: py38, docs, flake8, migrations, sphinxlint + 3.8: py38, docs, lint, migrations, migrate_swapped, sphinxlint 3.9: py39 3.10: py310 + 3.11: py311 + 3.12: py312 + 3.13: py313 -[pytest] -django_find_project = false -addopts = - --cov=oauth2_provider - --cov-report= - --cov-append - -s -markers = - oauth2_settings: Custom OAuth2 settings to use - use with oauth2_settings fixture +[gh-actions:env] +DJANGO = + 4.2: dj42 + 5.0: dj50 + 5.1: dj51 + main: djmain [testenv] commands = @@ -36,12 +37,12 @@ setenv = PYTHONPATH = {toxinidir} PYTHONWARNINGS = all deps = - dj22: Django>=2.2,<3 - dj32: Django>=3.2,<3.3 - dj40: Django>=4.0.0,<4.1 + 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.1.0 + oauthlib>=3.2.2 jwcrypto coverage pytest @@ -50,10 +51,11 @@ deps = pytest-xdist pytest-mock requests + pytz; python_version < '3.9' passenv = PYTEST_ADDOPTS -[testenv:py{38,39,310}-djmain] +[testenv:py{310,311,312,313}-djmain] ignore_errors = true ignore_outcome = true @@ -66,14 +68,14 @@ commands = [testenv:{docs,livedocs}] basepython = python3.8 changedir = docs -whitelist_externals = make +allowlist_externals = make commands = docs: make html livedocs: make livehtml deps = Jinja2<3.1 sphinx<3 - oauthlib>=3.1.0 + oauthlib>=3.2.2 m2r>=0.2.1 mistune<2 sphinx-rtd-theme @@ -81,15 +83,13 @@ deps = jwcrypto django -[testenv:flake8] +[testenv:lint] basepython = python3.8 +deps = ruff>=0.6 skip_install = True -commands = flake8 {toxinidir} -deps = - flake8 - flake8-isort - flake8-quotes - flake8-black +commands = + ruff format --check + ruff check [testenv:migrations] setenv = @@ -98,37 +98,26 @@ setenv = 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 = - setuptools>=39.0 - wheel -whitelist_externals = rm + build + twine +allowlist_externals = rm commands = rm -rf dist - python setup.py sdist bdist_wheel - -[coverage:run] -source = oauth2_provider -omit = */migrations/* - -[coverage:report] -show_missing = True - -[flake8] -max-line-length = 110 -exclude = docs/, oauth2_provider/migrations/, tests/migrations/, .tox/, build/, dist/ -application-import-names = oauth2_provider -inline-quotes = double -extend-ignore = E203, W503 - -[isort] -default_section = THIRDPARTY -known_first_party = oauth2_provider -line_length = 110 -lines_after_imports = 2 -multi_line_output = 3 -include_trailing_comma = True -force_grid_wrap = 0 -use_parentheses = True -ensure_newline_before_comments = True -skip = oauth2_provider/migrations/, .tox/, tests/migrations/ + python -m build + twine check dist/*