diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 000000000..0d4a01360 --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,20 @@ +# Dependency Review Action +# +# This Action will scan dependency manifest files that change as part of a Pull Request, surfacing known-vulnerable versions of the packages declared or updated in the PR. Once installed, if the workflow run is marked as required, PRs introducing known-vulnerable packages will be blocked from merging. +# +# Source repository: https://github.com/actions/dependency-review-action +# Public documentation: https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-dependency-review#dependency-review-enforcement +name: 'Dependency Review' +on: [pull_request] + +permissions: + contents: read + +jobs: + dependency-review: + runs-on: ubuntu-latest + steps: + - name: 'Checkout Repository' + uses: actions/checkout@v4 + - name: 'Dependency Review' + uses: actions/dependency-review-action@v4 diff --git a/.github/workflows/lint_python.yml b/.github/workflows/lint_python.yml index 3b3be00e6..25e7f88bb 100644 --- a/.github/workflows/lint_python.yml +++ b/.github/workflows/lint_python.yml @@ -3,23 +3,21 @@ on: [pull_request, push] jobs: lint_python: runs-on: ubuntu-latest + permissions: + contents: read steps: - - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 - - run: pip install --upgrade pip wheel - - run: pip install bandit black codespell flake8 flake8-2020 flake8-bugbear - flake8-comprehensions isort mypy pytest pyupgrade safety - - run: bandit --recursive --skip B101 . || true # B101 is assert statements - - run: black --check . || true - - run: codespell || true # --ignore-words-list="" --skip="*.css,*.js,*.lock" - - run: flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - - run: flake8 . --count --exit-zero --max-complexity=10 --max-line-length=88 - --show-source --statistics - - run: isort --check-only --profile black . || true - - run: pip install -r requirements.txt || pip install --editable . || true + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: 3.x + check-latest: true + - run: pip install --upgrade pip setuptools wheel + - run: pip install codespell mypy pytest ruff safety + - run: ruff check --output-format=github . + - run: codespell --ignore-words-list="implementor,mimiced,provicers,re-use,THIRDPARTY,assertIn" # --skip="*.css,*.js,*.lock" + - run: pip install -r requirements-test.txt + - run: pip install --editable . - run: mkdir --parents --verbose .mypy_cache - run: mypy --ignore-missing-imports --install-types --non-interactive . || true - - run: pytest . || true - - run: pytest --doctest-modules . || true - - run: shopt -s globstar && pyupgrade --py36-plus **/*.py || true - - run: safety check + - run: pytest + - run: safety check || true diff --git a/.github/workflows/python-build.yml b/.github/workflows/python-build.yml new file mode 100644 index 000000000..51623fad2 --- /dev/null +++ b/.github/workflows/python-build.yml @@ -0,0 +1,91 @@ +name: Python Tests +run-name: Run Tests by ${{ github.actor }} +on: [push, pull_request, workflow_dispatch] +jobs: + tests: + env: + FORCE_COLOR: 1 + strategy: + matrix: + python: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Set up Python ${{ matrix.python }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + allow-prereleases: true + - name: Check out repository code + uses: actions/checkout@v4 + - name: Install prereq + run: pip install tox coveralls + - name: Run python tests + run: tox -e ${{ matrix.python }} + - name: Coveralls + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + COVERALLS_FLAG_NAME: ${{ matrix.python-version }} + COVERALLS_PARALLEL: true + run: coveralls + coveralls: + name: Indicate completion to coveralls.io + needs: tests + runs-on: ubuntu-latest + container: python:3-slim + permissions: + contents: read + steps: + - name: Finished + run: | + pip3 install --upgrade coveralls + coveralls --service=github --finish + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + docs: + strategy: + matrix: + toxenv: ["docs", "readme"] + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - run: sudo apt install -y graphviz + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Check out repository code + uses: actions/checkout@v4 + - name: Install prereq + run: pip install tox + - name: Run python tests + run: tox -e ${{ matrix.toxenv }} + pypi-publish: + needs: + - tests + - docs + - coveralls + if: ${{ success() }} && github.repository == 'oauthlib/oauthlib' && startsWith(github.ref, 'refs/tags') + name: Upload release to PyPI + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/oauthlib + permissions: + id-token: write # IMPORTANT: this permission is mandatory for trusted publishing + steps: + - name: Check out repository code + uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.10' + - name: Install prereq + run: pip install build twine + - name: Build python package + run: python -m build + - name: Check python package + run: twine check dist/* + - name: Publish package distributions to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 000000000..d76e42c77 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,16 @@ +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details +version: 2 +build: + os: ubuntu-22.04 + tools: + python: "3.12" +sphinx: + builder: html + configuration: docs/conf.py + fail_on_warning: true +# the requirements.txt override some RTD defaults. +# ideally it has to be updated from time to time +# with latest libraries versions. +python: + install: + - requirements: docs/requirements.txt diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 6b5e6e304..000000000 --- a/.travis.yml +++ /dev/null @@ -1,58 +0,0 @@ -language: python -python: 3.8 -os: linux -dist: bionic -cache: pip -jobs: - include: - - python: "3.6" - env: TOXENV=py36 - - python: "3.7" - env: TOXENV=py37,docs - - python: "3.8" - env: TOXENV=py38,bandit,readme - - python: "3.9" - env: TOXENV=py39 - - python: "3.10.2" - env: TOXENV=py310 - - python: "3.11-dev" - env: TOXENV=py311 - - python: "pypy3" - env: TOXENV=pypy3 - allow_failures: - - python: "3.11-dev" -before_install: - - sudo apt-get install graphviz - - python -m pip install --upgrade pip "setuptools<60.9" - - python -m pip install tox coveralls - - if [ "$TOXENV" == "pypy3" ]; then curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && source $HOME/.cargo/env ; fi -script: tox -after_success: COVERALLS_PARALLEL=true coveralls -notifications: - webhooks: - urls: - - https://coveralls.io/webhook - - https://webhooks.gitter.im/e/6008c872bf0ecee344f4 - on_success: always - on_failure: always - on_start: never -deploy: - - provider: releases - token: - secure: "eqEWOzKWZCuvd1a77CA03OX/HCrsYlsu1/Sz/RhXQIEhKz6tKp10KGw9zr57bHAIl0OfJFK9k63lI2HOctAmwkKeeQ4HdNqw4pHFa8Gk3liGp31KSmshVtHX8Rtn0DuFA028Wm7w5n+fOVc8tJVU/UsKjsfsAzRHnQjMamckoXU=" - skip_cleanup: true - on: - tags: true - all_branches: true - condition: $TOXENV = py36 - repo: oauthlib/oauthlib - - provider: pypi - username: JonathanHuot - password: - secure: "OozNM16flVLvqDoNzmoTENchhS1w0/dEJZvXBQK2KWmh8fyGj2UZus1vkl6bA5V3Yu9MZLYFpDcltl/qraY3Up6iXQpwKz4q+ICygAudYM2kJ5l8ZEe+wy2FikWbD6LkXf5uKIJJnPNSC8AI86ZyxM/XZxbYjj/+jXyJ1YFZwwQ=" - distributions: sdist bdist_wheel - on: - tags: true - all_branches: true - condition: $TOXENV = py36 - repo: oauthlib/oauthlib diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 9e150100d..2b2b6d565 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,11 +1,45 @@ Changelog ========= +3.3.1 (2025-06-19): +------------------ +OAuth2.0 Client: +* #906: fix regression of expires_in parsing when float in string. + + +3.3.0 (2025-06-17): +------------------ +OAuth2.0 Provider: +* OIDC: #879 Changed in how ui_locales is parsed +* RFC8628: Added OAuth2.0 Device Authorization Grant support +* PKCE: #876, #893 Fixed `create_code_verifier` length +* OIDC: Pre-configured OIDC server to use Refresh Token by default + +OAuth2.0 Common: +* OAuth2Error: Allow 0 to be a valid state + +OAuth2.0 Client: +* #745: expires_at is forced to be an int +* #899: expires_at clarification + +General: +* Removed Python 3.5, 3.6, 3.7 support +* #859, #883: Added Python 3.12, 3.13 Support +* Added dependency-review GitHub Action +* Updated various references of license (SPDX identifier..) +* Added GitHub Action for lint, replaced bandy with ruff, removed isort... +* Migrated to GitHub Actions from Travis +* Added Security Policy + +3.2.2 (2022-10-17) +------------------ +OAuth2.0 Provider: +* CVE-2022-36087 + 3.2.1 (2022-09-09) ------------------ OAuth2.0 Provider: * #803: Metadata endpoint support of non-HTTPS -* CVE-2022-36087 OAuth1.0: * #818: Allow IPv6 being parsed by signature diff --git a/LICENSE b/LICENSE index d5a9e9acd..ffab12676 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2019 The OAuthlib Community +Copyright (c) The OAuthlib Community All rights reserved. Redistribution and use in source and binary forms, with or without @@ -11,14 +11,14 @@ modification, are permitted provided that the following conditions are met: notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. - 3. Neither the name of this project nor the names of its contributors may - be used to endorse or promote products derived from this software without - specific prior written permission. + 3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from this + software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER diff --git a/Makefile b/Makefile index b1fbb39d3..9622f70d5 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,7 @@ # # Please specify your library as well as primary contacts. # Since these contacts will be addressed with Github mentions they -# need to be Github users (for now)(sorry Bitbucket). +# need to be Github users. # clean: clean-eggs clean-build @find . -iname '*.pyc' -delete @@ -34,36 +34,29 @@ clean-build: @rm -fr dist/ @rm -fr *.egg-info -format fmt: - isort --recursive oauthlib tests +format fmt black: + black . -lint: - isort --recursive --check-only --diff oauthlib tests +lint ruff: + ruff check . test: tox bottle: #--------------------------- - # Library thomsonreuters/bottle-oauthlib + # Library refinitiv/bottle-oauthlib # Contacts: Jonathan.Huot - cd bottle-oauthlib 2>/dev/null || git clone https://github.com/thomsonreuters/bottle-oauthlib.git + cd bottle-oauthlib 2>/dev/null || git clone https://github.com/refinitiv/bottle-oauthlib.git cd bottle-oauthlib && sed -i.old 's,deps =,deps= --editable=file://{toxinidir}/../,' tox.ini && sed -i.old '/oauthlib/d' requirements.txt && tox -flask: - #--------------------------- - # Library: lepture/flask-oauthLib - # Contacts: lepture,widnyana - cd flask-oauthlib 2>/dev/null || git clone https://github.com/lepture/flask-oauthlib.git - cd flask-oauthlib && sed -i.old 's,deps =,deps= --editable=file://{toxinidir}/../,' tox.ini && sed -i.old '/oauthlib/d' requirements.txt && tox - django: #--------------------------- # Library: evonove/django-oauth-toolkit # Contacts: evonove,masci # (note: has tox.ini already) cd django-oauth-toolkit 2>/dev/null || git clone https://github.com/evonove/django-oauth-toolkit.git - cd django-oauth-toolkit && sed -i.old 's,deps =,deps= --editable=file://{toxinidir}/../,' tox.ini && tox -e py27,py35,py36 + cd django-oauth-toolkit && sed -i.old 's,deps =,deps= --editable=file://{toxinidir}/../,' tox.ini && tox requests: #--------------------------- @@ -81,4 +74,4 @@ dance: .DEFAULT_GOAL := all .PHONY: clean test bottle dance django flask requests -all: clean test bottle dance django flask requests +all: lint test bottle dance django flask requests diff --git a/README.rst b/README.rst index eb8c452d0..840c33d56 100644 --- a/README.rst +++ b/README.rst @@ -2,11 +2,11 @@ OAuthLib - Python Framework for OAuth1 & OAuth2 =============================================== *A generic, spec-compliant, thorough implementation of the OAuth request-signing -logic for Python 3.6+.* +logic for Python 3.8+* -.. image:: https://app.travis-ci.com/oauthlib/oauthlib.svg?branch=master - :target: https://app.travis-ci.com/oauthlib/oauthlib - :alt: Travis +.. image:: https://github.com/oauthlib/oauthlib/actions/workflows/python-build.yml/badge.svg + :target: https://github.com/oauthlib/oauthlib/actions + :alt: GitHub Actions .. image:: https://coveralls.io/repos/oauthlib/oauthlib/badge.svg?branch=master :target: https://coveralls.io/r/oauthlib/oauthlib :alt: Coveralls @@ -75,7 +75,9 @@ Which web frameworks are supported? The following packages provide OAuth support using OAuthLib. -- For Django there is `django-oauth-toolkit`_, which includes `Django REST framework`_ support. +- For Django there is: + - `django-oauth-toolkit`_, which includes `Django REST framework`_ support. + - `django-allauth`_, which includes `Django REST framework`_ as well as `Django Ninja`_ support. - For Flask there is `flask-oauthlib`_ and `Flask-Dance`_. - For Pyramid there is `pyramid-oauthlib`_. - For Bottle there is `bottle-oauthlib`_. @@ -89,6 +91,8 @@ please open a Pull Request, updating the documentation. .. _`Flask-Dance`: https://github.com/singingwolfboy/flask-dance .. _`pyramid-oauthlib`: https://github.com/tilgovi/pyramid-oauthlib .. _`bottle-oauthlib`: https://github.com/thomsonreuters/bottle-oauthlib +.. _`django-allauth`: https://allauth.org/ +.. _`Django Ninja`: https://django-ninja.dev/ Using OAuthLib? Please get in touch! ------------------------------------ @@ -110,7 +114,7 @@ have the pleasure to run into each other, please send a docs pull request =) License ------- -OAuthLib is yours to use and abuse according to the terms of the BSD license. +OAuthLib is yours to use and abuse according to the terms of the BSD-3-Clause license. Check the LICENSE file for full details. Credits diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..f1a3fb80f --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,27 @@ +# Security Policy + +## Supported Versions + +The following versions are currently being supported with security updates. + +| Version | Supported | +| ------- | ------------------ | +| 3.3.x | :white_check_mark: | +| 3.2.x | :white_check_mark: | +| 3.1.x | :x: | +| < 3.1 | :x: | + +## Reporting a Vulnerability + +Please raise a draft advisory to start discussing about the vulnerability in a private channel with OAuthlib Admin: https://github.com/oauthlib/oauthlib/security/advisories/new + +## Incident Response Plan + +The Incident Response Plan for oauthlib is composed of four steps: + +- Triage: discussion about the validity of the vulnerability with the reporter + in the private channel. +- Mitigate: work on a fix and release a newer version. +- Disclose: let downstream applications some time to update to the latest + release, then make the CVE public. +- Learn: discuss about any potential actions that could have prevented the vulnerability. diff --git a/bandit.json b/bandit.json deleted file mode 100644 index 7161f0055..000000000 --- a/bandit.json +++ /dev/null @@ -1,1222 +0,0 @@ -{ - "errors": [], - "generated_at": "2019-05-13T12:51:49Z", - "metrics": { - "_totals": { - "CONFIDENCE.HIGH": 3.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 10.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 12.0, - "SEVERITY.MEDIUM": 1.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 8338, - "nosec": 0 - }, - "oauthlib/__init__.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 25, - "nosec": 0 - }, - "oauthlib/common.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 337, - "nosec": 0 - }, - "oauthlib/oauth1/__init__.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 16, - "nosec": 0 - }, - "oauthlib/oauth1/rfc5849/__init__.py": { - "CONFIDENCE.HIGH": 1.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 1.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 230, - "nosec": 0 - }, - "oauthlib/oauth1/rfc5849/endpoints/__init__.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 8, - "nosec": 0 - }, - "oauthlib/oauth1/rfc5849/endpoints/access_token.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 152, - "nosec": 0 - }, - "oauthlib/oauth1/rfc5849/endpoints/authorization.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 135, - "nosec": 0 - }, - "oauthlib/oauth1/rfc5849/endpoints/base.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 142, - "nosec": 0 - }, - "oauthlib/oauth1/rfc5849/endpoints/pre_configured.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 10, - "nosec": 0 - }, - "oauthlib/oauth1/rfc5849/endpoints/request_token.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 141, - "nosec": 0 - }, - "oauthlib/oauth1/rfc5849/endpoints/resource.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 97, - "nosec": 0 - }, - "oauthlib/oauth1/rfc5849/endpoints/signature_only.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 53, - "nosec": 0 - }, - "oauthlib/oauth1/rfc5849/errors.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 58, - "nosec": 0 - }, - "oauthlib/oauth1/rfc5849/parameters.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 75, - "nosec": 0 - }, - "oauthlib/oauth1/rfc5849/request_validator.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 630, - "nosec": 0 - }, - "oauthlib/oauth1/rfc5849/signature.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 379, - "nosec": 0 - }, - "oauthlib/oauth1/rfc5849/utils.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 61, - "nosec": 0 - }, - "oauthlib/oauth2/__init__.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 33, - "nosec": 0 - }, - "oauthlib/oauth2/rfc6749/__init__.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 14, - "nosec": 0 - }, - "oauthlib/oauth2/rfc6749/clients/__init__.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 13, - "nosec": 0 - }, - "oauthlib/oauth2/rfc6749/clients/backend_application.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 56, - "nosec": 0 - }, - "oauthlib/oauth2/rfc6749/clients/base.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 3.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 3.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 384, - "nosec": 0 - }, - "oauthlib/oauth2/rfc6749/clients/legacy_application.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 67, - "nosec": 0 - }, - "oauthlib/oauth2/rfc6749/clients/mobile_application.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 140, - "nosec": 0 - }, - "oauthlib/oauth2/rfc6749/clients/service_application.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 144, - "nosec": 0 - }, - "oauthlib/oauth2/rfc6749/clients/web_application.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 165, - "nosec": 0 - }, - "oauthlib/oauth2/rfc6749/endpoints/__init__.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 18, - "nosec": 0 - }, - "oauthlib/oauth2/rfc6749/endpoints/authorization.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 85, - "nosec": 0 - }, - "oauthlib/oauth2/rfc6749/endpoints/base.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 71, - "nosec": 0 - }, - "oauthlib/oauth2/rfc6749/endpoints/introspect.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 98, - "nosec": 0 - }, - "oauthlib/oauth2/rfc6749/endpoints/metadata.py": { - "CONFIDENCE.HIGH": 2.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 2.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 182, - "nosec": 0 - }, - "oauthlib/oauth2/rfc6749/endpoints/pre_configured.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 5.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 5.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 189, - "nosec": 0 - }, - "oauthlib/oauth2/rfc6749/endpoints/resource.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 65, - "nosec": 0 - }, - "oauthlib/oauth2/rfc6749/endpoints/revocation.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 96, - "nosec": 0 - }, - "oauthlib/oauth2/rfc6749/endpoints/token.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 76, - "nosec": 0 - }, - "oauthlib/oauth2/rfc6749/errors.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 311, - "nosec": 0 - }, - "oauthlib/oauth2/rfc6749/grant_types/__init__.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 10, - "nosec": 0 - }, - "oauthlib/oauth2/rfc6749/grant_types/authorization_code.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 389, - "nosec": 0 - }, - "oauthlib/oauth2/rfc6749/grant_types/base.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 199, - "nosec": 0 - }, - "oauthlib/oauth2/rfc6749/grant_types/client_credentials.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 96, - "nosec": 0 - }, - "oauthlib/oauth2/rfc6749/grant_types/implicit.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 259, - "nosec": 0 - }, - "oauthlib/oauth2/rfc6749/grant_types/refresh_token.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 102, - "nosec": 0 - }, - "oauthlib/oauth2/rfc6749/grant_types/resource_owner_password_credentials.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 156, - "nosec": 0 - }, - "oauthlib/oauth2/rfc6749/parameters.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 1.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 1.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 335, - "nosec": 0 - }, - "oauthlib/oauth2/rfc6749/request_validator.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 504, - "nosec": 0 - }, - "oauthlib/oauth2/rfc6749/tokens.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 277, - "nosec": 0 - }, - "oauthlib/oauth2/rfc6749/utils.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 70, - "nosec": 0 - }, - "oauthlib/openid/__init__.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 8, - "nosec": 0 - }, - "oauthlib/openid/connect/__init__.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 0, - "nosec": 0 - }, - "oauthlib/openid/connect/core/__init__.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 0, - "nosec": 0 - }, - "oauthlib/openid/connect/core/endpoints/__init__.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 9, - "nosec": 0 - }, - "oauthlib/openid/connect/core/endpoints/pre_configured.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 1.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 1.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 93, - "nosec": 0 - }, - "oauthlib/openid/connect/core/endpoints/userinfo.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 83, - "nosec": 0 - }, - "oauthlib/openid/connect/core/exceptions.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 117, - "nosec": 0 - }, - "oauthlib/openid/connect/core/grant_types/__init__.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 15, - "nosec": 0 - }, - "oauthlib/openid/connect/core/grant_types/authorization_code.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 32, - "nosec": 0 - }, - "oauthlib/openid/connect/core/grant_types/base.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 234, - "nosec": 0 - }, - "oauthlib/openid/connect/core/grant_types/dispatchers.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 66, - "nosec": 0 - }, - "oauthlib/openid/connect/core/grant_types/exceptions.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 26, - "nosec": 0 - }, - "oauthlib/openid/connect/core/grant_types/hybrid.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 38, - "nosec": 0 - }, - "oauthlib/openid/connect/core/grant_types/implicit.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 35, - "nosec": 0 - }, - "oauthlib/openid/connect/core/request_validator.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 235, - "nosec": 0 - }, - "oauthlib/openid/connect/core/tokens.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 42, - "nosec": 0 - }, - "oauthlib/signals.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 32, - "nosec": 0 - }, - "oauthlib/tokens/__init__.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 0, - "nosec": 0 - }, - "oauthlib/tokens/access_token.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 13, - "nosec": 0 - }, - "oauthlib/tokens/base.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 8, - "nosec": 0 - }, - "oauthlib/tokens/id_token.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 6, - "nosec": 0 - }, - "oauthlib/uri_validate.py": { - "CONFIDENCE.HIGH": 0.0, - "CONFIDENCE.LOW": 0.0, - "CONFIDENCE.MEDIUM": 0.0, - "CONFIDENCE.UNDEFINED": 0.0, - "SEVERITY.HIGH": 0.0, - "SEVERITY.LOW": 0.0, - "SEVERITY.MEDIUM": 0.0, - "SEVERITY.UNDEFINED": 0.0, - "loc": 93, - "nosec": 0 - } - }, - "results": [ - { - "code": "183 if request.body is not None and content_type_eligible:\n184 params.append(('oauth_body_hash', base64.b64encode(hashlib.sha1(request.body.encode('utf-8')).digest()).decode('utf-8')))\n185 \n", - "filename": "oauthlib/oauth1/rfc5849/__init__.py", - "issue_confidence": "HIGH", - "issue_severity": "MEDIUM", - "issue_text": "Use of insecure MD2, MD4, MD5, or SHA1 hash function.", - "line_number": 184, - "line_range": [ - 184 - ], - "more_info": "https://bandit.readthedocs.io/en/latest/blacklists/blacklist_calls.html#b303-md5", - "test_id": "B303", - "test_name": "blacklist" - }, - { - "code": "49 \"\"\"\n50 refresh_token_key = 'refresh_token'\n51 \n52 def __init__(self, client_id,\n", - "filename": "oauthlib/oauth2/rfc6749/clients/base.py", - "issue_confidence": "MEDIUM", - "issue_severity": "LOW", - "issue_text": "Possible hardcoded password: 'refresh_token'", - "line_number": 50, - "line_range": [ - 50, - 51 - ], - "more_info": "https://bandit.readthedocs.io/en/latest/plugins/b105_hardcoded_password_string.html", - "test_id": "B105", - "test_name": "hardcoded_password_string" - }, - { - "code": "51 \n52 def __init__(self, client_id,\n53 default_token_placement=AUTH_HEADER,\n54 token_type='Bearer',\n55 access_token=None,\n56 refresh_token=None,\n57 mac_key=None,\n58 mac_algorithm=None,\n59 token=None,\n60 scope=None,\n61 state=None,\n62 redirect_url=None,\n63 state_generator=generate_token,\n64 **kwargs):\n65 \"\"\"Initialize a client with commonly used attributes.\n66 \n67 :param client_id: Client identifier given by the OAuth provider upon\n68 registration.\n69 \n70 :param default_token_placement: Tokens can be supplied in the Authorization\n71 header (default), the URL query component (``query``) or the request\n72 body (``body``).\n73 \n74 :param token_type: OAuth 2 token type. Defaults to Bearer. Change this\n75 if you specify the ``access_token`` parameter and know it is of a\n76 different token type, such as a MAC, JWT or SAML token. Can\n77 also be supplied as ``token_type`` inside the ``token`` dict parameter.\n78 \n79 :param access_token: An access token (string) used to authenticate\n80 requests to protected resources. Can also be supplied inside the\n81 ``token`` dict parameter.\n82 \n83 :param refresh_token: A refresh token (string) used to refresh expired\n84 tokens. Can also be supplied inside the ``token`` dict parameter.\n85 \n86 :param mac_key: Encryption key used with MAC tokens.\n87 \n88 :param mac_algorithm: Hashing algorithm for MAC tokens.\n89 \n90 :param token: A dict of token attributes such as ``access_token``,\n91 ``token_type`` and ``expires_at``.\n92 \n93 :param scope: A list of default scopes to request authorization for.\n94 \n95 :param state: A CSRF protection string used during authorization.\n96 \n97 :param redirect_url: The redirection endpoint on the client side to which\n98 the user returns after authorization.\n99 \n100 :param state_generator: A no argument state generation callable. Defaults\n101 to :py:meth:`oauthlib.common.generate_token`.\n102 \"\"\"\n103 \n104 self.client_id = client_id\n105 self.default_token_placement = default_token_placement\n106 self.token_type = token_type\n107 self.access_token = access_token\n108 self.refresh_token = refresh_token\n109 self.mac_key = mac_key\n110 self.mac_algorithm = mac_algorithm\n111 self.token = token or {}\n112 self.scope = scope\n113 self.state_generator = state_generator\n114 self.state = state\n115 self.redirect_url = redirect_url\n116 self.code = None\n117 self.expires_in = None\n118 self._expires_at = None\n119 self.populate_token_attributes(self.token)\n120 \n121 @property\n", - "filename": "oauthlib/oauth2/rfc6749/clients/base.py", - "issue_confidence": "MEDIUM", - "issue_severity": "LOW", - "issue_text": "Possible hardcoded password: 'Bearer'", - "line_number": 52, - "line_range": [ - 52, - 53, - 54, - 55, - 56, - 57, - 58, - 59, - 60, - 61, - 62, - 63, - 64, - 65, - 66, - 67, - 68, - 69, - 70, - 71, - 72, - 73, - 74, - 75, - 76, - 77, - 78, - 79, - 80, - 81, - 82, - 83, - 84, - 85, - 86, - 87, - 88, - 89, - 90, - 91, - 92, - 93, - 94, - 95, - 96, - 97, - 98, - 99, - 100, - 101, - 102, - 103, - 104, - 105, - 106, - 107, - 108, - 109, - 110, - 111, - 112, - 113, - 114, - 115, - 116, - 117, - 118, - 119, - 120 - ], - "more_info": "https://bandit.readthedocs.io/en/latest/plugins/b107_hardcoded_password_default.html", - "test_id": "B107", - "test_name": "hardcoded_password_default" - }, - { - "code": "313 \n314 def prepare_token_revocation_request(self, revocation_url, token,\n315 token_type_hint=\"access_token\", body='', callback=None, **kwargs):\n316 \"\"\"Prepare a token revocation request.\n317 \n318 :param revocation_url: Provider token revocation endpoint URL.\n319 \n320 :param token: The access or refresh token to be revoked (string).\n321 \n322 :param token_type_hint: ``\"access_token\"`` (default) or\n323 ``\"refresh_token\"``. This is optional and if you wish to not pass it you\n324 must provide ``token_type_hint=None``.\n325 \n326 :param body:\n327 \n328 :param callback: A jsonp callback such as ``package.callback`` to be invoked\n329 upon receiving the response. Not that it should not include a () suffix.\n330 \n331 :param kwargs: Additional parameters to included in the request.\n332 \n333 :returns: The prepared request tuple with (url, headers, body).\n334 \n335 Note that JSONP request may use GET requests as the parameters will\n336 be added to the request URL query as opposed to the request body.\n337 \n338 An example of a revocation request\n339 \n340 .. code-block: http\n341 \n342 POST /revoke HTTP/1.1\n343 Host: server.example.com\n344 Content-Type: application/x-www-form-urlencoded\n345 Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW\n346 \n347 token=45ghiukldjahdnhzdauz&token_type_hint=refresh_token\n348 \n349 An example of a jsonp revocation request\n350 \n351 .. code-block: http\n352 \n353 GET /revoke?token=agabcdefddddafdd&callback=package.myCallback HTTP/1.1\n354 Host: server.example.com\n355 Content-Type: application/x-www-form-urlencoded\n356 Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW\n357 \n358 and an error response\n359 \n360 .. code-block: http\n361 \n362 package.myCallback({\"error\":\"unsupported_token_type\"});\n363 \n364 Note that these requests usually require client credentials, client_id in\n365 the case for public clients and provider specific authentication\n366 credentials for confidential clients.\n367 \"\"\"\n368 if not is_secure_transport(revocation_url):\n369 raise InsecureTransportError()\n370 \n371 return prepare_token_revocation_request(revocation_url, token,\n372 token_type_hint=token_type_hint, body=body, callback=callback,\n373 **kwargs)\n374 \n375 def parse_request_body_response(self, body, scope=None, **kwargs):\n", - "filename": "oauthlib/oauth2/rfc6749/clients/base.py", - "issue_confidence": "MEDIUM", - "issue_severity": "LOW", - "issue_text": "Possible hardcoded password: 'access_token'", - "line_number": 314, - "line_range": [ - 314, - 315, - 316, - 317, - 318, - 319, - 320, - 321, - 322, - 323, - 324, - 325, - 326, - 327, - 328, - 329, - 330, - 331, - 332, - 333, - 334, - 335, - 336, - 337, - 338, - 339, - 340, - 341, - 342, - 343, - 344, - 345, - 346, - 347, - 348, - 349, - 350, - 351, - 352, - 353, - 354, - 355, - 356, - 357, - 358, - 359, - 360, - 361, - 362, - 363, - 364, - 365, - 366, - 367, - 368, - 369, - 370, - 371, - 372, - 373, - 374 - ], - "more_info": "https://bandit.readthedocs.io/en/latest/plugins/b107_hardcoded_password_default.html", - "test_id": "B107", - "test_name": "hardcoded_password_default" - }, - { - "code": "45 def __init__(self, endpoints, claims={}, raise_errors=True):\n46 assert isinstance(claims, dict)\n47 for endpoint in endpoints:\n", - "filename": "oauthlib/oauth2/rfc6749/endpoints/metadata.py", - "issue_confidence": "HIGH", - "issue_severity": "LOW", - "issue_text": "Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.", - "line_number": 46, - "line_range": [ - 46 - ], - "more_info": "https://bandit.readthedocs.io/en/latest/plugins/b101_assert_used.html", - "test_id": "B101", - "test_name": "assert_used" - }, - { - "code": "47 for endpoint in endpoints:\n48 assert isinstance(endpoint, BaseEndpoint)\n49 \n", - "filename": "oauthlib/oauth2/rfc6749/endpoints/metadata.py", - "issue_confidence": "HIGH", - "issue_severity": "LOW", - "issue_text": "Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.", - "line_number": 48, - "line_range": [ - 48 - ], - "more_info": "https://bandit.readthedocs.io/en/latest/plugins/b101_assert_used.html", - "test_id": "B101", - "test_name": "assert_used" - }, - { - "code": "70 default_token_type=bearer)\n71 ResourceEndpoint.__init__(self, default_token='Bearer',\n72 token_types={'Bearer': bearer})\n73 RevocationEndpoint.__init__(self, request_validator)\n", - "filename": "oauthlib/oauth2/rfc6749/endpoints/pre_configured.py", - "issue_confidence": "MEDIUM", - "issue_severity": "LOW", - "issue_text": "Possible hardcoded password: 'Bearer'", - "line_number": 71, - "line_range": [ - 71, - 72 - ], - "more_info": "https://bandit.readthedocs.io/en/latest/plugins/b106_hardcoded_password_funcarg.html", - "test_id": "B106", - "test_name": "hardcoded_password_funcarg" - }, - { - "code": "109 default_token_type=bearer)\n110 ResourceEndpoint.__init__(self, default_token='Bearer',\n111 token_types={'Bearer': bearer})\n112 RevocationEndpoint.__init__(self, request_validator)\n", - "filename": "oauthlib/oauth2/rfc6749/endpoints/pre_configured.py", - "issue_confidence": "MEDIUM", - "issue_severity": "LOW", - "issue_text": "Possible hardcoded password: 'Bearer'", - "line_number": 110, - "line_range": [ - 110, - 111 - ], - "more_info": "https://bandit.readthedocs.io/en/latest/plugins/b106_hardcoded_password_funcarg.html", - "test_id": "B106", - "test_name": "hardcoded_password_funcarg" - }, - { - "code": "142 default_token_type=bearer)\n143 ResourceEndpoint.__init__(self, default_token='Bearer',\n144 token_types={'Bearer': bearer})\n145 RevocationEndpoint.__init__(self, request_validator,\n", - "filename": "oauthlib/oauth2/rfc6749/endpoints/pre_configured.py", - "issue_confidence": "MEDIUM", - "issue_severity": "LOW", - "issue_text": "Possible hardcoded password: 'Bearer'", - "line_number": 143, - "line_range": [ - 143, - 144 - ], - "more_info": "https://bandit.readthedocs.io/en/latest/plugins/b106_hardcoded_password_funcarg.html", - "test_id": "B106", - "test_name": "hardcoded_password_funcarg" - }, - { - "code": "181 default_token_type=bearer)\n182 ResourceEndpoint.__init__(self, default_token='Bearer',\n183 token_types={'Bearer': bearer})\n184 RevocationEndpoint.__init__(self, request_validator)\n", - "filename": "oauthlib/oauth2/rfc6749/endpoints/pre_configured.py", - "issue_confidence": "MEDIUM", - "issue_severity": "LOW", - "issue_text": "Possible hardcoded password: 'Bearer'", - "line_number": 182, - "line_range": [ - 182, - 183 - ], - "more_info": "https://bandit.readthedocs.io/en/latest/plugins/b106_hardcoded_password_funcarg.html", - "test_id": "B106", - "test_name": "hardcoded_password_funcarg" - }, - { - "code": "214 default_token_type=bearer)\n215 ResourceEndpoint.__init__(self, default_token='Bearer',\n216 token_types={'Bearer': bearer})\n217 RevocationEndpoint.__init__(self, request_validator,\n", - "filename": "oauthlib/oauth2/rfc6749/endpoints/pre_configured.py", - "issue_confidence": "MEDIUM", - "issue_severity": "LOW", - "issue_text": "Possible hardcoded password: 'Bearer'", - "line_number": 215, - "line_range": [ - 215, - 216 - ], - "more_info": "https://bandit.readthedocs.io/en/latest/plugins/b106_hardcoded_password_funcarg.html", - "test_id": "B106", - "test_name": "hardcoded_password_funcarg" - }, - { - "code": "164 \n165 def prepare_token_revocation_request(url, token, token_type_hint=\"access_token\",\n166 callback=None, body='', **kwargs):\n167 \"\"\"Prepare a token revocation request.\n168 \n169 The client constructs the request by including the following parameters\n170 using the \"application/x-www-form-urlencoded\" format in the HTTP request\n171 entity-body:\n172 \n173 :param token: REQUIRED. The token that the client wants to get revoked.\n174 \n175 :param token_type_hint: OPTIONAL. A hint about the type of the token\n176 submitted for revocation. Clients MAY pass this\n177 parameter in order to help the authorization server\n178 to optimize the token lookup. If the server is\n179 unable to locate the token using the given hint, it\n180 MUST extend its search across all of its supported\n181 token types. An authorization server MAY ignore\n182 this parameter, particularly if it is able to detect\n183 the token type automatically.\n184 \n185 This specification defines two values for `token_type_hint`:\n186 \n187 * access_token: An access token as defined in [RFC6749],\n188 `Section 1.4`_\n189 \n190 * refresh_token: A refresh token as defined in [RFC6749],\n191 `Section 1.5`_\n192 \n193 Specific implementations, profiles, and extensions of this\n194 specification MAY define other values for this parameter using the\n195 registry defined in `Section 4.1.2`_.\n196 \n197 .. _`Section 1.4`: https://tools.ietf.org/html/rfc6749#section-1.4\n198 .. _`Section 1.5`: https://tools.ietf.org/html/rfc6749#section-1.5\n199 .. _`Section 4.1.2`: https://tools.ietf.org/html/rfc7009#section-4.1.2\n200 \n201 \"\"\"\n202 if not is_secure_transport(url):\n203 raise InsecureTransportError()\n204 \n205 params = [('token', token)]\n206 \n207 if token_type_hint:\n208 params.append(('token_type_hint', token_type_hint))\n209 \n210 for k in kwargs:\n211 if kwargs[k]:\n212 params.append((str(k), kwargs[k]))\n213 \n214 headers = {'Content-Type': 'application/x-www-form-urlencoded'}\n215 \n216 if callback:\n217 params.append(('callback', callback))\n218 return add_params_to_uri(url, params), headers, body\n219 else:\n220 return url, headers, add_params_to_qs(body, params)\n221 \n222 \n223 def parse_authorization_code_response(uri, state=None):\n", - "filename": "oauthlib/oauth2/rfc6749/parameters.py", - "issue_confidence": "MEDIUM", - "issue_severity": "LOW", - "issue_text": "Possible hardcoded password: 'access_token'", - "line_number": 165, - "line_range": [ - 165, - 166, - 167, - 168, - 169, - 170, - 171, - 172, - 173, - 174, - 175, - 176, - 177, - 178, - 179, - 180, - 181, - 182, - 183, - 184, - 185, - 186, - 187, - 188, - 189, - 190, - 191, - 192, - 193, - 194, - 195, - 196, - 197, - 198, - 199, - 200, - 201, - 202, - 203, - 204, - 205, - 206, - 207, - 208, - 209, - 210, - 211, - 212, - 213, - 214, - 215, - 216, - 217, - 218, - 219, - 220, - 221, - 222 - ], - "more_info": "https://bandit.readthedocs.io/en/latest/plugins/b107_hardcoded_password_default.html", - "test_id": "B107", - "test_name": "hardcoded_password_default" - }, - { - "code": "104 default_token_type=bearer)\n105 ResourceEndpoint.__init__(self, default_token='Bearer',\n106 token_types={'Bearer': bearer, 'JWT': jwt})\n107 RevocationEndpoint.__init__(self, request_validator)\n", - "filename": "oauthlib/openid/connect/core/endpoints/pre_configured.py", - "issue_confidence": "MEDIUM", - "issue_severity": "LOW", - "issue_text": "Possible hardcoded password: 'Bearer'", - "line_number": 105, - "line_range": [ - 105, - 106 - ], - "more_info": "https://bandit.readthedocs.io/en/latest/plugins/b106_hardcoded_password_funcarg.html", - "test_id": "B106", - "test_name": "hardcoded_password_funcarg" - } - ] -} \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py index f4b92c477..e609c5410 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -46,7 +46,7 @@ # General information about the project. project = 'OAuthLib' -copyright = '2019, The OAuthlib Community' +copyright = 'The OAuthlib Community' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the @@ -54,7 +54,7 @@ # # The short X.Y version. -from oauthlib import __version__ as v +from oauthlib import __version__ as v # noqa: E402 version = v[:3] # The full version, including alpha/beta/rc tags. release = v diff --git a/docs/contributing.rst b/docs/contributing.rst index 19ff9c9cf..9da1370cc 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -152,8 +152,8 @@ request that fails this test suite will be **rejected**. Testing multiple versions of Python ----------------------------------- -OAuthLib supports Python 3.5, 3.6, 3.7 and PyPy 2.7 & PyPy 3. Testing -all versions conveniently can be done using `Tox`_. +OAuthLib supports Python 3.8+ & PyPy 3. Testing +all versions conveniently at once can be done using `Tox`_. .. sourcecode:: bash @@ -167,11 +167,10 @@ The versions beloew may not be up to date. .. sourcecode:: bash - $ pyenv install 3.5.7 - $ pyenv install 3.6.9 - $ pyenv install 3.7.4 - $ pyenv install pypy2.7-7.1.1 - $ pyenv install pypy3.6-7.1.1 + $ pyenv install -l # check which versions you want to use + $ pyenv install 3.8.18 + $ pyenv install 3.11.7 + $ pyenv install pypy3.10-7.3.13 .. _`Tox`: https://tox.readthedocs.io/en/latest/install.html .. _`virtualenv`: https://virtualenv.pypa.io/en/latest/installation/ diff --git a/docs/oauth1/security.rst b/docs/oauth1/security.rst index d8b7d6b5a..a9274c290 100644 --- a/docs/oauth1/security.rst +++ b/docs/oauth1/security.rst @@ -17,7 +17,7 @@ A few important facts regarding OAuth security * **Tokens must be random**, OAuthLib provides a method for generating secure tokens and it's packed into ``oauthlib.common.generate_token``, use it. If you decide to roll your own, use ``secrets.SystemRandom`` - for Python 3.6 and later. The ``secrets`` module is designed for + for Python 3.8 and later. The ``secrets`` module is designed for generating cryptographically strong random numbers. For earlier versions of Python, use ``random.SystemRandom`` which is based on ``os.urandom`` rather than the default ``random`` based on the efficient but not truly diff --git a/docs/oauth2/endpoints/device.rst b/docs/oauth2/endpoints/device.rst new file mode 100644 index 000000000..c88437a1a --- /dev/null +++ b/docs/oauth2/endpoints/device.rst @@ -0,0 +1,64 @@ +============= +Device +============= + +The device endpoint is used to initiate the authorization flow by requesting a set of +verification codes from the authorization server by making an HTTP "POST" request to +the device authorization endpoint. + +** Device Authorization Request ** + The client makes a device authorization request to the device + authorization endpoint by including the following parameters using + the "application/x-www-form-urlencoded" format: + + POST /device_authorization HTTP/1.1 + Host: server.example.com + Content-Type: application/x-www-form-urlencoded + client_id=123456&scope=example_scope + +.. code-block:: python + + # Initial setup + from your_validator import your_validator + verification_uri = "https://example.com/device" + + # verification_uri_complete can either be a callable that receives user code as an arg + # or a string (e.g verification_uri_complete = "https://example.com/device=1234") + verification_uri_complete = lambda user_code: f"https://example.com/device={user_code}" + + def user_code(): + # some logic to generate a random string... + return "123-456" + + # user code is optional + server = DeviceApplicationServer( + request_validator=your_validator, + verification_uri=verification_uri, + verification_uri_complete=verification_uri_complete, + user_code=user_code + ) + + headers, data, status = server.create_device_authorization_response(request) + + # response from /device_authorization endpoint on your server + from your_framework import http_response + http_response(data, status=status, headers=headers) + + + +.. code-block:: python + + # example response + { + "device_code": "GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS", + "user_code": "123-456", + "verification_uri": "https://example.com/device", + "verification_uri_complete": + "https://example.com/device?user_code=WDJB-MJHT", + "expires_in": 1800, + "interval": 5 + } + + +.. autoclass:: oauthlib.oauth2.DeviceAuthorizationEndpoint + :members: diff --git a/docs/oauth2/endpoints/endpoints.rst b/docs/oauth2/endpoints/endpoints.rst index f05c44b65..d2a4a07ab 100644 --- a/docs/oauth2/endpoints/endpoints.rst +++ b/docs/oauth2/endpoints/endpoints.rst @@ -15,6 +15,7 @@ client attempts to access the user resources on their behalf. :maxdepth: 2 authorization + device introspect token metadata diff --git a/docs/oauth2/grants/device_code.rst b/docs/oauth2/grants/device_code.rst new file mode 100644 index 000000000..ff372b5c7 --- /dev/null +++ b/docs/oauth2/grants/device_code.rst @@ -0,0 +1,12 @@ +Device code Grant +----------------- + +.. autoclass:: oauthlib.oauth2.DeviceCodeGrant + :members: + :inherited-members: + + +An pseudocode/skeleton example of how the device flow can be implemented is +available in the `examples`_ folder on GitHub. + +.. _`examples`: https://github.com/oauthlib/oauthlib/blob/master/examples/device_code_flow.py diff --git a/docs/oauth2/grants/grants.rst b/docs/oauth2/grants/grants.rst index e18376177..d877bac6c 100644 --- a/docs/oauth2/grants/grants.rst +++ b/docs/oauth2/grants/grants.rst @@ -10,6 +10,7 @@ Grant types password credentials refresh + device_code jwt custom_validators custom_grant @@ -26,6 +27,13 @@ degree of trust between the resource owner and the client, and when other authorization grant types are not available. This is also often used for legacy applications to incrementally transition to OAuth 2. +The device code grant(officially referred to as 'urn:ietf:params:oauth:grant-type:device_code') +is used when trying to authenticate device with limited or no input capabilities by getting +the user to approve the login on an external device (like a mobile phone or laptop) in their +possession that they're already logged into. Unlike the previously mentioned grants it is an extension grant, which is a type of grant +to address specific authorization scenarios. +:doc:`Device code grant ` + The main purpose of the grant types is to authorize access to protected resources in various ways with different security credentials. diff --git a/docs/oauth2/preconfigured_servers.rst b/docs/oauth2/preconfigured_servers.rst index e1f629c2d..a32cf8a1b 100644 --- a/docs/oauth2/preconfigured_servers.rst +++ b/docs/oauth2/preconfigured_servers.rst @@ -36,3 +36,7 @@ This function is passed the request object and a boolean indicating whether to g .. autoclass:: oauthlib.oauth2.BackendApplicationServer :members: + + +.. autoclass:: oauthlib.oauth2.DeviceApplicationServer + :members: diff --git a/docs/release_process.rst b/docs/release_process.rst index 2796f29c3..8588ce289 100644 --- a/docs/release_process.rst +++ b/docs/release_process.rst @@ -51,3 +51,35 @@ Minor point (1.1.0) releases will introduce non API breaking new features and changes. Bug releases (1.0.1) will include minor fixes that needs to be released quickly (e.g. after a bigger release unintentionally introduced a bug). + +For maintainer - Publishing a newer version +-------------------------------------------- + +List of tasks to do a release from a maintainer point of view: + + - Create a Branch ``xyz-release`` + - Update ``oauthlib/__init__.py`` version + - Update ``CHANGELOG.rst`` accordingly + - Review Github Issues and PR, and associate the milestone of the version + - Run ``make`` to cover the release readiness + - Create a PR to let downstreams developers test their apps and comments + - Create a tag and push tag, it will automatically publish the release to pypi + - Create a release with GitHub Releases + - Merge PR, close Github milestone + +In case of issues with CICD and a manual publish is required, follow these steps: + + - Install dependencies `pip install build twine` + - Run `python -m build` + - Run `twine check dist/*` + - Run `twine upload dist/*` + +Initial setup: + - Because we currently use "trusted publisher", it does not require to setup + token. However, OIDC Authorization flow has to be configured in `pypi publishing`. + + - During setup, refer to the environment and name of the workflow directly in the code. + - GitHub Restrictions: tag protection must be enabled + + +.. _`pypi publishing`: https://pypi.org/manage/project/oauthlib/settings/publishing/ diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 000000000..812eac276 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,3 @@ +sphinx +sphinx_rtd_theme +readthedocs-sphinx-ext diff --git a/examples/__init__.py b/examples/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/device_code_flow.py b/examples/device_code_flow.py new file mode 100644 index 000000000..d281491fb --- /dev/null +++ b/examples/device_code_flow.py @@ -0,0 +1,260 @@ +import enum +import json +import datetime +from datetime import timedelta + +from oauthlib.oauth2 import RequestValidator, Server, DeviceApplicationServer +from oauthlib.oauth2.rfc8628 import errors as device_flow_errors +from oauthlib.oauth2.rfc8628.errors import AccessDenied, AuthorizationPendingError, ExpiredTokenError, SlowDownError + + +""" +A pseudocode implementation of the device flow code under an Oauth2 provider. + +This example is not concerned with openid in any way. + +This example is also not a 1:1 pseudocode implementation. Please refer to the rfc +for the full details. +https://datatracker.ietf.org/doc/html/rfc8628 + +This module is just acting as a way to demonstrate the main pieces +needed in oauthlib to implement the flow + + +We also assume you already have the /token & /login endpoint in your provider. + +Your provider will also need the following endpoints(which will be discussed +in the example below): + - /device_authorization (part of rfc) + - /device (part of rfc) + - /approve-deny (up to your implementation, this is an example) +""" + + +""" +Device flow pseudocode implementation step by step: + 0. Providing some way to represent the device flow session + + Some python object to represent the current state of the device during + the device flow. This, for example, could be an object that persists + and represents the device in a database +""" + + +class Device: + class DeviceFlowStatus(enum.Enum): + AUTHORIZED = "Authorized" + AUTHORIZATION_PENDING = "Authorization_pending" + EXPIRED = "Expired" + DENIED = "Denied" + + # https://datatracker.ietf.org/doc/html/rfc8628#section-3.2 + # https://datatracker.ietf.org/doc/html/rfc8628#section-3.4 + id = ... # if Device is representing a database object, this will be the id of that row + device_code = ... + user_code = ... + scope = ... + interval = ... # in seconds, default is 5 + expires = ... # seconds + status = ... # DeviceFlowStatus with AUTHORIZATION_PENDING as the default + + client_id = ... + last_checked = ... # datetime + + +""" + 1. User goes on their device(client) and the client sends a request to /device_authorization + against the provider: + https://datatracker.ietf.org/doc/html/rfc8628#section-3.1 + https://datatracker.ietf.org/doc/html/rfc8628#section-3.2 + + + POST /device_authorization HTTP/1.1 + Host: server.example.com + Content-Type: application/x-www-form-urlencoded + + client_id=1406020730&scope=example_scope + + Response: + HTTP/1.1 200 OK + Content-Type: application/json + Cache-Control: no-store + + { + "device_code": "GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS", + "user_code": "WDJB-MJHT", + "verification_uri": "https://example.com/device", + "verification_uri_complete": + "https://example.com/device?user_code=WDJB-MJHT", + "expires_in": 1800, + "interval": 5 + } +""" + + +class DeviceAuthorizationEndpoint: + @staticmethod + def create_device_authorization_response(request): + server = DeviceApplicationServer(interval=5, verification_uri="https://example.com/device") + return server.create_device_authorization_response(request) + + def post(self, request): + headers, data, status = self.create_device_authorization_response(request) + device_response = ... + + # Create an instance of examples.device_flow.Device` using `request` and `data`that encapsulates + # https://datatracker.ietf.org/doc/html/rfc8628#section-3.1 & + # https://datatracker.ietf.org/doc/html/rfc8628#section-3.2 + + return device_response + + +""" + 2. Client presents the information to the user + (There's a section on non visual capable devices as well + https://datatracker.ietf.org/doc/html/rfc8628#section-5.7) + +-------------------------------------------------+ + | | + | Scan the QR code or, using +------------+ | + | a browser on another device, |[_].. . [_]| | + | visit: | . .. . .| | + | https://example.com/device | . . . ....| | + | |. . . . | | + | And enter the code: |[_]. ... . | | + | WDJB-MJHT +------------+ | + | | + +-------------------------------------------------+ +""" +# The implementation for step 2 is up to the owner of device. + + +"""" + 3 (The browser flow). User goes to https://example.com/device where they're presented with a + form to fill in the user code. + + Implement that endpoint on your provider and follow the logic in the rfc. + + Making use of the errors in `oauthlib.oauth2.rfc8628.errors` + + raise AccessDenied/AuthorizationPendingError/ExpiredTokenError where appropriate making use of + `examples.device_flow.Device` to get and update current state of the device during the session + + If the user isn't logged in(after inputting the user-code), they should be redirected to the provider's /login + endpoint and redirected back to an /approve-deny endpoint(The name and implementation of /approve-deny is up + to the owner of the provider, this is just an example). + They should then see an "approve" or "deny" button to authorize the device. + + Again, using `examples.device_flow.Device` to update the status appropriately during the session. +""" +# /device and /approve-deny is up to the owner of the provider to implement. Again, make sure to +# keep referring to the rfc when implementing. + + +""" +4 (The polling flow) + https://datatracker.ietf.org/doc/html/rfc8628#section-3.4 + https://datatracker.ietf.org/doc/html/rfc8628#section-3.5 + + + Right after step 2, the device polls the /token endpoint every "interval" amount of seconds + to check if user has approved or denied the request. + + When grant type is `urn:ietf:params:oauth:grant-type:device_code`, + `oauthlib.oauth2.rfc8628.grant_types.device_code.DeviceCodeGrant` will be the handler + that handles token generation. +""" + + +# This is purely for illustrative purposes +# to demonstrate rate limiting on the token endpoint for the device flow. +# It is up to as the provider to decide how you want +# to rate limit the device during polling. +def rate_limit(func, rate="1/5s"): + def wrapper(): + # some logic to ensure client device is rate limited by a minimum + # of 1 request every 5 seconds during device polling + # https://datatracker.ietf.org/doc/html/rfc8628#section-3.2 + + # use device_code to retrieve device + device = Device + + # get the time in seconds since the device polled the /token endpoint + now = datetime.datetime.now(tz=datetime.UTC) + diff = now - timedelta(device.last_checked) + total_seconds_since_last_device_poll = diff.total_seconds() + + device.last_checked = now + + # for illustrative purposes. 1/5s means 1 request every 5 seconds. + # so if `total_seconds_since_last_device_poll` is 4 seconds, this will + # raise an error + if total_seconds_since_last_device_poll < rate: + raise device_flow_errors.SlowDownError() + + result = func() + return result + + return wrapper + + +class ExampleRequestValidator(RequestValidator): + # All the other methods that need to be implemented... + # see examples.skeleton_oauth2_web_application_server.SkeletonValidator + # for a more complete example. + + # Here our main concern is this method: + def create_token_response(self): ... + + +class ServerSetupForTokenEndpoint: + def __init__(self): + validator = ExampleRequestValidator + self.server = Server(validator) + + +# You should already have the /token endpoint implemented in your provider. +class TokenEndpoint(ServerSetupForTokenEndpoint): + def default_flow_token_response(self, request): + url, headers, body, status = self.server.create_token_response(request) + access_token = json.loads(body).get("access_token") + + # return access_token in a http response + return access_token + + @rate_limit # this will raise the SlowDownError + def device_flow_token_response(self, request, device_code): + """ + Following the rfc, this will route the device request accordingly and raise + required errors. + + Remember that unlike other auth flows, the device if polling this endpoint once + every "interval" amount of seconds. + """ + # using device_code arg to retrieve the correct device object instance + device = Device + + if device.status == device.DeviceFlowStatus.AUTHORIZATION_PENDING: + raise AuthorizationPendingError() + + # If user clicked "deny" in the /approve-deny page endpoint. + # the device gets set to 'authorized' in /approve-deny and /device checks + # if someone tries to input a code for a user code that's already been authorized + if device.status == device.DeviceFlowStatus.DENIED: + raise AccessDenied() + + url, headers, body, status = self.server.create_token_response(request) + + access_token = json.loads(body).get("access_token") + + device.status = device.EXPIRED + + # return access_token in a http response + return access_token + + # Example of how token endpoint could handle the token creation depending on + # the grant type during a POST to /token. + def post(self, request): + params = request.POST + if params.get("grant_type") == "urn:ietf:params:oauth:grant-type:device_code": + return self.device_flow_token_response(request, params["device_code"]) + return self.default_flow_token_response(request) diff --git a/oauthlib/__init__.py b/oauthlib/__init__.py index 9b7eff2f1..462612f85 100644 --- a/oauthlib/__init__.py +++ b/oauthlib/__init__.py @@ -5,30 +5,30 @@ A generic, spec-compliant, thorough implementation of the OAuth request-signing logic. - :copyright: (c) 2019 by The OAuthlib Community - :license: BSD, see LICENSE for details. + :copyright: (c) The OAuthlib Community + :license: BSD-3-Clause, see LICENSE for details. """ import logging from logging import NullHandler __author__ = 'The OAuthlib Community' -__version__ = '3.2.1' +__version__ = '3.3.1' logging.getLogger('oauthlib').addHandler(NullHandler()) _DEBUG = False def set_debug(debug_val): - """Set value of debug flag - + """Set value of debug flag + :param debug_val: Value to set. Must be a bool value. - """ - global _DEBUG - _DEBUG = debug_val + """ + global _DEBUG # noqa: PLW0603 + _DEBUG = debug_val def get_debug(): - """Get debug mode value. - - :return: `True` if debug mode is on, `False` otherwise - """ - return _DEBUG + """Get debug mode value. + + :return: `True` if debug mode is on, `False` otherwise + """ + return _DEBUG diff --git a/oauthlib/common.py b/oauthlib/common.py index 395e75efc..dfa851798 100644 --- a/oauthlib/common.py +++ b/oauthlib/common.py @@ -34,7 +34,7 @@ always_safe = ('ABCDEFGHIJKLMNOPQRSTUVWXYZ' 'abcdefghijklmnopqrstuvwxyz' - '0123456789' '_.-') + '0123456789_.-') log = logging.getLogger('oauthlib') @@ -198,7 +198,7 @@ def generate_token(length=30, chars=UNICODE_ASCII_CHARACTER_SET): def generate_signed_token(private_pem, request): - import jwt + import jwt # noqa: PLC0415 now = datetime.datetime.utcnow() @@ -216,7 +216,7 @@ def generate_signed_token(private_pem, request): def verify_signed_token(public_pem, token): - import jwt + import jwt # noqa: PLC0415 return jwt.decode(token, public_pem, algorithms=['RS256']) @@ -316,7 +316,7 @@ def __getitem__(self, k): return super().__getitem__(key) def get(self, k, default=None): - return self[k] if k in self else default + return self[k] if k in self else default # noqa: SIM401 def __setitem__(self, k, v): super().__setitem__(k, v) @@ -346,7 +346,8 @@ class Request: def __init__(self, uri, http_method='GET', body=None, headers=None, encoding='utf-8'): # Convert to unicode using encoding if given, else assume unicode - encode = lambda x: to_unicode(x, encoding) if encoding else x + def encode(x): + return to_unicode(x, encoding) if encoding else x self.uri = encode(uri) self.http_method = encode(http_method) diff --git a/oauthlib/oauth1/rfc5849/__init__.py b/oauthlib/oauth1/rfc5849/__init__.py index c559251fe..85e0b90b6 100644 --- a/oauthlib/oauth1/rfc5849/__init__.py +++ b/oauthlib/oauth1/rfc5849/__init__.py @@ -121,7 +121,8 @@ def __init__(self, client_key, :param timestamp: Use this timestamp instead of using current. (Mainly for testing) """ # Convert to unicode using encoding if given, else assume unicode - encode = lambda x: to_unicode(x, encoding) if encoding else x + def encode(x): + return to_unicode(x, encoding) if encoding else x self.client_key = encode(client_key) self.client_secret = encode(client_secret) @@ -219,7 +220,7 @@ def get_oauth_params(self, request): content_type = request.headers.get('Content-Type', None) content_type_eligible = content_type and content_type.find('application/x-www-form-urlencoded') < 0 if request.body is not None and content_type_eligible: - params.append(('oauth_body_hash', base64.b64encode(hashlib.sha1(request.body.encode('utf-8')).digest()).decode('utf-8'))) + params.append(('oauth_body_hash', base64.b64encode(hashlib.sha1(request.body.encode('utf-8')).digest()).decode('utf-8'))) # noqa: S324 return params diff --git a/oauthlib/oauth1/rfc5849/endpoints/base.py b/oauthlib/oauth1/rfc5849/endpoints/base.py index 7831be7c5..8d3d89c67 100644 --- a/oauthlib/oauth1/rfc5849/endpoints/base.py +++ b/oauthlib/oauth1/rfc5849/endpoints/base.py @@ -69,12 +69,10 @@ def _get_signature_type_and_params(self, request): def _create_request(self, uri, http_method, body, headers): # Only include body data from x-www-form-urlencoded requests headers = CaseInsensitiveDict(headers or {}) - if ("Content-Type" in headers and - CONTENT_TYPE_FORM_URLENCODED in headers["Content-Type"]): + if "Content-Type" in headers and CONTENT_TYPE_FORM_URLENCODED in headers["Content-Type"]: # noqa: SIM108 request = Request(uri, http_method, body, headers) else: request = Request(uri, http_method, '', headers) - signature_type, params, oauth_params = ( self._get_signature_type_and_params(request)) @@ -129,8 +127,7 @@ def _check_mandatory_parameters(self, request): # Considerations section (`Section 4`_) before deciding on which # method to support. # .. _`Section 4`: https://tools.ietf.org/html/rfc5849#section-4 - if (not request.signature_method in - self.request_validator.allowed_signature_methods): + if (request.signature_method not in self.request_validator.allowed_signature_methods): raise errors.InvalidSignatureMethodError( description="Invalid signature, {} not in {!r}.".format( request.signature_method, @@ -180,9 +177,7 @@ def _check_mandatory_parameters(self, request): def _check_signature(self, request, is_token_request=False): # ---- RSA Signature verification ---- - if request.signature_method == SIGNATURE_RSA_SHA1 or \ - request.signature_method == SIGNATURE_RSA_SHA256 or \ - request.signature_method == SIGNATURE_RSA_SHA512: + if request.signature_method in {SIGNATURE_RSA_SHA1, SIGNATURE_RSA_SHA256, SIGNATURE_RSA_SHA512}: # RSA-based signature method # The server verifies the signature per `[RFC3447] section 8.2.2`_ diff --git a/oauthlib/oauth1/rfc5849/signature.py b/oauthlib/oauth1/rfc5849/signature.py index 9cb1a517e..a27cb2e76 100644 --- a/oauthlib/oauth1/rfc5849/signature.py +++ b/oauthlib/oauth1/rfc5849/signature.py @@ -45,6 +45,7 @@ from oauthlib.common import extract_params, safe_string_equals, urldecode from . import utils +import contextlib log = logging.getLogger(__name__) @@ -188,10 +189,9 @@ def base_string_uri(uri: str, host: str = None) -> str: raise ValueError('missing host') # NOTE: Try guessing if we're dealing with IP or hostname - try: + with contextlib.suppress(ValueError): hostname = ipaddress.ip_address(hostname) - except ValueError: - pass + if isinstance(hostname, ipaddress.IPv6Address): hostname = f"[{hostname}]" @@ -568,7 +568,7 @@ def _get_jwt_rsa_algorithm(hash_algorithm_name: str): # Not in cache: instantiate a new RSAAlgorithm # PyJWT has some nice pycrypto/cryptography abstractions - import jwt.algorithms as jwt_algorithms + import jwt.algorithms as jwt_algorithms # noqa: PLC0415 m = { 'SHA-1': jwt_algorithms.hashes.SHA1, 'SHA-256': jwt_algorithms.hashes.SHA256, diff --git a/oauthlib/oauth1/rfc5849/utils.py b/oauthlib/oauth1/rfc5849/utils.py index 8fb8302e3..0915105bc 100644 --- a/oauthlib/oauth1/rfc5849/utils.py +++ b/oauthlib/oauth1/rfc5849/utils.py @@ -30,7 +30,8 @@ def wrapper(params, *args, **kwargs): def filter_oauth_params(params): """Removes all non oauth parameters from a dict or a list of params.""" - is_oauth = lambda kv: kv[0].startswith("oauth_") + def is_oauth(kv): + return kv[0].startswith('oauth_') if isinstance(params, dict): return list(filter(is_oauth, list(params.items()))) else: @@ -59,7 +60,7 @@ def unescape(u): return unquote(u) -def parse_keqv_list(l): +def parse_keqv_list(l): # noqa: E741 """A unicode-safe version of urllib2.parse_keqv_list""" # With Python 2.6, parse_http_list handles unicode fine return urllib2.parse_keqv_list(l) diff --git a/oauthlib/oauth2/__init__.py b/oauthlib/oauth2/__init__.py index deefb1af7..3bb510217 100644 --- a/oauthlib/oauth2/__init__.py +++ b/oauthlib/oauth2/__init__.py @@ -5,32 +5,66 @@ This module is a wrapper for the most recent implementation of OAuth 2.0 Client and Server classes. """ + from .rfc6749.clients import ( - BackendApplicationClient, Client, LegacyApplicationClient, - MobileApplicationClient, ServiceApplicationClient, WebApplicationClient, + BackendApplicationClient, + Client, + LegacyApplicationClient, + MobileApplicationClient, + ServiceApplicationClient, + WebApplicationClient, ) from .rfc6749.endpoints import ( - AuthorizationEndpoint, BackendApplicationServer, IntrospectEndpoint, - LegacyApplicationServer, MetadataEndpoint, MobileApplicationServer, - ResourceEndpoint, RevocationEndpoint, Server, TokenEndpoint, + AuthorizationEndpoint, + BackendApplicationServer, + IntrospectEndpoint, + LegacyApplicationServer, + MetadataEndpoint, + MobileApplicationServer, + ResourceEndpoint, + RevocationEndpoint, + Server, + TokenEndpoint, WebApplicationServer, ) from .rfc6749.errors import ( - AccessDeniedError, FatalClientError, InsecureTransportError, - InvalidClientError, InvalidClientIdError, InvalidGrantError, - InvalidRedirectURIError, InvalidRequestError, InvalidRequestFatalError, - InvalidScopeError, MismatchingRedirectURIError, MismatchingStateError, - MissingClientIdError, MissingCodeError, MissingRedirectURIError, - MissingResponseTypeError, MissingTokenError, MissingTokenTypeError, - OAuth2Error, ServerError, TemporarilyUnavailableError, TokenExpiredError, - UnauthorizedClientError, UnsupportedGrantTypeError, - UnsupportedResponseTypeError, UnsupportedTokenTypeError, + AccessDeniedError, + FatalClientError, + InsecureTransportError, + InvalidClientError, + InvalidClientIdError, + InvalidGrantError, + InvalidRedirectURIError, + InvalidRequestError, + InvalidRequestFatalError, + InvalidScopeError, + MismatchingRedirectURIError, + MismatchingStateError, + MissingClientIdError, + MissingCodeError, + MissingRedirectURIError, + MissingResponseTypeError, + MissingTokenError, + MissingTokenTypeError, + OAuth2Error, + ServerError, + TemporarilyUnavailableError, + TokenExpiredError, + UnauthorizedClientError, + UnsupportedGrantTypeError, + UnsupportedResponseTypeError, + UnsupportedTokenTypeError, ) from .rfc6749.grant_types import ( - AuthorizationCodeGrant, ClientCredentialsGrant, ImplicitGrant, - RefreshTokenGrant, ResourceOwnerPasswordCredentialsGrant, + AuthorizationCodeGrant, + ClientCredentialsGrant, + ImplicitGrant, + RefreshTokenGrant, + ResourceOwnerPasswordCredentialsGrant, ) from .rfc6749.request_validator import RequestValidator from .rfc6749.tokens import BearerToken, OAuth2Token from .rfc6749.utils import is_secure_transport from .rfc8628.clients import DeviceClient +from oauthlib.oauth2.rfc8628.endpoints import DeviceAuthorizationEndpoint, DeviceApplicationServer +from oauthlib.oauth2.rfc8628.grant_types import DeviceCodeGrant diff --git a/oauthlib/oauth2/rfc6749/clients/base.py b/oauthlib/oauth2/rfc6749/clients/base.py index d5eb0cc15..17f833d23 100644 --- a/oauthlib/oauth2/rfc6749/clients/base.py +++ b/oauthlib/oauth2/rfc6749/clients/base.py @@ -8,17 +8,16 @@ """ import base64 import hashlib -import re -import secrets import time import warnings -from oauthlib.common import generate_token +from oauthlib.common import UNICODE_ASCII_CHARACTER_SET, generate_token from oauthlib.oauth2.rfc6749 import tokens from oauthlib.oauth2.rfc6749.errors import ( InsecureTransportError, TokenExpiredError, ) from oauthlib.oauth2.rfc6749.parameters import ( + parse_expires, parse_token_response, prepare_token_request, prepare_token_revocation_request, ) @@ -207,7 +206,7 @@ def add_token(self, uri, http_method='GET', body=None, headers=None, case_insensitive_token_types = { k.lower(): v for k, v in self.token_types.items()} - if not self.token_type.lower() in case_insensitive_token_types: + if self.token_type.lower() not in case_insensitive_token_types: raise ValueError("Unsupported token type: %s" % self.token_type) if not (self.access_token or self.token.get('access_token')): @@ -466,7 +465,7 @@ def _add_bearer_token(self, uri, http_method='GET', body=None, return uri, headers, body def create_code_verifier(self, length): - """Create PKCE **code_verifier** used in computing **code_challenge**. + """Create PKCE **code_verifier** used in computing **code_challenge**. See `RFC7636 Section 4.1`_ :param length: REQUIRED. The length of the code_verifier. @@ -491,11 +490,7 @@ def create_code_verifier(self, length): if not length <= 128: raise ValueError("Length must be less than or equal to 128") - allowed_characters = re.compile('^[A-Zaa-z0-9-._~]') - code_verifier = secrets.token_urlsafe(length) - - if not re.search(allowed_characters, code_verifier): - raise ValueError("code_verifier contains invalid characters") + code_verifier = generate_token(length, UNICODE_ASCII_CHARACTER_SET + "-._~") self.code_verifier = code_verifier @@ -530,10 +525,10 @@ def create_code_challenge(self, code_verifier, code_challenge_method=None): """ code_challenge = None - if code_verifier == None: + if code_verifier is None: raise ValueError("Invalid code_verifier") - if code_challenge_method == None: + if code_challenge_method is None: code_challenge_method = "plain" self.code_challenge_method = code_challenge_method code_challenge = code_verifier @@ -587,15 +582,13 @@ def populate_token_attributes(self, response): if 'token_type' in response: self.token_type = response.get('token_type') - if 'expires_in' in response: - self.expires_in = response.get('expires_in') - self._expires_at = time.time() + int(self.expires_in) - - if 'expires_at' in response: - try: - self._expires_at = int(response.get('expires_at')) - except: - self._expires_at = None + vin, vat, v_at = parse_expires(response) + if vin: + self.expires_in = vin + if vat: + self.expires_at = vat + if v_at: + self._expires_at = v_at if 'mac_key' in response: self.mac_key = response.get('mac_key') diff --git a/oauthlib/oauth2/rfc6749/clients/mobile_application.py b/oauthlib/oauth2/rfc6749/clients/mobile_application.py index b10b41ced..023cf2362 100644 --- a/oauthlib/oauth2/rfc6749/clients/mobile_application.py +++ b/oauthlib/oauth2/rfc6749/clients/mobile_application.py @@ -43,7 +43,7 @@ class MobileApplicationClient(Client): redirection URI, it may be exposed to the resource owner and other applications residing on the same device. """ - + response_type = 'token' def prepare_request_uri(self, uri, redirect_uri=None, scope=None, diff --git a/oauthlib/oauth2/rfc6749/clients/service_application.py b/oauthlib/oauth2/rfc6749/clients/service_application.py index 8fb173776..abf22d2d8 100644 --- a/oauthlib/oauth2/rfc6749/clients/service_application.py +++ b/oauthlib/oauth2/rfc6749/clients/service_application.py @@ -91,7 +91,7 @@ def prepare_request_body(self, ``https://provider.com/oauth2/token``. :param expires_at: A unix expiration timestamp for the JWT. Defaults - to an hour from now, i.e. ``time.time() + 3600``. + to an hour from now, i.e. ``round(time.time()) + 3600``. :param issued_at: A unix timestamp of when the JWT was created. Defaults to now, i.e. ``time.time()``. @@ -149,7 +149,7 @@ def prepare_request_body(self, .. _`Section 3.2.1`: https://tools.ietf.org/html/rfc6749#section-3.2.1 """ - import jwt + import jwt # noqa: PLC0415 key = private_key or self.private_key if not key: diff --git a/oauthlib/oauth2/rfc6749/clients/web_application.py b/oauthlib/oauth2/rfc6749/clients/web_application.py index 50890fbf8..3bf94c4b5 100644 --- a/oauthlib/oauth2/rfc6749/clients/web_application.py +++ b/oauthlib/oauth2/rfc6749/clients/web_application.py @@ -33,7 +33,7 @@ class WebApplicationClient(Client): browser) and capable of receiving incoming requests (via redirection) from the authorization server. """ - + grant_type = 'authorization_code' def __init__(self, client_id, code=None, **kwargs): @@ -62,8 +62,8 @@ def prepare_request_uri(self, uri, redirect_uri=None, scope=None, to the client. The parameter SHOULD be used for preventing cross-site request forgery as described in `Section 10.12`_. - :param code_challenge: OPTIONAL. PKCE parameter. REQUIRED if PKCE is enforced. - A challenge derived from the code_verifier that is sent in the + :param code_challenge: OPTIONAL. PKCE parameter. REQUIRED if PKCE is enforced. + A challenge derived from the code_verifier that is sent in the authorization request, to be verified against later. :param code_challenge_method: OPTIONAL. PKCE parameter. A method that was used to derive code challenge. diff --git a/oauthlib/oauth2/rfc6749/endpoints/base.py b/oauthlib/oauth2/rfc6749/endpoints/base.py index 3f239917c..987fac6a8 100644 --- a/oauthlib/oauth2/rfc6749/endpoints/base.py +++ b/oauthlib/oauth2/rfc6749/endpoints/base.py @@ -32,7 +32,7 @@ def valid_request_methods(self, valid_request_methods): if valid_request_methods is not None: valid_request_methods = [x.upper() for x in valid_request_methods] self._valid_request_methods = valid_request_methods - + @property def available(self): @@ -40,7 +40,7 @@ def available(self): @available.setter def available(self, available): - self._available = available + self._available = available @property def catch_errors(self): diff --git a/oauthlib/oauth2/rfc6749/endpoints/introspect.py b/oauthlib/oauth2/rfc6749/endpoints/introspect.py index 3cc61e662..ef73988d4 100644 --- a/oauthlib/oauth2/rfc6749/endpoints/introspect.py +++ b/oauthlib/oauth2/rfc6749/endpoints/introspect.py @@ -74,7 +74,7 @@ def create_introspect_response(self, uri, http_method='POST', body=None, request ) if claims is None: - return resp_headers, json.dumps(dict(active=False)), 200 + return resp_headers, json.dumps({'active': False}), 200 if "active" in claims: claims.pop("active") return resp_headers, json.dumps(dict(active=True, **claims)), 200 diff --git a/oauthlib/oauth2/rfc6749/endpoints/metadata.py b/oauthlib/oauth2/rfc6749/endpoints/metadata.py index a2820f28a..34274cba6 100644 --- a/oauthlib/oauth2/rfc6749/endpoints/metadata.py +++ b/oauthlib/oauth2/rfc6749/endpoints/metadata.py @@ -38,9 +38,9 @@ class MetadataEndpoint(BaseEndpoint): """ def __init__(self, endpoints, claims={}, raise_errors=True): - assert isinstance(claims, dict) + assert isinstance(claims, dict) # noqa: S101 for endpoint in endpoints: - assert isinstance(endpoint, BaseEndpoint) + assert isinstance(endpoint, BaseEndpoint) # noqa: S101 BaseEndpoint.__init__(self) self.raise_errors = raise_errors diff --git a/oauthlib/oauth2/rfc6749/endpoints/pre_configured.py b/oauthlib/oauth2/rfc6749/endpoints/pre_configured.py index d64a16639..8f6aa32b7 100644 --- a/oauthlib/oauth2/rfc6749/endpoints/pre_configured.py +++ b/oauthlib/oauth2/rfc6749/endpoints/pre_configured.py @@ -5,9 +5,13 @@ This module is an implementation of various endpoints needed for providing OAuth 2.0 RFC6749 servers. """ + from ..grant_types import ( - AuthorizationCodeGrant, ClientCredentialsGrant, ImplicitGrant, - RefreshTokenGrant, ResourceOwnerPasswordCredentialsGrant, + AuthorizationCodeGrant, + ClientCredentialsGrant, + ImplicitGrant, + RefreshTokenGrant, + ResourceOwnerPasswordCredentialsGrant, ) from ..tokens import BearerToken from .authorization import AuthorizationEndpoint @@ -15,16 +19,26 @@ from .resource import ResourceEndpoint from .revocation import RevocationEndpoint from .token import TokenEndpoint +from oauthlib.oauth2.rfc8628.grant_types import DeviceCodeGrant -class Server(AuthorizationEndpoint, IntrospectEndpoint, TokenEndpoint, - ResourceEndpoint, RevocationEndpoint): - - """An all-in-one endpoint featuring all four major grant types.""" +class Server( + AuthorizationEndpoint, IntrospectEndpoint, TokenEndpoint, ResourceEndpoint, RevocationEndpoint +): + """ + An all-in-one endpoint featuring all four major grant types + and extension grants. + """ - def __init__(self, request_validator, token_expires_in=None, - token_generator=None, refresh_token_generator=None, - *args, **kwargs): + def __init__( + self, + request_validator, + token_expires_in=None, + token_generator=None, + refresh_token_generator=None, + *args, + **kwargs, + ): """Construct a new all-grants-in-one server. :param request_validator: An implementation of @@ -40,43 +54,58 @@ def __init__(self, request_validator, token_expires_in=None, """ self.auth_grant = AuthorizationCodeGrant(request_validator) self.implicit_grant = ImplicitGrant(request_validator) - self.password_grant = ResourceOwnerPasswordCredentialsGrant( - request_validator) + self.password_grant = ResourceOwnerPasswordCredentialsGrant(request_validator) self.credentials_grant = ClientCredentialsGrant(request_validator) self.refresh_grant = RefreshTokenGrant(request_validator) + self.device_code_grant = DeviceCodeGrant(request_validator, **kwargs) + + self.bearer = BearerToken( + request_validator, token_generator, token_expires_in, refresh_token_generator + ) + + AuthorizationEndpoint.__init__( + self, + default_response_type="code", + response_types={ + "code": self.auth_grant, + "token": self.implicit_grant, + "none": self.auth_grant, + }, + default_token_type=self.bearer, + ) - self.bearer = BearerToken(request_validator, token_generator, - token_expires_in, refresh_token_generator) - - AuthorizationEndpoint.__init__(self, default_response_type='code', - response_types={ - 'code': self.auth_grant, - 'token': self.implicit_grant, - 'none': self.auth_grant - }, - default_token_type=self.bearer) - - TokenEndpoint.__init__(self, default_grant_type='authorization_code', - grant_types={ - 'authorization_code': self.auth_grant, - 'password': self.password_grant, - 'client_credentials': self.credentials_grant, - 'refresh_token': self.refresh_grant, - }, - default_token_type=self.bearer) - ResourceEndpoint.__init__(self, default_token='Bearer', - token_types={'Bearer': self.bearer}) + TokenEndpoint.__init__( + self, + default_grant_type="authorization_code", + grant_types={ + "authorization_code": self.auth_grant, + "password": self.password_grant, + "client_credentials": self.credentials_grant, + "refresh_token": self.refresh_grant, + "urn:ietf:params:oauth:grant-type:device_code": self.device_code_grant, + }, + default_token_type=self.bearer, + ) + ResourceEndpoint.__init__( + self, default_token="Bearer", token_types={"Bearer": self.bearer} + ) RevocationEndpoint.__init__(self, request_validator) IntrospectEndpoint.__init__(self, request_validator) -class WebApplicationServer(AuthorizationEndpoint, IntrospectEndpoint, TokenEndpoint, - ResourceEndpoint, RevocationEndpoint): - +class WebApplicationServer( + AuthorizationEndpoint, IntrospectEndpoint, TokenEndpoint, ResourceEndpoint, RevocationEndpoint +): """An all-in-one endpoint featuring Authorization code grant and Bearer tokens.""" - def __init__(self, request_validator, token_generator=None, - token_expires_in=None, refresh_token_generator=None, **kwargs): + def __init__( + self, + request_validator, + token_generator=None, + token_expires_in=None, + refresh_token_generator=None, + **kwargs, + ): """Construct a new web application server. :param request_validator: An implementation of @@ -92,30 +121,44 @@ def __init__(self, request_validator, token_generator=None, """ self.auth_grant = AuthorizationCodeGrant(request_validator) self.refresh_grant = RefreshTokenGrant(request_validator) - self.bearer = BearerToken(request_validator, token_generator, - token_expires_in, refresh_token_generator) - AuthorizationEndpoint.__init__(self, default_response_type='code', - response_types={'code': self.auth_grant}, - default_token_type=self.bearer) - TokenEndpoint.__init__(self, default_grant_type='authorization_code', - grant_types={ - 'authorization_code': self.auth_grant, - 'refresh_token': self.refresh_grant, - }, - default_token_type=self.bearer) - ResourceEndpoint.__init__(self, default_token='Bearer', - token_types={'Bearer': self.bearer}) + self.bearer = BearerToken( + request_validator, token_generator, token_expires_in, refresh_token_generator + ) + AuthorizationEndpoint.__init__( + self, + default_response_type="code", + response_types={"code": self.auth_grant}, + default_token_type=self.bearer, + ) + TokenEndpoint.__init__( + self, + default_grant_type="authorization_code", + grant_types={ + "authorization_code": self.auth_grant, + "refresh_token": self.refresh_grant, + }, + default_token_type=self.bearer, + ) + ResourceEndpoint.__init__( + self, default_token="Bearer", token_types={"Bearer": self.bearer} + ) RevocationEndpoint.__init__(self, request_validator) IntrospectEndpoint.__init__(self, request_validator) -class MobileApplicationServer(AuthorizationEndpoint, IntrospectEndpoint, - ResourceEndpoint, RevocationEndpoint): - +class MobileApplicationServer( + AuthorizationEndpoint, IntrospectEndpoint, ResourceEndpoint, RevocationEndpoint +): """An all-in-one endpoint featuring Implicit code grant and Bearer tokens.""" - def __init__(self, request_validator, token_generator=None, - token_expires_in=None, refresh_token_generator=None, **kwargs): + def __init__( + self, + request_validator, + token_generator=None, + token_expires_in=None, + refresh_token_generator=None, + **kwargs, + ): """Construct a new implicit grant server. :param request_validator: An implementation of @@ -130,27 +173,39 @@ def __init__(self, request_validator, token_generator=None, token-, resource-, and revocation-endpoint constructors. """ self.implicit_grant = ImplicitGrant(request_validator) - self.bearer = BearerToken(request_validator, token_generator, - token_expires_in, refresh_token_generator) - AuthorizationEndpoint.__init__(self, default_response_type='token', - response_types={ - 'token': self.implicit_grant}, - default_token_type=self.bearer) - ResourceEndpoint.__init__(self, default_token='Bearer', - token_types={'Bearer': self.bearer}) - RevocationEndpoint.__init__(self, request_validator, - supported_token_types=['access_token']) - IntrospectEndpoint.__init__(self, request_validator, - supported_token_types=['access_token']) - - -class LegacyApplicationServer(TokenEndpoint, IntrospectEndpoint, - ResourceEndpoint, RevocationEndpoint): + self.bearer = BearerToken( + request_validator, token_generator, token_expires_in, refresh_token_generator + ) + AuthorizationEndpoint.__init__( + self, + default_response_type="token", + response_types={"token": self.implicit_grant}, + default_token_type=self.bearer, + ) + ResourceEndpoint.__init__( + self, default_token="Bearer", token_types={"Bearer": self.bearer} + ) + RevocationEndpoint.__init__( + self, request_validator, supported_token_types=["access_token"] + ) + IntrospectEndpoint.__init__( + self, request_validator, supported_token_types=["access_token"] + ) + +class LegacyApplicationServer( + TokenEndpoint, IntrospectEndpoint, ResourceEndpoint, RevocationEndpoint +): """An all-in-one endpoint featuring Resource Owner Password Credentials grant and Bearer tokens.""" - def __init__(self, request_validator, token_generator=None, - token_expires_in=None, refresh_token_generator=None, **kwargs): + def __init__( + self, + request_validator, + token_generator=None, + token_expires_in=None, + refresh_token_generator=None, + **kwargs, + ): """Construct a resource owner password credentials grant server. :param request_validator: An implementation of @@ -164,30 +219,40 @@ def __init__(self, request_validator, token_generator=None, :param kwargs: Extra parameters to pass to authorization-, token-, resource-, and revocation-endpoint constructors. """ - self.password_grant = ResourceOwnerPasswordCredentialsGrant( - request_validator) + self.password_grant = ResourceOwnerPasswordCredentialsGrant(request_validator) self.refresh_grant = RefreshTokenGrant(request_validator) - self.bearer = BearerToken(request_validator, token_generator, - token_expires_in, refresh_token_generator) - TokenEndpoint.__init__(self, default_grant_type='password', - grant_types={ - 'password': self.password_grant, - 'refresh_token': self.refresh_grant, - }, - default_token_type=self.bearer) - ResourceEndpoint.__init__(self, default_token='Bearer', - token_types={'Bearer': self.bearer}) + self.bearer = BearerToken( + request_validator, token_generator, token_expires_in, refresh_token_generator + ) + TokenEndpoint.__init__( + self, + default_grant_type="password", + grant_types={ + "password": self.password_grant, + "refresh_token": self.refresh_grant, + }, + default_token_type=self.bearer, + ) + ResourceEndpoint.__init__( + self, default_token="Bearer", token_types={"Bearer": self.bearer} + ) RevocationEndpoint.__init__(self, request_validator) IntrospectEndpoint.__init__(self, request_validator) -class BackendApplicationServer(TokenEndpoint, IntrospectEndpoint, - ResourceEndpoint, RevocationEndpoint): - +class BackendApplicationServer( + TokenEndpoint, IntrospectEndpoint, ResourceEndpoint, RevocationEndpoint +): """An all-in-one endpoint featuring Client Credentials grant and Bearer tokens.""" - def __init__(self, request_validator, token_generator=None, - token_expires_in=None, refresh_token_generator=None, **kwargs): + def __init__( + self, + request_validator, + token_generator=None, + token_expires_in=None, + refresh_token_generator=None, + **kwargs, + ): """Construct a client credentials grant server. :param request_validator: An implementation of @@ -202,15 +267,21 @@ def __init__(self, request_validator, token_generator=None, token-, resource-, and revocation-endpoint constructors. """ self.credentials_grant = ClientCredentialsGrant(request_validator) - self.bearer = BearerToken(request_validator, token_generator, - token_expires_in, refresh_token_generator) - TokenEndpoint.__init__(self, default_grant_type='client_credentials', - grant_types={ - 'client_credentials': self.credentials_grant}, - default_token_type=self.bearer) - ResourceEndpoint.__init__(self, default_token='Bearer', - token_types={'Bearer': self.bearer}) - RevocationEndpoint.__init__(self, request_validator, - supported_token_types=['access_token']) - IntrospectEndpoint.__init__(self, request_validator, - supported_token_types=['access_token']) + self.bearer = BearerToken( + request_validator, token_generator, token_expires_in, refresh_token_generator + ) + TokenEndpoint.__init__( + self, + default_grant_type="client_credentials", + grant_types={"client_credentials": self.credentials_grant}, + default_token_type=self.bearer, + ) + ResourceEndpoint.__init__( + self, default_token="Bearer", token_types={"Bearer": self.bearer} + ) + RevocationEndpoint.__init__( + self, request_validator, supported_token_types=["access_token"] + ) + IntrospectEndpoint.__init__( + self, request_validator, supported_token_types=["access_token"] + ) diff --git a/oauthlib/oauth2/rfc6749/endpoints/resource.py b/oauthlib/oauth2/rfc6749/endpoints/resource.py index f7562255d..d1ff5049d 100644 --- a/oauthlib/oauth2/rfc6749/endpoints/resource.py +++ b/oauthlib/oauth2/rfc6749/endpoints/resource.py @@ -81,4 +81,4 @@ def find_token_type(self, request): """ estimates = sorted(((t.estimate_type(request), n) for n, t in self.tokens.items()), reverse=True) - return estimates[0][1] if len(estimates) else None + return estimates[0][1] if estimates else None diff --git a/oauthlib/oauth2/rfc6749/errors.py b/oauthlib/oauth2/rfc6749/errors.py index da24feab7..be8e7a1ec 100644 --- a/oauthlib/oauth2/rfc6749/errors.py +++ b/oauthlib/oauth2/rfc6749/errors.py @@ -6,6 +6,8 @@ defined error responses for all four core grant types. """ import json +import inspect +import sys from oauthlib.common import add_params_to_uri, urlencode @@ -60,7 +62,7 @@ def __init__(self, description=None, uri=None, state=None, self.response_type = request.response_type self.response_mode = request.response_mode self.grant_type = request.grant_type - if not state: + if state is None: self.state = request.state else: self.redirect_uri = None @@ -150,7 +152,6 @@ class FatalClientError(OAuth2Error): Instead the user should be informed of the error by the provider itself. """ - pass class InvalidRequestFatalError(FatalClientError): @@ -387,8 +388,6 @@ def __init__(self, error, *args, **kwargs): def raise_from_error(error, params=None): - import inspect - import sys kwargs = { 'description': params.get('error_description'), 'uri': params.get('error_uri'), diff --git a/oauthlib/oauth2/rfc6749/grant_types/authorization_code.py b/oauthlib/oauth2/rfc6749/grant_types/authorization_code.py index 858855a17..09dc61998 100644 --- a/oauthlib/oauth2/rfc6749/grant_types/authorization_code.py +++ b/oauthlib/oauth2/rfc6749/grant_types/authorization_code.py @@ -387,7 +387,7 @@ def validate_authorization_request(self, request): raise errors.MissingResponseTypeError(request=request) # Value MUST be set to "code" or one of the OpenID authorization code including # response_types "code token", "code id_token", "code token id_token" - elif not 'code' in request.response_type and request.response_type != 'none': + elif 'code' not in request.response_type and request.response_type != 'none': raise errors.UnsupportedResponseTypeError(request=request) if not self.request_validator.validate_response_type(request.client_id, @@ -400,9 +400,8 @@ def validate_authorization_request(self, request): # OPTIONAL. Validate PKCE request or reply with "error"/"invalid_request" # https://tools.ietf.org/html/rfc6749#section-4.4.1 - if self.request_validator.is_pkce_required(request.client_id, request) is True: - if request.code_challenge is None: - raise errors.MissingCodeChallengeError(request=request) + if self.request_validator.is_pkce_required(request.client_id, request) is True and request.code_challenge is None: + raise errors.MissingCodeChallengeError(request=request) if request.code_challenge is not None: request_info["code_challenge"] = request.code_challenge diff --git a/oauthlib/oauth2/rfc6749/grant_types/base.py b/oauthlib/oauth2/rfc6749/grant_types/base.py index ca343a119..d96a2db4f 100644 --- a/oauthlib/oauth2/rfc6749/grant_types/base.py +++ b/oauthlib/oauth2/rfc6749/grant_types/base.py @@ -143,7 +143,7 @@ def add_token(self, token, token_handler, request): :type request: oauthlib.common.Request """ # Only add a hybrid access token on auth step if asked for - if not request.response_type in ["token", "code token", "id_token token", "code id_token token"]: + if request.response_type not in ["token", "code token", "id_token token", "code id_token token"]: return token token.update(token_handler.create_token(request, refresh_token=False)) @@ -199,10 +199,7 @@ def prepare_authorization_response(self, request, token, headers, body, status): if request.response_type == 'none': state = token.get('state', None) - if state: - token_items = [('state', state)] - else: - token_items = [] + token_items = [('state', state)] if state else [] if request.response_mode == 'query': headers['Location'] = add_params_to_uri( diff --git a/oauthlib/oauth2/rfc6749/grant_types/client_credentials.py b/oauthlib/oauth2/rfc6749/grant_types/client_credentials.py index e7b461897..35c544027 100644 --- a/oauthlib/oauth2/rfc6749/grant_types/client_credentials.py +++ b/oauthlib/oauth2/rfc6749/grant_types/client_credentials.py @@ -107,11 +107,10 @@ def validate_token_request(self, request): if not self.request_validator.authenticate_client(request): log.debug('Client authentication failed, %r.', request) raise errors.InvalidClientError(request=request) - else: - if not hasattr(request.client, 'client_id'): - raise NotImplementedError('Authenticate client must set the ' - 'request.client.client_id attribute ' - 'in authenticate_client.') + elif not hasattr(request.client, 'client_id'): + raise NotImplementedError('Authenticate client must set the ' + 'request.client.client_id attribute ' + 'in authenticate_client.') # Ensure client is authorized use of this grant type self.validate_grant_type(request) diff --git a/oauthlib/oauth2/rfc6749/grant_types/implicit.py b/oauthlib/oauth2/rfc6749/grant_types/implicit.py index 6110b6f33..cd3bfeb64 100644 --- a/oauthlib/oauth2/rfc6749/grant_types/implicit.py +++ b/oauthlib/oauth2/rfc6749/grant_types/implicit.py @@ -233,10 +233,7 @@ def create_token_response(self, request, token_handler): # In OIDC implicit flow it is possible to have a request_type that does not include the access_token! # "id_token token" - return the access token and the id token # "id_token" - don't return the access token - if "token" in request.response_type.split(): - token = token_handler.create_token(request, refresh_token=False) - else: - token = {} + token = token_handler.create_token(request, refresh_token=False) if 'token' in request.response_type.split() else {} if request.state is not None: token['state'] = request.state diff --git a/oauthlib/oauth2/rfc6749/grant_types/refresh_token.py b/oauthlib/oauth2/rfc6749/grant_types/refresh_token.py index ce33df0e7..43bf55ac9 100644 --- a/oauthlib/oauth2/rfc6749/grant_types/refresh_token.py +++ b/oauthlib/oauth2/rfc6749/grant_types/refresh_token.py @@ -101,6 +101,9 @@ def validate_token_request(self, request): if not self.request_validator.authenticate_client(request): log.debug('Invalid client (%r), denying access.', request) raise errors.InvalidClientError(request=request) + # Ensure that request.client_id is set. + if request.client_id is None and request.client is not None: + request.client_id = request.client.client_id elif not self.request_validator.authenticate_client_id(request.client_id, request): log.debug('Client authentication failed, %r.', request) raise errors.InvalidClientError(request=request) diff --git a/oauthlib/oauth2/rfc6749/grant_types/resource_owner_password_credentials.py b/oauthlib/oauth2/rfc6749/grant_types/resource_owner_password_credentials.py index 4b0de5bf6..55d928709 100644 --- a/oauthlib/oauth2/rfc6749/grant_types/resource_owner_password_credentials.py +++ b/oauthlib/oauth2/rfc6749/grant_types/resource_owner_password_credentials.py @@ -180,12 +180,11 @@ def validate_token_request(self, request): request.password, request.client, request): raise errors.InvalidGrantError( 'Invalid credentials given.', request=request) - else: - if not hasattr(request.client, 'client_id'): - raise NotImplementedError( - 'Validate user must set the ' - 'request.client.client_id attribute ' - 'in authenticate_client.') + elif not hasattr(request.client, 'client_id'): + raise NotImplementedError( + 'Validate user must set the ' + 'request.client.client_id attribute ' + 'in authenticate_client.') log.debug('Authorizing access to user %r.', request.user) # Ensure client is authorized use of this grant type diff --git a/oauthlib/oauth2/rfc6749/parameters.py b/oauthlib/oauth2/rfc6749/parameters.py index 8f6ce2c7f..8268ef927 100644 --- a/oauthlib/oauth2/rfc6749/parameters.py +++ b/oauthlib/oauth2/rfc6749/parameters.py @@ -45,10 +45,10 @@ def prepare_grant_uri(uri, client_id, response_type, redirect_uri=None, back to the client. The parameter SHOULD be used for preventing cross-site request forgery as described in `Section 10.12`_. - :param code_challenge: PKCE parameter. A challenge derived from the - code_verifier that is sent in the authorization + :param code_challenge: PKCE parameter. A challenge derived from the + code_verifier that is sent in the authorization request, to be verified against later. - :param code_challenge_method: PKCE parameter. A method that was used to derive the + :param code_challenge_method: PKCE parameter. A method that was used to derive the code_challenge. Defaults to "plain" if not present in the request. :param kwargs: Extra arguments to embed in the grant/authorization URL. @@ -150,9 +150,8 @@ def prepare_token_request(grant_type, body='', include_client_id=True, code_veri # pull the `client_id` out of the kwargs. client_id = kwargs.pop('client_id', None) - if include_client_id: - if client_id is not None: - params.append(('client_id', client_id)) + if include_client_id and client_id is not None: + params.append(('client_id', client_id)) # use code_verifier if code_challenge was passed in the authorization request if code_verifier is not None: @@ -274,13 +273,13 @@ def parse_authorization_code_response(uri, state=None): query = urlparse.urlparse(uri).query params = dict(urlparse.parse_qsl(query)) - if state and params.get('state', None) != state: + if state and params.get('state') != state: raise MismatchingStateError() if 'error' in params: raise_from_error(params.get('error'), params) - if not 'code' in params: + if 'code' not in params: raise MissingCodeError("Missing code parameter in response.") return params @@ -337,17 +336,20 @@ def parse_implicit_response(uri, state=None, scope=None): fragment = urlparse.urlparse(uri).fragment params = dict(urlparse.parse_qsl(fragment, keep_blank_values=True)) - for key in ('expires_in',): - if key in params: # cast things to int - params[key] = int(params[key]) - if 'scope' in params: params['scope'] = scope_to_list(params['scope']) - if 'expires_in' in params: - params['expires_at'] = time.time() + int(params['expires_in']) - - if state and params.get('state', None) != state: + vin, vat, v_at = parse_expires(params) + if vin: + params['expires_in'] = vin + elif 'expires_in' in params: + params.pop('expires_in') + if vat: + params['expires_at'] = vat + elif 'expires_at' in params: + params.pop('expires_at') + + if state and params.get('state') != state: raise ValueError("Mismatching or missing state in params.") params = OAuth2Token(params, old_scope=scope) @@ -424,18 +426,19 @@ def parse_token_response(body, scope=None): # https://github.com/oauthlib/oauthlib/issues/267 params = dict(urlparse.parse_qsl(body)) - for key in ('expires_in',): - if key in params: # cast things to int - params[key] = int(params[key]) if 'scope' in params: params['scope'] = scope_to_list(params['scope']) - if 'expires_in' in params: - if params['expires_in'] is None: - params.pop('expires_in') - else: - params['expires_at'] = time.time() + int(params['expires_in']) + vin, vat, v_at = parse_expires(params) + if vin: + params['expires_in'] = vin + elif 'expires_in' in params: + params.pop('expires_in') + if vat: + params['expires_at'] = vat + elif 'expires_at' in params: + params.pop('expires_at') params = OAuth2Token(params, old_scope=scope) validate_token_parameters(params) @@ -447,12 +450,11 @@ def validate_token_parameters(params): if 'error' in params: raise_from_error(params.get('error'), params) - if not 'access_token' in params: + if 'access_token' not in params: raise MissingTokenError(description="Missing access token parameter.") - if not 'token_type' in params: - if os.environ.get('OAUTHLIB_STRICT_TOKEN_TYPE'): - raise MissingTokenTypeError() + if 'token_type' not in params and os.environ.get('OAUTHLIB_STRICT_TOKEN_TYPE'): + raise MissingTokenTypeError() # If the issued access token scope is different from the one requested by # the client, the authorization server MUST include the "scope" response @@ -469,3 +471,58 @@ def validate_token_parameters(params): w.old_scope = params.old_scopes w.new_scope = params.scopes raise w + +def parse_expires(params): + """Parse `expires_in`, `expires_at` fields from params + + Parse following these rules: + - `expires_in` must be either integer, float or None. If a float, it is converted into an integer. + - `expires_at` is not in specification so it does its best to: + - convert into a int, else + - convert into a float, else + - reuse the same type as-is (usually string) + - `_expires_at` is a special internal value returned to be always an `int`, based + either on the presence of `expires_at`, or reuse the current time plus + `expires_in`. This is typically used to validate token expiry. + + :param params: Dict with expires_in and expires_at optionally set + :return: Tuple of `expires_in`, `expires_at`, and `_expires_at`. None if not set. + """ + expires_in = None + expires_at = None + _expires_at = None + + if 'expires_in' in params: + if isinstance(params.get('expires_in'), int): + expires_in = params.get('expires_in') + elif isinstance(params.get('expires_in'), float): + expires_in = int(params.get('expires_in')) + elif isinstance(params.get('expires_in'), str): + try: + # Attempt to convert to int + expires_in = int(params.get('expires_in')) + except ValueError: + raise ValueError("expires_in must be an int") + elif params.get('expires_in') is not None: + raise ValueError("expires_in must be an int") + + if 'expires_at' in params: + if isinstance(params.get('expires_at'), (float, int)): + expires_at = params.get('expires_at') + _expires_at = expires_at + elif isinstance(params.get('expires_at'), str): + try: + # Attempt to convert to int first, then float if int fails + expires_at = int(params.get('expires_at')) + _expires_at = expires_at + except ValueError: + try: + expires_at = float(params.get('expires_at')) + _expires_at = expires_at + except ValueError: + # no change from str + expires_at = params.get('expires_at') + if _expires_at is None and expires_in: + expires_at = round(time.time()) + expires_in + _expires_at = expires_at + return expires_in, expires_at, _expires_at diff --git a/oauthlib/oauth2/rfc6749/request_validator.py b/oauthlib/oauth2/rfc6749/request_validator.py index 3910c0b91..6d6ebaa8e 100644 --- a/oauthlib/oauth2/rfc6749/request_validator.py +++ b/oauthlib/oauth2/rfc6749/request_validator.py @@ -48,12 +48,12 @@ def authenticate_client(self, request, *args, **kwargs): Headers may be accesses through request.headers and parameters found in both body and query can be obtained by direct attribute access, i.e. request.client_id for client_id in the URL query. - + The authentication process is required to contain the identification of the client (i.e. search the database based on the client_id). In case the client doesn't exist based on the received client_id, this method has to return False and the HTTP response created by the library will contain - 'invalid_client' message. + 'invalid_client' message. After the client identification succeeds, this method needs to set the client on the request, i.e. request.client = client. A client object's diff --git a/oauthlib/oauth2/rfc6749/tokens.py b/oauthlib/oauth2/rfc6749/tokens.py index 0757d07ea..73b8c66a9 100644 --- a/oauthlib/oauth2/rfc6749/tokens.py +++ b/oauthlib/oauth2/rfc6749/tokens.py @@ -24,7 +24,7 @@ class OAuth2Token(dict): def __init__(self, params, old_scope=None): super().__init__(params) self._new_scope = None - if 'scope' in params and params['scope']: + if params.get('scope'): self._new_scope = set(utils.scope_to_list(params['scope'])) if old_scope is not None: self._old_scope = set(utils.scope_to_list(old_scope)) @@ -123,10 +123,7 @@ def prepare_mac_header(token, uri, key, http_method, sch, net, path, par, query, fra = urlparse(uri) - if query: - request_uri = path + '?' + query - else: - request_uri = path + request_uri = path + '?' + query if query else path # Hash the body/payload if body is not None and draft == 0: @@ -305,10 +302,7 @@ def create_token(self, request, refresh_token=False, **kwargs): "If you do, call `request_validator.save_token()` instead.", DeprecationWarning) - if callable(self.expires_in): - expires_in = self.expires_in(request) - else: - expires_in = self.expires_in + expires_in = self.expires_in(request) if callable(self.expires_in) else self.expires_in request.expires_in = expires_in diff --git a/oauthlib/oauth2/rfc8628/__init__.py b/oauthlib/oauth2/rfc8628/__init__.py index 531929dcc..65891445e 100644 --- a/oauthlib/oauth2/rfc8628/__init__.py +++ b/oauthlib/oauth2/rfc8628/__init__.py @@ -5,6 +5,12 @@ This module is an implementation of various logic needed for consuming and providing OAuth 2.0 Device Authorization RFC8628. """ + +from oauthlib.oauth2.rfc8628.errors import ( + SlowDownError, + AuthorizationPendingError, + ExpiredTokenError, +) import logging log = logging.getLogger(__name__) diff --git a/oauthlib/oauth2/rfc8628/clients/device.py b/oauthlib/oauth2/rfc8628/clients/device.py index b9ba2150a..ee0ccf8d6 100644 --- a/oauthlib/oauth2/rfc8628/clients/device.py +++ b/oauthlib/oauth2/rfc8628/clients/device.py @@ -45,9 +45,9 @@ def prepare_request_uri(self, uri, scope=None, **kwargs): if scope: params.append(('scope', list_to_scope(scope))) - for k in kwargs: - if kwargs[k]: - params.append((str(k), kwargs[k])) + for k,v in kwargs.items(): + if v: + params.append((str(k), v)) return add_params_to_uri(uri, params) diff --git a/oauthlib/oauth2/rfc8628/endpoints/__init__.py b/oauthlib/oauth2/rfc8628/endpoints/__init__.py new file mode 100644 index 000000000..dc834797c --- /dev/null +++ b/oauthlib/oauth2/rfc8628/endpoints/__init__.py @@ -0,0 +1,10 @@ +""" +oauthlib.oauth2.rfc8628 +~~~~~~~~~~~~~~~~~~~~~~~ + +This module is an implementation of various logic needed +for consuming and providing OAuth 2.0 Device Authorization RFC8628. +""" + +from .device_authorization import DeviceAuthorizationEndpoint +from .pre_configured import DeviceApplicationServer diff --git a/oauthlib/oauth2/rfc8628/endpoints/device_authorization.py b/oauthlib/oauth2/rfc8628/endpoints/device_authorization.py new file mode 100644 index 000000000..3f38a5405 --- /dev/null +++ b/oauthlib/oauth2/rfc8628/endpoints/device_authorization.py @@ -0,0 +1,232 @@ +""" +oauthlib.oauth2.rfc8628 +~~~~~~~~~~~~~~~~~~~~~~~ + +This module is an implementation of various logic needed +for consuming and providing OAuth 2.0 RFC8628. +""" + +import logging +from typing import Callable + +from oauthlib.common import Request, generate_token +from oauthlib.oauth2.rfc6749 import errors +from oauthlib.oauth2.rfc6749.endpoints.base import ( + BaseEndpoint, + catch_errors_and_unavailability, +) + +log = logging.getLogger(__name__) + + +class DeviceAuthorizationEndpoint(BaseEndpoint): + """DeviceAuthorization endpoint - used by the client to initiate + the authorization flow by requesting a set of verification codes + from the authorization server by making an HTTP "POST" request to + the device authorization endpoint. + + The client authentication requirements of Section 3.2.1 of [RFC6749] + apply to requests on this endpoint, which means that confidential + clients (those that have established client credentials) authenticate + in the same manner as when making requests to the token endpoint, and + public clients provide the "client_id" parameter to identify + themselves. + """ + + def __init__( + self, + request_validator, + verification_uri, + expires_in=1800, + interval=None, + verification_uri_complete=None, + user_code_generator: Callable[[None], str] = None, + ): + """ + :param request_validator: An instance of RequestValidator. + :type request_validator: oauthlib.oauth2.rfc6749.RequestValidator. + :param verification_uri: a string containing the URL that can be polled by the client application + :param expires_in: a number that represents the lifetime of the `user_code` and `device_code` + :param interval: an option number that represents the number of seconds between each poll requests + :param verification_uri_complete: a string of a function that can be called with `user_data` as parameter + :param user_code_generator: a callable that returns a configurable user code + """ + self.request_validator = request_validator + self._expires_in = expires_in + self._interval = interval + self._verification_uri = verification_uri + self._verification_uri_complete = verification_uri_complete + self.user_code_generator = user_code_generator + + BaseEndpoint.__init__(self) + + @property + def interval(self): + """The minimum amount of time in seconds that the client + SHOULD wait between polling requests to the token endpoint. If no + value is provided, clients MUST use 5 as the default. + """ + return self._interval + + @property + def expires_in(self): + """The lifetime in seconds of the "device_code" and "user_code".""" + return self._expires_in + + @property + def verification_uri(self): + """The end-user verification URI on the authorization + server. The URI should be short and easy to remember as end users + will be asked to manually type it into their user agent. + """ + return self._verification_uri + + def verification_uri_complete(self, user_code): + if not self._verification_uri_complete: + return None + if isinstance(self._verification_uri_complete, str): + return self._verification_uri_complete.format(user_code=user_code) + if callable(self._verification_uri_complete): + return self._verification_uri_complete(user_code) + return None + + @catch_errors_and_unavailability + def validate_device_authorization_request(self, request): + """Validate the device authorization request. + + The client_id is required if the client is not authenticating with the + authorization server as described in `Section 3.2.1. of [RFC6749]`_. + The client identifier as described in `Section 2.2 of [RFC6749]`_. + + .. _`Section 3.2.1. of [RFC6749]`: https://www.rfc-editor.org/rfc/rfc6749#section-3.2.1 + .. _`Section 2.2 of [RFC6749]`: https://www.rfc-editor.org/rfc/rfc6749#section-2.2 + """ + + # First check duplicate parameters + for param in ("client_id", "scope"): + try: + duplicate_params = request.duplicate_params + except ValueError: + raise errors.InvalidRequestFatalError( + description="Unable to parse query string", request=request + ) + if param in duplicate_params: + raise errors.InvalidRequestFatalError( + description="Duplicate %s parameter." % param, request=request + ) + + # the "application/x-www-form-urlencoded" format, per Appendix B of [RFC6749] + # https://www.rfc-editor.org/rfc/rfc6749#appendix-B + if request.headers["Content-Type"] != "application/x-www-form-urlencoded": + raise errors.InvalidRequestError( + "Content-Type must be application/x-www-form-urlencoded", + request=request, + ) + + # REQUIRED. The client identifier as described in Section 2.2. + # https://tools.ietf.org/html/rfc6749#section-2.2 + # TODO: extract client_id an helper validation function. + if not request.client_id: + raise errors.MissingClientIdError(request=request) + + if not self.request_validator.validate_client_id(request.client_id, request): + raise errors.InvalidClientIdError(request=request) + + # The client authentication requirements of Section 3.2.1 of [RFC6749] + # apply to requests on this endpoint, which means that confidential + # clients (those that have established client credentials) authenticate + # in the same manner as when making requests to the token endpoint, and + # public clients provide the "client_id" parameter to identify + # themselves. + self._raise_on_invalid_client(request) + + @catch_errors_and_unavailability + def create_device_authorization_response( + self, uri, http_method="POST", body=None, headers=None + ): + """ + Generate a unique device verification code and an end-user code that are valid for a limited time. + Include them in the HTTP response body using the "application/json" format [RFC8259] with a + 200 (OK) status code, as described in `Section-3.2`_. + + :param uri: The full URI of the token request. + :type uri: str + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :param user_code_generator: + A callable that returns a string for the user code. + This allows the caller to decide how the `user_code` should be formatted. + :type user_code_generator: Callable[[], str] + :return: A tuple of three elements: + 1. A dict of headers to set on the response. + 2. The response body as a string. + 3. The response status code as an integer. + :rtype: tuple + + The response contains the following parameters: + + device_code + **REQUIRED.** The device verification code. + + user_code + **REQUIRED.** The end-user verification code. + + verification_uri + **REQUIRED.** The end-user verification URI on the authorization server. + The URI should be short and easy to remember as end users will be asked + to manually type it into their user agent. + + verification_uri_complete + **OPTIONAL.** A verification URI that includes the `user_code` (or + other information with the same function as the `user_code`), which is + designed for non-textual transmission. + + expires_in + **REQUIRED.** The lifetime in seconds of the `device_code` and `user_code`. + + interval + **OPTIONAL.** The minimum amount of time in seconds that the client + SHOULD wait between polling requests to the token endpoint. If no + value is provided, clients MUST use 5 as the default. + + **For example:** + + .. code-block:: http + + HTTP/1.1 200 OK + Content-Type: application/json + Cache-Control: no-store + + { + "device_code": "GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS", + "user_code": "WDJB-MJHT", + "verification_uri": "https://example.com/device", + "verification_uri_complete": + "https://example.com/device?user_code=WDJB-MJHT", + "expires_in": 1800, + "interval": 5 + } + + .. _`Section-3.2`: https://www.rfc-editor.org/rfc/rfc8628#section-3.2 + """ + request = Request(uri, http_method, body, headers) + self.validate_device_authorization_request(request) + log.debug("Pre resource owner authorization validation ok for %r.", request) + + headers = {} + user_code = self.user_code_generator() if self.user_code_generator else generate_token() + data = { + "verification_uri": self.verification_uri, + "expires_in": self.expires_in, + "user_code": user_code, + "device_code": generate_token(), + } + if self.interval is not None: + data["interval"] = self.interval + + + verification_uri_complete = self.verification_uri_complete(user_code) + if verification_uri_complete: + data["verification_uri_complete"] = verification_uri_complete + + return headers, data, 200 diff --git a/oauthlib/oauth2/rfc8628/endpoints/pre_configured.py b/oauthlib/oauth2/rfc8628/endpoints/pre_configured.py new file mode 100644 index 000000000..6cce68330 --- /dev/null +++ b/oauthlib/oauth2/rfc8628/endpoints/pre_configured.py @@ -0,0 +1,36 @@ +from oauthlib.oauth2.rfc8628.endpoints.device_authorization import ( + DeviceAuthorizationEndpoint, +) + +from typing import Callable, Optional +from oauthlib.openid.connect.core.request_validator import RequestValidator + + +class DeviceApplicationServer(DeviceAuthorizationEndpoint): + """An all-in-one endpoint featuring Authorization code grant and Bearer tokens.""" + + def __init__( + self, + request_validator: RequestValidator, + verification_uri: str, + interval: int = 5, + verification_uri_complete: Optional[str] = None, # noqa: FA100 + user_code_generator: Callable[[None], str] = None, + **kwargs, + ): + """Construct a new web application server. + + :param request_validator: An implementation of + oauthlib.oauth2.rfc8626.RequestValidator. + :param interval: How long the device needs to wait before polling the server + :param verification_uri: the verification_uri to be send back. + :param user_code_generator: a callable that allows the user code to be configured. + """ + DeviceAuthorizationEndpoint.__init__( + self, + request_validator, + interval=interval, + verification_uri=verification_uri, + user_code_generator=user_code_generator, + verification_uri_complete=verification_uri_complete, + ) diff --git a/oauthlib/oauth2/rfc8628/errors.py b/oauthlib/oauth2/rfc8628/errors.py new file mode 100644 index 000000000..a43593898 --- /dev/null +++ b/oauthlib/oauth2/rfc8628/errors.py @@ -0,0 +1,55 @@ +from oauthlib.oauth2.rfc6749.errors import OAuth2Error + +""" +oauthlib.oauth2.rfc8628.errors +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Error used both by OAuth2 clients and providers to represent the spec +defined error responses specific to the the device grant +""" + + +class AuthorizationPendingError(OAuth2Error): + """ + For the device authorization grant; + The authorization request is still pending as the end user hasn't + yet completed the user-interaction steps (Section 3.3). The + client SHOULD repeat the access token request to the token + endpoint (a process known as polling). Before each new request, + the client MUST wait at least the number of seconds specified by + the "interval" parameter of the device authorization response, + or 5 seconds if none was provided, and respect any + increase in the polling interval required by the "slow_down" + error. + """ + + error = "authorization_pending" + + +class SlowDownError(OAuth2Error): + """ + A variant of "authorization_pending", the authorization request is + still pending and polling should continue, but the interval MUST + be increased by 5 seconds for this and all subsequent requests. + """ + + error = "slow_down" + + +class ExpiredTokenError(OAuth2Error): + """ + The "device_code" has expired, and the device authorization + session has concluded. The client MAY commence a new device + authorization request but SHOULD wait for user interaction before + restarting to avoid unnecessary polling. + """ + + error = "expired_token" + + +class AccessDenied(OAuth2Error): + """ + The authorization request was denied. + """ + + error = "access_denied" diff --git a/oauthlib/oauth2/rfc8628/grant_types/__init__.py b/oauthlib/oauth2/rfc8628/grant_types/__init__.py new file mode 100644 index 000000000..418dba775 --- /dev/null +++ b/oauthlib/oauth2/rfc8628/grant_types/__init__.py @@ -0,0 +1 @@ +from oauthlib.oauth2.rfc8628.grant_types.device_code import DeviceCodeGrant diff --git a/oauthlib/oauth2/rfc8628/grant_types/device_code.py b/oauthlib/oauth2/rfc8628/grant_types/device_code.py new file mode 100644 index 000000000..082daf0fc --- /dev/null +++ b/oauthlib/oauth2/rfc8628/grant_types/device_code.py @@ -0,0 +1,111 @@ +from __future__ import annotations +import json + +from typing import Callable + +from oauthlib import common # noqa: TC001 + +from oauthlib.oauth2.rfc6749 import errors as rfc6749_errors +from oauthlib.oauth2.rfc6749.grant_types.base import GrantTypeBase + + +class DeviceCodeGrant(GrantTypeBase): + def create_authorization_response( + self, request: common.Request, token_handler: Callable + ) -> tuple[dict, str, int]: + """ + Validate the device flow request -> create the access token + -> persist the token -> return the token. + """ + headers = self._get_default_headers() + try: + self.validate_token_request(request) + except rfc6749_errors.OAuth2Error as e: + headers.update(e.headers) + return headers, e.json, e.status_code + + token = token_handler.create_token(request, refresh_token=False) + + for modifier in self._token_modifiers: + token = modifier(token) + + self.request_validator.save_token(token, request) + + return self.create_token_response(request, token_handler) + + def validate_token_request(self, request: common.Request) -> None: + """ + Performs the necessary check against the request to ensure + it's allowed to retrieve a token. + """ + for validator in self.custom_validators.pre_token: + validator(request) + + if not getattr(request, "grant_type", None): + raise rfc6749_errors.InvalidRequestError( + "Request is missing grant type.", request=request + ) + + if request.grant_type != "urn:ietf:params:oauth:grant-type:device_code": + raise rfc6749_errors.UnsupportedGrantTypeError(request=request) + + for param in ("grant_type", "scope"): + if param in request.duplicate_params: + raise rfc6749_errors.InvalidRequestError( + description=f"Duplicate {param} parameter.", request=request + ) + + if not self.request_validator.authenticate_client(request): + raise rfc6749_errors.InvalidClientError(request=request) + elif not hasattr(request.client, "client_id"): + raise NotImplementedError( + "Authenticate client must set the " + "request.client.client_id attribute " + "in authenticate_client." + ) + + # Ensure client is authorized use of this grant type + self.validate_grant_type(request) + + request.client_id = request.client_id or request.client.client_id + self.validate_scopes(request) + + for validator in self.custom_validators.post_token: + validator(request) + + def create_token_response( + self, request: common.Request, token_handler: Callable + ) -> tuple[dict, str, int]: + """Return token or error in json format. + + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :param token_handler: A token handler instance, for example of type + oauthlib.oauth2.BearerToken. + + If the access token request is valid and authorized, the + authorization server issues an access token and optional refresh + token as described in `Section 5.1`_. If the request failed client + authentication or is invalid, the authorization server returns an + error response as described in `Section 5.2`_. + .. _`Section 5.1`: https://tools.ietf.org/html/rfc6749#section-5.1 + .. _`Section 5.2`: https://tools.ietf.org/html/rfc6749#section-5.2 + """ + headers = self._get_default_headers() + try: + if self.request_validator.client_authentication_required( + request + ) and not self.request_validator.authenticate_client(request): + raise rfc6749_errors.InvalidClientError(request=request) + + self.validate_token_request(request) + + except rfc6749_errors.OAuth2Error as e: + headers.update(e.headers) + return headers, e.json, e.status_code + + token = token_handler.create_token(request, self.refresh_token) + + self.request_validator.save_token(token, request) + + return headers, json.dumps(token), 200 diff --git a/oauthlib/oauth2/rfc8628/request_validator.py b/oauthlib/oauth2/rfc8628/request_validator.py new file mode 100644 index 000000000..70ee7824e --- /dev/null +++ b/oauthlib/oauth2/rfc8628/request_validator.py @@ -0,0 +1,25 @@ +from oauthlib.oauth2 import RequestValidator as OAuth2RequestValidator + + +class RequestValidator(OAuth2RequestValidator): + def client_authentication_required(self, request, *args, **kwargs): + """Determine if client authentication is required for current request. + + According to the rfc8628, client authentication is required in the following cases: + - Device Authorization Request follows the, the client authentication requirements + of Section 3.2.1 of [RFC6749] apply to requests on this endpoint, which means that + confidential clients (those that have established client credentials) authenticate + in the same manner as when making requests to the token endpoint, and + public clients provide the "client_id" parameter to identify themselves, + see `Section 3.1`_. + + :param request: OAuthlib request. + :type request: oauthlib.common.Request + :rtype: True or False + + Method is used by: + - Device Authorization Request + + .. _`Section 3.1`: https://www.rfc-editor.org/rfc/rfc8628#section-3.1 + """ + return True diff --git a/oauthlib/openid/connect/core/endpoints/pre_configured.py b/oauthlib/openid/connect/core/endpoints/pre_configured.py index 8ce8bee67..17df516d1 100644 --- a/oauthlib/openid/connect/core/endpoints/pre_configured.py +++ b/oauthlib/openid/connect/core/endpoints/pre_configured.py @@ -5,34 +5,60 @@ This module is an implementation of various endpoints needed for providing OpenID Connect servers. """ + from oauthlib.oauth2.rfc6749.endpoints import ( - AuthorizationEndpoint, IntrospectEndpoint, ResourceEndpoint, - RevocationEndpoint, TokenEndpoint, + AuthorizationEndpoint, + IntrospectEndpoint, + ResourceEndpoint, + RevocationEndpoint, + TokenEndpoint, ) from oauthlib.oauth2.rfc6749.grant_types import ( AuthorizationCodeGrant as OAuth2AuthorizationCodeGrant, - ClientCredentialsGrant, ImplicitGrant as OAuth2ImplicitGrant, - RefreshTokenGrant, ResourceOwnerPasswordCredentialsGrant, + ClientCredentialsGrant, + ImplicitGrant as OAuth2ImplicitGrant, + ResourceOwnerPasswordCredentialsGrant, ) +from oauthlib.oauth2.rfc8628.grant_types import DeviceCodeGrant from oauthlib.oauth2.rfc6749.tokens import BearerToken -from ..grant_types import AuthorizationCodeGrant, HybridGrant, ImplicitGrant +from ..grant_types import ( + AuthorizationCodeGrant, + HybridGrant, + ImplicitGrant, + RefreshTokenGrant, +) from ..grant_types.dispatchers import ( - AuthorizationCodeGrantDispatcher, AuthorizationTokenGrantDispatcher, + AuthorizationCodeGrantDispatcher, + AuthorizationTokenGrantDispatcher, ImplicitTokenGrantDispatcher, ) from ..tokens import JWTToken from .userinfo import UserInfoEndpoint -class Server(AuthorizationEndpoint, IntrospectEndpoint, TokenEndpoint, - ResourceEndpoint, RevocationEndpoint, UserInfoEndpoint): - - """An all-in-one endpoint featuring all four major grant types.""" +class Server( + AuthorizationEndpoint, + IntrospectEndpoint, + TokenEndpoint, + ResourceEndpoint, + RevocationEndpoint, + UserInfoEndpoint, +): + """ + An all-in-one endpoint featuring all four major grant types + and extension grants. + """ - def __init__(self, request_validator, token_expires_in=None, - token_generator=None, refresh_token_generator=None, - *args, **kwargs): + def __init__( + self, + request_validator, + token_expires_in=None, + token_generator=None, + refresh_token_generator=None, + *args, + **kwargs, + ): """Construct a new all-grants-in-one server. :param request_validator: An implementation of @@ -48,50 +74,66 @@ def __init__(self, request_validator, token_expires_in=None, """ self.auth_grant = OAuth2AuthorizationCodeGrant(request_validator) self.implicit_grant = OAuth2ImplicitGrant(request_validator) - self.password_grant = ResourceOwnerPasswordCredentialsGrant( - request_validator) + self.password_grant = ResourceOwnerPasswordCredentialsGrant(request_validator) self.credentials_grant = ClientCredentialsGrant(request_validator) self.refresh_grant = RefreshTokenGrant(request_validator) self.openid_connect_auth = AuthorizationCodeGrant(request_validator) self.openid_connect_implicit = ImplicitGrant(request_validator) self.openid_connect_hybrid = HybridGrant(request_validator) + self.device_code_grant = DeviceCodeGrant(request_validator, **kwargs) - self.bearer = BearerToken(request_validator, token_generator, - token_expires_in, refresh_token_generator) + self.bearer = BearerToken( + request_validator, token_generator, token_expires_in, refresh_token_generator + ) - self.jwt = JWTToken(request_validator, token_generator, - token_expires_in, refresh_token_generator) + self.jwt = JWTToken( + request_validator, token_generator, token_expires_in, refresh_token_generator + ) - self.auth_grant_choice = AuthorizationCodeGrantDispatcher(default_grant=self.auth_grant, oidc_grant=self.openid_connect_auth) - self.implicit_grant_choice = ImplicitTokenGrantDispatcher(default_grant=self.implicit_grant, oidc_grant=self.openid_connect_implicit) + self.auth_grant_choice = AuthorizationCodeGrantDispatcher( + default_grant=self.auth_grant, oidc_grant=self.openid_connect_auth + ) + self.implicit_grant_choice = ImplicitTokenGrantDispatcher( + default_grant=self.implicit_grant, oidc_grant=self.openid_connect_implicit + ) # See http://openid.net/specs/oauth-v2-multiple-response-types-1_0.html#Combinations for valid combinations # internally our AuthorizationEndpoint will ensure they can appear in any order for any valid combination - AuthorizationEndpoint.__init__(self, default_response_type='code', - response_types={ - 'code': self.auth_grant_choice, - 'token': self.implicit_grant_choice, - 'id_token': self.openid_connect_implicit, - 'id_token token': self.openid_connect_implicit, - 'code token': self.openid_connect_hybrid, - 'code id_token': self.openid_connect_hybrid, - 'code id_token token': self.openid_connect_hybrid, - 'none': self.auth_grant - }, - default_token_type=self.bearer) + AuthorizationEndpoint.__init__( + self, + default_response_type="code", + response_types={ + "code": self.auth_grant_choice, + "token": self.implicit_grant_choice, + "id_token": self.openid_connect_implicit, + "id_token token": self.openid_connect_implicit, + "code token": self.openid_connect_hybrid, + "code id_token": self.openid_connect_hybrid, + "code id_token token": self.openid_connect_hybrid, + "none": self.auth_grant, + }, + default_token_type=self.bearer, + ) - self.token_grant_choice = AuthorizationTokenGrantDispatcher(request_validator, default_grant=self.auth_grant, oidc_grant=self.openid_connect_auth) + self.token_grant_choice = AuthorizationTokenGrantDispatcher( + request_validator, default_grant=self.auth_grant, oidc_grant=self.openid_connect_auth + ) - TokenEndpoint.__init__(self, default_grant_type='authorization_code', - grant_types={ - 'authorization_code': self.token_grant_choice, - 'password': self.password_grant, - 'client_credentials': self.credentials_grant, - 'refresh_token': self.refresh_grant, - }, - default_token_type=self.bearer) - ResourceEndpoint.__init__(self, default_token='Bearer', - token_types={'Bearer': self.bearer, 'JWT': self.jwt}) + TokenEndpoint.__init__( + self, + default_grant_type="authorization_code", + grant_types={ + "authorization_code": self.token_grant_choice, + "password": self.password_grant, + "client_credentials": self.credentials_grant, + "refresh_token": self.refresh_grant, + "urn:ietf:params:oauth:grant-type:device_code": self.device_code_grant, + }, + default_token_type=self.bearer, + ) + ResourceEndpoint.__init__( + self, default_token="Bearer", token_types={"Bearer": self.bearer, "JWT": self.jwt} + ) RevocationEndpoint.__init__(self, request_validator) IntrospectEndpoint.__init__(self, request_validator) UserInfoEndpoint.__init__(self, request_validator) diff --git a/oauthlib/openid/connect/core/exceptions.py b/oauthlib/openid/connect/core/exceptions.py index 099b84e2d..291cf1377 100644 --- a/oauthlib/openid/connect/core/exceptions.py +++ b/oauthlib/openid/connect/core/exceptions.py @@ -5,6 +5,9 @@ Error used both by OAuth 2 clients and providers to represent the spec defined error responses for all four core grant types. """ +import inspect +import sys + from oauthlib.oauth2.rfc6749.errors import FatalClientError, OAuth2Error @@ -72,8 +75,8 @@ class InvalidRequestURI(OpenIDClientError): contains invalid data. """ error = 'invalid_request_uri' - description = 'The request_uri in the Authorization Request returns an ' \ - 'error or contains invalid data.' + description = ('The request_uri in the Authorization Request returns an ' + 'error or contains invalid data.') class InvalidRequestObject(OpenIDClientError): @@ -137,8 +140,6 @@ class InsufficientScopeError(OAuth2Error): def raise_from_error(error, params=None): - import inspect - import sys kwargs = { 'description': params.get('error_description'), 'uri': params.get('error_uri'), diff --git a/oauthlib/openid/connect/core/grant_types/base.py b/oauthlib/openid/connect/core/grant_types/base.py index 33411dad7..29d583eb1 100644 --- a/oauthlib/openid/connect/core/grant_types/base.py +++ b/oauthlib/openid/connect/core/grant_types/base.py @@ -310,11 +310,15 @@ def openid_authorization_validator(self, request): msg = "Session user does not match client supplied user." raise LoginRequired(request=request, description=msg) + ui_locales = request.ui_locales if request.ui_locales else [] + if hasattr(ui_locales, 'split'): + ui_locales = ui_locales.strip().split() + request_info = { 'display': request.display, 'nonce': request.nonce, 'prompt': prompt, - 'ui_locales': request.ui_locales.split() if request.ui_locales else [], + 'ui_locales': ui_locales, 'id_token_hint': request.id_token_hint, 'login_hint': request.login_hint, 'claims': request.claims diff --git a/oauthlib/openid/connect/core/grant_types/dispatchers.py b/oauthlib/openid/connect/core/grant_types/dispatchers.py index 5aa7d4698..7e0739684 100644 --- a/oauthlib/openid/connect/core/grant_types/dispatchers.py +++ b/oauthlib/openid/connect/core/grant_types/dispatchers.py @@ -80,9 +80,9 @@ def _handler_for_request(self, request): handler = self.default_grant scopes = () parameters = dict(request.decoded_body) - client_id = parameters.get('client_id', None) - code = parameters.get('code', None) - redirect_uri = parameters.get('redirect_uri', None) + client_id = parameters.get('client_id') + code = parameters.get('code') + redirect_uri = parameters.get('redirect_uri') # If code is not present fallback to `default_grant` which will # raise an error for the missing `code` in `create_token_response` step. diff --git a/oauthlib/openid/connect/core/grant_types/hybrid.py b/oauthlib/openid/connect/core/grant_types/hybrid.py index 7cb0758b8..9c1fc702f 100644 --- a/oauthlib/openid/connect/core/grant_types/hybrid.py +++ b/oauthlib/openid/connect/core/grant_types/hybrid.py @@ -54,10 +54,9 @@ def openid_authorization_validator(self, request): # Token. Sufficient entropy MUST be present in the `nonce` # values used to prevent attackers from guessing values. For # implementation notes, see Section 15.5.2. - if request.response_type in ["code id_token", "code id_token token"]: - if not request.nonce: - raise InvalidRequestError( - request=request, - description='Request is missing mandatory nonce parameter.' - ) + if request.response_type in ["code id_token", "code id_token token"] and not request.nonce: + raise InvalidRequestError( + request=request, + description='Request is missing mandatory nonce parameter.' + ) return request_info diff --git a/oauthlib/openid/connect/core/request_validator.py b/oauthlib/openid/connect/core/request_validator.py index 47c4cd940..e3cea79b7 100644 --- a/oauthlib/openid/connect/core/request_validator.py +++ b/oauthlib/openid/connect/core/request_validator.py @@ -143,7 +143,7 @@ def finalize_id_token(self, id_token, token, token_handler, request): Token MUST NOT be accepted by the RP when performing authentication with the OP. - Additionals claims must be added, note that `request.scope` + Additional claims must be added, note that `request.scope` should be used to determine the list of claims. More information can be found at `OpenID Connect Core#Claims`_ diff --git a/oauthlib/openid/connect/core/tokens.py b/oauthlib/openid/connect/core/tokens.py index 936ab52e3..3ab354924 100644 --- a/oauthlib/openid/connect/core/tokens.py +++ b/oauthlib/openid/connect/core/tokens.py @@ -27,10 +27,7 @@ def __init__(self, request_validator=None, token_generator=None, def create_token(self, request, refresh_token=False): """Create a JWT Token, using requestvalidator method.""" - if callable(self.expires_in): - expires_in = self.expires_in(request) - else: - expires_in = self.expires_in + expires_in = self.expires_in(request) if callable(self.expires_in) else self.expires_in request.expires_in = expires_in diff --git a/oauthlib/signals.py b/oauthlib/signals.py index 8fd347a5c..9538d098c 100644 --- a/oauthlib/signals.py +++ b/oauthlib/signals.py @@ -7,7 +7,7 @@ try: from blinker import Namespace signals_available = True -except ImportError: # noqa +except ImportError: class Namespace: def signal(self, name, doc=None): return _FakeSignal(name, doc) @@ -26,7 +26,8 @@ def _fail(self, *args, **kwargs): raise RuntimeError('signalling support is unavailable ' 'because the blinker library is ' 'not installed.') - send = lambda *a, **kw: None + def send(*a, **kw): + return None connect = disconnect = has_receivers_for = receivers_for = \ temporarily_connected_to = connected_to = _fail del _fail diff --git a/oauthlib/uri_validate.py b/oauthlib/uri_validate.py index a6fe0fb23..69d2c9506 100644 --- a/oauthlib/uri_validate.py +++ b/oauthlib/uri_validate.py @@ -174,8 +174,7 @@ URI_reference = r"^(?: %(URI)s | %(relative_ref)s )$" % locals() # absolute-URI = scheme ":" hier-part [ "?" query ] -absolute_URI = r"^(?: %(scheme)s : %(hier_part)s (?: \? %(query)s )? )$" % locals( -) +absolute_URI = r"^(?: %(scheme)s : %(hier_part)s (?: \? %(query)s )? )$" % locals() # noqa: N816 def is_uri(uri): diff --git a/requirements-test.txt b/requirements-test.txt index 6d8d6e9dc..521036a4a 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,3 +1,4 @@ -r requirements.txt pytest>=4.0 pytest-cov>=2.6 +pytest-subtests diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 000000000..ec45dd4b7 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,113 @@ +# NOTE: You have to use single-quoted strings in TOML for regular expressions. +# It's the equivalent of r-strings in Python. Multiline strings are treated as +# verbose regular expressions by Black. Use [ ] to denote a significant space +# character. + +# When switching from ruff.toml to pyproject.toml, use the section names that +# start with [tool.ruff + +# [tool.ruff] +lint.select = [ + "A", # flake8-builtins + "AIR", # Airflow + "ASYNC", # flake8-async + "BLE", # flake8-blind-except + "C4", # flake8-comprehensions + "C90", # McCabe cyclomatic complexity + "CPY", # flake8-copyright + "DJ", # flake8-django + "E", # pycodestyle + "EXE", # flake8-executable + "F", # Pyflakes + "FA", # flake8-future-annotations + "FLY", # flynt + "ICN", # flake8-import-conventions + "INP", # flake8-no-pep420 + "INT", # flake8-gettext + "ISC", # flake8-implicit-str-concat + "N", # pep8-naming + "NPY", # NumPy-specific rules + "PD", # pandas-vet + "PERF", # Perflint + "PGH", # pygrep-hooks + "PIE", # flake8-pie + "PL", # Pylint + "PT", # flake8-pytest-style + "PYI", # flake8-pyi + "RSE", # flake8-raise + "RUF", # Ruff-specific rules + "S", # flake8-bandit + "SIM", # flake8-simplify + "SLOT", # flake8-slots + "T10", # flake8-debugger + "T20", # flake8-print + "TCH", # flake8-type-checking + "W", # pycodestyle + "YTT", # flake8-2020 + # "ANN", # flake8-annotations + # "ARG", # flake8-unused-arguments + # "B", # flake8-bugbear + # "COM", # flake8-commas + # "D", # pydocstyle + # "DTZ", # flake8-datetimez + # "EM", # flake8-errmsg + # "ERA", # eradicate + # "FBT", # flake8-boolean-trap + # "FIX", # flake8-fixme + # "G", # flake8-logging-format + # "I", # isort + # "PTH", # flake8-use-pathlib + # "Q", # flake8-quotes + # "RET", # flake8-return + # "SLF", # flake8-self + # "TD", # flake8-todos + # "TID", # flake8-tidy-imports + # "TRY", # tryceratops + # "UP", # pyupgrade +] +lint.ignore = [ + "F401", + "F403", + "F405", + "F841", + "FLY002", + "N806", + "N818", + "PERF401", + "PLW2901", + "PT008", + "RSE102", + "RUF005", + "RUF012", + "RUF013", + "S105", + "S106", + "S107", +] +line-length = 255 +target-version = "py37" + +# [tool.ruff.mccabe] +[lint.mccabe] +max-complexity = 24 # default is 10 + +# [tool.ruff.per-file-ignores] +[lint.per-file-ignores] +"docs/conf.py" = ["A001", "INP001"] +"oauthlib/oauth2/rfc6749/clients/base.py" = ["E722"] +"oauthlib/oauth2/rfc6749/endpoints/base.py" = ["BLE001"] +"oauthlib/openid/connect/core/grant_types/base.py" = ["BLE001"] +"tests/*" = ["PT009", "PT027", "S101"] +"oauthlib/oauth1/rfc5849/endpoints/resource.py" = ["A005"] +"oauthlib/oauth2/rfc6749/endpoints/resource.py" = ["A005"] +"oauthlib/oauth2/rfc6749/endpoints/token.py" = ["A005"] +"oauthlib/oauth2/rfc6749/parameters.py" = ["PLC0206"] +"oauthlib/oauth2/rfc6749/tokens.py" = ["RUF023"] +"oauthlib/openid/connect/core/tokens.py" = ["RUF023"] + +# [tool.ruff.pylint] +[lint.pylint] +allow-magic-value-types = ["int", "str"] +max-args = 16 # default is 5 +max-branches = 24 # default is 12 +max-statements = 56 # default is 50 diff --git a/setup.cfg b/setup.cfg index ca59291b1..286d6cbef 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [metadata] -license_file = LICENSE +license_files = LICENSE [isort] combine_as_imports = true diff --git a/setup.py b/setup.py index 0192458bb..a35de99b8 100755 --- a/setup.py +++ b/setup.py @@ -1,8 +1,9 @@ +#!/usr/bin/env python3 # Hack because logging + setuptools sucks. -try: +import contextlib +with contextlib.suppress(ImportError): import multiprocessing -except ImportError: - pass + from os.path import dirname, join @@ -27,14 +28,13 @@ def fread(fn): long_description=fread('README.rst'), long_description_content_type='text/x-rst', author='The OAuthlib Community', - author_email='idan@gazit.me', - maintainer='Ib Lundgren', - maintainer_email='ib.lundgren@gmail.com', + maintainer='Jonathan Huot', + maintainer_email='jonathan.huot@gmail.com', url='https://github.com/oauthlib/oauthlib', platforms='any', - license='BSD', - packages=find_packages(exclude=('docs', 'tests', 'tests.*')), - python_requires='>=3.6', + license='BSD-3-Clause', + packages=find_packages(exclude=('docs', 'examples', 'tests', 'tests.*')), + python_requires='>=3.8', extras_require={ 'rsa': rsa_require, 'signedtoken': signedtoken_require, @@ -44,18 +44,17 @@ def fread(fn): 'Development Status :: 5 - Production/Stable', 'Environment :: Web Environment', 'Intended Audience :: Developers', - 'License :: OSI Approved', - 'License :: OSI Approved :: BSD License', 'Operating System :: MacOS', 'Operating System :: POSIX', 'Operating System :: POSIX :: Linux', 'Programming Language :: Python', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', + 'Programming Language :: Python :: 3.13', 'Programming Language :: Python :: 3 :: Only', 'Programming Language :: Python :: Implementation', 'Programming Language :: Python :: Implementation :: CPython', diff --git a/tests/oauth1/rfc5849/endpoints/test_base.py b/tests/oauth1/rfc5849/endpoints/test_base.py index e87f359ba..792aaccf4 100644 --- a/tests/oauth1/rfc5849/endpoints/test_base.py +++ b/tests/oauth1/rfc5849/endpoints/test_base.py @@ -304,7 +304,7 @@ def dummy_access_token(self): def validate_timestamp_and_nonce(self, client_key, timestamp, nonce, request, request_token=None, access_token=None): resource_owner_key = request_token if request_token else access_token - return not (client_key, nonce, timestamp, resource_owner_key) in self.nonces + return (client_key, nonce, timestamp, resource_owner_key) not in self.nonces def validate_client_key(self, client_key): return client_key in self.clients diff --git a/tests/oauth1/rfc5849/test_signatures.py b/tests/oauth1/rfc5849/test_signatures.py index 2d4735eaf..2c4ce3df9 100644 --- a/tests/oauth1/rfc5849/test_signatures.py +++ b/tests/oauth1/rfc5849/test_signatures.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +from jwt import InvalidKeyError from oauthlib.oauth1.rfc5849.signature import ( base_string_uri, collect_parameters, normalize_parameters, sign_hmac_sha1_with_client, sign_hmac_sha256_with_client, @@ -82,12 +83,13 @@ class SignatureTests(TestCase): # ==== Example test vector ======================================= - eg_signature_base_string =\ - 'POST&http%3A%2F%2Fexample.com%2Frequest&a2%3Dr%2520b%26a3%3D2%2520q' \ - '%26a3%3Da%26b5%3D%253D%25253D%26c%2540%3D%26c2%3D%26oauth_consumer_' \ - 'key%3D9djdj82h48djs9d2%26oauth_nonce%3D7d8f3e4a%26oauth_signature_m' \ - 'ethod%3DHMAC-SHA1%26oauth_timestamp%3D137131201%26oauth_token%3Dkkk' \ + eg_signature_base_string = ( + 'POST&http%3A%2F%2Fexample.com%2Frequest&a2%3Dr%2520b%26a3%3D2%2520q' + '%26a3%3Da%26b5%3D%253D%25253D%26c%2540%3D%26c2%3D%26oauth_consumer_' + 'key%3D9djdj82h48djs9d2%26oauth_nonce%3D7d8f3e4a%26oauth_signature_m' + 'ethod%3DHMAC-SHA1%26oauth_timestamp%3D137131201%26oauth_token%3Dkkk' '9d7dh3k39sjv7' + ) # The _signature base string_ above is copied from the end of # RFC 5849 section 3.4.1.1. @@ -101,11 +103,11 @@ class SignatureTests(TestCase): eg_base_string_uri = 'http://example.com/request' - eg_normalized_parameters =\ - 'a2=r%20b&a3=2%20q&a3=a&b5=%3D%253D&c%40=&c2=&oauth_consumer_key=9dj' \ - 'dj82h48djs9d2&oauth_nonce=7d8f3e4a&oauth_signature_method=HMAC-SHA1' \ + eg_normalized_parameters = ( + 'a2=r%20b&a3=2%20q&a3=a&b5=%3D%253D&c%40=&c2=&oauth_consumer_key=9dj' + 'dj82h48djs9d2&oauth_nonce=7d8f3e4a&oauth_signature_method=HMAC-SHA1' '&oauth_timestamp=137131201&oauth_token=kkk9d7dh3k39sjv7' - + ) # The above _normalized parameters_ corresponds to the parameters below. # # The parameters below is copied from the table at the end of @@ -133,12 +135,12 @@ class SignatureTests(TestCase): eg_body = 'c2&a3=2+q' - eg_authorization_header =\ - 'OAuth realm="Example", oauth_consumer_key="9djdj82h48djs9d2",' \ - ' oauth_token="kkk9d7dh3k39sjv7", oauth_signature_method="HMAC-SHA1",' \ - ' oauth_timestamp="137131201", oauth_nonce="7d8f3e4a",' \ + eg_authorization_header = ( + 'OAuth realm="Example", oauth_consumer_key="9djdj82h48djs9d2",' + ' oauth_token="kkk9d7dh3k39sjv7", oauth_signature_method="HMAC-SHA1",' + ' oauth_timestamp="137131201", oauth_nonce="7d8f3e4a",' ' oauth_signature="djosJKDKJSD8743243%2Fjdk33klY%3D"' - + ) # ==== Signature base string calculating function tests ========== def test_signature_base_string(self): @@ -465,10 +467,10 @@ def test_normalize_parameters(self): expected_signature_hmac_sha256 = \ 'wdfdHUKXHbOnOGZP8WFAWMSAmWzN3EVBWWgXGlC/Eo4=' - expected_signature_hmac_sha512 = \ - 'u/vlyZFDxOWOZ9UUXwRBJHvq8/T4jCA74ocRmn2ECnjUBTAeJiZIRU8hDTjS88Tz' \ + expected_signature_hmac_sha512 = ( + 'u/vlyZFDxOWOZ9UUXwRBJHvq8/T4jCA74ocRmn2ECnjUBTAeJiZIRU8hDTjS88Tz' '1fGONffMpdZxUkUTW3k1kg==' - + ) def test_sign_hmac_sha1_with_client(self): """ Test sign and verify with HMAC-SHA1. @@ -632,21 +634,21 @@ def test_hmac_false_positives(self): # Note: the "echo -n" is needed to remove the last newline character, which # most text editors will add. - expected_signature_rsa_sha1 = \ - 'mFY2KOEnlYWsTvUA+5kxuBIcvBYXu+ljw9ttVJQxKduMueGSVPCB1tK1PlqVLK738' \ - 'HK0t19ecBJfb6rMxUwrriw+MlBO+jpojkZIWccw1J4cAb4qu4M81DbpUAq4j/1w/Q' \ + expected_signature_rsa_sha1 = ( + 'mFY2KOEnlYWsTvUA+5kxuBIcvBYXu+ljw9ttVJQxKduMueGSVPCB1tK1PlqVLK738' + 'HK0t19ecBJfb6rMxUwrriw+MlBO+jpojkZIWccw1J4cAb4qu4M81DbpUAq4j/1w/Q' 'yTR4TWCODlEfN7Zfgy8+pf+TjiXfIwRC1jEWbuL1E=' - - expected_signature_rsa_sha256 = \ - 'jqKl6m0WS69tiVJV8ZQ6aQEfJqISoZkiPBXRv6Al2+iFSaDpfeXjYm+Hbx6m1azR' \ - 'drZ/35PM3cvuid3LwW/siAkzb0xQcGnTyAPH8YcGWzmnKGY7LsB7fkqThchNxvRK' \ + ) + expected_signature_rsa_sha256 = ( + 'jqKl6m0WS69tiVJV8ZQ6aQEfJqISoZkiPBXRv6Al2+iFSaDpfeXjYm+Hbx6m1azR' + 'drZ/35PM3cvuid3LwW/siAkzb0xQcGnTyAPH8YcGWzmnKGY7LsB7fkqThchNxvRK' '/N7s9M1WMnfZZ+1dQbbwtTs1TG1+iexUcV7r3M7Heec=' - - expected_signature_rsa_sha512 = \ - 'jL1CnjlsNd25qoZVHZ2oJft47IRYTjpF5CvCUjL3LY0NTnbEeVhE4amWXUFBe9GL' \ - 'DWdUh/79ZWNOrCirBFIP26cHLApjYdt4ZG7EVK0/GubS2v8wT1QPRsog8zyiMZkm' \ + ) + expected_signature_rsa_sha512 = ( + 'jL1CnjlsNd25qoZVHZ2oJft47IRYTjpF5CvCUjL3LY0NTnbEeVhE4amWXUFBe9GL' + 'DWdUh/79ZWNOrCirBFIP26cHLApjYdt4ZG7EVK0/GubS2v8wT1QPRsog8zyiMZkm' 'g4JXdWCGXG8YRvRJTg+QKhXuXwS6TcMNakrgzgFIVhA=' - + ) def test_sign_rsa_sha1_with_client(self): """ Test sign and verify with RSA-SHA1. @@ -764,12 +766,17 @@ def test_rsa_bad_keys(self): # Signing needs a private key - for bad_value in [None, '', 'foobar']: + for bad_value in [None, '']: self.assertRaises(ValueError, sign_rsa_sha1_with_client, self.eg_signature_base_string, MockClient(rsa_key=bad_value)) + self.assertRaises(InvalidKeyError, + sign_rsa_sha1_with_client, + self.eg_signature_base_string, + MockClient(rsa_key='foobar')) + self.assertRaises(AttributeError, sign_rsa_sha1_with_client, self.eg_signature_base_string, diff --git a/tests/oauth1/rfc5849/test_utils.py b/tests/oauth1/rfc5849/test_utils.py index 013c71a91..221289081 100644 --- a/tests/oauth1/rfc5849/test_utils.py +++ b/tests/oauth1/rfc5849/test_utils.py @@ -53,11 +53,11 @@ def test_filter_params(self): # The following is an isolated test function used to test the filter_params decorator. @filter_params def special_test_function(params, realm=None): - """ I am a special test function """ + """I am a special test function""" return 'OAuth ' + ','.join(['='.join([k, v]) for k, v in params]) # check that the docstring got through - self.assertEqual(special_test_function.__doc__, " I am a special test function ") + self.assertEqual(special_test_function.__doc__, "I am a special test function") # Check that the decorator filtering works as per design. # Any param that does not start with 'oauth' diff --git a/tests/oauth2/rfc6749/clients/test_base.py b/tests/oauth2/rfc6749/clients/test_base.py index 70a22834c..b0970f2d4 100644 --- a/tests/oauth2/rfc6749/clients/test_base.py +++ b/tests/oauth2/rfc6749/clients/test_base.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- import datetime +import json +from unittest.mock import patch from oauthlib import common from oauthlib.oauth2 import Client, InsecureTransportError, TokenExpiredError @@ -302,31 +304,6 @@ def test_prepare_refresh_token_request(self): self.assertEqual(h, {'Content-Type': 'application/x-www-form-urlencoded'}) self.assertFormBodyEqual(b, 'grant_type=refresh_token&scope={}&refresh_token={}'.format(scope, token)) - def test_parse_token_response_invalid_expires_at(self): - token_json = ('{ "access_token":"2YotnFZFEjr1zCsicMWpAA",' - ' "token_type":"example",' - ' "expires_at":"2006-01-02T15:04:05Z",' - ' "scope":"/profile",' - ' "example_parameter":"example_value"}') - token = { - "access_token": "2YotnFZFEjr1zCsicMWpAA", - "token_type": "example", - "expires_at": "2006-01-02T15:04:05Z", - "scope": ["/profile"], - "example_parameter": "example_value" - } - - client = Client(self.client_id) - - # Parse code and state - response = client.parse_request_body_response(token_json, scope=["/profile"]) - self.assertEqual(response, token) - self.assertEqual(None, client._expires_at) - self.assertEqual(client.access_token, response.get("access_token")) - self.assertEqual(client.refresh_token, response.get("refresh_token")) - self.assertEqual(client.token_type, response.get("token_type")) - - def test_create_code_verifier_min_length(self): client = Client(self.client_id) length = 43 @@ -339,6 +316,12 @@ def test_create_code_verifier_max_length(self): code_verifier = client.create_code_verifier(length=length) self.assertEqual(client.code_verifier, code_verifier) + def test_create_code_verifier_length(self): + client = Client(self.client_id) + length = 96 + code_verifier = client.create_code_verifier(length=length) + self.assertEqual(len(code_verifier), length) + def test_create_code_challenge_plain(self): client = Client(self.client_id) code_verifier = client.create_code_verifier(length=128) @@ -353,3 +336,43 @@ def test_create_code_challenge_s256(self): code_verifier = client.create_code_verifier(length=128) code_challenge_s256 = client.create_code_challenge(code_verifier=code_verifier, code_challenge_method='S256') self.assertEqual(code_challenge_s256, client.code_challenge) + + def test_parse_token_response_expires_at_types(self): + for title, fieldjson, expected, generated in [ + ('int', 1661185148, 1661185148, 1661185148), + ('float', 1661185148.6437678, 1661185148.6437678, 1661185148.6437678), + ('str', "\"2006-01-02T15:04:05Z\"", "2006-01-02T15:04:05Z", None), + ('str-as-int', "\"1661185148\"", 1661185148, 1661185148), + ('str-as-float', "\"1661185148.42\"", 1661185148.42, 1661185148.42), + ]: + with self.subTest(msg=title): + token_json = ('{{ "access_token":"2YotnFZFEjr1zCsicMWpAA",' + ' "token_type":"example",' + ' "expires_at":{expires_at},' + ' "scope":"/profile",' + ' "example_parameter":"example_value"}}'.format(expires_at=fieldjson)) + + client = Client(self.client_id) + response = client.parse_request_body_response(token_json, scope=["/profile"]) + + self.assertEqual(response['expires_at'], expected, "response attribute wrong") + self.assertEqual(client.expires_at, expected, "client attribute wrong") + if generated: + self.assertEqual(client._expires_at, generated, "internal expiration wrong") + + @patch('time.time') + def test_parse_token_response_generated_expires_at_is_int(self, t): + t.return_value = 1661185148.6437678 + expected_expires_at = round(t.return_value) + 3600 + token_json = ('{ "access_token":"2YotnFZFEjr1zCsicMWpAA",' + ' "token_type":"example",' + ' "expires_in":3600,' + ' "scope":"/profile",' + ' "example_parameter":"example_value"}') + + client = Client(self.client_id) + + response = client.parse_request_body_response(token_json, scope=["/profile"]) + + self.assertEqual(response['expires_at'], expected_expires_at) + self.assertEqual(client._expires_at, expected_expires_at) diff --git a/tests/oauth2/rfc6749/clients/test_service_application.py b/tests/oauth2/rfc6749/clients/test_service_application.py index b97d8554e..84361d8bc 100644 --- a/tests/oauth2/rfc6749/clients/test_service_application.py +++ b/tests/oauth2/rfc6749/clients/test_service_application.py @@ -166,7 +166,7 @@ def test_request_body_no_initial_private_key(self, t): @patch('time.time') def test_parse_token_response(self, t): t.return_value = time() - self.token['expires_at'] = self.token['expires_in'] + t.return_value + self.token['expires_at'] = self.token['expires_in'] + round(t.return_value) client = ServiceApplicationClient(self.client_id) diff --git a/tests/oauth2/rfc6749/clients/test_web_application.py b/tests/oauth2/rfc6749/clients/test_web_application.py index 7a7112151..f9a4c9d11 100644 --- a/tests/oauth2/rfc6749/clients/test_web_application.py +++ b/tests/oauth2/rfc6749/clients/test_web_application.py @@ -252,18 +252,34 @@ def test_prepare_request_body(self): self.assertEqual(r4b_params['client_id'], self.client_id) # scenario Warnings - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always") # catch all - - # warning1 - raise a DeprecationWarning if a `client_id` is submitted - rWarnings1 = client.prepare_request_body(client_id=self.client_id) - self.assertEqual(len(w), 1) - self.assertIsInstance(w[0].message, DeprecationWarning) - + # warning1 - raise a DeprecationWarning if a `client_id` is submitted + with self.assertWarns(DeprecationWarning): + client.prepare_request_body(client_id=self.client_id) # testing the exact warning message in Python2&Python3 is a pain # scenario Exceptions # exception1 - raise a ValueError if the a different `client_id` is submitted - with self.assertRaises(ValueError) as cm: + with self.assertWarns(DeprecationWarning), self.assertRaises(ValueError): client.prepare_request_body(client_id='different_client_id') # testing the exact exception message in Python2&Python3 is a pain + + def test_expires_in_as_str(self): + """ + see regression issue #906 + """ + + client = WebApplicationClient( + client_id="dummy", + token={"access_token": "xyz", "expires_in": "3600"} + ) + self.assertIsNotNone(client) + client = WebApplicationClient( + client_id="dummy", + token={"access_token": "xyz", "expires_in": 3600} + ) + self.assertIsNotNone(client) + client = WebApplicationClient( + client_id="dummy", + token={"access_token": "xyz", "expires_in": 3600.12} + ) + self.assertIsNotNone(client) diff --git a/tests/oauth2/rfc6749/endpoints/test_metadata.py b/tests/oauth2/rfc6749/endpoints/test_metadata.py index 1f5b91210..c36d94bfc 100644 --- a/tests/oauth2/rfc6749/endpoints/test_metadata.py +++ b/tests/oauth2/rfc6749/endpoints/test_metadata.py @@ -20,8 +20,8 @@ def test_openid_oauth2_preconfigured(self): "introspection_endpoint": "https://foo.bar/introspect", "token_endpoint": "https://foo.bar/token" } - from oauthlib.oauth2 import Server as OAuth2Server - from oauthlib.openid import Server as OpenIDServer + from oauthlib.oauth2 import Server as OAuth2Server # noqa: PLC0415 + from oauthlib.openid import Server as OpenIDServer # noqa: PLC0415 endpoint = OAuth2Server(None) metadata = MetadataEndpoint([endpoint], default_claims) @@ -98,6 +98,7 @@ def test_server_metadata(self): "scopes_supported": ["email", "profile"], "grant_types_supported": [ "authorization_code", + "urn:ietf:params:oauth:grant-type:device_code", "password", "client_credentials", "refresh_token", @@ -130,8 +131,8 @@ def test_server_metadata(self): } def sort_list(claims): - for k in claims.keys(): - claims[k] = sorted(claims[k]) + for key, value in claims.items(): + claims[key] = sorted(value) sort_list(metadata.claims) sort_list(expected_claims) diff --git a/tests/oauth2/rfc6749/grant_types/test_refresh_token.py b/tests/oauth2/rfc6749/grant_types/test_refresh_token.py index 581f2a4d6..f963444a1 100644 --- a/tests/oauth2/rfc6749/grant_types/test_refresh_token.py +++ b/tests/oauth2/rfc6749/grant_types/test_refresh_token.py @@ -130,6 +130,22 @@ def test_authentication_required(self): self.request) self.mock_validator.client_authentication_required.assert_called_once_with(self.request) + + def test_authentication_required_populate_client_id(self): + """ + Make sure that request.client_id is populated from + request.client.client_id if None. + + """ + self.mock_validator.client_authentication_required.return_value = True + self.mock_validator.authenticate_client.return_value = True + # self.mock_validator.authenticate_client_id.return_value = False + # self.request.code = 'waffles' + self.request.client_id = None + self.request.client.client_id = 'foobar' + self.auth.validate_token_request(self.request) + self.request.client_id = 'foobar' + def test_invalid_grant_type(self): self.request.grant_type = 'wrong_type' self.assertRaises(errors.UnsupportedGrantTypeError, @@ -168,7 +184,7 @@ def test_valid_token_request(self): # all ok but without request.scope del self.request.scope self.auth.validate_token_request(self.request) - self.assertEqual(self.request.scopes, 'foo bar baz'.split()) + self.assertEqual(self.request.scopes, ['foo', 'bar', 'baz']) # CORS diff --git a/tests/oauth2/rfc6749/test_parameters.py b/tests/oauth2/rfc6749/test_parameters.py index cd8c9e952..bd8a8b61a 100644 --- a/tests/oauth2/rfc6749/test_parameters.py +++ b/tests/oauth2/rfc6749/test_parameters.py @@ -302,3 +302,30 @@ def record_scope_change(sender, message, old, new): finally: signals.scope_changed.disconnect(record_scope_change) del os.environ['OAUTHLIB_RELAX_TOKEN_SCOPE'] + + + def test_parse_expires(self): + for title, arg, expected in [ + ('none', (None, None), (None, None, None)), + ('expires_in only', (3600, None), (3600, 4600, 4600)), + ('expires_in and expires_at', (3600, 200), (3600, 200, 200)), + ('expires_in and expires_at float', (3600, 200.42), (3600, 200.42, 200.42)), + ('expires_in and expires_at str-int', (3600, "200"), (3600, 200, 200)), + ('expires_in and expires_at str-float', (3600, "200.42"), (3600, 200.42, 200.42)), + ('expires_in float only', (3600.12, None), (3600, 4600, 4600)), + ('expires_in float and expires_at', (3600.12, 200), (3600, 200, 200)), + ('expires_in float and expires_at float', (3600.12, 200.42), (3600, 200.42, 200.42)), + ('expires_in float and expires_at str-int', (3600.12, "200"), (3600, 200, 200)), + ('expires_in float and expires_at str-float', (3600.12, "200.42"), (3600, 200.42, 200.42)), + ('expires_in str only', ("3600", None), (3600, 4600, 4600)), + ('expires_in str and expires_at', ("3600", 200), (3600, 200, 200)), + ('expires_in str and expires_at float', ("3600", 200.42), (3600, 200.42, 200.42)), + ('expires_in str and expires_at str-int', ("3600", "200"), (3600, 200, 200)), + ('expires_in str and expires_at str-float', ("3600", "200.42"), (3600, 200.42, 200.42)), + ]: + with self.subTest(msg=title): + params = { + "expires_in": arg[0], + "expires_at": arg[1] + } + self.assertEqual(expected, parse_expires(params)) diff --git a/tests/oauth2/rfc6749/test_tokens.py b/tests/oauth2/rfc6749/test_tokens.py index fa6b1c092..ec8efca4a 100644 --- a/tests/oauth2/rfc6749/test_tokens.py +++ b/tests/oauth2/rfc6749/test_tokens.py @@ -76,7 +76,7 @@ class TokenTest(TestCase): bearer_uri = 'http://server.example.com/resource?access_token=vF9dft4qmT' def _mocked_validate_bearer_token(self, token, scopes, request): - if not token: + if not token: # noqa: SIM103 return False return True diff --git a/tests/oauth2/rfc6749/test_utils.py b/tests/oauth2/rfc6749/test_utils.py index 329959192..8417fe567 100644 --- a/tests/oauth2/rfc6749/test_utils.py +++ b/tests/oauth2/rfc6749/test_utils.py @@ -78,7 +78,7 @@ def test_list_to_scope(self): for x in string_list: assert x in set_scope - self.assertRaises(ValueError, list_to_scope, object()) + self.assertRaises(ValueError, list_to_scope, object()) def test_scope_to_list(self): expected = ['foo', 'bar', 'baz'] diff --git a/tests/oauth2/rfc8628/endpoints/__init__.py b/tests/oauth2/rfc8628/endpoints/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/oauth2/rfc8628/endpoints/test_device_application_server.py b/tests/oauth2/rfc8628/endpoints/test_device_application_server.py new file mode 100644 index 000000000..f0436754a --- /dev/null +++ b/tests/oauth2/rfc8628/endpoints/test_device_application_server.py @@ -0,0 +1,26 @@ +import json +from unittest import TestCase, mock + +from oauthlib.common import Request, urlencode +from oauthlib.oauth2.rfc6749 import errors +from oauthlib.oauth2.rfc8628.endpoints.pre_configured import DeviceApplicationServer +from oauthlib.oauth2.rfc8628.request_validator import RequestValidator + + +def test_server_set_up_device_endpoint_instance_attributes_correctly(): + """ + Simple test that just instantiates DeviceApplicationServer + and asserts the important attributes are present + """ + validator = mock.MagicMock(spec=RequestValidator) + validator.get_default_redirect_uri.return_value = None + validator.get_code_challenge.return_value = None + + verification_uri = "test.com/device" + verification_uri_complete = "test.com/device?user_code=123" + device = DeviceApplicationServer(validator, verification_uri=verification_uri, verification_uri_complete=verification_uri_complete) + device_vars = vars(device) + assert device_vars["_verification_uri_complete"] == "test.com/device?user_code=123" + assert device_vars["_verification_uri"] == "test.com/device" + assert device_vars["_expires_in"] == 1800 + assert device_vars["_interval"] == 5 diff --git a/tests/oauth2/rfc8628/endpoints/test_error_responses.py b/tests/oauth2/rfc8628/endpoints/test_error_responses.py new file mode 100644 index 000000000..c799cc81a --- /dev/null +++ b/tests/oauth2/rfc8628/endpoints/test_error_responses.py @@ -0,0 +1,95 @@ +import json +from unittest import TestCase, mock + +from oauthlib.common import Request, urlencode +from oauthlib.oauth2.rfc6749 import errors +from oauthlib.oauth2.rfc8628.endpoints.pre_configured import DeviceApplicationServer +from oauthlib.oauth2.rfc8628.request_validator import RequestValidator + + +class ErrorResponseTest(TestCase): + def set_client(self, request): + request.client = mock.MagicMock() + request.client.client_id = "mocked" + return True + + def build_request(self, uri="https://example.com/device_authorize", client_id="foo"): + body = "" + if client_id: + body = f"client_id={client_id}" + return Request( + uri, + http_method="POST", + body=body, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + + def assert_request_raises(self, error, request): + """Test that the request fails similarly on the validation and response endpoint.""" + self.assertRaises( + error, + self.device.validate_device_authorization_request, + request, + ) + self.assertRaises( + error, + self.device.create_device_authorization_response, + uri=request.uri, + http_method=request.http_method, + body=request.body, + headers=request.headers, + ) + + def setUp(self): + self.validator = mock.MagicMock(spec=RequestValidator) + self.validator.get_default_redirect_uri.return_value = None + self.validator.get_code_challenge.return_value = None + self.device = DeviceApplicationServer(self.validator, "https://example.com/verify") + + def test_missing_client_id(self): + # Device code grant + request = self.build_request(client_id=None) + self.assert_request_raises(errors.MissingClientIdError, request) + + def test_empty_client_id(self): + # Device code grant + self.assertRaises( + errors.MissingClientIdError, + self.device.create_device_authorization_response, + "https://i.l/", + "POST", + "client_id=", + {"Content-Type": "application/x-www-form-urlencoded"}, + ) + + def test_invalid_client_id(self): + request = self.build_request(client_id="foo") + # Device code grant + self.validator.validate_client_id.return_value = False + self.assert_request_raises(errors.InvalidClientIdError, request) + + def test_duplicate_client_id(self): + request = self.build_request() + request.body = "client_id=foo&client_id=bar" + # Device code grant + self.validator.validate_client_id.return_value = False + self.assert_request_raises(errors.InvalidRequestFatalError, request) + + def test_unauthenticated_confidential_client(self): + self.validator.client_authentication_required.return_value = True + self.validator.authenticate_client.return_value = False + request = self.build_request() + self.assert_request_raises(errors.InvalidClientError, request) + + def test_unauthenticated_public_client(self): + self.validator.client_authentication_required.return_value = False + self.validator.authenticate_client_id.return_value = False + request = self.build_request() + self.assert_request_raises(errors.InvalidClientError, request) + + def test_duplicate_scope_parameter(self): + request = self.build_request() + request.body = "client_id=foo&scope=foo&scope=bar" + # Device code grant + self.validator.validate_client_id.return_value = False + self.assert_request_raises(errors.InvalidRequestFatalError, request) diff --git a/tests/oauth2/rfc8628/grant_types/__init__.py b/tests/oauth2/rfc8628/grant_types/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/oauth2/rfc8628/grant_types/test_device_code.py b/tests/oauth2/rfc8628/grant_types/test_device_code.py new file mode 100644 index 000000000..da0592f74 --- /dev/null +++ b/tests/oauth2/rfc8628/grant_types/test_device_code.py @@ -0,0 +1,172 @@ +import json +from unittest import mock +import pytest + +from oauthlib import common + +from oauthlib.oauth2.rfc8628.grant_types import DeviceCodeGrant +from oauthlib.oauth2.rfc6749.tokens import BearerToken + +def create_request(body: str = "") -> common.Request: + request = common.Request("http://a.b/path", body=body or None) + request.scopes = ("hello", "world") + request.expires_in = 1800 + request.client = "batman" + request.client_id = "abcdef" + request.code = "1234" + request.response_type = "code" + request.grant_type = "urn:ietf:params:oauth:grant-type:device_code" + request.redirect_uri = "https://a.b/" + return request + + +def create_device_code_grant(mock_validator: mock.MagicMock) -> DeviceCodeGrant: + return DeviceCodeGrant(request_validator=mock_validator) + + +def test_custom_auth_validators_unsupported(): + custom_validator = mock.Mock() + validator = mock.MagicMock() + + expected = ( + "DeviceCodeGrant does not " + "support authorization validators. Use token validators instead." + ) + with pytest.raises(ValueError, match=expected): + DeviceCodeGrant(validator, pre_auth=[custom_validator]) + + with pytest.raises(ValueError, match=expected): + DeviceCodeGrant(validator, post_auth=[custom_validator]) + + expected = "'tuple' object has no attribute 'append'" + auth = DeviceCodeGrant(validator) + with pytest.raises(AttributeError, match=expected): + auth.custom_validators.pre_auth.append(custom_validator) + + +def test_custom_pre_and_post_token_validators(): + client = mock.MagicMock() + + validator = mock.MagicMock() + pre_token_validator = mock.Mock() + post_token_validator = mock.Mock() + + request: common.Request = create_request() + request.client = client + + auth = DeviceCodeGrant(validator) + + auth.custom_validators.pre_token.append(pre_token_validator) + auth.custom_validators.post_token.append(post_token_validator) + + bearer = BearerToken(validator) + auth.create_token_response(request, bearer) + + pre_token_validator.assert_called() + post_token_validator.assert_called() + + +def test_create_token_response(): + validator = mock.MagicMock() + request: common.Request = create_request() + request.client = mock.Mock() + + auth = DeviceCodeGrant(validator) + + bearer = BearerToken(validator) + + headers, body, status_code = auth.create_token_response(request, bearer) + token = json.loads(body) + + assert headers == { + "Content-Type": "application/json", + "Cache-Control": "no-store", + "Pragma": "no-cache", + } + + # when a custom token generator callable isn't used + # the random generator is used as default for the access token + assert token == { + "access_token": mock.ANY, + "expires_in": 3600, + "token_type": "Bearer", + "scope": "hello world", + "refresh_token": mock.ANY, + } + + assert status_code == 200 + + validator.save_token.assert_called_once() + + +def test_invalid_client_error(): + validator = mock.MagicMock() + request: common.Request = create_request() + request.client = mock.Mock() + + auth = DeviceCodeGrant(validator) + bearer = BearerToken(validator) + + validator.authenticate_client.return_value = False + + headers, body, status_code = auth.create_token_response(request, bearer) + body = json.loads(body) + + assert headers == { + "Content-Type": "application/json", + "Cache-Control": "no-store", + "Pragma": "no-cache", + "WWW-Authenticate": 'Bearer error="invalid_client"', + } + assert body == {"error": "invalid_client"} + assert status_code == 401 + + validator.save_token.assert_not_called() + + +def test_invalid_grant_type_error(): + validator = mock.MagicMock() + request: common.Request = create_request() + request.client = mock.Mock() + + request.grant_type = "not_device_code" + + auth = DeviceCodeGrant(validator) + bearer = BearerToken(validator) + + headers, body, status_code = auth.create_token_response(request, bearer) + body = json.loads(body) + + assert headers == { + "Content-Type": "application/json", + "Cache-Control": "no-store", + "Pragma": "no-cache", + } + assert body == {"error": "unsupported_grant_type"} + assert status_code == 400 + + validator.save_token.assert_not_called() + + +def test_duplicate_params_error(): + validator = mock.MagicMock() + request: common.Request = create_request( + "client_id=123&scope=openid&scope=openid" + ) + request.client = mock.Mock() + + auth = DeviceCodeGrant(validator) + bearer = BearerToken(validator) + + headers, body, status_code = auth.create_token_response(request, bearer) + body = json.loads(body) + + assert headers == { + "Content-Type": "application/json", + "Cache-Control": "no-store", + "Pragma": "no-cache", + } + assert body == {"error": "invalid_request", "error_description": "Duplicate scope parameter."} + assert status_code == 400 + + validator.save_token.assert_not_called() diff --git a/tests/oauth2/rfc8628/test_server.py b/tests/oauth2/rfc8628/test_server.py new file mode 100644 index 000000000..520250328 --- /dev/null +++ b/tests/oauth2/rfc8628/test_server.py @@ -0,0 +1,113 @@ +import json +from unittest import mock + +from oauthlib.oauth2.rfc8628.endpoints import DeviceAuthorizationEndpoint +from oauthlib.oauth2.rfc8628.request_validator import RequestValidator + +from tests.unittest import TestCase + + +class DeviceAuthorizationEndpointTest(TestCase): + def _configure_endpoint( + self, interval=None, verification_uri_complete=None, user_code_generator=None + ): + self.endpoint = DeviceAuthorizationEndpoint( + request_validator=mock.MagicMock(spec=RequestValidator), + verification_uri=self.verification_uri, + interval=interval, + verification_uri_complete=verification_uri_complete, + user_code_generator=user_code_generator, + ) + + def setUp(self): + self.request_validator = mock.MagicMock(spec=RequestValidator) + self.verification_uri = "http://i.b/l/verify" + self.uri = "http://i.b/l" + self.http_method = "POST" + self.body = "client_id=abc" + self.headers = {"Content-Type": "application/x-www-form-urlencoded"} + self._configure_endpoint() + + def response_payload(self): + return self.uri, self.http_method, self.body, self.headers + + @mock.patch("oauthlib.oauth2.rfc8628.endpoints.device_authorization.generate_token") + def test_device_authorization_grant(self, generate_token): + generate_token.side_effect = ["abc", "def"] + _, body, status_code = self.endpoint.create_device_authorization_response( + *self.response_payload() + ) + expected_payload = { + "verification_uri": "http://i.b/l/verify", + "user_code": "abc", + "device_code": "def", + "expires_in": 1800, + } + self.assertEqual(200, status_code) + self.assertEqual(body, expected_payload) + + @mock.patch( + "oauthlib.oauth2.rfc8628.endpoints.device_authorization.generate_token", + lambda: "abc", + ) + def test_device_authorization_grant_interval(self): + self._configure_endpoint(interval=5) + _, body, _ = self.endpoint.create_device_authorization_response(*self.response_payload()) + self.assertEqual(5, body["interval"]) + + @mock.patch( + "oauthlib.oauth2.rfc8628.endpoints.device_authorization.generate_token", + lambda: "abc", + ) + def test_device_authorization_grant_interval_with_zero(self): + self._configure_endpoint(interval=0) + _, body, _ = self.endpoint.create_device_authorization_response(*self.response_payload()) + self.assertEqual(0, body["interval"]) + + @mock.patch( + "oauthlib.oauth2.rfc8628.endpoints.device_authorization.generate_token", + lambda: "abc", + ) + def test_device_authorization_grant_verify_url_complete_string(self): + self._configure_endpoint(verification_uri_complete="http://i.l/v?user_code={user_code}") + _, body, _ = self.endpoint.create_device_authorization_response(*self.response_payload()) + self.assertEqual( + "http://i.l/v?user_code=abc", + body["verification_uri_complete"], + ) + + @mock.patch( + "oauthlib.oauth2.rfc8628.endpoints.device_authorization.generate_token", + lambda: "abc", + ) + def test_device_authorization_grant_verify_url_complete_callable(self): + self._configure_endpoint(verification_uri_complete=lambda u: f"http://i.l/v?user_code={u}") + _, body, _ = self.endpoint.create_device_authorization_response(*self.response_payload()) + self.assertEqual( + "http://i.l/v?user_code=abc", + body["verification_uri_complete"], + ) + + @mock.patch( + "oauthlib.oauth2.rfc8628.endpoints.device_authorization.generate_token", + lambda: "abc", + ) + def test_device_authorization_grant_user_gode_generator(self): + def user_code(): + """ + A friendly user code the device can display and the user + can type in. It's up to the device how + this code should be displayed. e.g 123-456 + """ + return "123456" + + self._configure_endpoint( + verification_uri_complete=lambda u: f"http://i.l/v?user_code={u}", + user_code_generator=user_code, + ) + + _, body, _ = self.endpoint.create_device_authorization_response(*self.response_payload()) + self.assertEqual( + "http://i.l/v?user_code=123456", + body["verification_uri_complete"], + ) diff --git a/tests/openid/connect/core/endpoints/test_openid_connect_params_handling.py b/tests/openid/connect/core/endpoints/test_openid_connect_params_handling.py index c55136fbf..5b04edff6 100644 --- a/tests/openid/connect/core/endpoints/test_openid_connect_params_handling.py +++ b/tests/openid/connect/core/endpoints/test_openid_connect_params_handling.py @@ -28,7 +28,8 @@ def setUp(self): 'redirect_uri': 'https://a.b/cb', 'response_type': 'code', 'client_id': 'abcdef', - 'scope': 'hello openid' + 'scope': 'hello openid', + 'ui_locales': 'en-US' } self.url = 'http://a.b/path?' + urlencode(params) @@ -76,3 +77,4 @@ def test_oidc_params_preservation(self): self.assertEqual(creds['prompt'], {'consent'}) self.assertEqual(creds['nonce'], 'abcd') self.assertEqual(creds['display'], 'touch') + self.assertEqual(creds['ui_locales'], ['en-US']) diff --git a/tests/openid/connect/core/endpoints/test_refresh_token.py b/tests/openid/connect/core/endpoints/test_refresh_token.py new file mode 100644 index 000000000..9161f5a5f --- /dev/null +++ b/tests/openid/connect/core/endpoints/test_refresh_token.py @@ -0,0 +1,32 @@ +"""Ensure that the server correctly uses the OIDC flavor of +the Refresh token grant type when appropriate. + +When the OpenID scope is provided, the refresh token response +should include a fresh ID token. +""" +import json +from unittest import mock + +from oauthlib.openid import RequestValidator +from oauthlib.openid.connect.core.endpoints.pre_configured import Server + +from tests.unittest import TestCase + + +class TestRefreshToken(TestCase): + + def setUp(self): + self.validator = mock.MagicMock(spec=RequestValidator) + self.validator.get_id_token.return_value='id_token' + + self.server = Server(self.validator) + + def test_refresh_token_with_openid(self): + request_body = 'scope=openid+test_scope&grant_type=refresh_token&refresh_token=abc' + headers, body, status = self.server.create_token_response('', body=request_body) + self.assertIn('id_token', json.loads(body)) + + def test_refresh_token_no_openid(self): + request_body = 'scope=test_scope&grant_type=refresh_token&refresh_token=abc' + headers, body, status = self.server.create_token_response('', body=request_body) + self.assertNotIn('id_token', json.loads(body)) diff --git a/tests/test_uri_validate.py b/tests/test_uri_validate.py index 6a9f8ea60..f1ac404e0 100644 --- a/tests/test_uri_validate.py +++ b/tests/test_uri_validate.py @@ -1,4 +1,6 @@ +from datetime import datetime import unittest + from oauthlib.uri_validate import is_absolute_uri from tests.unittest import TestCase @@ -76,7 +78,6 @@ def test_failures(self): self.assertIsNone(is_absolute_uri('http://[abcd:efgh::1]/')) def test_recursive_regex(self): - from datetime import datetime t0 = datetime.now() is_absolute_uri('http://[::::::::::::::::::::::::::]/path') t1 = datetime.now() diff --git a/tox.ini b/tox.ini index 4eb0813b2..ae49af481 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,6 @@ +# require tox>=4 [tox] -envlist = py36,py37,py38,py39,py310,py311,pypy3,docs,readme,bandit,isort +envlist = py38,py39,py310,py311,py312,py313,pypy3,docs,readme,ruff [testenv] deps= @@ -7,37 +8,27 @@ deps= commands= pytest --cov=oauthlib tests/ - # tox -e docs to mimic readthedocs build. -# as of today, RTD is using python3.7 and doesn't run "setup.py install" [testenv:docs] -basepython=python3.7 +basepython=python3.11 skipsdist=True deps= sphinx sphinx_rtd_theme changedir=docs -whitelist_externals=make +allowlist_externals=make commands=make clean html # tox -e readme to mimic PyPI long_description check [testenv:readme] -basepython=python3.8 +basepython=python3.11 deps=twine>=1.12.0 -whitelist_externals=echo +allowlist_externals=echo commands= - twine check .tox/dist/* - -[testenv:bandit] -basepython=python3.8 -skipsdist=True -deps=bandit -commands=bandit -b bandit.json -r oauthlib/ -whitelist_externals=bandit + twine check .tox/.pkg/dist/* -[testenv:isort] -basepython = python3.8 -usedevelop = false -deps = isort -changedir = {toxinidir} -commands = isort --recursive --check-only --diff oauthlib tests +[testenv:ruff] +deps=ruff +allowlist_externals=ruff +skip_install=true +commands=ruff check .